Skip to content

Version 3.3.0 ~~compatibility fix: date-ranged searches without comptype~~#682

Draft
tobixen wants to merge 64 commits into
masterfrom
fix/issue-681-timerange-vcalendar
Draft

Version 3.3.0 ~~compatibility fix: date-ranged searches without comptype~~#682
tobixen wants to merge 64 commits into
masterfrom
fix/issue-681-timerange-vcalendar

Conversation

@tobixen

@tobixen tobixen commented Jun 4, 2026

Copy link
Copy Markdown
Member

This started as a fix for issue #681, but I fell into some few rabbit-holes and decided to commit to the same branch rather than mass-spawning draft pull requests (and particularly for changes in the compatibility_hints.py, changes has to be done lock-step in the caldav-server-tester, so linearly adding stuff to both projects saves a lot of hassle), so I'm repurposing this pull request as a version 3.3.0 prereview.

Mostly AI-generated code, not so much human review - changes should be examinated carefully, git messages should probably be reweritten, need to test it thoroughly, etc, hence it's in "draft" mode as for now.

tobixen and others added 14 commits June 7, 2026 00:03
A CALDAV:time-range filter is only valid inside a comp-filter for
VEVENT/VTODO/VJOURNAL/VFREEBUSY/VALARM (RFC4791 section 9.7), never
directly under VCALENDAR.  When search() was called with a time-range
but no component type, the library emitted an illegal query that
SabreDAV-based servers (Baikal, Nextcloud, ...) reject with HTTP 400
"You cannot add time-range filters on the VCALENDAR component".

This worked previously only because lenient servers tolerated it.

Fixes:
- new feature search.time-range.comp-type.optional (default: unsupported,
  which is fully RFC-compliant and not a server defect).  When not
  supported, a comp-type-less time-range search is split into one query
  per component type.
- reactive fallback: if the feature is configured as supported but the
  server still rejects the query, retry by splitting per component type.
- driver fix: search()'s generator driver now feeds exceptions raised
  while executing an action back INTO the generator via gen.throw(), so
  the search logic's own try/except branches (the #681 fallback, the
  per-object load error handling, the backward-compat report retry) are
  no longer dead code.

Async driver mirror and integration tests follow separately.

#681

prompt: look into github issue #681 - any suggestions?
followup-prompt: So I think we need: 1) split the search.comp-type.optional
  test in caldav-server-tester, 2) explicit test without comp-type but with
  date-range, 3) run twice with patched config, 4) workaround in code.
followup-prompt: deal with sync tests and logic first, then async. All sync
  integration tests should be mirrored in the async integration tests.
followup-prompt: we should have test code for the driver fix, too

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: I closed  `git bug bug show e44ee06` aka  #667 as I have the impression that the work there is done.  Please verify.
claude-sonnet-4-6: look into github issue #681 - any suggestions?
claude-sonnet-4-6: Don't we have any integration tests doing `cal.search(start=..., end=...)`, or perhaps it passes because of `'search.comp-type.optional': {'support': 'ungraceful'}` in the compatibility matrix?
claude-sonnet-4-6: So I think we need: 1) in the caldav-server-tester, the test for `search.comp-type.optional` must be split - perhaps a separate `search.time-range.comp-type.optional` (with comments that False is completely fine according to the RFC)) - and the search logic in the caldav library must be fixed similarly.  2) We need an explicit test that does a search without comp-type but with date-range, 3) for servers not supporting search.time-range.comp-type.optional said integration test should be run twice, once with the server feature configuration patched up.  The latter test is supposed to fail at baikla.  4) We need a workaround in the code so that such errors will be caught and handled, causing the test to pass.

AI Prompts:
claude-sonnet-4-6: Won't user@domain usernames work at all?  The scheduling-RFC sometimes asserts email-usernames, so for full testing of scheduling it's probably a good thing to use username@domain ?
search(..., compatibility_workarounds=False) disables every server-
compatibility workaround in _search_impl (comp-type splitting, filter
rewriting, sliding-window time-range injection, fallback retries, the
todo pending-task multi-query) and sends the query the searcher
describes verbatim as a single REPORT.

This lets the server-compatibility checker observe raw server behaviour
instead of the worked-around behaviour - needed now that the issue #681
fix makes the library rewrite comp-type-less time-range queries.

The flag is stored as a dataclass field so it propagates to clones via
dataclasses.replace(); the search() parameter defaults to None so
internal recursive calls leave the searcher's setting untouched.

#681

prompt: I suggest adding a flag to the search function to supress any
  comaptibility-workarounds

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r.search

- Calendar.search() now accepts and forwards compatibility_workarounds to
  the searcher (previously it was swallowed into **searchargs and wrongly
  turned into a bogus property filter).
- async_search() gains the same compatibility_workarounds parameter for
  symmetry (async tests/driver mirror still to come).
- new integration test testSearchWithoutCompTypeWithDateRange: a
  comp-type-less time-range search must work, including a second run with
  search.time-range.comp-type.optional forced on to exercise the reactive
  HTTP-400 fallback.  Verified passing against Baikal (SabreDAV 4.7.0,
  which rejects the raw query with the issue #681 error).

#681

followup-prompt: I suggest adding a flag to the search function to supress
  any comaptibility-workarounds

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- async_search()'s generator driver now mirrors the sync driver: it feeds
  exceptions raised while executing an action back into the generator via
  gen.throw(), so the search logic's own try/except branches (the issue
  #681 reactive time-range fallback, per-object load error handling) work
  in the async path too.
- new async integration test test_search_without_comptype_with_date_range
  mirrors the sync testSearchWithoutCompTypeWithDateRange.

Verified passing against Baikal and Nextcloud (both SabreDAV-based and both
reject the raw comp-type-less time-range query with the issue #681 error).

#681

followup-prompt: deal with sync tests and logic first, then async. All sync
  integration tests should be mirrored in the async integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue
#681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that the comp-type.optional probe no longer carries a time-range, the
observed values for several servers changed (the old "ungraceful" readings
were artifacts of a checker bug where the probe sent a time-range that
SabreDAV-likes reject):

- nextcloud, cyrus: search.comp-type.optional ungraceful -> full
- sogo: search.comp-type.optional ungraceful -> unsupported (returns nothing
  without a comp-type) and search.time-range.comp-type.optional -> full
  (works when a time-range is present)
- bedework, zimbra: search.time-range.comp-type.optional -> full

Verified: testCheckCompatibility passes for all five servers.

#681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: `pytest -k 'compat and xandikos'` fails, radicale fails similarly.  I find it a bit weird if search.comp-type.optional is ungraceful while search.time-range.comp-type.optional is supported.  Please investigate. ERROR    root:checks_base.py:70 Server checker found something unexpected for search.comp-type.optional.  Expected: {'support': 'full'}, observed: {'support': 'ungraceful'} ERROR    root:checks_base.py:70 Server checker found something unexpected for search.time-range.comp-type.optional.  Expected: {'support': 'unsupported'}, observed: {'support': 'full'}
claude-sonnet-4-6: <task-notification> <task-id>bwekpm388</task-id> <tool-use-id>toolu_012V75iTS2hPpbKUqyipHxTW</tool-use-id> <output-file>/tmp/claude-7385/-home-tobias-caldav/a1535ac2-6269-4140-8902-1a870106aaab/tasks/bwekpm388.output</output-file> <status>completed</status> <summary>Background command "Run all testCheckCompatibility tests" completed (exit code 0)</summary> </task-notification>
claude-sonnet-4-6: continue calibrating compatibility_hints for issue #681 from the b93fha8id compat run results
claude-sonnet-4-6: <task-notification> <task-id>blvu1dml7</task-id> <tool-use-id>toolu_014LYExUYBbQ4j7RmHvkqn8d</tool-use-id> <output-file>/tmp/claude-7385/-home-tobias-caldav/a1535ac2-6269-4140-8902-1a870106aaab/tasks/blvu1dml7.output</output-file> <status>completed</status> <summary>Background command "Re-run 5 servers' compat after hint calibration" completed (exit code 0)</summary> </task-notification>
Follow-up to the earlier calibration.  The first pass marked SOGo, Bedework
and Zimbra as supporting search.time-range.comp-type.optional, but the
checker probe only verified the query did not error - SOGo and Bedework
silently return NOTHING for a comp-type-less time-range query, which is not
real support.  With the probe now requiring that a known in-range event is
actually returned, the verified picture is:

- genuinely supported (event returned): xandikos, radicale, davical, zimbra
  -> search.time-range.comp-type.optional = full
- silently returns nothing: sogo, bedework -> unsupported (the default)
- rejects with HTTP 400 (SabreDAV): nextcloud, cyrus, baikal, davis, ... ->
  unsupported (the default)

Verified: testCheckCompatibility passes for all servers (Zimbra's remaining
failure is an unrelated, pre-existing flake on scheduling.auto-schedule).

#681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue: verify the 5-server compat re-run (blvu1dml7) passed, then commit the compatibility_hints calibration and the list() checker fix for issue #681
claude-sonnet-4-6: Some servers does not support comp-type.optional, but do support time-range.comp-type.optional - that sounds odd, please verify that it's actually true.
claude-sonnet-4-6: <task-notification> <task-id>bofybhimi</task-id> <tool-use-id>toolu_0152eaafMhsKhM2UKPXJGtWz</tool-use-id> <output-file>/tmp/claude-7385/-home-tobias-caldav/a1535ac2-6269-4140-8902-1a870106aaab/tasks/bofybhimi.output</output-file> <status>completed</status> <summary>Background command "Re-run servers with corrected probe" completed (exit code 0)</summary> </task-notification>
claude-sonnet-4-6: <task-notification> <task-id>bg939r71a</task-id> <tool-use-id>toolu_01VWyJoCZybKHUM5nUE16WP2</tool-use-id> <output-file>/tmp/claude-7385/-home-tobias-caldav/a1535ac2-6269-4140-8902-1a870106aaab/tasks/bg939r71a.output</output-file> <status>completed</status> <summary>Background command "Verify import and run full compat suite" completed (exit code 0)</summary> </task-notification>

AI Prompts:
claude-sonnet-4-6: what is the connection details for the nextcloud docker server?  (please give it in json format, for inclusion in my calendar config file)
claude-sonnet-4-6: Fix the README
claude-sonnet-4-6: continue: read bg939r71a full compat results; if all green commit the corrected hints (caldav) and the stricter probe (server-tester) for issue #681; otherwise fix the flagged server hints
Generalises the issue #681 fix from time-range filters to property
(prop-filter) filters.  A search with a property filter (CATEGORIES,
SUMMARY, ...) but no component type put the prop-filter directly under the
VCALENDAR comp-filter, where it matches VCALENDAR's own properties - which
do not include component properties like CATEGORIES - so servers (Xandikos,
SabreDAV, ...) silently returned nothing.

The library now splits such a search into one query per component type,
governed by the new feature search.text.comp-type.optional (default
unsupported - verified to be the universal case, since a prop-filter under
VCALENDAR is meaningless on every tested server).  The reactive
per-component-type fallback is likewise extended to property filters for
servers that reject (rather than silently ignore) the query.

Adds unit tests, sync (testSearchWithoutCompTypeWithCategory) and async
integration tests.  Verified against Baikal and Xandikos; full
testCheckCompatibility matrix unaffected (no server needs calibration).

#681

prompt: Now the same issue applies when filtering i.e. on CATEGORIES and
  other attributes.  The VCALENDAR does not have such a property, so at
  least xandikos will not find anything is searching for a specific
  category but without a compfilter.  We need a check for this, too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: Now the same issue applies when filtering i.e. on CATEGORIES and other attributes.  The VCALENDAR does not have such a property, so at least xandikos will not find anything is searching for a specific category but without a compfilter. We need a check for this, too.
CI failure: test_compatibility_hints.test_intermediate_feature_derives_from_children
broke because the intermediate grouping nodes search.time-range.comp-type and
search.text.comp-type (added so find_feature could walk the dotted parents of
...comp-type.optional) had no explicit default, so they were counted as
subfeatures of search.time-range / search.text and polluted those parents'
subfeature-derivation.

Fix: use a single dotted segment "comp-type-optional" (dash, not dot), so the
features are direct children of search.time-range / search.text and need no
intermediate grouping node:

  search.time-range.comp-type.optional -> search.time-range.comp-type-optional
  search.text.comp-type.optional       -> search.text.comp-type-optional

The grouping nodes are removed.  The original search.comp-type.optional feature
is untouched.

#681

prompt: actually, the proper fix is probably to replace a dot with a dash.
  comp-type-optional instead of comp-type.optional.  At all three places.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: run the full compat matrix once more to confirm green
claude-sonnet-4-6: <task-notification> <task-id>b09i5stdr</task-id> <tool-use-id>toolu_01PhuKFr8hK8KSG9Dh9jULse</tool-use-id> <output-file>/tmp/claude-7385/-home-tobias-caldav/a1535ac2-6269-4140-8902-1a870106aaab/tasks/b09i5stdr.output</output-file> <status>completed</status> <summary>Background command "Full compat matrix run" completed (exit code 0)</summary> </task-notification>
claude-sonnet-4-6: continue: read b09i5stdr full compat matrix results and report whether all servers are green
claude-sonnet-4-6: github runs failed
…tion

CI failure on Cyrus: testSearchWithoutCompTypeWithDateRange's second run
forced search.time-range.comp-type-optional ON and asserted the event was
returned, relying on the reactive HTTP-400 fallback.  But that fallback only
recovers from a ReportError (SabreDAV's 400) - Cyrus instead returns nothing
(CI) or 403 (locally), so the forced run either found nothing or crashed.

The forced "Run 2" is only meaningful where the raw comp-type-less time-range
query raises a ReportError (Baikal, Nextcloud).  The test now probes the raw
behaviour first and only runs Run 2 in that case; other servers exercise just
the proactive split (Run 1).  The reactive fallback itself remains covered
deterministically by the unit test.

#681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mplete

When a partial set of sibling subfeatures is configured - some positive
(e.g. full) and some negative (e.g. unsupported) - the derivation cannot
conclude anything about unset siblings.  Previously it returned 'unknown'
(-> False) in this case, which caused save-load.event to be evaluated as
unsupported for OX because save-load.todo:full and save-load.journal:unsupported
were both set, making the save-load parent derive as unknown, which then
propagated to the unset save-load.event child.

The fix: return None (inconclusive) when has_positive but not all_same and
not is_complete.  Only return 'unknown' when ALL relevant subfeatures have
been seen (is_complete) and they disagree.

Symptom: testSearchWithoutCompTypeWithDateRange failed on OX because
_search_with_comptypes skipped Event (is_supported("save-load.event") == False)
and returned no results.

prompt: looking at failing tests, tracing through is_supported derivation logic
to find why save-load.event returns False for OX when not explicitly set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: What's this: $ git bug bridge pull  [d2eb6fa] new comment: da2de1b6f2a12ed6b2cef78a1c61dbe69eab99d837fa2a45535025bcba42e2eb import error: comment edit: Multiple matching operation found: 10ff07a60a294778f661b01b19e8cae7d3610aa3a613f1f14354ddd7b6194010 cd4552443b669b8b1d1d7f2df416d8adb291d286adf4ea1c311e806270160995 ad12de1ba823b2f5c85efac023ac38004b5d51ee909c083e24e4a688d88724be imported 0 issues and 0 identities with default bridge
claude-sonnet-4-6: Just something I noticed
claude-sonnet-4-6: tests/test_caldav.py:3462: AssertionError =============================================== short test summary info =============================================== FAILED tests/test_async_integration.py::TestAsyncForOx::test_search_without_comptype_with_date_range - AssertionError: comp-type-less time-range search did not return the event FAILED tests/test_caldav.py::TestForServerNextcloud::testCheckCompatibility - AssertionError: expectation is full, observation is unsupported for create-calendar.set-displayname FAILED tests/test_caldav.py::TestForServerOx::testSearchWithoutCompTypeWithDateRange - AssertionError: comp-type-less time-range search did not return the event ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_invite_and_respond - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_freebusy - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_schedule_tag_returned_on_save - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_schedule_tag_stable_on_partstate_update - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_schedule_tag_changes_on_organizer_update - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_schedule_tag_mismatch_raises_error - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_async_integration.py::TestAsyncSchedulingForCyrus::test_schedule_tag_match_succeeds - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testFreeBusy - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testInviteAndRespond - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testAcceptInviteUsernameEmailFallback - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testScheduleTagReturnedOnSave - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testScheduleTagStableOnPartstateUpdate - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testScheduleTagChangesOnOrganizerUpdate - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testScheduleTagMismatchRaisesError - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ERROR tests/test_caldav.py::TestSchedulingForServerCyrus::testScheduleTagMatchSucceeds - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8802/dav/calendars/user/user1', reaso... ========================= 3 failed, 2 passed, 2352 deselected, 15 errors in 134.50s (0:02:14) =========================
…tion

The four standalone derivation tests at the bottom of TestFeatureSetCollapse
were testing is_supported() derivation logic, not the collapse() method.
They were also largely redundant with matrix entries already in
TestDeriveFromSubfeatures (in particular the new entries from the prior commit).

- Remove test_independent_subfeature_not_derived (→ explicit_default_overrides_children)
- Remove test_parent_default_not_overridden_by_subfeature_derivation (same)
- Remove test_hierarchical_vs_independent_subfeatures (→ mixed_children + explicit_default_overrides_children)
- Remove test_intermediate_feature_derives_from_children (sub-cases covered by
  all_children_unsupported, mixed_children, parent_explicit_overrides_children)
- Add partial_mixed_children_query_parent_falls_to_default matrix entry: the
  fs1b sub-case (partial+mixed children → inconclusive → default) was unique and
  is the key regression test for the _derive_from_subfeatures None-return fix
- Rename class TestDeriveFromSubfeatures → TestImplicitDerivation and broaden docstring

prompt: "I added some stuff to tests/test_compatibility_hints.py in last commit,
but I believe it's redundant. Have a look through all the tests handling implicit
support derivation, perhaps more logic can be moved into TestDeriveFromSubfeatures?
If so, it should change name."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: I added some stuff to tests/test_compatibility_hints.py in last commit, but I believe it's redundant.  Have a look through all the tests handling implicit support derivation, perhaps more logic can be moved into TestDeriveFromSubfeatures?  If so, it should change name.
The server tester used to mislabel search.comp-type.optional as
fragile/ungraceful on servers that store journals/tasks in a separate
calendar (it compared a comp-type-less search against a cnt that counted
those objects).  With the fixed checker the feature is full on all test
servers except SOGo.

- baikal, davis, ccs, ox, zimbra, davical, bedework: comp-type.optional -> full
- stalwart: add time-range.comp-type-optional + text.comp-type-optional = full
- bedework: add time-range.comp-type-optional = fragile (flaps between runs)

Kept the dot in search.comp-type.optional for backward compatibility.
Refs #681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: create-calendar.set-displayname is now unsupported for NextCloud, but expected to work.  Please investigate
claude-sonnet-4-6: make this fix in the caldav-server-tester.
…nd ox

Zimbra and OX were pinned to create-calendar.set-displayname: unsupported.
That value matched the checker bug fixed in caldav-server-tester d2fc2b0:
the probe verified the feature by looking the calendar up by display name,
which a leftover/colliding calendar would shadow, yielding a false negative.

The probe now reads the display name back by cal_id via PROPFIND.  For both
servers it returns the requested name ("Yep"), distinct from the cal_id
("caldav-server-checker-mkdel-test") - so setting a display name AT creation
time works and the expectation is corrected to full.

For Zimbra this also retires the old_flags note claiming the display name can
only equal the cal_id; that no longer holds for zcs-foss:latest.  For OX the
old "unsupported" conflated set-at-create (works) with rename-after-create via
PROPPATCH (still unsupported, and not what the probe tests).

prompt: $ pytest -k compat ... I thought this was fixed in d2fc2b0?
followup-prompt: yes please fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tobixen tobixen force-pushed the fix/issue-681-timerange-vcalendar branch from 2f2bf2c to 83e0dd0 Compare June 7, 2026 07:05
tobixen and others added 15 commits June 7, 2026 11:10
There is no TEST_STALWART/TEST_NEXTCLOUD/... mechanism in the test
harness. Server selection is driven by the literal `enabled:` field
plus PYTHON_CALDAV_TEST_{DOCKER,EMBEDDED,EXTERNAL}, and running docker
containers are auto-discovered, so `pytest` alone suffices after start.sh.

Worse, `enabled: ${TEST_X:-false}` never worked: expand_env_vars always
returns a string, and the non-empty string "false" is truthy, so every
docker server in the example was effectively enabled. Replaced the
interpolations with literal booleans (baikal true, others false).

- caldav_test_servers.yaml.example: literal enabled values, rewrote the
  misleading "auto" comment block
- docker-test-servers/*/README.md and */start.sh: dropped TEST_X=...
  references; fixed baikal's bogus `enabled: auto` mention
- deleted tests/test_servers.yaml.example, a stale, less-complete
  duplicate of caldav_test_servers.yaml.example (no doc referenced it)

prompt: There has been a hallucination that environmental variables like
TEST_STALWART=true are needed for running tests towards Stalwart, and same
for the other test servers. ... Please look through and clean up.
followup-prompt: Please delete the stale file and fix all references, then commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: When running from the fix/issue-681-timerange-vcaelndar branch, I get this: =================================================================== short test summary info =================================================================== FAILED tests/test_async_integration.py::TestAsyncForStalwart::test_recurring_date_with_exception_search - assert 3 == 2 FAILED tests/test_async_integration.py::TestAsyncForOx::test_lookup_event - caldav.lib.error.NotFoundError: NotFoundError at '20010712T182145Z-123401@example.com not found on server', reason no reason FAILED tests/test_async_integration.py::TestAsyncForOx::test_create_overwrite_delete_event - Failed: DID NOT RAISE <class 'caldav.lib.error.ConsistencyError'> FAILED tests/test_async_integration.py::TestAsyncForOx::test_load_event - assert 0 >= 1 FAILED tests/test_async_integration.py::TestAsyncForOx::test_copy_event - IndexError: list index out of range FAILED tests/test_async_integration.py::TestAsyncForOx::test_object_by_sync_token - assert 1 == 2 FAILED tests/test_async_integration.py::TestAsyncForOx::test_sync - assert 1 == 2 FAILED tests/test_async_integration.py::TestAsyncForOx::test_change_attendee_status_with_email_given - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8810/caldav/pythoncaldav-async-test/test1.ics', reason Forbidden FAILED tests/test_async_integration.py::TestAsyncForOx::test_utf8_event - assert 0 == 1 FAILED tests/test_async_integration.py::TestAsyncForOx::test_create_calendar_and_event_from_vobject - assert 0 == 1 FAILED tests/test_async_integration.py::TestAsyncForSOGo::test_set_calendar_properties - caldav.lib.error.PropsetError: PropsetError at '409 Conflict FAILED tests/test_caldav.py::TestForServerZimbra::testCreateEvent - assert URL(https://zimbra-docker.zimbra.io:8808/dav/testuser%40zimbra.io/Yep/) == URL(https://zimbra-docker.zimbra.io:8808/dav/testuser%40zimbra.io/python... FAILED tests/test_caldav.py::TestForServerZimbra::testCreateTaskListAndTodo - assert 3 == 2 FAILED tests/test_caldav.py::TestForServerZimbra::testTodoCompletion - assert 3 == 2 FAILED tests/test_caldav.py::TestForServerZimbra::testLookupEvent - caldav.lib.error.NotFoundError: NotFoundError at '404 Not Found FAILED tests/test_caldav.py::TestForServerZimbra::testCreateOverwriteDeleteEvent - caldav.lib.error.NotFoundError: NotFoundError at '404 Not Found FAILED tests/test_caldav.py::TestForServerStalwart::testTodoDatesearch - IndexError: list index out of range FAILED tests/test_caldav.py::TestForServerStalwart::testRecurringDateWithExceptionSearch - assert 3 == 2 FAILED tests/test_caldav.py::TestForServerOx::testChangeAttendeeStatusWithEmailGiven - caldav.lib.error.AuthorizationError: AuthorizationError at 'http://localhost:8810/caldav/pythoncaldav-test/test1.ics', reason Forbidden FAILED tests/test_caldav.py::TestForServerOx::testCreateEvent - assert 0 == 1 FAILED tests/test_caldav.py::TestForServerOx::testObjectBySyncToken - assert 1 == 2 FAILED tests/test_caldav.py::TestForServerOx::testSync - assert 1 == 2 FAILED tests/test_caldav.py::TestForServerOx::testLoadEvent - IndexError: list index out of range FAILED tests/test_caldav.py::TestForServerOx::testCopyEvent - IndexError: list index out of range FAILED tests/test_caldav.py::TestForServerOx::testCreateCalendarAndEventFromVobject - assert 0 == 1 FAILED tests/test_caldav.py::TestForServerOx::testUtf8Event - assert 0 == 1 FAILED tests/test_caldav.py::TestForServerOx::testUnicodeEvent - assert 0 == 1 FAILED tests/test_caldav.py::TestForServerOx::testLookupEvent - caldav.lib.error.NotFoundError: NotFoundError at '20010712T182145Z-123401@example.com not found on server', reason no reason FAILED tests/test_caldav.py::TestForServerOx::testCreateOverwriteDeleteEvent - caldav.lib.error.NotFoundError: NotFoundError at '20010712T182145Z-123401@example.com not found on server', reason no reason FAILED tests/test_caldav.py::TestForServerSOGo::testCreateEvent - assert URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav-test-tasks/) == URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav... ================================================== 30 failed, 1 passed, 2342 deselected in 411.44s (0:06:51) ==================================================  when running from master: $ pytest --last-failed ===================================================================== test session starts ===================================================================== platform linux -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0 rootdir: /home/tobias/caldav configfile: pyproject.toml plugins: socket-0.8.0, hypothesis-6.153.0, respx-0.23.1, timeout-2.4.0, typeguard-4.5.2, anyio-4.13.0, http-snapshot-0.1.9, inline-snapshot-0.32.6, asyncio-1.3.0, mock-3.14.1, pyfakefs-6.2.0, cov-7.1.0, xdist-3.8.0, repeat-0.9.4 asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 2314 items / 2284 deselected / 30 selected                                                                                                           run-last-failure: rerun previous 30 failures  tests/test_async_integration.py ssssssssssF                                                                                                             [ 36%] tests/test_caldav.py .....sssssssssssssF                                                                                                                [100%]  =================================================================== short test summary info =================================================================== FAILED tests/test_async_integration.py::TestAsyncForSOGo::test_set_calendar_properties - caldav.lib.error.PropsetError: PropsetError at '409 Conflict FAILED tests/test_caldav.py::TestForServerSOGo::testCreateEvent - assert URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav-test-tasks/) == URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav... ============================================ 2 failed, 5 passed, 23 skipped, 2284 deselected in 363.75s (0:06:03) =============================================  ... I'm pretty sure that tests were passing at master earlier, so the SOGo tests are probably due to regressions in the SOGo test server. Please investigate.
claude-sonnet-4-6: continue, but the TEST_$SERVER=true environment does nothing, it's never been anything but a hallucination.  I'm curious if the need for this environment variable is present in some documentaiton or memory file somewhere.
claude-sonnet-4-6: When specifying that `tests/test_caldav.py::TestForServerStalwart::somehting` or `-k stalwart`, then integration tests towards Stalwart will be run - if the docker test container is available.
claude-sonnet-4-6: There has been a hallucination that environmental variables like `TEST_STALWART=true` are needed for running tests torwards Stalwart, and same for the other test servers.  This has been written to the documents tests/caldav_test_servers.yaml.example and the README.md / start.sh files under tests/docker.  Those enivronmental variables have no effect.  Please look through and clean up.
claude-sonnet-4-6: Please write the matrix above into a file ~/caldav/docs/design/tmp-failure-report
claude-sonnet-4-6: Ox has lots of test failures now.  The problem is that `save-load.event` earlier resolved to `unknown` due to some derivation bug and due to the feature not having an explicit default (hence marking it as an independent feature).  With `save-load.event` not explicitly set to True, lots of tests where earlier skipped for Ox - now they fail.  Please investigate.
claude-sonnet-4-6: Please delete the stale file and fix all references, then commit.
…rouping defaults

Give save-load and save-load.{event,todo,journal} explicit "full" defaults so
they are treated as independent features rather than grouping nodes.  This also
fixes a half-finished manual edit that left them string-concatenating their
description into the default, breaking module import.  Without explicit defaults
these parents could wrongly derive a negative status from a single negative leaf
such as save-load.event.timezone.

Add a new save-load.stable-url feature (default full): some servers report a
calendar object under a different canonical URL than the one the client used to
PUT it.  OX App Suite exposes a calendar both under its display name and under an
internal cal://0/NNN id, so an object looked up via REPORT comes back under a
different calendar path (a direct GET on the original URL still works via an
alias).  OX is marked save-load.stable-url=unsupported.

testLookupEvent now uses a near-now date - OX hides old events from REPORT via a
sliding window, ref search.unlimited-time-range - and only asserts URL equality
for servers that preserve the URL; otherwise it loads the object and compares the
UID.  The matching detection check lives in caldav-server-tester
(PrepareCalendar._check_stable_url).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a near_now_ics() helper that shifts an ical event's DTSTART/DTEND to ~now.
Servers with a sliding REPORT window (e.g. OX App Suite, ref
search.unlimited-time-range) hide the static year-2006/2007 dates in
ev1/broken_ev1/ev3 from get_events()/get_event_by_uid(), so save-then-search
tests saw zero results.  Apply it in testCreateEvent, testUtf8Event,
testUnicodeEvent, testCreateCalendarAndEventFromVobject, testLoadEvent,
testCopyEvent, testObjectBySyncToken, testSync and (refactored to use the helper)
testLookupEvent.

Also gate the URL-equality assertions on save-load.stable-url: where an object
or calendar is looked up via search/name, OX reports it under a different
canonical URL than the one used to PUT it, so compare by UID instead (testSync
gains a synced_match() UID-fallback for its objects_by_url() lookups).

All nine tests pass on OX and still pass on Baikal.  testCreateOverwriteDeleteEvent
and testChangeAttendeeStatusWithEmailGiven are left for a separate slice: they hit
a distinct OX PUT/overwrite quirk (409 on re-add / Forbidden on ATTENDEE PUT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: `git bisect run  pytest -k 'testLookupEvent and zimbra'` gave me this:          try:             r = self.client.request(str(self.url))             if r.status and r.status == 404: >               raise error.NotFoundError(errmsg(r)) E               caldav.lib.error.NotFoundError: NotFoundError at '404 Not Found E                E               ', reason no reason  caldav/calendarobjectresource.py:974: NotFoundError =========================== short test summary info =========================== FAILED tests/test_caldav.py::TestForServerZimbra::testLookupEvent - caldav.lib.error.NotFoundError: NotFoundError at '404 Not Found ================ 1 failed, 2372 deselected in 63.06s (0:01:03) ================ 83e0dd0 is the first bad commit commit 83e0dd0 Author: Tobias Brox <tobias@redpill-linpro.com> Date:   Sun Jun 7 00:58:48 2026 +0200      fix: correct create-calendar.set-displayname expectation for zimbra and ox          Zimbra and OX were pinned to create-calendar.set-displayname: unsupported.     That value matched the checker bug fixed in caldav-server-tester d2fc2b0:     the probe verified the feature by looking the calendar up by display name,     which a leftover/colliding calendar would shadow, yielding a false negative.          The probe now reads the display name back by cal_id via PROPFIND.  For both     servers it returns the requested name ("Yep"), distinct from the cal_id     ("caldav-server-checker-mkdel-test") - so setting a display name AT creation     time works and the expectation is corrected to full.          For Zimbra this also retires the old_flags note claiming the display name can     only equal the cal_id; that no longer holds for zcs-foss:latest.  For OX the     old "unsupported" conflated set-at-create (works) with rename-after-create via     PROPPATCH (still unsupported, and not what the probe tests).          prompt: $ pytest -k compat ... I thought this was fixed in d2fc2b0?     followup-prompt: yes please fix          Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>   CHANGELOG.md                  |  1 +  caldav/compatibility_hints.py | 15 ++++++++++++---  2 files changed, 13 insertions(+), 3 deletions(-) bisect found first bad commit  please investigate
claude-sonnet-4-6: continue
claude-sonnet-4-6: continue
Apply the same sliding-window fix as the sync suite to the async mirrors: wrap
the old-date ev1_static/ev3_static in near_now_ics() (imported from test_caldav)
so the events stay visible to OX's REPORT-based lookups, and gate the
URL-equality assertions on save-load.stable-url (test_sync gains the same
synced_match() UID-fallback for objects_by_url()).

Fixes test_lookup_event, test_load_event, test_copy_event,
test_object_by_sync_token, test_sync, test_utf8_event and
test_create_calendar_and_event_from_vobject on OX; still pass on Baikal.
test_create_overwrite_delete_event and test_change_attendee_status_with_email_given
remain for the separate OX PUT/overwrite-quirk slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit 83e0dd0 flipped Zimbra's create-calendar.set-displayname from
unsupported to full.  That flag is load-bearing: Calendar._create() only
sends the DisplayName in the MKCALENDAR body when it is set, and the test
fixture _fixCalendar() then creates the test calendar with a display name.

Zimbra couples the display name to the calendar URL - MKCALENDAR uses the
DisplayName as the URL segment and ignores the requested cal_id path
(falling back to the cal_id path only when the display-name-derived URL is
already taken).  So the created calendar relocates to e.g. .../Yep/ while
the library keeps self.url pointed at the cal_id path, and every later
URL-based operation (event_by_url, get_event_by_uid, ...) 404s.  Found by
git bisect on TestForServerZimbra::testLookupEvent.

The checker briefly observed the feature as full only because a leftover
'Yep' calendar from prior runs forced the cal_id fallback; the outcome is
state-dependent, i.e. fragile.  Set it to fragile: is_supported() then
returns False (so _create and _fixCalendar omit the display name and the
calendar stays addressable at the cal_id path) and testCheckCompatibility
tolerates whatever the state-dependent probe observes - which was the
reason 83e0dd0 was made in the first place.  OX stays full (it stores the
display name as a property separate from the URL; no coupling).

Verified against zcs-foss:latest: testLookupEvent, testCheckCompatibility,
testCreateOverwriteDeleteEvent and testCreateCalendar all pass for Zimbra.

prompt: git bisect run pytest -k 'testLookupEvent and zimbra' ... please investigate
followup-prompt: commit your work

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: Investigate the Ox PUT errors first.  I have another claude agent looking into zimbra/sogo.
claude-sonnet-4-6: commit your work
…stat; gate OX

Two OX App Suite PUT quirks were exposed once the save-then-search event tests
started running on OX.  Both are real server behaviours, not library bugs:

  * OX enforces optimistic concurrency: a no-If-Match overwrite PUT is rejected
    with 409 Conflict (it tolerates the first overwrite, then refuses the next).
    An etag-conditional save() still works, so save-load.mutable stays full.
    New feature save-load.put-overwrite (default full) captures this; OX is
    marked unsupported and the add_event()-overwrite block in
    testCreateOverwriteDeleteEvent is gated on it.

  * OX forbids changing an attendee's PARTSTAT via a direct PUT (403 Forbidden
    even with a matching etag); it must go through iTIP scheduling.  New feature
    save-load.mutable.attendee-partstat (default full) captures this; OX is
    marked unsupported and testChangeAttendeeStatusWithEmailGiven skips on it.

Both flags are detected by the caldav-server-tester (CheckPutOverwrite,
CheckAttendeePartstat).  The sync and async variants are kept in sync; the async
test_create_overwrite_delete_event also had an inverted `if not is_supported(
"save-load.mutable")` guard (the overwrite block never ran on mutable servers) -
corrected to match the sync test, plus near-now dates so the no_overwrite/UID
lookups see the event on OX.

All four affected tests now pass or skip on OX and still pass on Baikal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: We need a better check under ~/caldav-server-tester and possibly new feature flag(s) to fully describe the zimbra-behaviour, that would be better than just marking it fragile
…derivation

Rename save-load.put-overwrite -> save-load.mutable.if-match-optional: it belongs
under the mutable umbrella (alongside attendee-partstat), and the positive-polarity
name centred on the HTTP If-Match header keeps the matrix convention "default full =
standard behaviour" (full = If-Match optional for overwrite; OX unsupported = it is
required, 409 on a no-If-Match PUT).

Nesting it exposed a second, divergent derivation path: collapse() (used by
dotted_feature_set_list / testCheckCompatibility) re-implemented "derive a parent
from its children" with its own loop, and that loop ignored the rule that a node
with an explicit default is independent.  So when both refinements under
save-load.mutable were unsupported, collapse() wrongly folded the parent down to
unsupported, while is_supported() correctly kept it full -> compatibility mismatch.

collapse() now delegates the derivation to the single path
(is_supported() -> _derive_from_subfeatures()) and only adds a losslessness guard
(fold children in only when every grouping child is explicitly set and matches the
derived value).  Independent nodes and independent children are left alone.  The
obsolete "a single child never affects the parent" special case is dropped (the
explicit-default rule subsumes it).  Adds a TestImplicitDerivation case asserting an
independent parent's default trumps all-unsupported children.

All servers' testCheckCompatibility still pass (OX, Baikal, Davical, Nextcloud, CCS,
Davis); 39 compatibility unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: save-load.put-overwrite should perhaps be moved under the save-load.mutable umbrella?
claude-sonnet-4-6: this is weird.  As long as save-load has an explicit default of full, it should by default be considered supported even if all the children are unsupported.  I just added a unit test for this, and it passes.
…name.stable-url

Supersedes the `create-calendar.set-displayname: fragile` stopgap (12ac6db)
with a precise two-feature model.

Zimbra and OX both apply a display name set at creation, but couple it to the
calendar URL: the calendar is then exposed under a display-name-derived URL
(Zimbra) or an internal `cal://0/NNN` id (OX), not the requested cal_id.  An
alias may linger at the cal_id URL but is unreliable (Zimbra,
cf. save-load.get-by-url) or non-canonical (OX, cf. save-load.stable-url), so
relying on it breaks later URL-based lookups (the testLookupEvent regression
found by git bisect).

New child feature `create-calendar.set-displayname.stable-url` (default full)
describes whether setting the display name leaves the calendar URL unchanged.
`set-displayname` stays `full` for both servers (the name does stick); the new
flag is `unsupported` for both.  `Calendar._create()` now omits the display
name from MKCALENDAR when stable-url is unsupported, keeping the calendar
addressable at the cal_id path (verified: nameless creation stays at cal_id on
both servers).

Test fixture `_fixCalendar` gives the calendar a name only when both
set-displayname and stable-url are supported; testCreateEvent and
testSetCalendarProperties (sync + async) gate their name-based assertions on
stable-url as well.

Verified against zcs-foss:latest, OX App Suite and Baikal: testCheckCompatibility
and testLookupEvent pass for all three.

prompt: We need a better check under ~/caldav-server-tester and possibly new
feature flag(s) to fully describe the zimbra-behaviour, that would be better
than just marking it fragile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eight feature nodes that are directly probed by the server-tester yet also
have refinement sub-features lacked an explicit `default`, so the code
classified them as grouping nodes - meaning is_supported()/collapse() would
derive or fold them away from their children.  Give them their own default
so they are treated as independent capabilities:

  get-current-user-principal, create-calendar.set-displayname,
  delete-calendar, save-load.todo.recurrences, search.text.category,
  search.recurrences.includes-implicit.todo, scheduling, sync-token

principal-search deliberately keeps NO default: it is a genuine OR-grouping
(supported iff at least one sub-search works), which the checker computes
directly because the library's all-children-agree derivation cannot express
OR.

The rewritten collapse() already skips independent nodes, so the three
sync-token collapse unit tests (which used sync-token as a grouping example)
were updated to use a genuine grouping pair (principal-search.by-name/.self),
and a new test locks in that independent parents are never collapsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: the information in the memory note should be taken care of by inline comments and strings in the compatibility matrix and tests
claude-sonnet-4-6: Was the work here done?  Please commit the changes in the caldav library
…trix

With caldav-server-tester now placing its fixtures in the near future (next
year) instead of year 2000, OX and CCS - both of which restrict their search
window and hid the year-2000 fixtures - complete a full compatibility run for
the first time.  This reveals that implicit recurrence expansion actually works
within their search window; it was previously recorded "unsupported" only
because the probe data was invisible.

- ccs: drop the blanket search.recurrences=unsupported; only infinite-scope and
  server-side VTODO expansion remain unsupported.
- ox: replace the blanket includes-implicit/expanded=unsupported with explicit
  per-child entries (datetime-event and exception expansion work; VTODO
  recurrence, datetime server-side expansion and infinite scope do not), and
  record search.time-range.todo.strict=broken (OX ignores the time-range on
  VTODO queries and returns every task).

The far-past/far-future features themselves (old-dates, unlimited-time-range)
already matched observation and are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: We still have some test-failures on Stalwart.  Hypothesis is that the "rolling window"-behaviour of Stalwart (ignore all events that are far in the past or far in the future doing searches) causes this, and that the tests are built with hard-coded DTSTART, DTEND, DUE etc.  Please investigate.  Here is the test output:340028 [Mon Jun 08 12:07:34] tobias@archlinux:~/caldav (fix/issue-681-timerange-vcalendar) $ pytest -k stalwart --last-failed ===================================================================== test session starts ===================================================================== platform linux -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0 rootdir: /home/tobias/caldav configfile: pyproject.toml plugins: socket-0.8.0, hypothesis-6.153.0, respx-0.23.1, timeout-2.4.0, typeguard-4.5.2, anyio-4.13.0, http-snapshot-0.1.9, inline-snapshot-0.32.6, asyncio-1.3.0, mock-3.14.1, pyfakefs-6.2.0, cov-7.1.0, xdist-3.8.0, repeat-0.9.4 asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 2375 items / 2372 deselected / 3 selected                                                                                                            run-last-failure: rerun previous 3 failures  tests/test_async_integration.py F                                                                                                                       [ 33%] tests/test_caldav.py FF                                                                                                                                 [100%]  ========================================================================== FAILURES =========================================================================== _______________________________________________ TestAsyncForStalwart.test_recurring_date_with_exception_search ________________________________________________  self = <tests.test_async_integration.TestAsyncForStalwart object at 0x7fb5f98be620> async_calendar = Calendar(http://localhost:8809/dav/cal/testuser%40example.org/pythoncaldav-async-test/)      @pytest.mark.asyncio     async def test_recurring_date_with_exception_search(self, async_calendar: Any) -> None:         """Bi-weekly event with exception: expanded search returns correct RECURRENCE-IDs."""         self.skip_unless_support("search")         self.skip_unless_support("search.time-range.event.old-dates")         c = async_calendar              await c.add_event(evr2_static)              rc = await c.search(             start=datetime(2024, 3, 31, 0, 0),             end=datetime(2024, 5, 4, 0, 0),             event=True,             expand=True,         )         rs = await c.search(             start=datetime(2024, 3, 31, 0, 0),             end=datetime(2024, 5, 4, 0, 0),             event=True,             server_expand=True,         )              if self.is_supported("save-load.event.recurrences.exception") or self.is_supported(             "search.recurrences.expanded.exception"         ):             assert len(rc) == 2             assert "RRULE" not in rc[0].data             assert "RRULE" not in rc[1].data              if self.is_supported("search.recurrences.expanded.event") and self.is_supported(             "search.recurrences.expanded.exception"         ): >           assert len(rs) == 2 E           assert 3 == 2 E            +  where 3 = len([Event(http://localhost:8809/dav/cal/testuser%40example.org/pythoncaldav-async-test/c26921f4-0653-11ef-b756-58ce2a14e2...http://localhost:8809/dav/cal/testuser%40example.org/pythoncaldav-async-test/c26921f4-0653-11ef-b756-58ce2a14e2e5.ics)])  tests/test_async_integration.py:1507: AssertionError -------------------------------------------------------------------- Captured stdout setup -------------------------------------------------------------------- [OK] Stalwart is already running ------------------------------------------------------------------ Captured stdout teardown ------------------------------------------------------------------- [OK] Stalwart was already running - leaving it running __________________________________________________________ TestForServerStalwart.testTodoDatesearch ___________________________________________________________  self = <tests.test_caldav.TestForServerStalwart object at 0x7fb5f9554730>      @pytest.mark.filterwarnings("ignore:use `calendar.search:DeprecationWarning")     def testTodoDatesearch(self):         """         Let's see how the date search method works for todo events.              Note: This test intentionally uses the deprecated date_search method         to ensure backward compatibility.         """         self.skip_unless_support("save-load.todo")         self.skip_unless_support("search.time-range.todo")         self.skip_unless_support("search.time-range.todo.old-dates")         c = self._fixCalendar(supported_calendar_component_set=["VTODO"])              # add todo-item         t1 = c.add_todo(todo)         t2 = c.add_todo(todo2)         t3 = c.add_todo(todo3)         t4 = c.add_todo(todo4)         t5 = c.add_todo(todo5)         t6 = c.add_todo(todo6)         todos = c.get_todos()         assert len(todos) == 6              with pytest.deprecated_call():             notodos = c.date_search(  # default compfilter is events                 start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), expand=False             )             assert not notodos              # Now, this is interesting.         # t1 has due set but not dtstart set         # t2 and t3 has dtstart and due set         # t4 has neither dtstart nor due set.         # t5 has dtstart and due set prior to the search window         # t6 has dtstart and due set prior to the search window, but is yearly recurring.         # What will a date search yield?         with pytest.deprecated_call():             todos1 = c.date_search(                 start=datetime(1997, 4, 14),                 end=datetime(2015, 5, 14),                 compfilter="VTODO",                 expand=True,             )         todos2 = c.search(             start=datetime(1997, 4, 14),             end=datetime(2015, 5, 14),             todo=True,             expand=True,             split_expanded=False,             include_completed=True,         )         todos3 = c.search(             start=datetime(1997, 4, 14),             end=datetime(2015, 5, 14),             todo=True,             expand=True,             split_expanded=False,             include_completed=True,         )         todos4 = c.search(             start=datetime(1997, 4, 14),             end=datetime(2015, 5, 14),             todo=True,             expand=True,             split_expanded=False,             include_completed=True,         )         # The RFCs are pretty clear on this.  rfc5545 states:              # A "VTODO" calendar component without the "DTSTART" and "DUE" (or         # "DURATION") properties specifies a to-do that will be associated         # with each successive calendar date, until it is completed.              # and RFC4791, section 9.9 also says that events without         # dtstart or due should be counted.  The expanded yearly event         # should be returned as one object with multiple BEGIN:VEVENT         # and DTSTART lines.              # Hence a compliant server should chuck out all the todos except t5.         # Not all servers perform according to (my interpretation of) the RFC.         foo = 5         implicit_todo_fragile = (             self.is_supported("search.recurrences.includes-implicit.todo", str) == "fragile"         )         if not self.is_supported("search.recurrences.includes-implicit.todo"):             foo -= 1  ## t6 will not be returned         if self.check_compatibility_flag(             "vtodo_datesearch_nodtstart_task_is_skipped"         ) or self.check_compatibility_flag(             "vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range"         ):             foo -= 2  ## t1 and t4 not returned         elif self.check_compatibility_flag("vtodo_datesearch_notime_task_is_skipped"):             foo -= 1  ## t4 not returned         if implicit_todo_fragile:             assert len(todos1) in (foo, foo + 1)             assert len(todos2) in (foo, foo + 1)         else:             assert len(todos1) == foo             assert len(todos2) == foo              ## verify that "expand" works         if self.is_supported("search.recurrences.includes-implicit.todo"):             ## todo1 and todo2 should be the same (todo1 using legacy method)             ## todo1 and todo2 tries doing server side expand, with fallback             ## to client side expand             assert len([x for x in todos1 if "DTSTART:20020415T1330" in x.data]) == 1             assert len([x for x in todos2 if "DTSTART:20020415T1330" in x.data]) == 1             if self.is_supported("search.recurrences.expanded.todo"):                 assert len([x for x in todos4 if "DTSTART:20020415T1330" in x.data]) == 1             ## todo3 is client side expand, should always work             assert len([x for x in todos3 if "DTSTART:20020415T1330" in x.data]) == 1             ## todo4 is server side expand, may work dependent on server              ## exercise the default for expand (maybe -> False for open-ended search)         with pytest.deprecated_call():             todos1 = c.date_search(start=datetime(2025, 4, 14), compfilter="VTODO")         todos2 = c.search(start=datetime(2025, 4, 14), todo=True, include_completed=True)         todos3 = c.search(start=datetime(2025, 4, 14), todo=True)              if self.is_supported("search.time-range.open.end"): >           assert isinstance(todos1[0], Todo)                               ^^^^^^^^^ E           IndexError: list index out of range  tests/test_caldav.py:3439: IndexError ---------------------------------------------------------------------- Captured log call ---------------------------------------------------------------------- WARNING  caldav:vcal.py:133 Ical data was modified to avoid compatibility issues (Your calendar server breaks the icalendar standard) This is probably harmless, particularly if not editing events or tasks (error count: 1 - this error is ratelimited) ---  +++  @@ -1,4 +1,3 @@ -  BEGIN:VCALENDAR  VERSION:2.0  PRODID:-//Example Corp.//CalDAV Client//EN WARNING  caldav:vcal.py:133 Ical data was modified to avoid compatibility issues (Your calendar server breaks the icalendar standard) This is probably harmless, particularly if not editing events or tasks (error count: 2 - this error is ratelimited) ---  +++  @@ -1,4 +1,3 @@ -  BEGIN:VCALENDAR  VERSION:2.0  PRODID:-//Example Corp.//CalDAV Client//EN WARNING  caldav:vcal.py:133 Ical data was modified to avoid compatibility issues (Your calendar server breaks the icalendar standard) This is probably harmless, particularly if not editing events or tasks (error count: 4 - this error is ratelimited) ---  +++  @@ -1,4 +1,3 @@ -  BEGIN:VCALENDAR  VERSION:2.0  PRODID:-//Example Corp.//CalDAV Client//EN _________________________________________________ TestForServerStalwart.testRecurringDateWithExceptionSearch __________________________________________________  self = <tests.test_caldav.TestForServerStalwart object at 0x7fb5f9554ff0>      def testRecurringDateWithExceptionSearch(self):         self.skip_unless_support("search")         self.skip_unless_support("search.time-range.event.old-dates")         c = self._fixCalendar()              # evr2 is a bi-weekly event starting 2024-04-11         ## It has an exception, edited summary for recurrence id 20240425T123000Z         e = c.add_event(evr2)              rc = c.search(             start=datetime(2024, 3, 31, 0, 0),             end=datetime(2024, 5, 4, 0, 0, 0),             event=True,             expand=True,         )         ## client expand removed, since that's default from 2.0         rs = c.search(             start=datetime(2024, 3, 31, 0, 0),             end=datetime(2024, 5, 4, 0, 0, 0),             event=True,             server_expand=True,         )              ## Only assert exact count and RRULE-free output when exception handling         ## is reliable (either client-side or server-side expansion works correctly).         if self.is_supported("save-load.event.recurrences.exception") or self.is_supported(             "search.recurrences.expanded.exception"         ):             assert len(rc) == 2             assert "RRULE" not in rc[0].data             assert "RRULE" not in rc[1].data              if self.is_supported("search.recurrences.expanded.event") and self.is_supported(             "search.recurrences.expanded.exception"         ): >           assert len(rs) == 2 E           assert 3 == 2 E            +  where 3 = len([Event(http://localhost:8809/dav/cal/testuser%40example.org/pythoncaldav-test/c26921f4-0653-11ef-b756-58ce2a14e2e5.ics...Event(http://localhost:8809/dav/cal/testuser%40example.org/pythoncaldav-test/c26921f4-0653-11ef-b756-58ce2a14e2e5.ics)])  tests/test_caldav.py:4235: AssertionError =================================================================== short test summary info =================================================================== FAILED tests/test_async_integration.py::TestAsyncForStalwart::test_recurring_date_with_exception_search - assert 3 == 2 FAILED tests/test_caldav.py::TestForServerStalwart::testTodoDatesearch - IndexError: list index out of range FAILED tests/test_caldav.py::TestForServerStalwart::testRecurringDateWithExceptionSearch - assert 3 == 2 ============================================================= 3 failed, 2372 deselected in 2.51s ==============================================================  340029 [Mon Jun 08 12:22:15] tobias@archlinux:~/caldav (fix/issue-681-timerange-vcalendar) $
claude-sonnet-4-6: The Stalwart server is ignoring events in the far future and far past, I think the test server has a testing calendar with events in year 2000 that cannot even be found through `calendar.get_events()`.  Please check if they are available through `calendar.get_children()`.
claude-sonnet-4-6: Run same tests tpwards the Ox server
… empty open-ended todo search

Stalwart's search.recurrences.expanded.exception was inheriting the default
"full", but Stalwart's server-side CALDAV:expand only suppresses an
exception-overridden occurrence when SEQUENCE is absent.  With SEQUENCE
present (as real clients emit, e.g. evr2 from Thunderbird) it returns both
the original occurrence and the override, so testRecurringDateWithException
Search saw 3 expanded objects instead of 2.  Corrected to "fragile" with a
behaviour note, backed by the new csc_monthly_recurring_with_exception_seq
probe in caldav-server-tester; is_supported() now returns False so the
strict assert is skipped.

Also guard testTodoDatesearch's open-ended-search type checks: Stalwart
legitimately returns zero todos there (it skips no-dtstart todos and marks
implicit-recurrence todos fragile), so todos1[0] raised IndexError.  The
per-todo presence is already verified by the urls_found logic below.

prompt: We still have some test-failures on Stalwart ... rolling-window
behaviour ... tests are built with hard-coded DTSTART/DTEND/DUE ... investigate.
followup-prompt: if the matrix is wrong, then we have a bug in
~/caldav-server-tester which needs fixing first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that caldav-server-tester probes with near-future fixtures instead of
year 2000, several features these servers rejected/mis-detected only for the
old date range are correctly observed and must be un-marked in the matrix:

- ccs: free-busy and open-ended time-range searches work with near-future dates
  (CCS rejected the year-2000 range with errors).  Drop freebusy-query=ungraceful
  and the grouping search.time-range.open=ungraceful; the leaves default to full.
- sogo: old-date time-range search works (probe found, definite-future object
  excluded).  The earlier event/todo old-dates=False was an artifact of the old
  count==1 check that a next-year open-start DUE-only task inflated.

TestForServer{CCS,SOGo}::testCheckCompatibility pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AI Prompts:
claude-sonnet-4-6: we now have two feature branches, both of them with work done on the compatibility matrix.  Please go through the commits and consider if not all of it can be pushed on the existing pull request (even if it originates in a bug report with a specific search/comp-type bug)
claude-sonnet-4-6: By using the conventional commit pattern, the feat-commit should stand out among all the commits marked with test and docs (note that there is some special rules in CONTRIBUTIONS.md for the compatibility_hints file: The `compatibility_hints.py` has been moved from the test directory to the codebase not so very long ago.  Some special rules here:  * Adjusting the feature set for some calendar server?  Check if there exists some workarounds etc in the code for said feature, if so, then it should be considered a fix or a feature.  Perhaps even a breaking change.  Otherwise, use `test: ...`.  (because it is relevant for the compatibility test, if nothing else). * Adding a new feature hint?  Ensure it's covered by the caldav-server-tester.  Since we have a compatibility test, it will be relevant for the test - so use `test: (...)`.  It should be covered by the caldav-serveer-tester, so refer to some issue or pull request for the caldav-server-tester in the commit message. * Changing some descriptions?  That goes as `docs: ...` even if it's actually changing a variable in the code.
claude-sonnet-4-6: relabel those four commits.  I believe save-load.stable-url is handled by the current working tree edition for ~/caldav-server-tester ?
…que-name servers (SOGo)

Two test-infrastructure bugs surfaced on SOGo, which combines the
wipe-calendar cleanup regime (calendars are reused, never deleted)
with a server that enforces per-principal unique calendar display names:

1. testSetCalendarProperties renamed the fixture calendar to "hooray"
   and never restored it.  Under wipe-calendar the rename persisted, so
   a second run read "hooray" instead of "Yep" (non-idempotent), and the
   lingering "hooray" calendar blocked the async set-properties test from
   renaming its own calendar to "hooray" (SOGo 409 "Existing name").
   Restore the canonical "Yep" name in a finally block.

2. The component-restricted fixtures (VTODO-only / VJOURNAL-only) were
   given the same "Yep" display name as the primary fixture, even though
   they are only ever looked up by cal_id.  That made
   principal.calendar(name="Yep") ambiguous in testCreateEvent (it
   returned the persistent -tasks calendar) and, on SOGo, would block the
   primary calendar from being (re)named "Yep".  Keep restricted fixtures
   nameless.

Verified idempotent across two full sync+async SOGo sessions, and the
fixture-touching tests still pass (twice) on Baikal and Nextcloud.

prompt: pytest --last-failure gives me this now: [3 failures - SOGo
testCreateEvent URL mismatch, async SOGo test_set_calendar_properties
409 Conflict, OX testCheckCompatibility search.comp-type]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: pytest --last-failure gives me this now: tests/test_caldav.py:2017: AssertionError =================================================================================================================================================== short test summary info =================================================================================================================================================== FAILED tests/test_async_integration.py::TestAsyncForSOGo::test_set_calendar_properties - caldav.lib.error.PropsetError: PropsetError at '409 Conflict FAILED tests/test_caldav.py::TestForServerOx::testCheckCompatibility - AssertionError: expectation is full, observation is broken for search.comp-type FAILED tests/test_caldav.py::TestForServerSOGo::testCreateEvent - assert URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav-test-tasks/) == URL(http://localhost:8803/SOGo/dav/testuser/Calendar/pythoncaldav-test/) ======================================================================================================================================== 3 failed, 3 passed, 2369 deselected in 5.00s =========================================================================================================================================
…and DTSTART+DURATION search

Reconciles the OX (Open-Xchange) feature matrix with directly-probed
behaviour (2026-06-09); each was confirmed by a standalone probe and via
the server-tester:

- search.comp-type: was the default "full"; OX silently ignores the
  CALDAV comp-filter and returns the whole calendar regardless of the
  requested component type.  No right-typed objects are dropped, so the
  library recovers via post-filtering -> "unsupported" (silently ignored),
  not "broken".  (Contrast bedework, which drops the todos = data loss =
  broken.)  The server-tester comp-type check was refined to draw this
  distinction; see the companion caldav-server-tester commit.
- search.is-not-defined{,.category,.class}: was the default "full"; OX
  silently ignores the is-not-defined prop-filter too (a no_category /
  no_class search still returns the matching objects) -> unsupported.
- search.time-range.open.start.duration: was "unsupported" (with a comment
  doubting the checker).  OX *does* find DTSTART+DURATION components by an
  overlapping time-range search; the previous "asymmetric/broken" reading
  came from OX not honouring the VTODO time-range strictly (out-of-range
  tasks leak in - already tracked as search.time-range.todo.strict=broken),
  which made the VTODO duration probe inconclusive, not failing.  The
  checker now treats that as inconclusive and judges from the conclusive
  VEVENT result -> full.

The transient "ungraceful" observations from the failing run
(search.text.*, save-load.icalendar.related-to) were OX rate-limiting
under load; the steady-state values (text unsupported, related-to broken)
already matched and are unchanged.

prompt: pytest --last-failure [OX testCheckCompatibility search.comp-type
full vs broken, plus follow-up "is the comp-filter broken or just
unsupported?"]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tobixen and others added 27 commits June 10, 2026 15:10
…me-range.todo.no-dtstart

Replaces the opt-in 'vtodo_datesearch_nodtstart_task_is_skipped' flag with a
server-tester-probed feature, search.time-range.todo.no-dtstart (default "full":
a DTSTART-less, DUE-only VTODO is returned by a date-range search, per RFC4791
section 9.9).  testTodoDatesearch and its async mirror now gate on
is_supported(...); the separate '..._in_closed_date_range' and
'vtodo_datesearch_notime_task_is_skipped' flags are left untouched (not in
old_flags).

Per-server results from probing:
 * davical: unsupported (skips DTSTART-less tasks; probe-confirmed)
 * synology: unsupported (external, carried over from the flag - not re-probed)
 * stalwart: fragile - behaviour is date dependent: a near-future DUE-only task
   is returned (probe sees "full"), but the old-date fixtures testTodoDatesearch
   uses are skipped.  "fragile" makes the checker skip it while is_supported()
   stays False so the integration test still expects the old-date task skipped.
 * bedework: derives "unsupported" from its parent search.time-range.todo (False),
   and testTodoDatesearch skips there anyway.

Also drops the now-dead 'vtodo_datesearch_nodtstart_task_is_skipped' entry from
incompatibility_description.

Requires caldav-server-tester CheckTodoNoDtstartSearch (committed separately).

Prompt: I'd like to get rid of the "old_flags" in compatibility_hints.py.
  Everything that is not used in tests can be just removed.  The rest needs
  proper tests in ~/caldav-server-tester and some rewriting in the tests.
Followup-prompt: continue with vtodo_datesearch_nodtstart_task_is_skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue with vtodo_datesearch_nodtstart_task_is_skipped
A config section carrying features (e.g. 'features: ecloud') but no
caldav_url was rejected by _extract_conn_params_from_section(), so
get_davclient(config_section=...) returned None.  Explicitly passed
parameters were already accepted with url OR features, since the
client constructor resolves the URL from the auto-connect.url
compatibility hints.  Make the config file path behave consistently.

prompt: I have this configuration in ~/.config/calendar.conf: [ecloud
section with caldav_pass, caldav_user, features but no URL].  The URL
should not be needed - the caldav library should find it based on the
ecloud configuration in compatibility_hints.py.  This seems to work
when running tests in the caldav library.  However, without the URL I
get this error: "No server specified" [from caldav-server-tester].

followup-prompt: right.  It's needed to fix the bug in the caldav
library obviously [...]

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: Running caldav-server-tester --name nextcloud --diff I get this:
claude-sonnet-4-6: Running caldav-server-tester --name nextcloud --diff I get this: [Pasted text #1 +9 lines] Possibly the compatibility matrix for Nextcloud needs to be updated?  Are there more matrixes that haven't been updated recently?  Ecloud is a nextcloud server, but I get a bit different results there: [Pasted text #2 +15 lines] The various problems with search.recurrences surprises me.  They show up as supported in main.
claude-sonnet-4-6: Oh noes - I've closed the windows.  You'll have to run the tests again, probably.  `caldav-server-tester  --config-section ecloud --diff`
claude-sonnet-4-6: Perhaps the content on the test calendar in ecloud is old and needs to be cleared.  Please investigate.  search.recurrences.expanded.todo and search.recurrences.includes-implicit.infinite-scope are expected to be unsupported, all other search.recurrences.* is supposed to be supported.  It's possible to create and delete calendars in ecloud, but deleted calendars aren't really deleted, they are moved to a thrashbin.

AI Prompts:
claude-sonnet-4-6: some work was now done on a new branch fix/config-features-without-url - please cherry-pick the latest commits into this branch
plann (and potentially other downstream tools) needs to map config
sections with caldav_-prefixed keys to DAVClient parameters.  The
logic already exists here - the config handling code is in the
process of being migrated from plann to the caldav library - so
expose it rather than having plann carry a duplicate code path.

prompt: plann also needs some refactoring, it should trust the caldav
library to do things right instead of carrying a separate code path
for this logic.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: Please do a full code review and save the results in the docs/design folder
Multi-agent AI review of the entire caldav/ package (~18.8k lines, not
differential): seven finder agents (correctness per layer, JMAP, cleanup,
security/altitude) followed by independent verification of every candidate.
All 31 externally verified candidates were confirmed, none refuted.

Highlights: 13 crash bugs, 21 silent-wrong-result bugs (incl. data
corruption in vcal.fix and credential leak in URL.canonical), 3 security
findings, 8 JMAP converter/protocol issues, plus duplication/altitude
cleanup items. Sync/async drift is the dominant bug source.

prompt: Please do a full code review and save the results in the docs/design folder
followup-prompt: but I want a full code review, not only a differential

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue
claude-sonnet-4-6: continue
The regex `COMPLETED(?:;VALUE=DATE)?:(\d+)\s` consumed the trailing
newline, so `COMPLETED:20240101\nSUMMARY:hello` became
`COMPLETED:20240101T120000ZSUMMARY:hello` — merging (and destroying)
the next property on every object returned by a server that stores
COMPLETED as a plain date.

Fixed by using a lookahead `(?=\s)` instead of consuming `\s`.

prompt: "please work on the code review comments in docs/design/FULL_CODE_REVIEW_2026-06.md" (§2.1, first item in the 'fix before next release' priority list)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: please work on the code review comments in design/docs/FULL_CODE_REVIEW_2026_06.md
Iterating a CaseInsensitiveDict (niquests) or httpx.Headers yields key
strings, not (name, value) tuples.  So `x[0]` was the first character of
each header name, never "location" — the list comprehension was always
empty and any 302 from a server raised IndexError.

Fixed both occurrences in _post_put by using r.headers.get("location").

Also update CHANGELOG and mark §1.1 and §2.1 as fixed in code-review doc.

prompt: "continue with §1.1" (from code review docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue with §1.1
Two related bugs in canonical():

(a) arr was built from self.url_parsed whose netloc still contains
    user:pass@, so the returned URL retained credentials.  This caused
    __eq__/__hash__ comparisons between an authenticated client URL and a
    credential-free server href to return False, breaking URL matching.

(b) unauth() returns self when there are no credentials; canonical() then
    overwrote url_raw/url_parsed in place.  A bare == or hash() call
    silently mutated the URL object, potentially re-encoding '+' to '%2B'
    and directing subsequent requests to the wrong resource.

Fix: use url.url_parsed (the auth-stripped form) for arr, and always
return URL(urlunparse(arr)) — a fresh object — instead of mutating url.

prompt: "continue with §2.5" (from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue with §2.5
…ters

The workaround for servers where time-range and property filters cannot be
combined (search.combined-is-logical-and: unsupported, e.g. Nextcloud)
strips all property filters from the server query and is supposed to apply
them client-side.  It passed the ambient post_filter (None) to filter()
instead of True.  _filter_search_results short-circuits when post_filter is
falsy, so a search with a time range + SUMMARY/LOCATION/etc filter returned
every object in the time range unfiltered.

The sibling workarounds in the same function (text-search unsupported,
substring unsupported) already forced post_filter=True; this branch now
does the same.

prompt: "continue with 2.6" (from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue with 2.6
§2.9 — calendarobjectresource._set_data() raw-string branch:
  Cleared _data/_vobject_instance/_icalendar_instance but never reset
  self._state.  Once _state was populated (e.g. by event.id or
  is_loaded()), _ensure_state() returned the stale state on all
  subsequent reads.  After load() fetched new data, get_data(),
  get_icalendar_instance(), and .id all served the pre-reload content.
  Fix: add self._state = RawDataState(self._data) in that branch.

§2.10 — datastate.RawDataState.get_component_type():
  Tested for 'BEGIN:FREEBUSY' but real iCalendar data uses
  'BEGIN:VFREEBUSY', so FreeBusy objects always got component_type=None:
  is_loaded()/has_component() returned False, save() silently no-oped,
  and load(only_if_unloaded=True) reloaded spuriously every call.
  Same typo in the base-class fallback parsers (comp.name 'FREEBUSY'
  vs the library's actual 'VFREEBUSY').
  Fix: s/FREEBUSY/VFREEBUSY/ in all three places in datastate.py.

prompt: "2.9/2.10" (from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: 2.9/2.10
_well_known_lookup never received require_tls.  A same-domain redirect
to http:// passed the _is_subdomain_or_same domain check and was
returned as ServiceInfo(tls=False).  discover_service returned it
unchecked, so a misconfigured or MITM server could silently downgrade
the connection to plaintext despite the documented guarantee that
require_tls=True (the default) "ONLY accepts TLS connections".

Fix: after _well_known_lookup returns, check well_known_info.tls
against require_tls and emit a warning + return None on mismatch.

prompt: "3.1" (from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: 3.1
claude-sonnet-4-6: continue
etree.XMLParser was called without resolve_entities=False or no_network=True,
leaving entity expansion and DTD network fetches enabled by default.  A
malicious or MITM server could inject arbitrary text into parsed property
values via inline DOCTYPE entity definitions.

Add resolve_entities=False and no_network=True.  dtd_validation=False is
lxml's default and not needed explicitly.

prompt: "§3.2" (from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… §2.16, §2.17

§1.6 add_attendee UnboundLocalError on uppercase MAILTO: scheme
  RFC 3986 §3.1 says URI schemes are case-insensitive; the startswith("mailto:")
  check was case-sensitive, so "MAILTO:user@example.com" fell through all string
  branches leaving attendee_obj unassigned.

§1.7 change_attendee_status bare KeyError + literal %s in error message
  ical_obj["attendee"] raises KeyError when no ATTENDEE property exists; the
  NotFoundError-catching dispatch loop could not catch it.  Also fixed the
  not-found message which contained an unsubstituted %s placeholder.

§1.8 lib/auth.py IndexError on WWW-Authenticate with trailing comma
  A header ending in "," (seen in the wild) made h.split()[0] raise IndexError
  on the empty segment.  Added if h.strip() guard.

§1.9 config.py expand_config_section KeyError on absent section name
  config[section] raised KeyError when section was not in the config dict,
  crashing caldav.get_calendars() with KeyError: 'default' on configs that
  have no default section.

§2.13 base_client.py: calendar with empty displayname dropped from results
  The truthiness check on get_display_name()'s return value treated "" as
  falsy.  Async already used is not None; sync is now consistent.

§2.16 async_davclient.py: HTML-on-401 hint checked self.headers instead of r.headers
  The diagnostic "HTML was returned, consider setting auth_type" hint read
  self.headers (client request headers) instead of r.headers (server response).

§2.17 config.py: disable:true ignored for sections fetched by explicit name
  expand_config_section used the literal string "section" as config key
  instead of the section variable, so disable was only effective under "*".

prompt: "yes" (continue with fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: yes
§1.10 compatibility_hints.py copyFeatureSet AssertionError on string override
  The 'support' not in server_node guard prevented a string-valued feature from
  being overridden if the key already existed, falling through to
  else: raise AssertionError.  Removed the guard — string values always overwrite.

§1.11 compatibility_hints.py copyFeatureSet stores unknown feature after warning
  A typo'd feature name emitted UserWarning but was still stored; a later
  collapse()/is_supported() hit a message-less AssertionError far from the config.
  Added continue after the warning so unknown keys are never stored.

§1.12 lib/vcal.py bare assert on truncated server-supplied data
  Truncated iCalendar without an END: line triggered a bare assert, giving no
  useful message and silently passing under python -O.  Now logs a warning and
  returns the data unchanged instead of crashing.

prompt: "yes" (continue fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… dict (§1.13)

create_event() has guarded against this since the original implementation:
if 'new-0' not in created: raise JMAPMethodError(...).  create_task() was
missing the same check in both sync and async clients, so a JMAP server
returning an empty created dict (possible when the server silently ignores
the create) raised a bare KeyError instead of the documented JMAPMethodError.

prompt: "yes" (continue fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
§2.11 _get_duration: isinstance check on vDDDTypes wrapper, not .dt
  isinstance(i["DTSTART"], datetime) tested the wrapper object (never a
  datetime), so a timed DTSTART with no DUE/DURATION returned 1 day instead
  of 0, shifting the next-occurrence due date by one day after completing a
  recurring task.

§2.12 _complete_recurring_safe: completion_timestamp not forwarded to complete()
  The sync path called completed.complete() with no timestamp (defaults to now).
  The async twin already passed the timestamp through via _complete_ical().

§2.18 get_connection_params: explicit kwargs dropped when url/features absent
  Explicit params (e.g. password='secret') were only respected when url or
  features was also given.  When an env or config-file source won, explicit
  params were silently discarded.  Now merged on top of the winning source.

§2.19 resolve_features and testing.py: module-level hint dicts mutated
  resolve_features(str) returned the module-level dict directly (no copy);
  XandikosServer/RadicaleServer used shallow .copy() then mutated a nested key.
  Both contaminated the module-level dict for the process lifetime.
  Fixed: use copy.deepcopy() in all three sites.

prompt: "yes" (continue fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…def alias, §2.14 GMX fallback

§1.2 DAVClient: URL with user but no password crashed + wrong credential precedence
  unquote(self.url.password) raised TypeError when URL has user@ but no :password.
  Also: URL credentials silently overrode explicit kwargs (async was the opposite).
  Fix: guard the password unquote; only use URL creds when kwargs are absent.

§1.3 rate-limit retry: None + float TypeError on second 429 with no Retry-After
  sleep_seconds += rate_limit_time_slept / 2 ran before the sleep_seconds is None
  check, so None += 2.5 raised TypeError instead of RateLimitError.
  Same bug copy-pasted in both sync and async clients.

§2.7 search.py undef operator misses category→CATEGORIES alias
  PropFilter("CATEGORY") queries a nonexistent property, so is-not-defined
  matched every object.  Applied the same alias as the non-undef branch.

§2.14 async get_calendars lacks GMX principal-URL fallback
  Sync client falls back to principal URL when calendar-home-set is absent;
  async returned [] immediately.  Parity restored.

prompt: "yes" (continue fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…dars (§1.4)

The name-based lookup called await principal.calendar(name=cal_name), but
Principal.calendar(name=...) is not async-aware: it calls self.get_calendars()
synchronously, which for an async client returns a coroutine.  Iterating a
coroutine object (not awaiting it) silently produced no matches, so every
calendar_name lookup returned nothing.

Fix: in aio.get_calendars(), fetch all calendars with await principal.get_calendars()
and filter by display-name directly, bypassing the non-async principal.calendar()
path.

prompt: "yes" (continue fix-soon items from docs/design/FULL_CODE_REVIEW_2026-06.md)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
§1.5 collection.py freebusy_request: async path called add_attendee() before
dispatching to _async_freebusy_request(), so Principal.get_vcal_address()
returned a coroutine instead of a vCalAddress — add_attendee then crashed
on attendee_obj.params[...].  Moved attendee loop into _async_freebusy_request
and added `await attendee.get_vcal_address()` for Principal objects.

§2.8 search.py: operator='==' exact-match guarantee was never enforced —
post_filter was only set True for 'in' operators; the server's substring
semantics leaked through.  icalendar_searcher.check_component() already
handles '==' as exact-match, so the fix is simply adding '==' to the
post_filter trigger condition.

§2.15 async_davclient.py: issue-#158 connection-abort workaround sent a probe
GET to detect the auth challenge.  If the probe returned anything other than
401+WWW-Authenticate the code fell through to `response = DAVResponse(probe_r,
self)`, silently returning the probe's response instead of the original
request's result or error.  Now the original exception is re-raised when the
probe does not yield a proper auth challenge.

prompt: (continuation from summarised session)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
§4.1 jscal_to_ical: override child VEVENT with no "start" patch key got
DTSTART from the master start_str instead of the occurrence's own time
(the override key).  Common title-only changes relocated every override to
the master's first occurrence, breaking override display entirely.  Default
is now the override_key itself.

§4.2 jscal_to_ical: EXDATE and RECURRENCE-ID values were always emitted as
naive floating DATE-TIMEs.  Per RFC 5545 the value type must match DTSTART.
A floating EXDATE on a TZID-anchored event does not match any instance, so
excluded occurrences reappeared.  Override keys are now parsed with the event
timezone applied (TZID events) or converted to date objects (all-day events).

§4.3 _utils.py _format_local_dt(): UTC datetimes produced a Z-suffixed string.
RFC 8984 §1.4 defines LocalDateTime (required for recurrenceOverrides keys and
recurrenceRules.until) as YYYY-MM-DDThh:mm:ss without any suffix.  Z-suffixed
override keys cannot match LocalDateTime occurrence keys on strict servers.
Function now always returns a timezone-stripped representation.

§4.4 ical_to_jscal and jscal_to_ical: STATUS was silently dropped in both
conversion directions.  STATUS:CANCELLED round-tripped as status:confirmed
(JSCalendar default), making cancelled meetings appear active.  Added mappings
CONFIRMED↔confirmed, TENTATIVE↔tentative, CANCELLED↔cancelled in both
directions.

prompt: (continuation from summarised session — §4.1–§4.4 JMAP fixes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RFC 8620 §3.3: absent keys in a PatchObject preserve the server value; only
explicit null entries delete a property. update_event sent the full converted
JSCalendar object as the patch, so properties the caller removed (LOCATION,
VALARM, DESCRIPTION, etc.) were simply absent and silently persisted on the
server after the update.

Fix: after converting ical_str to a JSCalendar dict, set all optional
top-level properties to null when they are absent from the result. The list
is maintained in caldav/jmap/convert/_patch.py and applied identically in
both the sync (client.py) and async (async_client.py) update_event methods.

prompt: (continuation from summarised session — §4.5 JMAP update_event null patch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/VTODO/VJOURNAL (§2.2)

When both alarm_* props and ical_fragment were supplied to create_ical(), the
fragment was inserted before the first "^END:V" line, which is END:VALARM when
an alarm is present.  An RRULE fragment, for example, ended up inside the alarm
component and was ignored as a recurrence rule.  Changed the regex to target
"^END:V(EVENT|TODO|JOURNAL)" specifically.

prompt: See docs/design/FULL_CODE_REVIEW_2026_06.md - can point 2.2 be fixed?

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: So tne other guy (andré) has this config: [Interface] PrivateKey = the one associated with pubkey 8FNbxr+h0D5152uT1qQ7DSluRMAxxL/sNtmnvGe5tCY= Address = 2a02:c0:1002:5802::4/128  [Peer] PublicKey = GfceHyt2pQRCuynfINet4ymyFpdjoAc/QKlMRU3KGBk= AllowedIPs = 2a02:c0:1000:5702:f816:3eff:fee9:3285/128 Endpoint = 185.47.41.100:51820 PersistentKeepalive = 25  I can see this in the wireguard server config: $ sudo cat /etc/systemd/network/wg0.netdev # THIS FILE IS MANAGED BY PUPPET # based on https://dn42.dev/howto/wireguard [NetDev] Name=wg0 Kind=wireguard  [WireGuard] PrivateKeyFile=/etc/wireguard/wg0 ListenPort=51820  [WireGuardPeer] Description=sundeep PublicKey=wGmcDVh/0/JblSwa3gfHdZql0vxP3EhIJGTlf7g9oj0= PersistentKeepalive=0 AllowedIPs=2a02:c0:1002:5802::2/128  [WireGuardPeer] Description=tobias PublicKey=tet2sX8mXP5W2Gd1Yyhi3K09mQMaYHIZBMCoU6XJ6Vk= PersistentKeepalive=0 AllowedIPs=2a02:c0:1002:5802::3/128  [WireGuardPeer] Description=andre PublicKey=8FNbxr+h0D5152uT1qQ7DSluRMAxxL/sNtmnvGe5tCY= PersistentKeepalive=0 AllowedIPs=2a02:c0:1002:5802::4/128  wireguard works for sundeep and tobias, but andre gets this error: 2026-06-11 17:42:42.868: [TUN] [linpro-wg] Handshake for peer 1 (185.47.41.100:51820) did not complete after 5 seconds, retrying (try 20) What can the problem be?
claude-sonnet-4-6: See docs/design/FULL_CODE_REVIEW_2026_06.md - can point 2.2 be fixed?
…TILINE (§2.3)

re.sub(" *$", "", fixed) without re.MULTILINE only matched at the very end
of the entire document string, never per-line.  iCloud X-APPLE-STRUCTURED-LOCATION
fold lines that have trailing spaces were left intact, which can distort
base64-encoded property values and trigger vobject parse errors.

prompt: can point 2.3 be fixed?

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: can point 2.3 be fixed?
…es (§2.4)

re.sub(r"\\+('\")", r"\1", fixed) used ('\"') as a group, which matches only
the literal two-character sequence '\" — not a character class.  A backslash
before a lone single quote or lone double quote was therefore not removed.
Changed group to character class ['\"].

prompt: continue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue
…_sync_token

§3.3: emit logging.warning() at import time when PYTHON_CALDAV_COMMDUMP is set,
reminding the operator that credentials and PII accumulate in /tmp files.

§4.6: JMAPCalendar.search() passed datetime args through isoformat(), producing
+HH:MM or bare datetimes instead of the UTCDate format (...Z) JMAP requires.
Added _to_utcdate() helper in calendar.py that converts to UTC and strips microseconds.

§4.7: get_objects_by_sync_token() discarded newState from CalendarEvent/changes
into _, forcing callers to do a separate get_sync_token() call (race window).
Now returns a 4-tuple (added, modified, deleted, new_sync_token). Updated all
callers in unit and integration tests.

prompt: continue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: continue
The PYTHON_CALDAV_COMMDUMP feature carried a security analysis when it was
introduced (v1.4.0).  Old CHANGELOG entries are pruned, so this note would
otherwise be lost.  Added to SECURITY.md alongside the new v3.4 import-time
warning that was added in the §3.3 fix.

prompt: please also find the original SECURITY-notes from an old version of the CHANGELOG and add it to ~/caldav/SECURITY.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: Consider the file SECURITY.md.  Please look through it and see if the text is good or not.  Anything else that is relevant to mention in this file?  Anything clearly irrelevant that should be removed?
claude-sonnet-4-6: Point 3.3 in the review document has not been marked as FIXED?
…rsing notes

- point to GitHub private vulnerability reporting for serious issues
- note SSRF/redirect risk in the RFC6764 discovery section
- new "XML parsing" section: XXE/billion-laughs hardening is on by
  default (resolve_entities=False, no_network=True); huge_tree disables it
- typo/grammar fixes: hijack, $5 wrench, GitHub, both have, has been done,
  CSAM, capitalisation, dedup intro sentence

Confirmed private vulnerability reporting is enabled on python-caldav/caldav.

prompt: Consider the file SECURITY.md. Please look through it and see if the text is good or not. Anything else that is relevant to mention? Anything clearly irrelevant that should be removed?
followup-prompt: I've done some edits to the file. Please correct both old and new typo mistakes and add the missing details.
followup-prompt: commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

AI Prompts:
claude-sonnet-4-6: I've done some edits to the file.  Please correct both old and new typo mistakes and add the missing details.
claude-sonnet-4-6: commit.
@tobixen tobixen changed the title compatibility fix: date-ranged searches without comptype Version 3.3.0 ~~compatibility fix: date-ranged searches without comptype~~ Jun 13, 2026
Comment thread tests/test_discovery.py Dismissed
if self.is_supported("save-load.mutable") and self.is_supported(
"save-load.mutable.if-match-optional"
):
e2 = await c.add_event(ev1_now)
Comment thread caldav/async_davclient.py
if display_name == cal_name:
calendars.append(cal)
break
except Exception:
Comment thread tests/test_caldav.py
## block other calendars from taking the "hooray" name.
try:
c.set_properties([dav.DisplayName("Yep")])
except error.PropsetError:
Comment thread tests/test_vcal.py
"END:VEVENT\n"
"END:VCALENDAR\n"
)
import re
Comment thread tests/test_caldav_unit.py

def test_resolve_features_string_returns_independent_copy(self):
"""resolve_features('xandikos') must return a deep copy, not the module object."""
import caldav.compatibility_hints as hints
Comment thread tests/test_discovery.py

from unittest import mock

import pytest
Comment thread tests/test_discovery.py

import pytest

from caldav.discovery import ServiceInfo, _well_known_lookup, discover_service
Comment thread tests/test_async_integration.py Dismissed
Comment thread tests/test_async_integration.py Dismissed
Comment thread tests/test_caldav.py
if is_time_based:
time.sleep(1)
obj3 = c.add_event(ev3)
obj3 = c.add_event(near_now_ics(ev3))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants