Skip to content

Commit 1f45dee

Browse files
tobixenclaude
andcommitted
feat: Schedule-Tag and etag support
Ref RFC 6638 §3.2-3.3 Schedule-Tag implementation: - `save()` and `add_event()` capture the Schedule-Tag from the response header and stores it in `self.props` (same logic with etag). - `save()` sends `If-Schedule-Tag-Match` or `If-Match`-header if etag or schedule-tag is set. - raises `ScheduleTagMismatchError` or `ETagMismatchError` on 412. - `_reply_to_invite_request()`: when the server auto-schedules the event into the attendee's calendar (`scheduling.auto-schedule` supported), search all attendee calendars first and update the existing copy in place to preserve the server-assigned Schedule-Tag. Fall through to `add_event()` only for non-auto-schedule servers. - Assume SEQUENCE:0 default when SEQUENCE property is absent (RFC 5546 section 2.1.4 requires incrementing for significant changes). test: add failing tests for Schedule-Tag support (RFC 6638) Also adds design docs: - docs/design/TODO_SCHEDULE_TAG.md (analysis and implementation plan, refs #660) - docs/design/TODO_COMPATIBILITY_HINTS.md (FeatureSet cleanup analysis, refs #659) The schedule-tag logic was predominantly hand-written (with some trivial bugfixing done by Claude). Claude contributed with design suggestions, which have been partly followed. Test code is predominantly AI-written. prompt: (exact prompt is lost, but I was discussing the schedule-tags, and the output is in the new file docs/design/TODO_SCHEDULE_TAG.md) prompt: Please write up test code (unit tests + integration tests) on the schedule tags. Don't fix the code yet. prompt: Please write up test code (unit tests + integration tests) on the schedule tags. Don't fix the code yet. followup-prompts: (discussions on test breakages. While debugging, Claude has been insisting on searching for the Schedule-Tag by using a PropFind if it wasn't included in the headers, but that does not make sense) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b447097 commit 1f45dee

12 files changed

Lines changed: 1360 additions & 143 deletions

AI-POLICY.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,25 @@
44

55
The most important rule: be honest and inform about it!
66

7-
Also: keep a log of the prompts used - prompts may be included in the
7+
Keep a log of the prompts used - prompts should preferably be included in the
88
git commits.
99

10+
Tools should generally be used for improving the quality of the
11+
project, not for rapidly adding new features.
12+
13+
Keep a log of the prompts used - prompts should be included
14+
verbatimely in the git commits as long as it's possible without making
15+
the messages too messy. When relevant, chat-output may also need to
16+
be included. The `docs/design`-folder can be used for dumping
17+
AI-generated design documents, code reviews, prompts that are too
18+
large for being included in the commit message, etc.
19+
20+
Keep it clear what is human-written vs what is AI-written. In a
21+
feature-branch, separate AI-commits and human-commits is preferable.
22+
Those should most often be squashed together before including it in
23+
the main branch, with a notice in the commit message on what parts o
24+
the commit is AI-generated.
25+
1026
## Transparency matters
1127

1228
If you've spent hours, perhaps a full day of your time writing up a
@@ -32,8 +48,7 @@ explain in details why I'm rejecting the pull request.
3248

3349
It's fine to ask the AI for help to analyze a bug and create a fix for
3450
it. By discovering the bug, reproducing it and testing it you're
35-
adding real value to the project - just be transparent about AI usage
36-
and do not take offence if the code changes are rejected, or completely
51+
adding real value to the project - just remember to be honest, if you have no clue what Claude did and why it solves the bug, then inform! Do not take offence if the code changes are rejected, or completely
3752
rewritten.
3853

3954
## General rules

caldav/base_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
log = logging.getLogger("caldav")
2424

25+
## Common HTTP headers
26+
ICALH = {"Content-Type": 'text/calendar; charset="utf-8"'}
27+
2528

2629
class BaseDAVClient(ABC):
2730
"""

caldav/calendarobjectresource.py

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
from contextlib import contextmanager
4141

42+
from .base_client import ICALH
4243
from .datastate import DataState, IcalendarState, NoDataState, RawDataState, VobjectState
4344
from .davobject import DAVObject
4445
from .elements import cdav, dav
@@ -95,6 +96,15 @@ class CalendarObjectResource(DAVObject):
9596
_state: DataState | None = None
9697
_borrowed: bool = False
9798

99+
# Schedule tag (ref https://github.com/python-caldav/caldav/issues/660 and docs/design/TODO-SCHEDULE.md)
100+
@property
101+
def schedule_tag(self) -> str | None:
102+
return self.props.get(cdav.ScheduleTag.tag)
103+
104+
@property
105+
def etag(self) -> str | None:
106+
return self.props.get(dav.GetEtag.tag)
107+
98108
@property
99109
def id(self) -> str | None:
100110
"""Returns the UID of the calendar object.
@@ -410,7 +420,7 @@ def get_relatives(
410420
acceptable relation types in reltypes, or by passing a lambda
411421
function in relfilter.
412422
413-
TODO: Make it possible to also check up reverse relationships
423+
TODO: Make it possible to also check up reverse relationships
414424
415425
TODO: this is partially overlapped by plann.lib._relships_by_type
416426
in the plann tool. Should consolidate the code.
@@ -773,18 +783,34 @@ def _reply_to_invite_request(self, partstat, calendar) -> None:
773783
## we need to modify the icalendar code, update our own participant status
774784
self.icalendar_instance.pop("METHOD")
775785
self.change_attendee_status(partstat=partstat)
776-
self.get_property(cdav.ScheduleTag(), use_cached=True)
786+
uid = self.id
787+
## On auto-scheduling servers the server already places the event in the attendee's
788+
## calendar with a Schedule-Tag set. We must update that copy rather than creating a
789+
## new one via add_event() — a plain attendee PUT won't get a Schedule-Tag because
790+
## servers only assign it on organizer-originated scheduling operations.
791+
if uid and self.client.features.is_supported("scheduling.auto-schedule"):
792+
for cal in self.client.principal().calendars():
793+
try:
794+
existing = cal.event_by_uid(uid)
795+
existing.load()
796+
existing.change_attendee_status(partstat=partstat)
797+
existing.save()
798+
return
799+
except error.NotFoundError:
800+
pass
777801
try:
778802
calendar.add_event(self.data)
779803
except Exception:
780-
## TODO - TODO - TODO
781-
## RFC6638 does not seem to be very clear (or
782-
## perhaps I should read it more thoroughly) neither on
783-
## how to handle conflicts, nor if the reply should be
784-
## posted to the "outbox", saved back to the same url or
785-
## sent to a calendar.
804+
## add_event() failed — the event likely already exists (e.g. non-auto-scheduling
805+
## server that still rejects duplicate UIDs). Reload self from the inbox so we have
806+
## fresh data (METHOD is restored), then retry via the outbox: posting an iTIP REPLY
807+
## to the outbox lets the server process the PARTSTAT update on our behalf, which is
808+
## the correct RFC 6638 mechanism when we cannot write directly to the calendar.
809+
## We intentionally do NOT do a separate PROPFIND for Schedule-Tag here: the tag must
810+
## be read atomically with the object data (a separate request could race with a
811+
## concurrent scheduling operation), and RFC 6638 requires the server to return it
812+
## as a response header on GET — so load() is sufficient if the server complies.
786813
self.load()
787-
self.get_property(cdav.ScheduleTag(), use_cached=False)
788814
outbox = self.client.principal().schedule_outbox()
789815
if calendar.url != outbox.url:
790816
self._reply_to_invite_request(partstat, calendar=outbox)
@@ -868,6 +894,7 @@ def load(self, only_if_unloaded: bool = False) -> Self:
868894
except Exception:
869895
return self.load_by_multiget()
870896

897+
## consider refactoring - this is repeated many places now
871898
if "Etag" in r.headers:
872899
self.props[dav.GetEtag.tag] = r.headers["Etag"]
873900
if "Schedule-Tag" in r.headers:
@@ -997,10 +1024,25 @@ def _find_id_path(self, id=None, path=None) -> None:
9971024
self.url = URL.objectify(path)
9981025

9991026
def _put(self, retry_on_failure=True):
1027+
## TODO: quite much overlapping with _async_put, should consolidate
1028+
## TODO: this is low-level http-communication - shouldn't it be in the davclient file rather than in calendarobjectresource.py?
10001029
## SECURITY TODO: we should probably have a check here to verify that no such object exists already
1001-
r = self.client.put(self.url, self.data, {"Content-Type": 'text/calendar; charset="utf-8"'})
1002-
if r.status == 302:
1003-
path = [x[1] for x in r.headers if x[0] == "location"][0]
1030+
headers = {} ## TODO: use some caseinsensitivedict
1031+
if self.schedule_tag:
1032+
headers["if-schedule-tag-match"] = self.schedule_tag
1033+
elif self.etag:
1034+
headers["if-match"] = self.etag
1035+
headers |= ICALH
1036+
r = self.client.put(self.url, self.data, headers)
1037+
if r.status == 412:
1038+
if self.schedule_tag:
1039+
raise error.ScheduleTagMismatchError(errmsg(r))
1040+
elif self.etag:
1041+
raise error.ETagMismatchError(errmsg(r))
1042+
else:
1043+
raise error.PutError(errmsg(r))
1044+
elif r.status == 302:
1045+
self.url = URL.objectify([x[1] for x in r.headers if x[0] == "location"][0])
10041046
elif r.status not in (204, 201):
10051047
if retry_on_failure:
10061048
try:
@@ -1014,14 +1056,14 @@ def _put(self, retry_on_failure=True):
10141056
return self._put(False)
10151057
else:
10161058
raise error.PutError(errmsg(r))
1059+
if "Etag" in r.headers:
1060+
self.props[dav.GetEtag.tag] = r.headers["Etag"]
1061+
if r.headers and r.headers.get("schedule-tag"):
1062+
self.props[cdav.ScheduleTag.tag] = r.headers["schedule-tag"]
10171063

10181064
async def _async_put(self, retry_on_failure=True):
10191065
"""Async version of _put for async clients."""
1020-
r = await self.client.put(
1021-
str(self.url),
1022-
str(self.data),
1023-
{"Content-Type": 'text/calendar; charset="utf-8"'},
1024-
)
1066+
r = await self.client.put(str(self.url), str(self.data), ICALH)
10251067
if r.status == 302:
10261068
path = [x[1] for x in r.headers if x[0] == "location"][0]
10271069
self.url = URL.objectify(path)
@@ -1036,6 +1078,11 @@ async def _async_put(self, retry_on_failure=True):
10361078
return await self._async_put(False)
10371079
else:
10381080
raise error.PutError(errmsg(r))
1081+
## TODO: refactor - those code lines are repeated all over the place
1082+
if "Etag" in r.headers:
1083+
self.props[dav.GetEtag.tag] = r.headers["Etag"]
1084+
if r.headers and r.headers.get("schedule-tag"):
1085+
self.props[cdav.ScheduleTag.tag] = r.headers["schedule-tag"]
10391086

10401087
def _create(self, id=None, path=None, retry_on_failure=True) -> None:
10411088
## TODO: Find a better method name
@@ -1120,7 +1167,6 @@ def save(
11201167
no_create: bool = False,
11211168
obj_type: str | None = None,
11221169
increase_seqno: bool = True,
1123-
if_schedule_tag_match: bool = False,
11241170
only_this_recurrence: bool = True,
11251171
all_recurrences: bool = False,
11261172
) -> Self:
@@ -1137,8 +1183,7 @@ def save(
11371183
11381184
The SEQUENCE should be increased when saving a new version of
11391185
the object. If this behaviour is unwanted, then
1140-
increase_seqno should be set to False. Also, if SEQUENCE is
1141-
not set, then this will be ignored.
1186+
increase_seqno should be set to False.
11421187
11431188
The behaviour when saving a single recurrence object to the
11441189
server is as far as I can understand not defined in the RFCs,
@@ -1170,7 +1215,6 @@ def save(
11701215
no_create=no_create,
11711216
obj_type=obj_type,
11721217
increase_seqno=increase_seqno,
1173-
if_schedule_tag_match=if_schedule_tag_match,
11741218
only_this_recurrence=only_this_recurrence,
11751219
all_recurrences=all_recurrences,
11761220
)
@@ -1285,19 +1329,17 @@ def _incorporate_recurrence_into_parent(self, obj, only_this_recurrence, all_rec
12851329
ici.add_component(self.icalendar_component)
12861330

12871331
def _maybe_increment_sequence(self, increase_seqno):
1288-
"""Increment SEQUENCE number if present and increase_seqno is True."""
1332+
"""Increment SEQUENCE number if increase_seqno is True."""
12891333
if increase_seqno and "SEQUENCE" in self.icalendar_component:
1290-
seqno = self.icalendar_component.pop("SEQUENCE", None)
1291-
if seqno is not None:
1292-
self.icalendar_component.add("SEQUENCE", seqno + 1)
1334+
seqno = self.icalendar_component.pop("SEQUENCE", 0)
1335+
self.icalendar_component.add("SEQUENCE", seqno + 1)
12931336

12941337
async def _async_save(
12951338
self,
12961339
no_overwrite: bool = False,
12971340
no_create: bool = False,
12981341
obj_type: str | None = None,
12991342
increase_seqno: bool = True,
1300-
if_schedule_tag_match: bool = False,
13011343
only_this_recurrence: bool = True,
13021344
all_recurrences: bool = False,
13031345
) -> Self:

caldav/collection.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from collections.abc import Iterable, Iterator, Sequence
3131
from typing import Literal
3232

33+
from .base_client import ICALH
3334
from .calendarobjectresource import (
3435
CalendarObjectResource,
3536
Event,
@@ -509,17 +510,13 @@ def freebusy_request(self, dtstart, dtend, attendees) -> dict[str, FreeBusy]:
509510

510511
caldavobj.add_organizer()
511512

512-
response = self.client.post(
513-
outbox.url,
514-
caldavobj.data,
515-
headers={"Content-Type": "text/calendar; charset=utf-8"},
516-
)
513+
response = self.client.post(outbox.url, caldavobj.data, headers=ICALH)
517514
return response._parse_scheduling_response_objects(parent=self)
518515

519516
async def _async_freebusy_request(self, outbox, fb_obj) -> dict:
520517
"""Async implementation of freebusy_request() for async clients."""
521518
## TODO: could we have common headers as global variable?
522-
headers = {"Content-Type": "text/calendar; charset=utf-8"}
519+
headers = ICALH
523520
outbox = await outbox
524521
## TODO: it's really bad that arbitrary methods returns
525522
## a coroutine in async mode. It's needed to make it much
@@ -1957,35 +1954,51 @@ def __init__(
19571954

19581955
def get_items(self):
19591956
"""
1960-
TODO: work in progress
1961-
TODO: perhaps this belongs to the super class?
1957+
Return all items currently in this scheduling mailbox (inbox or outbox).
1958+
1959+
Unlike regular calendars, schedule mailboxes contain raw iTIP messages
1960+
(METHOD:REQUEST, METHOD:REPLY, METHOD:CANCEL, …) rather than permanent
1961+
calendar objects. Items should be processed and then deleted; they are
1962+
not meant to be kept indefinitely.
1963+
1964+
Servers often do not support the sync-collection REPORT (RFC 6578) on
1965+
schedule-inbox/outbox — the inbox is not a full calendar collection and
1966+
may not be indexed the same way. We therefore attempt the sync-token
1967+
path first (efficient for repeat polling) but fall back transparently to
1968+
a plain PROPFIND depth-1 followed by individual GETs. Both paths return
1969+
loaded CalendarObjectResource objects.
1970+
1971+
This method does NOT belong on the Calendar super-class: Calendar exposes
1972+
type-specific accessors (get_events, get_todos, …) and uses search()
1973+
internally. The mailbox is a different beast — it holds transient,
1974+
mixed-type scheduling messages and must use children() as its fallback
1975+
because search() / REPORT queries against a mailbox URL are unreliable
1976+
across servers.
19621977
"""
1978+
1979+
def _load_from_children():
1980+
items = [CalendarObjectResource(url=x[0], client=self.client) for x in self.children()]
1981+
for x in items:
1982+
x.load()
1983+
return items
1984+
19631985
if not self._items:
19641986
try:
19651987
self._items = self.objects(load_objects=True)
19661988
except Exception:
19671989
logging.debug(
1968-
"caldav server does not seem to support a sync-token REPORT query on a scheduling mailbox"
1990+
"sync-collection REPORT not supported on scheduling mailbox %s; "
1991+
"falling back to PROPFIND depth-1",
1992+
self.url,
19691993
)
1970-
error.assert_("google" in str(self.url))
1971-
self._items = [
1972-
CalendarObjectResource(url=x[0], client=self.client) for x in self.children()
1973-
]
1974-
for x in self._items:
1975-
x.load()
1994+
self._items = _load_from_children()
19761995
else:
19771996
try:
19781997
self._items.sync()
19791998
except Exception:
1980-
self._items = [
1981-
CalendarObjectResource(url=x[0], client=self.client) for x in self.children()
1982-
]
1983-
for x in self._items:
1984-
x.load()
1999+
self._items = _load_from_children()
19852000
return self._items
19862001

1987-
## TODO: work in progress
1988-
19892002

19902003
# def get_invites():
19912004
# for item in self.get_items():

0 commit comments

Comments
 (0)