diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b11c74..e461a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h ### Fixed * `interactive manage-tasks`: a parent task postponed together with its child could be postponed once more in its own turn, and with caldav 3.6 this crashed with a "412 Precondition Failed" error. It is now skipped, with a note. A task changed on the server in the meantime is reloaded before it is postponed. +* `interactive manage-tasks` no longer stops with a traceback when the server refuses to store a task it postpones, e.g. with "423 Locked" from a Xandikos server busy with another write. The task is reported and the rest are still postponed. ## v1.3.0 - 2026-10-23 diff --git a/plann/lib.py b/plann/lib.py index a0cfe42..8586bb6 100644 --- a/plann/lib.py +++ b/plann/lib.py @@ -17,7 +17,7 @@ import click ## TODO - this should be removed, eventually import icalendar from caldav.config import extract_conn_params_from_section -from caldav.lib.error import ETagMismatchError, NotFoundError, ScheduleTagMismatchError, UidConflictError +from caldav.lib.error import ETagMismatchError, NotFoundError, PutError, ScheduleTagMismatchError, UidConflictError from plann.template import Template from plann.timespec import ( @@ -376,6 +376,10 @@ def _procrastinate(objs, delay, check_dependent="error", with_children=False, wi ## the child needs it moved, even if postponed already postponed.discard(p.get('UID')) _procrastinate([parent], new_due+max(parent.get_duration()+x.get_duration()+datetime.timedelta(minutes=1), datetime.timedelta(minutes=1)), check_dependent=check_dependent, err_callback=err_callback, confirm_callback=confirm_callback, postponed=postponed, recursivity=recursivity+1) + if p.get('UID') and p.get('UID') not in postponed: + ## the parent could not be saved - trying the child again would only loop + err_callback(f"{summary} not postponed, as the parent could not be saved") + continue _procrastinate([x], new_due, check_dependent=check_dependent, err_callback=err_callback, confirm_callback=confirm_callback, postponed=postponed, recursivity=recursivity+1) elif check_dependent == "return": return parent @@ -396,6 +400,10 @@ def _procrastinate(objs, delay, check_dependent="error", with_children=False, wi continue _procrastinate([x], delay, check_dependent, with_children, with_family, with_parent, err_callback, confirm_callback, postponed=postponed, reload_on_conflict=False, recursivity=recursivity+1) continue + except PutError as e: + ## i.e. 423 Locked from a busy server - don't abort the rest of the list + err_callback(f"{_summary(x)} could not be saved: {e}") + continue if uid: postponed.add(uid) if with_children: diff --git a/tests/test_lib.py b/tests/test_lib.py index 606583b..36907f6 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -429,6 +429,52 @@ def load(*args, **kwargs): _procrastinate([t], '10d', err_callback=msgs.append) assert any('deleted' in m for m in msgs) +def test_procrastinate_put_error_continues(): + """A task the server refuses to store (here 423 Locked, from a + git-backed Xandikos busy with another write) is reported, and the + rest of the list is still postponed""" + from caldav.lib import error + a = _todo('a') + b = _todo('b') + msgs = [] + postponed = set() + with patch.object(a, 'save', side_effect=error.PutError("423 Locked")), \ + patch.object(a, 'set_due', return_value=None), \ + patch.object(b, 'save') as b_save, patch.object(b, 'set_due', return_value=None): + _procrastinate([a, b], '10d', err_callback=msgs.append, postponed=postponed) + assert b_save.call_count == 1 + assert any('could not be saved' in m and '423' in m for m in msgs) + assert postponed == {'b'} + +def test_procrastinate_parent_put_error(): + """When the parent the child needs moved cannot be saved, the + child is reported as not postponed, and not retried over and over""" + from caldav.lib import error + parent = _todo('parent') + child = _todo('child', related_to='parent') + msgs = [] + with patch.object(child, 'set_due', return_value=parent) as child_set_due, \ + patch.object(child, 'save') as child_save, \ + patch.object(parent, 'save', side_effect=error.PutError("423 Locked")) as parent_save, \ + patch.object(parent, 'set_due', return_value=None): + _procrastinate([child], '10d', check_dependent='interactive', err_callback=msgs.append) + assert parent_save.call_count == 1 + assert child_set_due.call_count == 1 + assert child_save.call_count == 0 + assert any('not postponed, as the parent could not be saved' in m for m in msgs) + +def test_procrastinate_put_error_after_reload(): + """A PutError on the save after reloading is reported, too""" + from caldav.lib import error + t = _todo('changed') + msgs = [] + with patch.object(t, 'save', side_effect=[error.ETagMismatchError(), error.PutError("423 Locked")]) as save, \ + patch.object(t, 'load'), \ + patch.object(t, 'set_due', return_value=None): + _procrastinate([t], '10d', err_callback=msgs.append) + assert save.call_count == 2 + assert any('could not be saved' in m for m in msgs) + def test_procrastinate_without_uid(): """Tasks without a UID are not taken for one another""" a = _todo('a')