Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ff6db30
Fix bugs identified in v3.0 code review
tobixen Feb 26, 2026
ee9d1bb
Update V3_CODE_REVIEW.md to reflect fixed issues
tobixen Feb 26, 2026
f7c51c9
Reduce sync/async duplication across davobject, collection, and clients
tobixen Feb 26, 2026
2153c92
Update V3_CODE_REVIEW.md to reflect sync/async deduplication (commit …
tobixen Feb 26, 2026
ad042bb
Fix dead code, CalendarInfo collision, and response/xml_parsers dupli…
tobixen Feb 26, 2026
8e5cac8
Update V3_CODE_REVIEW.md to reflect dead-code/collision/duplication f…
tobixen Feb 26, 2026
f5c242d
Update V3_CODE_REVIEW.md: §5.3 detail, §8 items 12+16, Appendix A lin…
tobixen Feb 26, 2026
01b3352
Extract _quote_uid() helper to eliminate duplicate URL-quoting logic
tobixen Feb 26, 2026
7e7919e
Add Stalwart to Docker test server framework; fix expand_simple_props…
tobixen Feb 26, 2026
547cbf8
code review comments
tobixen Feb 26, 2026
819fdd7
Update V3_CODE_REVIEW.md: note residual URL-quoting in calendarobject…
tobixen Feb 27, 2026
5aa8447
Use _quote_uid() in calendarobjectresource._generate_url, drop quote …
tobixen Feb 27, 2026
e49856f
fighting more with tests and compatibility
tobixen Feb 27, 2026
640a313
Symmetric adaptive rate-limit retry in AsyncDAVClient; fix two bugs i…
tobixen Feb 27, 2026
8598485
Update V3_CODE_REVIEW.md: rate-limit fixes and async parity
tobixen Feb 27, 2026
cc2bb9d
testing the rate limits a bit
tobixen Feb 27, 2026
3b88454
Fix TypesFactory shadowing, read_config return value, and StopIterati…
tobixen Feb 27, 2026
33096e3
Inline _get_calendar_home_set() to remove sync/async duplication
tobixen Feb 27, 2026
8cacbc0
Fix Principal._async_get_property and add async multiget fallback in …
tobixen Feb 27, 2026
edf5c70
Add [Unreleased] section to CHANGELOG
tobixen Feb 27, 2026
5d243c8
ecloud
tobixen Feb 27, 2026
95f85cc
Fix Xandikos test server: XandikosBackend → SingleUserFilesystemBackend
tobixen Feb 28, 2026
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ 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]

### Breaking Changes

* The icalendar dependency is updated from 6 to 7 - not because 3.0 depends on icalendar7, but because I'm planning to use icalendar7-features in some upcoming 3.x. If this causes problems for you, just reach out and I will downgrade the dependency, release a new 3.0.1, and possibly procrastinate the icalendar7-stuff until 4.0.

### Added

* **Stalwart CalDAV server** added to Docker test server framework.
* **Async multiget fallback in `_async_load()`** -- `CalendarObjectResource._async_load()` now has the same two-stage fallback as sync `load()`: on a 404, first tries calendar-multiget REPORT, then re-fetches by UID. Previously the async path only had the UID re-fetch fallback.

### Fixed

* Fixed two bugs in `DAVClient` rate-limit auto-detection: (1) `is_supported('rate-limit', dict)` returns `{'enable': False}` (a truthy dict) for unconfigured clients, causing rate-limiting to be enabled unintentionally; (2) crash (`TypeError: '>' not supported between 'int' and 'NoneType'`) when `rate_limit_max_sleep` is `None` and a 429 is received.
* Fixed `AsyncDAVClient` rate-limit handling to be fully symmetric with the sync client: same `Optional[bool]` default, same features-based auto-detect, same adaptive backoff accumulating sleep time across retries.
* Fixed `Principal._async_get_property()` override having an incompatible signature (missing `use_cached` and `**passthrough`) and reimplementing PROPFIND logic already handled correctly by the parent `DAVObject._async_get_property()`. The override has been removed.
* Fixed inconsistent URL quoting for calendar object UIDs containing slashes -- both `_generate_url()` and `_find_id_and_path()` in `calendarobject_ops.py` now share a single `_quote_uid()` helper (related to https://github.com/python-caldav/caldav/issues/143).
* Fixed `expand_simple_props()` return value handling.

### Test Framework

* Added async rate-limit unit tests matching the sync test suite.

## [3.0.0a2] - 2026-02-25 (Alpha Release)

**This is an alpha release for testing purposes.** Please report issues at https://github.com/python-caldav/caldav/issues
Expand Down
173 changes: 56 additions & 117 deletions caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
_H2_AVAILABLE = True
except ImportError:
pass

class _HttpxBearerAuth(httpx.Auth):
"""httpx-compatible bearer token auth."""

def __init__(self, password: str) -> None:
self.password = password

def auth_flow(self, request):
request.headers["Authorization"] = f"Bearer {self.password}"
yield request

except ImportError:
pass

Expand Down Expand Up @@ -144,7 +155,7 @@ def __init__(
features: FeatureSet | dict | str | None = None,
enable_rfc6764: bool = True,
require_tls: bool = True,
rate_limit_handle: bool = False,
rate_limit_handle: Optional[bool] = None,
rate_limit_default_sleep: Optional[int] = None,
rate_limit_max_sleep: Optional[int] = None,
) -> None:
Expand All @@ -167,7 +178,8 @@ def __init__(
enable_rfc6764: Enable RFC6764 DNS-based service discovery.
require_tls: Require TLS for discovered services (security consideration).
rate_limit_handle: When True, automatically sleep and retry on 429/503
responses. When False (default), raise RateLimitError immediately.
responses. When None (default), auto-detected from server features.
When False, raise RateLimitError immediately.
rate_limit_default_sleep: Fallback sleep seconds when the server's 429
response omits a Retry-After header. None (default) means raise
rather than sleeping when no Retry-After is provided.
Expand Down Expand Up @@ -237,6 +249,8 @@ def __init__(
# Use explicit None check to preserve empty strings (needed for servers with no auth)
self.username = username if username is not None else url_username
self.password = password if password is not None else url_password
# Strip credentials from stored URL to avoid leaking them in log messages
self.url = self.url.unauth()

# Setup authentication
self.auth = auth
Expand All @@ -258,6 +272,16 @@ def __init__(
}
self.headers.update(headers)

rate_limit = self.features.is_supported("rate-limit", dict)
if rate_limit_handle is None:
if rate_limit and rate_limit.get("enable"):
rate_limit_handle = True
if "default_sleep" in rate_limit:
rate_limit_default_sleep = rate_limit["default_sleep"]
if "max_sleep" in rate_limit:
rate_limit_max_sleep = rate_limit["max_sleep"]
else:
rate_limit_handle = False
self.rate_limit_handle = rate_limit_handle
self.rate_limit_default_sleep = rate_limit_default_sleep
self.rate_limit_max_sleep = rate_limit_max_sleep
Expand Down Expand Up @@ -339,13 +363,16 @@ async def request(
method: str = "GET",
body: str = "",
headers: Mapping[str, str] | None = None,
rate_limit_time_slept: float = 0,
) -> AsyncDAVResponse:
"""
Send an async HTTP request, with optional rate-limit sleep-and-retry.

Catches RateLimitError from _async_request. When rate_limit_handle is
True and a usable sleep duration is available, sleeps then retries once.
Otherwise re-raises immediately.
True and a usable sleep duration is available, sleeps then retries with
adaptive backoff (each retry adds half the already-slept time). Stops
retrying when rate_limit_max_sleep is exceeded or no sleep duration is
available. Otherwise re-raises immediately.
"""
try:
return await self._async_request(url, method, body, headers)
Expand All @@ -357,10 +384,17 @@ async def request(
self.rate_limit_default_sleep,
self.rate_limit_max_sleep,
)
if sleep_seconds is None:
if rate_limit_time_slept:
sleep_seconds += rate_limit_time_slept / 2
if sleep_seconds is None or (
self.rate_limit_max_sleep is not None
and rate_limit_time_slept > self.rate_limit_max_sleep
):
raise
await asyncio.sleep(sleep_seconds)
return await self._async_request(url, method, body, headers)
return await self.request(
url, method, body, headers, rate_limit_time_slept + sleep_seconds
)

async def _async_request(
self,
Expand Down Expand Up @@ -846,7 +880,10 @@ def build_auth_object(self, auth_types: list[str] | None = None) -> None:

# Build auth object - use appropriate classes for httpx or niquests
if auth_type == "bearer":
self.auth = HTTPBearerAuth(self.password)
if _USE_HTTPX:
self.auth = _HttpxBearerAuth(self.password)
else:
self.auth = HTTPBearerAuth(self.password)
elif auth_type == "digest":
if _USE_HTTPX:
self.auth = httpx.DigestAuth(self.username, self.password)
Expand Down Expand Up @@ -930,12 +967,20 @@ async def get_calendars(self, principal: Optional["Principal"] = None) -> list["
from caldav.operations.calendarset_ops import (
_extract_calendars_from_propfind_results as extract_calendars,
)
from caldav.operations.principal_ops import (
_extract_calendar_home_set_from_results as extract_home_set,
)

if principal is None:
principal = await self.get_principal()

# Get calendar-home-set from principal
calendar_home_url = await self._get_calendar_home_set(principal)
response = await self.propfind(
str(principal.url),
props=self.CALENDAR_HOME_SET_PROPS,
depth=0,
)
calendar_home_url = extract_home_set(response.results)
if not calendar_home_url:
return []

Expand All @@ -958,73 +1003,6 @@ async def get_calendars(self, principal: Optional["Principal"] = None) -> list["
for info in calendar_infos
]

async def _get_calendar_home_set(self, principal: "Principal") -> str | None:
"""Get the calendar-home-set URL for a principal.

Args:
principal: Principal object

Returns:
Calendar home set URL or None
"""
from caldav.operations.principal_ops import (
_extract_calendar_home_set_from_results as extract_home_set,
)

# Try to get from principal properties
response = await self.propfind(
str(principal.url),
props=self.CALENDAR_HOME_SET_PROPS,
depth=0,
)

return extract_home_set(response.results)

async def get_events(
self,
calendar: "Calendar",
start: Any | None = None,
end: Any | None = None,
) -> list["Event"]:
"""Get events from a calendar.

This is a convenience method that searches for VEVENT objects in the
calendar, optionally filtered by date range.

Args:
calendar: Calendar to search
start: Start of date range (optional)
end: End of date range (optional)

Returns:
List of Event objects.

Example:
from datetime import datetime
events = await client.get_events(
calendar,
start=datetime(2024, 1, 1),
end=datetime(2024, 12, 31)
)
"""
return await self.search_calendar(calendar, event=True, start=start, end=end)

async def get_todos(
self,
calendar: "Calendar",
include_completed: bool = False,
) -> list["Todo"]:
"""Get todos from a calendar.

Args:
calendar: Calendar to search
include_completed: Whether to include completed todos

Returns:
List of Todo objects.
"""
return await self.search_calendar(calendar, todo=True, include_completed=include_completed)

async def search_calendar(
self,
calendar: "Calendar",
Expand Down Expand Up @@ -1109,50 +1087,11 @@ async def search_principals(self, name: str | None = None) -> list["Principal"]:
Raises:
ReportError: If the server doesn't support principal search
"""
from lxml import etree

from caldav.collection import CalendarSet, Principal
from caldav.elements import cdav, dav

if name:
name_filter = [
dav.PropertySearch() + [dav.Prop() + [dav.DisplayName()]] + dav.Match(value=name)
]
else:
name_filter = []

query = (
dav.PrincipalPropertySearch()
+ name_filter
+ [dav.Prop(), cdav.CalendarHomeSet(), dav.DisplayName()]
)
response = await self.report(str(self.url), etree.tostring(query.xmlelement()))

body = self._build_principal_search_query(name)
response = await self.report(str(self.url), body)
if response.status >= 300:
raise error.ReportError(f"{response.status} {response.reason} - {response.raw}")

principal_dict = response._find_objects_and_props()
ret = []
for x in principal_dict:
p = principal_dict[x]
if dav.DisplayName.tag not in p:
continue
pname = p[dav.DisplayName.tag].text
error.assert_(not p[dav.DisplayName.tag].getchildren())
error.assert_(not p[dav.DisplayName.tag].items())
chs = p[cdav.CalendarHomeSet.tag]
error.assert_(not chs.items())
error.assert_(not chs.text)
chs_href = chs.getchildren()
error.assert_(len(chs_href) == 1)
error.assert_(not chs_href[0].items())
error.assert_(not chs_href[0].getchildren())
chs_url = chs_href[0].text
calendar_home_set = CalendarSet(client=self, url=chs_url)
ret.append(
Principal(client=self, url=x, name=pname, calendar_home_set=calendar_home_set)
)
return ret
return self._parse_principal_search_response(response._find_objects_and_props())

async def principals(self, name: str | None = None) -> list["Principal"]:
"""
Expand Down
61 changes: 61 additions & 0 deletions caldav/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,67 @@ def _raise_authorization_error(self, url_str: str, reason_source: Any) -> NoRetu
reason = "None given"
raise error.AuthorizationError(url=url_str, reason=reason)

def _build_principal_search_query(self, name: str | None) -> bytes:
"""Build the XML body for a principal-property-search REPORT."""
from lxml import etree

from caldav.elements import cdav, dav

name_filter = (
[dav.PropertySearch() + [dav.Prop() + [dav.DisplayName()]] + dav.Match(value=name)]
if name
else []
)
query = (
dav.PrincipalPropertySearch()
+ name_filter
+ [dav.Prop(), cdav.CalendarHomeSet(), dav.DisplayName()]
)
return etree.tostring(query.xmlelement())

def _parse_principal_search_response(self, principal_dict: dict) -> list:
"""Parse principal-property-search REPORT results into Principal objects."""
from caldav.collection import CalendarSet, Principal
from caldav.elements import cdav, dav

ret = []
for x in principal_dict:
p = principal_dict[x]
if dav.DisplayName.tag not in p:
continue
name = p[dav.DisplayName.tag].text
error.assert_(not p[dav.DisplayName.tag].getchildren())
error.assert_(not p[dav.DisplayName.tag].items())
chs = p[cdav.CalendarHomeSet.tag]
error.assert_(not chs.items())
error.assert_(not chs.text)
chs_href = chs.getchildren()
error.assert_(len(chs_href) == 1)
error.assert_(not chs_href[0].items())
error.assert_(not chs_href[0].getchildren())
chs_url = chs_href[0].text
calendar_home_set = CalendarSet(client=self, url=chs_url)
ret.append(
Principal(client=self, url=x, name=name, calendar_home_set=calendar_home_set)
)
return ret

def get_events(self, calendar: Any, start: Any = None, end: Any = None) -> Any:
"""Get events from a calendar, optionally filtered by date range.

For sync clients returns a list directly.
For async clients returns a coroutine that must be awaited.
"""
return self.search_calendar(calendar, event=True, start=start, end=end)

def get_todos(self, calendar: Any, include_completed: bool = False) -> Any:
"""Get todos from a calendar.

For sync clients returns a list directly.
For async clients returns a coroutine that must be awaited.
"""
return self.search_calendar(calendar, todo=True, include_completed=include_completed)

@abstractmethod
def build_auth_object(self, auth_types: list[str] | None = None) -> None:
"""
Expand Down
Loading
Loading