Skip to content

Commit 3cd8942

Browse files
tobixenclaude
andcommitted
feat: make more methods async-aware and refactor
Invite-handling (accept_invite, decline_invite, tentatively_accept_invite) now returns awaitable coroutines when used in async mode. SynchronizableCalendarObjectCollection.sync() has also been dealt with, togheter with `MailBox.get_items()` and `DAVObject.children()` Also adds design docs: - docs/design/TODO_SCHEDULE_TAG.md (analysis and implementation plan, refs #660) - docs/design/TODO_COMPATIBILITY_HINTS.md (FeatureSet cleanup analysis, refs #659) The code logic here is partly human-created, mostly partly AI-created, certainly under human guideance. The final secision on how to handle async in 3.x has been carved in stone by a human. Test code is predominantly AI-created. The AI-generation involves tedious code duplication work and tedious routine refactoring, chances for mistakes are bigger when doing it by hand than by AI. I've been looking through the changes, and I trust the tests to uncover any errors slipping through. Some of the (many) commits dealing with this have been squashed into this commit. The return type of cached properties should always be an awaitable coroutine in async mode. prompt: Please make an async version of the get_items method in `caldav/collections.py` followup-prompt: I don't want workarounds in _async_get_items for async-unaware get_objects_by_sync_token. Fix _async_get_items assuming get_objects_by_sync_token will be made async-aware. followup-prompt: Please make an async-version of get_objects_by_sync_token prompt: Please investigate those failures: FAILED tests/test_async_integration.py::TestAsyncSchedulingForStalwart (...) prompt: make an async version of _reply_to_invite_request in `caldav/calendarobjectresource.py` prompt: In collection.py and calendarobjectresource.py and possibly in some of the other files as well, many methods are split up into a sync version and an async version. Please make appropriate type-hints for all methods that in async mode will yield a coroutine rather than an object Prompt: Let's refactor the async methods where it's possible. The existing pattern goes like this: * we have a sync version `foo` or `_foo` * we have an async version of the same method, `_async_foo` * `foo` is *most of the time* doing `if self.is_async_client: return self._async_foo(...)` I'd like to reduce the amount of duplicated code as well as to split out the IO-logic as much as possible. As for now, I want to go with this pattern: * `foo` should *always* do the `if self.is_async_client: return self._async_foo(...)`-logic * `self._async_foo` should never be called upon other places * Quite many of the methods are doing some preparations, firing off some other method causing I/O, and then doing some processing of the data returned from the server. Other methods are more complex, having mutliple code lines causing I/O. * For methods containing significant amount of logic (like, two or more code lines) before doing any IO, the `if self.is_async_client: return self._async_foo(...)`-logic should be moved to the last possible point in the method. * For methods containing significant amount of logic after doing the IO, split the logic out in a `_post_foo`-method. (the rules above was later moved to a document and tweaked a bit) prompt: Apply the rules from `docs/design/ASYNC_DUAL_MODE.md` for the `def sync` and `def async_sync` in `collection.py` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 33359d8 commit 3cd8942

14 files changed

Lines changed: 1039 additions & 589 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
3737

3838
* Open-ended time-range search compatibility hints: new `search.time-range.open`, `search.time-range.open.end`, `search.time-range.open.start`, and `search.time-range.open.start.duration` features (RFC4791 section 9.9). Old `no_search_openended` flag and `search.time-range.todo.duration`/`search.time-range.todo.open-start` features migrated. `testTodoSearch` updated to use `is_supported("search.time-range.open.end")` instead of the old compatibility flag.
3939
* RFC 6638 scheduling feature-detection infrastructure: new `scheduling`, `scheduling.mailbox`, and `scheduling.calendar-user-address-set` compatibility hints; legacy `no_scheduling` flags migrated. Default scheduling hints set for all the servers tested.
40+
* New `scheduling.schedule-tag` compatibility flag and tests covering RFC 6638 §3.2–3.3: `testScheduleTagReturnedOnSave`, `testScheduleTagStableOnPartstateUpdate`, `testScheduleTagChangesOnOrganizerUpdate`, `testScheduleTagMismatchRaisesError`, `testScheduleTagMatchSucceeds` — plus async counterparts of all five.
41+
* New `scheduling.schedule-tag.stable-partstat` compatibility hint: RFC6638 §3.2 requires the Schedule-Tag to remain unchanged when an attendee performs a PARTSTAT-only update; CCS does not comply and is marked `unsupported`. `testScheduleTagStableOnPartstateUpdate` (and its async counterpart) now skip on non-compliant servers.
42+
* New `scheduling.auto-schedule` compatibility flag (see Added section). Server entries updated: Baikal, Cyrus, DAViCal, Davis, CCS, Nextcloud, Stalwart gain explicit `inbox-delivery` + `auto-schedule` values; Zimbra: `inbox-delivery=False` + `auto-schedule=True`.
43+
* Scheduling freebusy-query: `scheduling.freebusy-query` feature flag (RFC 6638 outbox POST); `freebusy-query.rfc4791` merged into `freebusy-query` (RFC 4791 REPORT). `testFreeBusy` added to `_TestSchedulingBase`; async counterpart added to `_AsyncTestSchedulingBase`.
44+
* `search.time-range.todo.strict` compatibility flag: server must not return VTODOs whose time span is entirely outside the searched range; xandikos is marked `broken`.
45+
* New `save-load.property.related-to`, `search.time-range.todo.duration`, and `search.time-range.todo.open-start` feature flags replacing old-style flags. RFC links added to all FEATURES entries.
46+
* `_AsyncTestSchedulingBase` added: async counterpart of `_TestSchedulingBase` with `test_invite_and_respond` and `test_freebusy`; `TestAsyncSchedulingFor<Server>` classes generated for each server with `scheduling_users` configured.
47+
* New `scheduling.schedule-tag.stable-partstat` compatibility hint: RFC6638 §3.2 requires the Schedule-Tag to remain unchanged when an attendee performs a PARTSTAT-only update; CCS does not comply and is marked `unsupported`. `testScheduleTagStableOnPartstateUpdate` (and its async counterpart) now skip on non-compliant servers.
4048
* Calendar owner example (`examples/calendar_owner_examples.py`) demonstrating how to retrieve the owner of a calendar via `DAV:owner` and resolve their calendar-user address. `testFindCalendarOwner` now exercises the full owner → principal → `get_vcal_address()` chain. Closes https://github.com/python-caldav/caldav/issues/544
4149
* `testInviteAndRespond` implemented end-to-end: organizer creates an event, invites an attendee, attendee accepts, and the organizer verifies the updated `PARTSTAT`. Per-server compatibility flags applied for known quirks (Baikal, Cyrus, SOGo).
4250
* Multi-user RFC 6638 scheduling tests wired into the Docker server setup for Cyrus and Baikal (pre-populated `user1``user3`/`user1``user5`).

caldav/aio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"""
2828

2929
# Import the async client (this is truly async)
30-
from caldav.async_davclient import AsyncDAVClient, AsyncDAVResponse, get_calendar, get_calendars
30+
from caldav.async_davclient import AsyncDAVClient, DAVResponse, get_calendar, get_calendars
3131
from caldav.async_davclient import get_davclient as get_async_davclient
3232
from caldav.calendarobjectresource import CalendarObjectResource, Event, FreeBusy, Journal, Todo
3333
from caldav.collection import (
@@ -59,7 +59,7 @@
5959
__all__ = [
6060
# Client
6161
"AsyncDAVClient",
62-
"AsyncDAVResponse",
62+
"DAVResponse",
6363
"get_async_davclient",
6464
# Factory functions (async equivalents of caldav.get_calendar / get_calendars)
6565
"get_calendar",

caldav/async_davclient.py

Lines changed: 35 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -71,24 +71,8 @@ def auth_flow(self, request):
7171
from caldav.compatibility_hints import FeatureSet
7272
from caldav.lib import error
7373
from caldav.lib.python_utilities import to_wire
74-
from caldav.lib.url import URL
75-
from caldav.protocol.types import (
76-
CalendarQueryResult,
77-
PropfindResult,
78-
)
79-
from caldav.protocol.xml_builders import (
80-
_build_calendar_multiget_body,
81-
_build_calendar_query_body,
82-
_build_propfind_body,
83-
_build_sync_collection_body,
84-
)
85-
from caldav.protocol.xml_parsers import (
86-
_parse_calendar_query_response,
87-
_parse_propfind_response,
88-
_parse_sync_collection_response,
89-
)
9074
from caldav.requests import HTTPBearerAuth
91-
from caldav.response import BaseDAVResponse
75+
from caldav.response import CalendarQueryResult, DAVResponse, PropfindResult
9276

9377
log = logging.getLogger("caldav")
9478

@@ -98,31 +82,6 @@ def auth_flow(self, request):
9882
from typing import Self
9983

10084

101-
class AsyncDAVResponse(BaseDAVResponse):
102-
"""
103-
Response from an async DAV request.
104-
105-
This class handles the parsing of DAV responses, including XML parsing.
106-
End users typically won't interact with this class directly.
107-
108-
Response parsing methods are inherited from BaseDAVResponse.
109-
110-
New protocol-based attributes:
111-
results: Parsed results from protocol layer (List[PropfindResult], etc.)
112-
sync_token: Sync token from sync-collection response
113-
"""
114-
115-
# Protocol-based parsed results (new interface)
116-
results: list[PropfindResult | CalendarQueryResult] | None = None
117-
sync_token: str | None = None
118-
119-
def __init__(self, response: Any, davclient: Optional["AsyncDAVClient"] = None) -> None:
120-
"""Initialize from httpx.Response or niquests.Response."""
121-
self._init_from_response(response, davclient)
122-
123-
# Response parsing methods are inherited from BaseDAVResponse
124-
125-
12685
class AsyncDAVClient(BaseDAVClient):
12786
"""
12887
Async WebDAV/CalDAV client.
@@ -366,7 +325,7 @@ async def request(
366325
body: str = "",
367326
headers: Mapping[str, str] | None = None,
368327
rate_limit_time_slept: float = 0,
369-
) -> AsyncDAVResponse:
328+
) -> DAVResponse:
370329
"""
371330
Send an async HTTP request, with optional rate-limit sleep-and-retry.
372331
@@ -404,7 +363,7 @@ async def _async_request(
404363
method: str = "GET",
405364
body: str = "",
406365
headers: Mapping[str, str] | None = None,
407-
) -> AsyncDAVResponse:
366+
) -> DAVResponse:
408367
"""
409368
Async HTTP request implementation with auth negotiation.
410369
@@ -463,7 +422,7 @@ async def _async_request(
463422
if auth_types:
464423
msg += "\nSupported authentication types: {}".format(", ".join(auth_types))
465424
log.warning(msg)
466-
response = AsyncDAVResponse(r, self)
425+
response = DAVResponse(r, self)
467426
except Exception:
468427
# Workaround for servers that abort connection on unauthenticated requests
469428
# ref https://github.com/python-caldav/caldav/issues/158
@@ -498,7 +457,7 @@ async def _async_request(
498457
# Retry original request with auth
499458
request_kwargs["auth"] = self.auth
500459
r = await self.session.request(**request_kwargs)
501-
response = AsyncDAVResponse(r, self)
460+
response = DAVResponse(r, self)
502461

503462
# Handle 429/503 rate-limit responses
504463
error.raise_if_rate_limited(r.status_code, str(url_obj), r.headers.get("Retry-After"))
@@ -542,7 +501,7 @@ async def propfind(
542501
depth: int = 0,
543502
headers: Mapping[str, str] | None = None,
544503
props: list[str] | None = None,
545-
) -> AsyncDAVResponse:
504+
) -> DAVResponse:
546505
"""
547506
Send a PROPFIND request.
548507
@@ -554,7 +513,7 @@ async def propfind(
554513
props: List of property names to request (uses protocol layer).
555514
556515
Returns:
557-
AsyncDAVResponse with results attribute containing parsed PropfindResult list.
516+
DAVResponse with results attribute containing parsed PropfindResult list.
558517
"""
559518
# Use protocol layer to build XML if props provided
560519
if props is not None and not body:
@@ -580,7 +539,7 @@ async def report(
580539
body: str = "",
581540
depth: int | None = 0,
582541
headers: Mapping[str, str] | None = None,
583-
) -> AsyncDAVResponse:
542+
) -> DAVResponse:
584543
"""
585544
Send a REPORT request.
586545
@@ -592,7 +551,7 @@ async def report(
592551
headers: Additional headers.
593552
594553
Returns:
595-
AsyncDAVResponse
554+
DAVResponse
596555
"""
597556
final_headers = self._build_method_headers("REPORT", depth, headers)
598557
return await self.request(url or str(self.url), "REPORT", body, final_headers)
@@ -601,7 +560,7 @@ async def options(
601560
self,
602561
url: str | None = None,
603562
headers: Mapping[str, str] | None = None,
604-
) -> AsyncDAVResponse:
563+
) -> DAVResponse:
605564
"""
606565
Send an OPTIONS request.
607566
@@ -610,7 +569,7 @@ async def options(
610569
headers: Additional headers.
611570
612571
Returns:
613-
AsyncDAVResponse
572+
DAVResponse
614573
"""
615574
return await self.request(url or str(self.url), "OPTIONS", "", headers)
616575

@@ -621,7 +580,7 @@ async def proppatch(
621580
url: str,
622581
body: str = "",
623582
headers: Mapping[str, str] | None = None,
624-
) -> AsyncDAVResponse:
583+
) -> DAVResponse:
625584
"""
626585
Send a PROPPATCH request.
627586
@@ -631,7 +590,7 @@ async def proppatch(
631590
headers: Additional headers.
632591
633592
Returns:
634-
AsyncDAVResponse
593+
DAVResponse
635594
"""
636595
final_headers = self._build_method_headers("PROPPATCH", extra_headers=headers)
637596
return await self.request(url, "PROPPATCH", body, final_headers)
@@ -641,7 +600,7 @@ async def mkcol(
641600
url: str,
642601
body: str = "",
643602
headers: Mapping[str, str] | None = None,
644-
) -> AsyncDAVResponse:
603+
) -> DAVResponse:
645604
"""
646605
Send a MKCOL request.
647606
@@ -653,7 +612,7 @@ async def mkcol(
653612
headers: Additional headers.
654613
655614
Returns:
656-
AsyncDAVResponse
615+
DAVResponse
657616
"""
658617
final_headers = self._build_method_headers("MKCOL", extra_headers=headers)
659618
return await self.request(url, "MKCOL", body, final_headers)
@@ -663,7 +622,7 @@ async def mkcalendar(
663622
url: str,
664623
body: str = "",
665624
headers: Mapping[str, str] | None = None,
666-
) -> AsyncDAVResponse:
625+
) -> DAVResponse:
667626
"""
668627
Send a MKCALENDAR request.
669628
@@ -673,7 +632,7 @@ async def mkcalendar(
673632
headers: Additional headers.
674633
675634
Returns:
676-
AsyncDAVResponse
635+
DAVResponse
677636
"""
678637
final_headers = self._build_method_headers("MKCALENDAR", extra_headers=headers)
679638
return await self.request(url, "MKCALENDAR", body, final_headers)
@@ -683,7 +642,7 @@ async def put(
683642
url: str,
684643
body: str,
685644
headers: Mapping[str, str] | None = None,
686-
) -> AsyncDAVResponse:
645+
) -> DAVResponse:
687646
"""
688647
Send a PUT request.
689648
@@ -693,7 +652,7 @@ async def put(
693652
headers: Additional headers.
694653
695654
Returns:
696-
AsyncDAVResponse
655+
DAVResponse
697656
"""
698657
return await self.request(url, "PUT", body, headers)
699658

@@ -702,7 +661,7 @@ async def post(
702661
url: str,
703662
body: str,
704663
headers: Mapping[str, str] | None = None,
705-
) -> AsyncDAVResponse:
664+
) -> DAVResponse:
706665
"""
707666
Send a POST request.
708667
@@ -712,15 +671,15 @@ async def post(
712671
headers: Additional headers.
713672
714673
Returns:
715-
AsyncDAVResponse
674+
DAVResponse
716675
"""
717676
return await self.request(url, "POST", body, headers)
718677

719678
async def delete(
720679
self,
721680
url: str,
722681
headers: Mapping[str, str] | None = None,
723-
) -> AsyncDAVResponse:
682+
) -> DAVResponse:
724683
"""
725684
Send a DELETE request.
726685
@@ -729,7 +688,7 @@ async def delete(
729688
headers: Additional headers.
730689
731690
Returns:
732-
AsyncDAVResponse
691+
DAVResponse
733692
"""
734693
return await self.request(url, "DELETE", "", headers)
735694

@@ -747,7 +706,7 @@ async def calendar_query(
747706
expand: bool = False,
748707
depth: int = 1,
749708
headers: Mapping[str, str] | None = None,
750-
) -> AsyncDAVResponse:
709+
) -> DAVResponse:
751710
"""
752711
Execute a calendar-query REPORT to search for calendar objects.
753712
@@ -763,7 +722,7 @@ async def calendar_query(
763722
headers: Additional headers.
764723
765724
Returns:
766-
AsyncDAVResponse with results containing List[CalendarQueryResult].
725+
DAVResponse with results containing List[CalendarQueryResult].
767726
"""
768727

769728
body, _ = _build_calendar_query_body(
@@ -797,7 +756,7 @@ async def calendar_multiget(
797756
hrefs: list[str] | None = None,
798757
depth: int = 1,
799758
headers: Mapping[str, str] | None = None,
800-
) -> AsyncDAVResponse:
759+
) -> DAVResponse:
801760
"""
802761
Execute a calendar-multiget REPORT to fetch specific calendar objects.
803762
@@ -808,7 +767,7 @@ async def calendar_multiget(
808767
headers: Additional headers.
809768
810769
Returns:
811-
AsyncDAVResponse with results containing List[CalendarQueryResult].
770+
DAVResponse with results containing List[CalendarQueryResult].
812771
"""
813772
body = _build_calendar_multiget_body(hrefs or [])
814773

@@ -835,7 +794,7 @@ async def sync_collection(
835794
props: list[str] | None = None,
836795
depth: int = 1,
837796
headers: Mapping[str, str] | None = None,
838-
) -> AsyncDAVResponse:
797+
) -> DAVResponse:
839798
"""
840799
Execute a sync-collection REPORT for efficient synchronization.
841800
@@ -847,7 +806,7 @@ async def sync_collection(
847806
headers: Additional headers.
848807
849808
Returns:
850-
AsyncDAVResponse with results containing SyncCollectionResult.
809+
DAVResponse with results containing SyncCollectionResult.
851810
"""
852811
body = _build_sync_collection_body(sync_token=sync_token, props=props)
853812

@@ -869,6 +828,12 @@ async def sync_collection(
869828

870829
return response
871830

831+
def _value_or_coroutine(self, value):
832+
return self._async_value_or_coroutine(value)
833+
834+
async def _async_value_or_coroutine(self, value):
835+
return value
836+
872837
# ==================== Authentication Helpers ====================
873838

874839
def build_auth_object(self, auth_types: list[str] | None = None) -> None:
@@ -1121,21 +1086,6 @@ async def principal(self) -> "Principal":
11211086
"""
11221087
return await self.get_principal()
11231088

1124-
def calendar(self, **kwargs: Any) -> "Calendar":
1125-
"""Returns a calendar object.
1126-
1127-
Typically, a URL should be given as a named parameter (url)
1128-
1129-
No network traffic will be initiated by this method.
1130-
1131-
If you don't know the URL of the calendar, use
1132-
``await client.get_principal().get_calendars()`` instead, or
1133-
``await client.get_calendars()``
1134-
"""
1135-
from caldav.collection import Calendar
1136-
1137-
return Calendar(client=self, **kwargs)
1138-
11391089
async def check_dav_support(self) -> str | None:
11401090
"""
11411091
Check if the server supports DAV.

0 commit comments

Comments
 (0)