Skip to content
Merged
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
22 changes: 20 additions & 2 deletions .lycheeignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# Example domains that don't resolve
https?://your\.server\.example\.com/.*
https?://.*\.example\.com/.*
https?://.*\.example\.com(:\d+)?(/.*)?$
https?://domain/.*

# Localhost URLs for test servers (not accessible in CI)
http://localhost:\d+/.*
http://localhost/.*
http://.*@localhost:\d+/.*

# Internal Docker hostnames (only reachable inside compose network)
http://oxhost2:\d+/.*

# CalDAV endpoints that require authentication (401/403 expected)
https://caldav\.fastmail\.com/.*
Expand All @@ -24,8 +29,21 @@ http://nextcloud.org/ns
http://owncloud.org/ns


# Google API endpoints (require auth — 401/404/405 expected)
https://apidata\.googleusercontent\.com/.*
https://oauth2\.googleapis\.com/.*

# Personal/demo test server (may be down)
https?://davical\.bekkenstenveien53c\.oslo\.no/.*

# Dead or broken links we can't fix
http://fsf\.org/.*
http://oxpedia\.org/.*
http://httpd\.apache\.org/.*
# GitHub URL template in sphinx conf (contains encoded braces, always 404)
https://github\.com/python-caldav/caldav/blob/master/%7B.*

# 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/
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ repos:
stages: [commit-msg]

- repo: https://github.com/lycheeverse/lychee
rev: lychee-v0.22.0
rev: lychee-v0.24.1
hooks:
- id: lychee
args: ["--no-progress", "--timeout", "10", "--exclude-path", ".lycheeignore", "--max-cache-age=30d", "--cache"]
Expand Down
6 changes: 3 additions & 3 deletions caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def __init__(

Args:
url: CalDAV server URL, domain, or email address.
proxy: Proxy server (scheme://hostname:port).
proxy: Proxy server (e.g. http://proxy.example.com:8080).
username: Username for authentication.
password: Password for authentication.
auth: Custom auth object (httpx.Auth or niquests AuthBase).
Expand Down Expand Up @@ -835,9 +835,9 @@ def build_auth_object(self, auth_types: list[str] | None = None) -> None:
if _USE_HTTPX:
self.auth = httpx.DigestAuth(self.username, self.password)
else:
from niquests.auth import HTTPDigestAuth
from niquests.auth import AsyncHTTPDigestAuth

self.auth = HTTPDigestAuth(self.username, self.password)
self.auth = AsyncHTTPDigestAuth(self.username, self.password)
elif auth_type == "basic":
if _USE_HTTPX:
self.auth = httpx.BasicAuth(self.username, self.password)
Expand Down
6 changes: 5 additions & 1 deletion caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ async def _async_load_by_multiget(self) -> Self:
def _post_load_by_multiget(self, items):
if not items:
raise error.NotFoundError(self.url)
items = iter(items)
url_data = next(items, None)
if url_data is None:
## We shouldn't come here. Something is wrong.
Expand Down Expand Up @@ -1147,7 +1148,10 @@ def _put(self, retry_on_failure=True) -> "None | Coroutine[Any, Any, None]":

async def _async_put(self, headers, retry_on_failure=True):
r = await self.client.put(str(self.url), str(self.data), headers | ICALH)
return self._post_put(r, retry_on_failure)
result = self._post_put(r, retry_on_failure)
if result is not None:
# _post_put returned a retry coroutine (self._put(False) for async client)
await result

def _post_put(self, r, retry_on_failure):
if r.status == 412:
Expand Down
60 changes: 43 additions & 17 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,14 @@ def _create(

prop = dav.Prop()
display_name = None
if name:
# Some servers (e.g. Zimbra) use the DisplayName from the MKCALENDAR body
# as the calendar URL, ignoring the actual request path. When the server
# does not support setting a separate display name, omit it from the body so
# the request URL path is used as the calendar identifier.
supports_displayname = not self.client or self.client.features.is_supported(
"create-calendar.set-displayname"
)
if name and supports_displayname:
display_name = dav.DisplayName(name)
prop += [display_name]
if supported_calendar_component_set:
Expand All @@ -747,7 +754,7 @@ def _create(
# on setting the DisplayName on calendar creation
# (DAViCal, Zimbra, ...). Doing an attempt on explicitly setting the
# display name using PROPPATCH.
if name:
if display_name:
try:
self.set_properties([display_name])
except Exception:
Expand All @@ -766,7 +773,7 @@ async def _async_create(self, path, mkcol, method, name, display_name) -> None:
await self._query(root=mkcol, query_method=method, url=path, expected_return_value=201)

# COMPATIBILITY ISSUE - try to set display name explicitly
if name:
if display_name:
try:
await self.set_properties([display_name])
except Exception:
Expand Down Expand Up @@ -1080,6 +1087,7 @@ def _multiget(self, event_urls: Iterable[URL], raise_notfound: bool = False) ->
"""
get multiple events' data.
TODO: Does it overlap the _request_report_build_resultlist method
## WARNING: async logic is duplicated in _async_multiget — mirror any changes there
"""
if self.url is None:
raise ValueError("Unexpected value None for self.url")
Expand All @@ -1098,28 +1106,33 @@ def _multiget(self, event_urls: Iterable[URL], raise_notfound: bool = False) ->
for r in results:
yield (r, results[r][cdav.CalendarData.tag])

## Replace the last lines with
def _post_multiget(self, results: Iterable[tuple[str, str]]) -> list[_CC]:
"""Post-processing shared by multiget and _async_multiget_objects."""
return [
self._calendar_comp_class_by_data(data)(
self.client,
# Quote path to handle servers returning unencoded spaces (e.g., Zimbra)
url=self.url.join(quote(unquote(str(url)), safe="/:@")),
data=data,
parent=self,
)
for url, data in results
]

def multiget(self, event_urls: Iterable[URL], raise_notfound: bool = False) -> Iterable[_CC]:
"""
get multiple events' data
TODO: Does it overlap the _request_report_build_resultlist method?
@author mtorange@gmail.com (refactored by Tobias)
"""
results = self._multiget(event_urls, raise_notfound=raise_notfound)
for url, data in results:
# Quote path to handle servers returning unencoded spaces (e.g., Zimbra)
quoted_url = quote(unquote(str(url)), safe="/:@")
yield self._calendar_comp_class_by_data(data)(
self.client,
url=self.url.join(quoted_url),
data=data,
parent=self,
)
if self.is_async_client:
return self._async_multiget_objects(event_urls, raise_notfound=raise_notfound)
return self._post_multiget(self._multiget(event_urls, raise_notfound=raise_notfound))

async def _async_multiget(
self, event_urls: Iterable[URL], raise_notfound: bool = False
) -> list[tuple[str, str]]:
"""Async version of _multiget — returns a list of (url, data) tuples."""
## WARNING: sync logic is duplicated in _multiget — mirror any changes there
if self.url is None:
raise ValueError("Unexpected value None for self.url")

Expand All @@ -1134,12 +1147,20 @@ async def _async_multiget(
raise error.NotFoundError(f"Status {status} in {href}")
return [(r, results[r][cdav.CalendarData.tag]) for r in results]

async def _async_multiget_objects(
self, event_urls: Iterable[URL], raise_notfound: bool = False
) -> list[_CC]:
"""Async version of multiget."""
return self._post_multiget(
await self._async_multiget(event_urls, raise_notfound=raise_notfound)
)

def calendar_multiget(self, *largs, **kwargs):
"""
get multiple events' data
@author mtorange@gmail.com
(refactored by Tobias)
This is for backward compatibility. It may be removed in 3.0 or later release.
This is for backward compatibility. It may be removed in a later release.
"""
return list(self.multiget(*largs, **kwargs))

Expand Down Expand Up @@ -1605,7 +1626,12 @@ async def _async_get_object_by_uid(
) -> "Event":
"""Async helper for get_object_by_uid()."""
items_found = await self.search(
uid=uid, comp_class=comp_class, xml=comp_filter, post_filter=True, _hacks="insist"
uid=uid,
comp_class=comp_class,
xml=comp_filter,
post_filter=True,
_hacks="insist",
include_completed=True,
)
items_found = [o for o in items_found if o.id == uid]

Expand Down
29 changes: 11 additions & 18 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class FeatureSet:
## TODO: in the future, templates for the principal URL, calendar URLs etc may also be added.
}
},
"url": {
"type": "client-hints",
},
"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. Possibly observed missing on mail.ru, DavMail gateway and it is possible to configure the support in some sabre-based servers",
"links": ["https://datatracker.ietf.org/doc/html/rfc5397"],
Expand Down Expand Up @@ -177,6 +180,10 @@ class FeatureSet:
"default": {"support": "full"},
"links": ["https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.5"],
},
"save-load.mutable": {
"description": "A saved calendar object resource can be modified and PUT back to the server; the server accepts the update and returns the modified data on the next GET/REPORT. When 'unsupported', the server treats calendar objects as immutable after initial creation (e.g. Google Calendar's legacy CalDAV API). Replaces the old 'no_overwrite' compatibility flag.",
"default": {"support": "full"},
},
"search": {
"description": "calendar MUST support searching for objects using the REPORT method, as specified in RFC4791, section 7",
"links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-7"],
Expand Down Expand Up @@ -855,9 +862,6 @@ def dotted_feature_set_list(self, compact=False):
'vtodo-cannot-be-uncompleted':
"""If a VTODO object has been set with STATUS:COMPLETE, it's not possible to delete the COMPLTEDED attribute and change back to STATUS:IN-ACTION""",

'unique_calendar_ids':
"""For every test, generate a new and unique calendar id""",

'sticky_events':
"""Events should be deleted before the calendar is deleted, """
"""and/or deleting a calendar may not have immediate effect""",
Expand Down Expand Up @@ -927,7 +931,7 @@ def dotted_feature_set_list(self, compact=False):
"search.text.case-sensitive": {"support": "unsupported"},
"search.recurrences.includes-implicit.todo.pending": {"support": "fragile", "behaviour": "inconsistent results between runs"},
"search.recurrences.expanded.todo": {"support": "unsupported"},
"search.recurrences.expanded.exception": {"support": "unsupported"},
"search.recurrences.expanded.exception": {"support": "full"},
"principal-search": {"support": "unsupported"},
## this only applies for very simple installations
"auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"},
Expand All @@ -947,7 +951,6 @@ def dotted_feature_set_list(self, compact=False):
## I'm surprised, I'm quite sure this was reported ungraceful earlier. Passed with caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 2026-02-15. The commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad was however development done on the wrong branch and has been force-pushed awway. It was again observed ungraceful at commits be26d42b1ca3ff3b4fd183761b4a9b024ce12b84 / 537a23b145487006bb987dee5ab9e00cdebb0492
'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',
Expand All @@ -961,7 +964,6 @@ def dotted_feature_set_list(self, compact=False):
'principal-search.by-name.self': {'support': 'unsupported'},
'principal-search': {'support': 'ungraceful'},
'search.time-range.open.start.duration': 'broken',
#'old_flags': ['unique_calendar_ids'],
## I'm surprised, I'm quite sure this was passing earlier. Caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad
'search.combined-is-logical-and': False,
## Observed with Nextcloud 33: server delivers iTIP notification to the inbox AND
Expand Down Expand Up @@ -993,10 +995,11 @@ def dotted_feature_set_list(self, compact=False):
zimbra = {
'auto-connect.url': {'basepath': '/dav/'},
'delete-calendar': {'support': 'fragile', 'behaviour': 'may move to trashbin instead of deleting immediately'},
'save-load.get-by-url': {'support': 'fragile', 'behaviour': '404 most of the time - but sometimes 200. Weird, should be investigated more'},
## This is a zimbra bug when creating calendars with a display
## name. Now mitigated in the calendar creation code.
#'save-load.get-by-url': {'support': 'fragile', 'behaviour': '404 most of the time - but sometimes 200. Weird, should be investigated more'},
## Zimbra treats same-UID events across calendars as aliases of the same event
'save.duplicate-uid.cross-calendar': {'support': 'unsupported'},
'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify
'create-calendar.set-displayname': {'support': 'unsupported'},
'save-load.todo.mixed-calendar': {'support': 'unsupported'},
'save-load.todo.recurrences.count': {'support': 'unsupported'}, ## This is a new problem?
Expand Down Expand Up @@ -1060,7 +1063,6 @@ def dotted_feature_set_list(self, compact=False):
"search.text": False, ## sometimes 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'},
Expand Down Expand Up @@ -1095,7 +1097,6 @@ def dotted_feature_set_list(self, compact=False):
'search.is-not-defined': {'support': 'fragile', 'behaviour': 'works for CLASS but not for CATEGORIES'},
'search.text.case-sensitive': {'support': 'unsupported'},
'search.time-range.alarm': {'support': 'unsupported'},
"search.recurrences.expanded.exception": False,
'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'],
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
'scheduling.schedule-tag': False,
Expand All @@ -1109,7 +1110,6 @@ def dotted_feature_set_list(self, compact=False):
"http.multiplexing": "fragile", ## ref https://github.com/python-caldav/caldav/issues/564
'search.comp-type.optional': {'support': 'ungraceful'},
'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'},
Expand All @@ -1134,7 +1134,6 @@ 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'},
Expand All @@ -1156,7 +1155,6 @@ def dotted_feature_set_list(self, compact=False):

## See comments on https://github.com/python-caldav/caldav/issues/3
#icloud = [
# 'unique_calendar_ids',
# 'duplicate_in_other_calendar_with_same_uid_breaks',
# 'sticky_events',
# 'no_journal', ## it threw a 500 internal server error!
Expand All @@ -1175,7 +1173,6 @@ def dotted_feature_set_list(self, compact=False):
# into their calendar.
"scheduling.schedule-tag": False,
"search.comp-type.optional": { "support": "fragile" },
"search.recurrences.expanded.exception": { "support": "unsupported" },
"search.time-range.alarm": { "support": "unsupported" },
'sync-token': {'support': 'fragile'},
'principal-search': {'support': 'unsupported'},
Expand Down Expand Up @@ -1285,7 +1282,6 @@ def dotted_feature_set_list(self, compact=False):
"search.comp-type.optional": { "support": "ungraceful" },
"search.recurrences.expanded.todo": { "support": "unsupported" },
"search.recurrences.expanded.event": { "support": "fragile" },
"search.recurrences.expanded.exception": { "support": "unsupported" },
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
'principal-search': {'support': 'ungraceful'},
'freebusy-query': {'support': 'ungraceful'},
Expand Down Expand Up @@ -1327,7 +1323,6 @@ def dotted_feature_set_list(self, compact=False):
## foo ... "full" observed, 70938dc1cbb6a839978eee4315699746d38ee5f0/3cae24cf99da1702b851b5a74a9b88c8e5317dad, 2026-02-17
#'search.time-range.todo.old-dates': {'support': 'unsupported'},
'search.recurrences.expanded.todo': {'support': 'unsupported'},
'search.recurrences.expanded.exception': {'support': 'unsupported'},
'search.recurrences.includes-implicit.todo': {'support': 'unsupported'},
'search.combined-is-logical-and': {'support': 'unsupported'},
'sync-token': {'support': 'ungraceful'},
Expand Down Expand Up @@ -1356,7 +1351,6 @@ def dotted_feature_set_list(self, compact=False):
# attendee inbox AND auto-schedules into their calendar.
"scheduling.schedule-tag": 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"},
Expand Down Expand Up @@ -1467,7 +1461,6 @@ def dotted_feature_set_list(self, compact=False):
'search.time-range.event': {'support': 'fragile'},
## was: ungraceful - observed unsupported 2026-02 (for .old-dates)
'search.time-range.todo': {'support': 'fragile'},
'search.recurrences.expanded.exception': {'support': 'unsupported'},
'principal-search': {'support': 'ungraceful'},
'principal-search.by-name.self': {'support': 'ungraceful'},
'principal-search.list-all': {'support': 'ungraceful'},
Expand Down
Loading
Loading