diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml
index 5d4adac1..061798b9 100644
--- a/.github/workflows/linkcheck.yml
+++ b/.github/workflows/linkcheck.yml
@@ -1,18 +1,35 @@
name: Link check
-on: [push, pull_request]
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+ schedule:
+ - cron: "03 22 * * *"
jobs:
linkcheck:
runs-on: ubuntu-latest
+ permissions:
+ issues: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Check links with Lychee
+ id: lychee
uses: lycheeverse/lychee-action@v2
with:
- fail: true
+ fail: false
args: >-
+ --root-dir "$(pwd)"
--timeout 20
--max-retries 3
- '**/*.md'
- '**/*.rst'
+ --cache
+ --max-cache-age 14d
+ .
+ - name: Create Issue From File
+ if: steps.lychee.outputs.exit_code != 0
+ uses: peter-evans/create-issue-from-file@v5
+ with:
+ title: Link Checker Report
+ content-filepath: ./lychee/out.md
+ labels: report, automated issue
diff --git a/.lycheeignore b/.lycheeignore
index 7e7ebfdc..53ea0099 100644
--- a/.lycheeignore
+++ b/.lycheeignore
@@ -4,6 +4,7 @@ https?://.*\.example\.com/.*
# Localhost URLs for test servers (not accessible in CI)
http://localhost:\d+/.*
+http://localhost/.*
# CalDAV endpoints that require authentication (401/403 expected)
https://caldav\.fastmail\.com/.*
@@ -16,5 +17,17 @@ https://webmail\.all-inkl\.com/.*
https://www\.google\.com/calendar/dav/.*
https://caldav-jp\.larksuite\.com/.*
-# Apple namespace URL (returns 404 but is a valid XML namespace reference)
+# Misc old namespace URLs, few of them works
http://apple\.com/ns/ical/
+http://cal.me.com/_namespace/
+http://nextcloud.org/ns
+http://owncloud.org/ns
+
+
+# Other junk that was never meant to be followed
+# lychee converts unknown schemes to file:// URLs before matching ignore patterns
+file://.*/scheme:.*
+/17149682/.*
+http://x/
+https://ecloud\.global/remote\.php/.*
+https://tobixen@e\.email/remote\.php/dav
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 87116529..5f1d96c2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,10 +14,16 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
+ - repo: https://github.com/compilerla/conventional-pre-commit
+ rev: v3.4.0
+ hooks:
+ - id: conventional-pre-commit
+ stages: [commit-msg]
+
- repo: https://github.com/lycheeverse/lychee
rev: lychee-v0.22.0
hooks:
- id: lychee
- args: ["--no-progress", "--timeout", "10"]
- types: [markdown, rst]
- stages: [manual] # Run with: pre-commit run lychee --hook-stage manual
+ args: ["--no-progress", "--timeout", "10", "--exclude-path", ".lycheeignore"]
+ stages: [pre-push]
+ exclude: ^tests/test_caldav_unit\.py$
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f263889..bbcedd56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,20 @@ Changelogs prior to v2.0 is pruned, but was available in the v2.x releases
This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence.
+## [3.0.2] - 2026-03-05
+
+### Fixed
+
+* Communication dump (`PYTHON_CALDAV_COMMDUMP` / `debug_dump_communication`) was accidentally dropped during the v3.0 refactor. Restored, with the dump logic extracted into a shared helper so both the sync and async code paths benefit. Fixes https://github.com/python-caldav/caldav/issues/638
+* `search()` raised `NotImplementedError` when a full calendar-query XML was passed and the server does not support `search.comp-type.optional` (e.g. DavMail). Falls back to a single REPORT with the XML as-is. Fixes https://github.com/python-caldav/caldav/issues/637
+
+### Tests and documentation
+
+* All links to the RFC is now in a cannonical format. Links in docstrings and ReST-documntation follows the sphinx-standard. Fixes https://github.com/python-caldav/caldav/issues/635 - pull request https://github.com/python-caldav/caldav/pull/636
+* I've decided to try to stick to the conventionalcommits standard. This is documented in CONTRIBUTING.md, and I've added a pre-commit hookk for enforcing it (but it needs to be installed through pre-commit ... so I will most likely have to police pull requests manually)
+* Some code refactoring in the test code.
+* Improved the lychee link testing setup
+
## [3.0.1] - 2026-03-04
Highlights:
@@ -43,6 +57,10 @@ Highlights:
* The compatibility-hint key `search.comp-type-optional` has been renamed to `search.comp-type.optional` for consistency with the dotted-key naming convention used elsewhere. If you have this key set in a local server configuration, update it accordingly.
+### Documentation
+
+Some minor improvements, including a fix for https://github.com/python-caldav/caldav/issues/635 - use canonical RFC-links.
+
## [3.0.0] - 2026-03-03
Version 3.0 should be fully backward-compatible with version 2.x - but there are massive code changes in version 3.0, so if you're using the Python CalDAV client library in some sharp production environment, I would recommend to wait for two months before upgrading.
@@ -183,7 +201,7 @@ Additionally, direct `DAVClient()` instantiation should migrate to `get_davclien
### Changed
* Optimilizations on data conversions in the `CalendarObjectResource` properties (https://github.com/python-caldav/caldav/issues/613 )
-* Lazy imports (PEP 562) -- `import caldav` is now significantly faster. Heavy dependencies (lxml, niquests, icalendar) are deferred until first use. https://github.com/python-caldav/caldav/pull/621
+* Lazy imports (PEP 562) -- `import caldav` is now significantly faster. Heavy dependencies (lxml, niquests, icalendar) are deferred until first use. https://github.com/python-caldav/caldav/issues/621
* Search refactored to use generator-based Sans-I/O pattern -- `_search_impl` yields `(SearchAction, data)` tuples consumed by sync or async wrappers
* Configuration system expanded: `get_connection_params()` provides unified config discovery with clear priority (explicit params > test server config > env vars > config file)
* `${VAR}` and `${VAR:-default}` environment variable expansion in config values
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index db40c3e2..f4bbae22 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,37 @@
# Contributing
-Contributions are mostly welcome. If the length of this text scares you, then I'd rather want you to skip reading and just produce a pull-request in GitHub.
+Contributions are mostly welcome (but do inform about it if you've used AI or other tools). If the length of this text scares you, then I'd rather want you to skip reading and just produce a pull-request in GitHub.
+
+## Git commit messages
+
+Starting from v3.0.1, we'll stick to https://www.conventionalcommits.org/en/v1.0.0/ on the master branch. A good pull request contains one commit that follows the conventions. Otherwise the maintainer will have to rewrite the commit message.
+
+The types used should (as for now) be one of:
+
+* "revert" - a clean revert of a previous commit (should be used very infrequently on the master branch)
+* "feat" - a new feature added to the codebase/API. The commit should include test code, documentation and a CHANGELOG entry unless there are good reasons for procrastinating it. "feat" should not be used for new features that only affects the test framework, or changes only affectnig the documentation etc.
+* "fix" - a bugfix. Again, documentation and CHANGELOG-entry should be included in the commit (notable exception: if a bug was introduced after the previous release and fixed before the next release, it does not need to be mentioned in the CHANGELOG). If the only "fix" is that some typo is fixed in some existing documentation, then we should use "docs" instead.
+* "perf" - a code change in the codebase that is neither a bugfix or a feature, but intends to improve performance.
+* "refactor" - a code change in the codebase that is neither a bugfix or a feature, but makes the code more readable, shorter, better or more maintainable.
+* "test" - fixes, additions or improvements that only affects the test code or the test framework. The commit may include documentation.
+* "docs" - changes that *only* is done to the documentation, documentation framework - this includes minor typo fixes as well as new documentation, and it includes both the user documentation under `docs/source`, other documentation files (including CHANGELOG) as well as inline comments and docstrings in the code itself.
+* "other" - if nothing of the above fits
+
+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.
+
+This is not set in stone. If you feel strongly for using something else, use something else in the commit message and update this file in the same commit.
+
+"Imperative presens" or "imperative mood" is to be used in commit messages.
+
+The boundaries of breaking changes vs "non-breaking" changes [may be blurry](https://xkcd.com/1172/). In the CHANGELOG I've used the concept "potentially breaking changes" for things that most likely won't break anything for anyone. Potentially breaking changes should be marked with `!` in the commit header. Breaking changes should be marked both with `!` and `BREAKING CHANGE:`
+
+The conventionalcommits guide also says nothing about how to deal with security-relevant changes. Maybe it makes sense to start the commit message (after the "SECURITY: "
+
+As for now, we do not use the module field. If there is strong reasons for using it, then go ahead and update this file in the same commit.
## Usage of AI and other tools
@@ -31,11 +62,9 @@ Consider this procedures to be a more of a guideline than a rigid procedure. Us
* Write up your changes
-* Run `pytest` for a quick run of the tests. They should still pass.
-
-* Run `tox -e style` to verify a consistent code style (this may modify your code).
+* Run `pytest`. The tests should still pass. (Beware: tests may take very long time to run and may consume a lot of memory and disk space if the tests have the permissions to start docker containers).
-* Consider to write some lines in the documentation and/or examples covering your change
+* Consider to write some lines in the documentation, changelog and/or examples covering your change
* Add an entry in the `CHANGELOG.md` file.
diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py
index 18c82e8c..ae7b1228 100644
--- a/caldav/async_davclient.py
+++ b/caldav/async_davclient.py
@@ -527,6 +527,9 @@ async def _async_request(
if response.status in (401, 403):
self._raise_authorization_error(str(url_obj), response)
+ if error.debug_dump_communication:
+ error._dump_communication(method, url, combined_headers, body, response)
+
return response
# ==================== HTTP Method Wrappers ====================
diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py
index 182e9a19..8791122c 100644
--- a/caldav/compatibility_hints.py
+++ b/caldav/compatibility_hints.py
@@ -78,7 +78,7 @@ class FeatureSet:
}
},
"get-current-user-principal": {
- "description": "Support for RFC5397, current principal extension. Most CalDAV servers have this, but it is an extension to the DAV standard"},
+ "description": "Support for RFC5397, current principal extension. Most CalDAV servers have this, but it is an extension to the DAV standard. Possibly observed missing on mail.ru,DavMail gateway and it is possible to configure the support in some sabre-based servers"},
"get-current-user-principal.has-calendar": {
"type": "server-observation",
"description": "Principal has one or more calendars. Some servers and providers comes with a pre-defined calendar for each user, for other servers a calendar has to be explicitly created (supported means there exists a calendar - it may be because the calendar was already provisioned together with the principal, or it may be because a calendar was created manually, the checks can't see the difference)"},
@@ -195,6 +195,9 @@ class FeatureSet:
"search.is-not-defined.dtend": { ## TODO: this should most likely be removed - it was a client bug fixed in icalendar-search 1.0.5, not a server error. (Discovered in the last minute before releasing caldav v3.0.0 - I won't touch it now)
"description": "Supports searching for objects where the DTEND property is not defined (RFC4791 section 9.7.4). Some servers support is-not-defined for some properties but not DTEND"
},
+ "search.is-not-defined.class": {
+ "description": "Supports searching for objects where the CLASS property is not defined (RFC4791 section 9.7.4). Some servers support is-not-defined for CLASS but not for other properties like CATEGORIES"
+ },
"search.text": {
"description": "Search for text attributes should work"
},
@@ -704,9 +707,6 @@ def dotted_feature_set_list(self, compact=False):
## * Perhaps some more readable format should be considered (yaml?).
## * Consider how to get this into the documentation
incompatibility_description = {
- 'no_current-user-principal':
- """Current user principal not supported by the server (flag is ignored by the tests as for now - pass the principal URL as the testing URL and it will work, albeit with one warning""",
-
'no_scheduling':
"""RFC6833 is not supported""",
@@ -855,16 +855,23 @@ def dotted_feature_set_list(self, compact=False):
]
}
-xandikos_v0_3 = {
+xandikos = {
+ ## We've sometimes been observing internal server errors on freebusy-requests.
+ ## Should do more research on it next time it shows up.
+
+ ## Component type filtering is required - searches must specify event=True or todo=True
+ "search.comp-type.optional": "unsupported",
+
+ ## Principal property search returns 403 (not implemented)
+ "principal-search": "ungraceful",
+
+ ## Server-side recurrence expansion is buggy for tasks and event exceptions
+ "search.recurrences.expanded.todo": "unsupported",
+ "search.recurrences.expanded.exception": "unsupported",
+
## this only applies for very simple installations
"auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"},
- 'search.comp-type.optional': {'support': 'unsupported'},
- ## This suddenly disappeared. Should probably look more into the checks ...
- #"search.recurrences.includes-implicit.todo.pending": {"support": "unsupported"},
- 'search.recurrences.expanded.todo': {'support': 'unsupported'},
- 'search.recurrences.expanded.exception': {'support': 'unsupported'},
- 'principal-search': {'support': 'ungraceful'},
- 'freebusy-query.rfc4791': {'support': 'ungraceful', 'behaviour': '500 internal server error'},
+
"old_flags": [
## https://github.com/jelmer/xandikos/issues/8
'date_todo_search_ignores_duration',
@@ -872,25 +879,9 @@ def dotted_feature_set_list(self, compact=False):
## scheduling is not supported
"no_scheduling",
-
- ## The test with an rrule and an overridden event passes as
- ## long as it's with timestamps. With dates, xandikos gets
- ## into troubles. I've chosen to edit the test to use timestamp
- ## rather than date, just to have the test exercised ... but we
- ## should report this upstream
- #'broken_expand_on_exceptions',
-
]
}
-xandikos_main = xandikos_v0_3.copy()
-## woot ... a regression?
-## xandikos did support freebusy-query.rfc4791 for a while, now it trips on a traceback
-## TODO: do research into this and report
-#xandikos_main.pop('freebusy-query.rfc4791')
-
-xandikos = xandikos_main
-
## This seems to work as of version 3.5.4 of Radicale.
## There is much development going on at Radicale as of summar 2025,
## so I'm expecting this list to shrink a lot soon.
@@ -928,6 +919,7 @@ def dotted_feature_set_list(self, compact=False):
'search.comp-type.optional': {'support': 'ungraceful'},
'search.recurrences.expanded.todo': {'support': 'unsupported'},
'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify
+ "search.recurrences.includes-implicit.infinite-scope": False,
'delete-calendar': {
'support': 'fragile',
'behaviour': 'Deleting a recently created calendar fails'},
@@ -979,6 +971,7 @@ def dotted_feature_set_list(self, compact=False):
'sync-token': {'support': 'fragile'},
'search.is-not-defined': {'support': 'unsupported'},
'search.text': {'support': 'unsupported'},
+ "search.recurrences.includes-implicit.infinite-scope": False,
# sometimes throws a 500
'search.text.category': {'support': 'ungraceful'},
'search.recurrences.expanded.todo': { "support": "unsupported" },
@@ -1011,60 +1004,35 @@ def dotted_feature_set_list(self, compact=False):
}
bedework = {
- 'search.comp-type': {'support': 'broken', 'behaviour': 'Server returns everything when searching for events and nothing when searching for todos'},
- 'search.comp-type.optional': {'support': 'ungraceful'},
- #'search.time-range.event': {'support': 'unsupported'}, ## TODO: flapping??
- #"search.combined-is-logical-and": { "support": "unsupported" },
- ## TODO: play with this and see if it's needed
+ ## If tests are yielding unexpected results, try to increase this:
'search-cache': {'behaviour': 'delay', 'delay': 1.5},
- ## TODO: play with this and see if it's needed
- 'old_flags': [
- 'propfind_allprop_failure',
- 'duplicates_not_allowed',
- ],
- # Ephemeral Docker container: wipe objects (delete-calendar not supported)
+
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
'auto-connect.url': {'basepath': '/ucaldav/'},
'save-load.journal': {'support': 'ungraceful'},
'save-load.todo.recurrences.thisandfuture': {'support': 'ungraceful'},
'save-load.event.recurrences.exception': False,
- ## search.time-range.alarm: not checked by the server tester
'search.time-range.alarm': {'support': 'unsupported'},
- ## Huh? Non-deterministic behaviour of the checking script?
- #"save.duplicate-uid.cross-calendar": {
- # "support": "unsupported",
- # "behaviour": "silently-ignored"
- #},
- "freebusy-query.rfc4791": {
- "support": "full"
- },
- "search.time-range.todo": {
- "support": "unsupported"
- },
+ "freebusy-query.rfc4791": True,
+ "search.time-range.todo": False,
"search.text": False, ## sometimes ungraceful
- "search.is-not-defined": {
- "support": "fragile"
- },
- "search.recurrences.includes-implicit": {
- "support": "unsupported",
- "behaviour": "cannot reliably test due to broken comp-type filtering"
- },
- "sync-token": {
- "support": "fragile"
- },
- ## Check results are non-deterministic!?
- "search.recurrences.expanded.exception": {
- "support": "unsupported"
- },
- "search.recurrences.expanded.event": {
- "support": "unsupported"
- },
- "search.recurrences.expanded.todo": {
- "support": "unsupported"
- },
- "principal-search": {
- "support": "ungraceful",
- },
+ "search.recurrences.includes-implicit": False,
+ "sync-token": { "support": "fragile" },
+ "search.recurrences.expanded.exception": False,
+ "search.recurrences.expanded.event": False,
+ "search.recurrences.expanded.todo": False,
+ 'search.comp-type': {'support': 'broken', 'behaviour': 'Server returns everything when searching for events and nothing when searching for todos'},
+ 'search.comp-type.optional': {'support': 'ungraceful'},
+ 'search.is-not-defined.dtend': False,
+ "principal-search": { "support": "ungraceful" },
+ "search.unlimited-time-range": {"support": "broken"},
+
+ ## TODO: play with this and see if it's needed
+ 'old_flags': [
+ 'propfind_allprop_failure',
+ 'duplicates_not_allowed',
+ ],
+
}
synology = {
@@ -1086,6 +1054,7 @@ def dotted_feature_set_list(self, compact=False):
'search.recurrences.expanded.todo': {'support': 'unsupported'},
'search.recurrences.expanded.exception': {'support': 'unsupported'},
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
+ "search.recurrences.includes-implicit.infinite-scope": False,
'save-load.journal.mixed-calendar': {'support': 'unsupported'},
'principal-search': {'support': 'ungraceful'},
'principal-search.by-name.self': {'support': 'unsupported'},
@@ -1109,6 +1078,7 @@ def dotted_feature_set_list(self, compact=False):
cyrus = {
"search.comp-type.optional": {"support": "ungraceful"},
"search.recurrences.expanded.exception": {"support": "unsupported"},
+ "search.recurrences.includes-implicit.infinite-scope": False,
"search.time-range.alarm": {"support": "ungraceful"},
'principal-search': {'support': 'ungraceful'},
# Cyrus enforces unique UIDs across all calendars for a user
@@ -1322,6 +1292,7 @@ def dotted_feature_set_list(self, compact=False):
"search.recurrences.expanded.todo": {"support": "unsupported"},
"search.recurrences.expanded.exception": {"support": "unsupported"},
"search.recurrences.includes-implicit.todo": {"support": "unsupported"},
+ "search.recurrences.includes-implicit.infinite-scope": False,
"principal-search.by-name.self": {"support": "unsupported"},
"principal-search": {"support": "ungraceful"},
"save-load.journal.mixed-calendar": {"support": "unsupported"},
diff --git a/caldav/davclient.py b/caldav/davclient.py
index 61578083..b60374f9 100644
--- a/caldav/davclient.py
+++ b/caldav/davclient.py
@@ -936,6 +936,10 @@ def _sync_request(
self._raise_authorization_error(str(url_obj), r)
response = DAVResponse(r, self)
+
+ if error.debug_dump_communication:
+ error._dump_communication(method, url, combined_headers, body, response)
+
return response
diff --git a/caldav/lib/error.py b/caldav/lib/error.py
index 769dff6a..d1a685cf 100644
--- a/caldav/lib/error.py
+++ b/caldav/lib/error.py
@@ -34,6 +34,40 @@ def errmsg(r) -> str:
return "%s %s\n\n%s" % (r.status, r.reason, r.raw)
+def _dump_communication(method: str, url: str, combined_headers: dict, body, response) -> None:
+ """Write a request/response exchange to a uniquely-named temp file.
+
+ Called when ``debug_dump_communication`` is truthy. Works for both the
+ sync and async code paths because it only depends on the attributes that
+ ``BaseDAVResponse`` exposes: ``.status``, ``.reason``, ``.headers``,
+ ``.tree``, and ``._raw``.
+ """
+ import datetime
+ from tempfile import NamedTemporaryFile
+
+ from lxml import etree
+
+ from caldav.lib.python_utilities import to_wire
+
+ with NamedTemporaryFile(prefix="caldavcomm", delete=False) as commlog:
+ commlog.write(b"=" * 80 + b"\n")
+ commlog.write(f"{datetime.datetime.now():%FT%H:%M:%S}".encode())
+ commlog.write(b"\n====>\n")
+ commlog.write(f"{method} {url}\n".encode())
+ commlog.write(b"\n".join(to_wire(f"{k}: {v}") for k, v in combined_headers.items()))
+ commlog.write(b"\n\n")
+ commlog.write(to_wire(body) or b"")
+ commlog.write(b"\n<====\n")
+ commlog.write(f"{response.status} {response.reason}\n".encode())
+ commlog.write(b"\n".join(to_wire(f"{k}: {v}") for k, v in response.headers.items()))
+ commlog.write(b"\n\n")
+ if response.tree is not None:
+ commlog.write(to_wire(etree.tostring(response.tree, pretty_print=True)))
+ else:
+ commlog.write(to_wire(response._raw) or b"")
+ commlog.write(b"\n")
+
+
def weirdness(*reasons):
from caldav.lib.debug import xmlstring
diff --git a/caldav/search.py b/caldav/search.py
index 0548cfb6..b1a639e7 100644
--- a/caldav/search.py
+++ b/caldav/search.py
@@ -688,9 +688,12 @@ def _search_with_comptypes(
Internal method - does three searches, one for each comp class (event, journal, todo).
"""
if xml and (isinstance(xml, str) or "calendar-query" in xml.tag):
- raise NotImplementedError(
- "full xml given, and it has to be patched to include comp_type"
- )
+ # Full XML provided – cannot inject a comp-type filter into it.
+ # Fall back to a single REPORT request with the XML as-is; the
+ # server is expected to return whatever comp-types the query
+ # matches, and comp_class detection falls back to auto-detect.
+ _, objects = calendar._request_report_build_resultlist(xml, None, props)
+ return self.sort(objects)
objects = []
assert self.event is None and self.todo is None and self.journal is None
@@ -769,9 +772,10 @@ async def _async_search_with_comptypes(
Internal async method - does three searches, one for each comp class.
"""
if xml and (isinstance(xml, str) or "calendar-query" in xml.tag):
- raise NotImplementedError(
- "full xml given, and it has to be patched to include comp_type"
- )
+ # Full XML provided – cannot inject a comp-type filter into it.
+ # Fall back to a single REPORT request with the XML as-is.
+ _, objects = await calendar._request_report_build_resultlist(xml, None, props)
+ return self.sort(objects)
objects: list[AsyncCalendarObjectResource] = []
assert self.event is None and self.todo is None and self.journal is None
diff --git a/docs/design/TODO.md b/docs/design/TODO.md
index 1458b87d..70c8168f 100644
--- a/docs/design/TODO.md
+++ b/docs/design/TODO.md
@@ -1,152 +1,21 @@
# Known Issues and TODO Items
-## Calendar creation/location in integration tests
-
-* Currently there is quite some logic for the sync integration tests for fixing calendars (`test_caldav.py`). All boilerplate should be moved to the `fixture_helper.py` file - or simply removed if it's irrelevant.
-* In the `fixture_helpers.py` there is an `aget_or_create_test_calendar` and a `get_or_create_test_calendar`. There is quite much duplicated logic here. Work should be done to consolidate common code.
-* If the default calendar already exists, make sure to delete and recreate or wipe it (dependent on relevant features supported/configured) to avoid pollution from old test runs fouling the new test run.
-
## Calendar cleanup in integration tests
-Calendar cleanup in the integration tests is a bit of a mess and should be cleaned up. Also, the sync and async integration code now have different code paths, that's not acceptable, that should be cleaned up.
-
-Currently unsupported scenarios:
-
-* Test run towards read-only calendars (there exists service providers that have a partial caldav interface for read-only access to a calendar) ... maybe a future feature, but as for now it doesn't really work out, so let's ignore this possibility as for now.
-* Test runs towards a calendar that already has data in it, and without deleting the data in the calendar. I had the idea to allow this, but ... no, it doesn't work out, and let's keep it that way. BUT: currently, for servers where calendars cannot be created, the test will take the first available calendar it finds and wipe it. That's also not acceptable, not without the user explicitly confguring that this is OK.
-
-Server compatibility to consider:
-
-* Some servers disallows calendar creation.
-* On some servers calendar creation is not reliable (i.e. quota for how many calendars one can have. Workaround: manual creation of test calendar and explicitly specifying what calendar to use in the configuration).
-* On some servers calendar deletion is not possible or not reliable. (i.e. calendar "moved" to thrash bin rather than deleted).
-* On some servers, objects on the calendar are "sticky", not fully deleted (and is polluting the UID name space) even if the calendar is deleted. (perhaps due to the thrash-bin-issue above)
-* Sometimes separate calendars has to be used for tasks, events and journals.
-* In some situations the user may want to point testing towards one (or several) specific calendars - either due to the issues above or for other reasons
-
-There are also different modes of cleanup:
-
-* Wipe all "known objects", objects created by the tests (old "through" mode, it was needed for running tests towards existing production calendars without wiping the original content - but now I've decided not to support this. It may be needed if having problems with "sticky" events, but probably better to just "wipe" the calendar).
-* Wiping the calendar after every test (like, take out the list of objects, and delete every single event - `[x.delete() for x in calendar.search()]`)
-* Deleting the whole calendar after every test.
-* Do the same before every test
-
-The first option is needed if one wants to run the tests towards a "live" calendar without deleting content on the live calendar. Since this is anyway not supported, I think this code may as well be yanked out.
-
-Deleting a calendar is much faster than deleting every item on the calendar. However, for "sticky" objects wiping is necessary. For calendars not supporting calendar deletion, a "delete"-operation towards a calendar will automatically wipe it.
-
-Probably test cleanups should be done after the server run, but also before if needed.
+For servers where calendars cannot be created, the test will take the first available calendar it finds and wipe it. That is not acceptable without the user explicitly configuring that this is OK.
## Nextcloud UNIQUE Constraint Violations
**Status**: Known issue, needs upstream investigation
**Priority**: Low (doesn't block caldav work)
-**Estimated research time**: 6-12 hours
### Problem
-Nextcloud occasionally gets into an inconsistent internal state where it reports UNIQUE constraint violations when trying to save calendar objects:
-
-```
-SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed:
-oc_calendarobjects.calendarid, oc_calendarobjects.calendartype, oc_calendarobjects.uid
-```
-
-### Observations
-- **Server-specific**: Only affects Nextcloud, not Radicale, Baikal, Xandikos, etc.
-- **Intermittent**: Happens during `caldav_server_tester.ServerQuirkChecker.check_all()`
-- **Workaround**: Taking down and restarting the ephemeral Docker container resolves it
-- **Hypothesis**: Internal state corruption in Nextcloud, not a caldav library issue
-- **Pre-existing**: Test was already failing before starting to work on the async support
-
-### Example Failure
-```
-tests/test_caldav.py::TestForServerNextcloud::testCheckCompatibility
-E caldav.lib.error.PutError: PutError at '500 Internal Server Error
-E An exception occurred while executing a query: SQLSTATE[23000]:
- Integrity constraint violation: 19 UNIQUE constraint failed:
- oc_calendarobjects.calendarid, oc_calendarobjects.calendartype,
- oc_calendarobjects.uid
-```
-
-### Test Results: Hypothesis CONFIRMED ✓
-
-**Date**: 2025-12-17
-**Test script**: `/tmp/test_nextcloud_uid_reuse.py`
-
-**Finding**: Nextcloud does NOT allow reusing a UID after deletion. This is a **Nextcloud bug**.
-
-**Test steps**:
-1. Created event with UID `test-uid-reuse-hypothesis-12345` ✓
-2. Deleted the event ✓
-3. Confirmed deletion with `get_event_by_uid()` (throws NotFoundError) ✓
-4. Attempted to create new event with same UID → **FAILED with UNIQUE constraint** ✗
-
-**Error received**:
-```
-500 Internal Server Error
-SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed:
-oc_calendarobjects.calendarid, oc_calendarobjects.calendartype, oc_calendarobjects.uid
-```
-
-**Conclusion**:
-- This violates CalDAV RFC expectations - UIDs should be reusable after deletion
-- Nextcloud's internal database retains constraint even after CalDAV object is deleted
-- This explains why `ServerQuirkChecker.check_all()` fails - it likely deletes and recreates test objects
-- Container restart fixes it because it clears the internal state
-
-### Next Steps (when prioritized)
-1. ✓ ~~Test the UID reuse hypothesis~~ - **CONFIRMED**
-2. Search Nextcloud issue tracker for similar reports
-3. Create minimal bug report with reproduction steps
-4. File upstream bug report with Nextcloud
-5. Consider adding server quirk detection in caldav_server_tester
-6. Document workaround: avoid UID reuse with Nextcloud, or restart container between test runs
-
-### References
-- Test: `tests/test_caldav.py::TestForServerNextcloud::testCheckCompatibility`
-- Discussion: Session on 2025-12-17
-
----
-
-## Phase 2 Remaining Work
-
-### Test Suite Status
-- **Radicale**: 42 passed, 13 skipped ✓
-- **Baikal**: Some tests passing after path/auth fixes
-- **Nextcloud**: testCheckCompatibility failing (see above)
-- **Other servers**: Status unknown
-
-### Known Limitations (to be addressed in Phase 3)
-- AsyncPrincipal not implemented → path matching warnings for Principal objects
-- Async collection methods (get_event_by_uid, etc.) not implemented → no_create/no_overwrite validation done in sync wrapper
-- Recurrence handling done in sync wrapper → will move to async in Phase 3
-
-### Known Test Limitations
-
-#### MockedDAVClient doesn't work with async delegation
-**Status**: Known limitation in Phase 2
-**Affected test**: `tests/test_caldav_unit.py::TestCalDAV::testPathWithEscapedCharacters`
-
-MockedDAVClient overrides `request()` to return mocked responses without network calls.
-However, with async delegation, `_run_async()` creates a new async client that makes
-real HTTP connections, bypassing the mock.
-
-**Options to fix**:
-1. Make MockedDAVClient override `_get_async_client()` to return a mocked async client
-2. Update tests to use `@mock.patch` on async client methods
-3. Implement a fallback sync path for mocked clients
+Nextcloud does not allow reusing a UID after deletion — the internal database retains the constraint even after the CalDAV object is deleted. This causes intermittent UNIQUE constraint violation errors (500) when `ServerQuirkChecker.check_all()` deletes and recreates test objects with the same UID.
-**Current approach**: Raise clear NotImplementedError when mocked client tries to use
-async delegation, documenting that mocking needs to be updated for async support.
+This violates CalDAV RFC expectations and is a Nextcloud bug. Restarting the Docker container resolves it by clearing internal state.
-### Recently Fixed
-- ✓ Infinite redirect loop in multiplexing retry
-- ✓ Path matching assertion failures
-- ✓ HTTPDigestAuth sync→async conversion
-- ✓ UID generation issues
-- ✓ Async class type mapping (Event→AsyncEvent, etc.)
-- ✓ no_create/no_overwrite validation moved to sync wrapper
-- ✓ Recurrence handling moved to sync wrapper
-- ✓ Unit tests without client (load with only_if_unloaded)
-- ✓ Mocked client detection for unit tests (testAbsoluteURL)
-- ✓ Sync fallback in get_properties() for mocked clients
+### Next Steps
+1. Search Nextcloud issue tracker for similar reports
+2. File upstream bug report with reproduction steps
+3. Consider adding server quirk detection in caldav_server_tester
+4. Document workaround: avoid UID reuse with Nextcloud, or restart container between test runs
diff --git a/tests/fixture_helpers.py b/tests/fixture_helpers.py
index 55a48a1d..b20eb45a 100644
--- a/tests/fixture_helpers.py
+++ b/tests/fixture_helpers.py
@@ -5,6 +5,7 @@
ensuring consistent behavior and safeguards across sync and async tests.
"""
+import asyncio
import inspect
from typing import Any
@@ -53,31 +54,22 @@ def _filter_calendars_by_url_heuristic(
return matching
-def _filter_calendars_by_component_set(
+async def _filter_calendars_by_component_set(
calendars: list[Any],
supported_calendar_component_set: list[str],
- get_properties_fn: Any = None,
) -> list[Any] | None:
"""Filter calendars by supported component set.
Uses property lookup first, then URL-based heuristics as fallback.
Returns None if no matching calendars found (caller should skip test).
-
- Args:
- calendars: List of calendar objects to filter
- supported_calendar_component_set: Required component types
- get_properties_fn: Callable that takes (calendar, keys) and returns
- properties dict. If None, uses calendar.get_properties() directly.
+ Works with both sync and async calendar objects via _maybe_await.
"""
comp_set_key = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
matching_calendars = []
for c in calendars:
try:
- if get_properties_fn:
- props = get_properties_fn(c, [comp_set_key])
- else:
- props = c.get_properties([comp_set_key])
+ props = await _maybe_await(c.get_properties([comp_set_key]))
cal_components = props.get(comp_set_key, [])
if cal_components and all(
comp in cal_components for comp in supported_calendar_component_set
@@ -94,23 +86,14 @@ def _filter_calendars_by_component_set(
return matching_calendars or None
-def _find_test_calendar(
- calendars: list[Any],
- get_properties_fn: Any = None,
-) -> Any:
+async def _find_test_calendar(calendars: list[Any]) -> Any:
"""Find a dedicated test calendar by display name, or return first calendar.
- Args:
- calendars: List of calendar objects to search
- get_properties_fn: Callable that takes (calendar, keys) and returns
- properties dict. If None, uses calendar.get_properties() directly.
+ Works with both sync and async calendar objects via _maybe_await.
"""
for c in calendars:
try:
- if get_properties_fn:
- props = get_properties_fn(c, [])
- else:
- props = c.get_properties([])
+ props = await _maybe_await(c.get_properties([]))
display_name = props.get("{DAV:}displayname", "")
if "pythoncaldav-test" in str(display_name):
return c
@@ -119,28 +102,14 @@ def _find_test_calendar(
return calendars[0] if calendars else None
-def get_or_create_test_calendar(
+async def _get_or_create_impl(
client: Any,
principal: Any,
calendar_name: str | None = "pythoncaldav-test",
cal_id: str | None = None,
supported_calendar_component_set: list[str] | None = None,
) -> tuple[Any, bool]:
- """
- Get or create a test calendar (sync version), with fallback to existing calendars.
-
- Args:
- client: The DAV client
- principal: The principal object (or None to skip principal-based creation)
- calendar_name: Name for the test calendar, or None to skip setting name
- cal_id: Optional calendar ID
- supported_calendar_component_set: Component types this calendar should support
-
- Returns:
- Tuple of (calendar, was_created) where was_created indicates if
- we created the calendar (and should clean it up) or are using
- an existing one.
- """
+ """Shared async implementation for get_or_create_test_calendar."""
from caldav.lib import error
calendar = None
@@ -165,13 +134,13 @@ def get_or_create_test_calendar(
kwargs = _build_make_calendar_kwargs(
calendar_name, cal_id, supported_calendar_component_set
)
- calendar = principal.make_calendar(**kwargs)
+ calendar = await _maybe_await(principal.make_calendar(**kwargs))
created = True
except (error.MkcalendarError, error.AuthorizationError, error.NotFoundError):
# Creation failed - try to get by cal_id if available
if cal_id:
try:
- calendar = principal.calendar(cal_id=cal_id)
+ calendar = await _maybe_await(principal.calendar(cal_id=cal_id))
except Exception:
pass
@@ -181,69 +150,51 @@ def get_or_create_test_calendar(
if principal is not None:
try:
- calendars = principal.get_calendars()
+ calendars = await _maybe_await(principal.get_calendars())
except (error.NotFoundError, error.AuthorizationError):
pass
if calendars:
if supported_calendar_component_set:
- filtered = _filter_calendars_by_component_set(
+ filtered = await _filter_calendars_by_component_set(
calendars, supported_calendar_component_set
)
if filtered is None:
return None, False
calendars = filtered
- calendar = _find_test_calendar(calendars)
+ calendar = await _find_test_calendar(calendars)
return calendar, created
-async def _afilter_calendars_by_component_set(
- calendars: list[Any],
- supported_calendar_component_set: list[str],
-) -> list[Any] | None:
- """Async version of _filter_calendars_by_component_set.
-
- Uses async property lookup first, then URL-based heuristics as fallback.
- Returns None if no matching calendars found (caller should skip test).
+def get_or_create_test_calendar(
+ client: Any,
+ principal: Any,
+ calendar_name: str | None = "pythoncaldav-test",
+ cal_id: str | None = None,
+ supported_calendar_component_set: list[str] | None = None,
+) -> tuple[Any, bool]:
"""
- comp_set_key = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
-
- matching_calendars = []
- for c in calendars:
- try:
- props = await _maybe_await(c.get_properties([comp_set_key]))
- cal_components = props.get(comp_set_key, [])
- if cal_components and all(
- comp in cal_components for comp in supported_calendar_component_set
- ):
- matching_calendars.append(c)
- except Exception:
- pass
-
- if not matching_calendars:
- matching_calendars = _filter_calendars_by_url_heuristic(
- calendars, supported_calendar_component_set
- )
-
- return matching_calendars or None
-
+ Get or create a test calendar (sync version), with fallback to existing calendars.
-async def _afind_test_calendar(calendars: list[Any]) -> Any:
- """Async version of _find_test_calendar.
+ Args:
+ client: The DAV client
+ principal: The principal object (or None to skip principal-based creation)
+ calendar_name: Name for the test calendar, or None to skip setting name
+ cal_id: Optional calendar ID
+ supported_calendar_component_set: Component types this calendar should support
- Find a dedicated test calendar by display name, or return first calendar.
+ Returns:
+ Tuple of (calendar, was_created) where was_created indicates if
+ we created the calendar (and should clean it up) or are using
+ an existing one.
"""
- for c in calendars:
- try:
- props = await _maybe_await(c.get_properties([]))
- display_name = props.get("{DAV:}displayname", "")
- if "pythoncaldav-test" in str(display_name):
- return c
- except Exception:
- pass
- return calendars[0] if calendars else None
+ return asyncio.run(
+ _get_or_create_impl(
+ client, principal, calendar_name, cal_id, supported_calendar_component_set
+ )
+ )
async def aget_or_create_test_calendar(
@@ -268,59 +219,9 @@ async def aget_or_create_test_calendar(
we created the calendar (and should clean it up) or are using
an existing one.
"""
- from caldav.lib import error
-
- calendar = None
- created = False
-
- ## Check if the server test config specifies a dedicated calendar
- ## (mirrors the sync version)
- test_cal_info = client.features.is_supported("test-calendar", return_type=dict)
- if "name" in test_cal_info or "cal_url" in test_cal_info or "cal_id" in test_cal_info:
- return (principal.calendar(**test_cal_info), False)
-
- # Check if server supports calendar creation via features
- supports_create = True
- if hasattr(client, "features") and client.features:
- supports_create = client.features.is_supported("create-calendar")
-
- if supports_create and principal is not None:
- try:
- kwargs = _build_make_calendar_kwargs(
- calendar_name, cal_id, supported_calendar_component_set
- )
- calendar = await _maybe_await(principal.make_calendar(**kwargs))
- created = True
- except (error.MkcalendarError, error.AuthorizationError, error.NotFoundError):
- # Creation failed - try to get by cal_id if available
- if cal_id:
- try:
- calendar = await _maybe_await(principal.calendar(cal_id=cal_id))
- except Exception:
- pass
-
- if calendar is None:
- # Fall back to finding an existing calendar
- calendars = None
-
- if principal is not None:
- try:
- calendars = await _maybe_await(principal.get_calendars())
- except (error.NotFoundError, error.AuthorizationError):
- pass
-
- if calendars:
- if supported_calendar_component_set:
- filtered = await _afilter_calendars_by_component_set(
- calendars, supported_calendar_component_set
- )
- if filtered is None:
- return None, False
- calendars = filtered
-
- calendar = await _afind_test_calendar(calendars)
-
- return calendar, created
+ return await _get_or_create_impl(
+ client, principal, calendar_name, cal_id, supported_calendar_component_set
+ )
async def cleanup_calendar_objects(calendar: Any) -> None:
diff --git a/tests/test_caldav.py b/tests/test_caldav.py
index 837d22c5..1054655f 100644
--- a/tests/test_caldav.py
+++ b/tests/test_caldav.py
@@ -872,8 +872,6 @@ def _teardownCalendar(self, name=None, cal_id=None):
except:
pass
- ## TODO: Why do we have more logic here than in fixture_helpers.py?
- ## TODO: perhaps a decorator is a better pattern than a wrapper?
def _fixCalendar(self, **kwargs):
cal = self._fixCalendar_(**kwargs)
if self.cleanup_regime == "wipe-calendar":
@@ -1272,10 +1270,13 @@ def testCreateEventFromiCal(self, klass):
## Calendar.new() is supported from icalendar 7, which is yet to be released as of 2025-09
pytest.skip("Newer icalendar version required")
+ ## Use a near-future date so servers with a "sliding window" (e.g. OX) can find the event
+ start = datetime.now() + timedelta(days=30)
+ end = start + timedelta(hours=1)
icalevent = icalendar.Event.new(
uid="ctuid1",
- start=datetime(2015, 10, 10, 8, 7, 6),
- end=datetime(2015, 10, 10, 9, 7, 6),
+ start=start,
+ end=end,
summary="This is a test event",
)
icalcal.add_component(icalevent)
diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py
index 47850476..49b6499b 100755
--- a/tests/test_caldav_unit.py
+++ b/tests/test_caldav_unit.py
@@ -415,6 +415,40 @@ def testNonValidXMLNoContentLength(self, mocked):
with pytest.raises(lxml.etree.XMLSyntaxError):
client.request("/")
+ @mock.patch("caldav.davclient.requests.Session.request")
+ def testCommunicationDump(self, mocked):
+ """
+ ref https://github.com/python-caldav/caldav/issues/638
+ When PYTHON_CALDAV_COMMDUMP (or debug_dump_communication) is set,
+ request/response data should be written to a temp file.
+ """
+ import glob
+ import os
+ import tempfile
+
+ mocked().status_code = 200
+ mocked().headers = {"Content-Type": "text/plain"}
+ mocked().content = b""
+ mocked().reason = "OK"
+ mocked().reason_phrase = None
+
+ from caldav.lib import error as caldav_error
+
+ old_value = caldav_error.debug_dump_communication
+ caldav_error.debug_dump_communication = True
+ try:
+ client = DAVClient(url="http://test.example.com/")
+ before = set(glob.glob(os.path.join(tempfile.gettempdir(), "caldavcomm*")))
+ client.request("/")
+ after = set(glob.glob(os.path.join(tempfile.gettempdir(), "caldavcomm*")))
+ new_files = after - before
+ assert len(new_files) == 1
+ content = open(list(new_files)[0], "rb").read()
+ assert b"GET /" in content
+ assert b"200 OK" in content
+ finally:
+ caldav_error.debug_dump_communication = old_value
+
def testPathWithEscapedCharacters(self):
xml = b"""
diff --git a/tests/test_search.py b/tests/test_search.py
index 2d9dfdaa..83bb6f88 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -829,3 +829,41 @@ def mock_is_supported(feat, type_=bool):
# Only the recurring event without DTEND should be returned
assert len(result) == 1
assert result[0].icalendar_component.get("DTEND") is None
+
+
+class TestSearchWithCompTypesFullXML:
+ """Regression tests for issue #637.
+
+ When search() is called with a full calendar-query XML and the server does
+ not support search.comp-type.optional, the code used to raise
+ NotImplementedError. It should instead do a single REPORT request with
+ the XML as-is.
+ """
+
+ def test_search_full_xml_string_no_comp_type_optional(
+ self, mock_client: DAVClient, mock_url: str
+ ) -> None:
+ """Passing a full XML string to search() must not raise NotImplementedError
+ when the server does not support search.comp-type.optional."""
+
+ def mock_is_supported(feat, type_=bool):
+ if feat == "search.comp-type.optional":
+ return False
+ if type_ == str:
+ return "full"
+ return True
+
+ mock_client.features.is_supported = mock.Mock(side_effect=mock_is_supported)
+ mock_client.features.backward_compatibility_mode = False
+
+ event = Event(client=mock_client, url=mock_url, data=SIMPLE_EVENT)
+ calendar = mock.Mock()
+ calendar.client = mock_client
+ calendar._request_report_build_resultlist.return_value = (mock.Mock(), [event])
+
+ full_xml = ""
+ searcher = CalDAVSearcher()
+ result = searcher.search(calendar, xml=full_xml)
+
+ assert result == [event]
+ calendar._request_report_build_resultlist.assert_called_once_with(full_xml, None, None)