Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion plann/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading