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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .github/workflows/linkcheck.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,4 @@ repos:
hooks:
- id: lychee
args: ["--no-progress", "--timeout", "10"]
types: [markdown, rst]
stages: [manual] # Run with: pre-commit run lychee --hook-stage manual
stages: [pre-push]
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ 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.

## [Unreleased]

### 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

### Documentation

I've decided to try to stick to the conventionalcommits standard. This is documented in CONTRIBUTING.md. We'll see how many days it takes before I forget about it ...

## [3.0.1] - 2026-03-04

Highlights:
Expand Down Expand Up @@ -43,6 +54,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.
Expand Down Expand Up @@ -183,7 +198,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
Expand Down
33 changes: 32 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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 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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# caldav

This project is a CalDAV ([RFC4791](http://www.ietf.org/rfc/rfc4791.txt)) client library for Python.
This project is a CalDAV ([RFC4791](https://www.ietf.org/rfc/rfc4791.txt)) client library for Python.

Features:

Expand Down
3 changes: 3 additions & 0 deletions caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================
Expand Down
8 changes: 4 additions & 4 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"},
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -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""",

Expand Down
4 changes: 4 additions & 0 deletions caldav/davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
34 changes: 34 additions & 0 deletions caldav/lib/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 10 additions & 6 deletions caldav/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/test_caldav_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""<D:multistatus xmlns:D="DAV:" xmlns:caldav="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<D:response xmlns:carddav="urn:ietf:params:xml:ns:carddav" xmlns:cm="http://cal.me.com/_namespace/" xmlns:md="urn:mobileme:davservices">
Expand Down
38 changes: 38 additions & 0 deletions tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav'/>"
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)
Loading