Date: February 2026 Reviewer: Claude Opus 4.6 (AI-assisted review) Branch: v3.0-dev (119 commits since v2.2.6) Scope: All changes between tags v2.2.6 and HEAD
Status update (commit ff6db30): All "Must Fix" and "Should Fix" items from §8 have been resolved. Remaining open items are v3.1+ and v4.0 work.
Status update (commit f7c51c9): Recommendation 11 largely addressed — sync/async duplication reduced by ~177 lines across five files (see §5 for details).
Status update (commit ad042bb): §1.1 issues 2, 3, 4 fixed (dead code deleted, CalendarInfo collision resolved, response/xml_parsers duplication reduced).
The v3.0 release is a major architectural refactoring introducing Sans-I/O separation, full async support, and comprehensive API modernization -- all while maintaining backward compatibility with v2.x. The scope is large: 159 files changed, ~25,900 lines added, ~4,500 removed.
Overall assessment: The architecture is well-designed and the codebase is in good shape for an alpha release. The Sans-I/O protocol layer is clean and testable. However, there are several bugs that should be fixed before a stable release, significant code duplication between sync/async paths (~650 lines), and some test coverage gaps.
Key findings:
3 bugs that will cause runtime errorsfixed1 security concern (UUID1 leaks MAC address in calendar UIDs)fixed650 lines of sync/async duplication across domain objectsreduced to ~475 lines (commit f7c51c9)- Test coverage gaps in discovery module and sync client unit tests
fixedbreakpoint()left in production code
The protocol layer separates XML construction/parsing from I/O. This is the strongest part of the refactoring.
| File | Lines | Rating | Purpose |
|---|---|---|---|
types.py |
9/10 | Frozen dataclasses: DAVRequest, DAVResponse, PropfindResult, CalendarQueryResult | |
xml_builders.py |
7/10 | Pure functions building XML for PROPFIND, calendar-query, MKCALENDAR, etc. | |
xml_parsers.py |
6/10 | Parse XML responses into typed results | |
__init__.py |
46 | 8/10 | Clean re-exports |
Issues found:
-
BUG: NameError inFIXED --xml_parsers.py:260parse_calendar_multiget_response()was callingparse_calendar_query_response()(missing leading underscore). Fixed by adding the_prefix. -
Dead code inFIXED -- Deletedxml_builders.py_build_freebusy_query_body,_build_mkcol_body, and_to_utc_date_string(the last was a never-wired duplicate of the real_to_utc_date_stringinelements/cdav.py). -
FIXED -- DeletedCalendarInfoname collisionCalendarInfofromprotocol/types.py(it was never instantiated); the canonical one inoperations/calendarset_ops.pyis unaffected. -
Heavy duplication withPARTIALLY FIXED -- Extractedresponse.py_normalize_href()(Confluence %2540 fix + absolute-URL-to-path) intoxml_parsers.py;response.py._strip_to_multistatusandvalidate_statusnow delegate to theirxml_parserscounterparts. The propstat-loop duplication (_find_objects_and_propsvs_extract_properties) is intentionally left: converging them requires changing the return type of the publicexpand_simple_props()API.
Pure functions for CalDAV business logic. Well-structured but has some issues.
| File | Lines | Rating | Purpose |
|---|---|---|---|
base.py |
189 | 8/10 | QuerySpec dataclass, URL helpers |
davobject_ops.py |
293 | 7/10 | DAV property CRUD operations |
calendarobject_ops.py |
531 | 6/10 | Calendar object lifecycle |
calendar_ops.py |
261 | 7/10 | Search and sync-token operations |
calendarset_ops.py |
245 | 7/10 | Calendar collection management |
principal_ops.py |
162 | 7/10 | Principal discovery |
search_ops.py |
445 | 6/10 | Advanced search query building |
Issues found:
-
SECURITY: UUID1 leaks MAC address (FIXED -- Replacedcalendarobject_ops.py:55)uuid.uuid1()withuuid.uuid4(). -
search_ops.pymutates inputs (line 381-389) --_build_search_xml_querycallssetattr(searcher, flag, True)on the passed-in searcher object. This violates the "pure functions" contract and causes side effects in callers. -
MD5 in FIPS environments (FIXED -- Addedcalendar_ops.py:132)usedforsecurity=Falseto bothcalendar_ops.pyandcollection.py. -
Duplicate URL quotingFIXED -- Extracted_quote_uid()helper incalendarobject_ops.py;_generate_url,_find_id_and_path, andcalendarobjectresource.CalendarObjectResource._generate_urlall now call it.quoteimport removed fromcalendarobjectresource.py.
Rating: 9/10 -- Excellent implementation of the State pattern for managing calendar data representations (raw string, icalendar, vobject). Smart optimizations for lazy switching between formats. Addresses issue #613.
Rating: 7/10 -- BaseDAVResponse provides shared XML parsing for sync/async clients. The main duplication with the protocol layer has been reduced; remaining open item is thread-unsafe mutable state.
Issues found:
-
Unguarded index access (line 169)FIXED -- Addedlen(tree) > 0guard beforetree[0]access in_strip_to_multistatus, matching the equivalent guard inxml_parsers.py. -
Thread-unsafe --
self.objects,self.results,self._responsesare mutable instance state set during parsing. If a response object is shared between threads, results could be corrupt.
Rating: 7/10 -- Good ABC extracting shared auth logic, URL handling, and factory functions.
Issues:
CalendarResult.__getattr__(line 255) hidesNonecalendars behindAttributeError-- confusing error message- Missing
__aenter__/__aexit__onCalendarCollection/CalendarResult-- asyncget_calendars()returns a plain list with no cleanup mechanism get_davclientandget_calendarsfactory functions are duplicated inasync_davclient.py
Rating: 6/10 -- Functional but has bugs, duplication, and architectural concerns.
Issues found:
-
BUG:FIXED -- AddedHTTPBearerAuthincompatible with httpx_HttpxBearerAuth(httpx.Auth)class withauth_flowgenerator inside the httpx import block.build_auth_objectnow uses it on the httpx path and falls back toHTTPBearerAuthfor niquests. -
BUG: MissingFIXED -- Addedurl.unauth()callself.url = self.url.unauth()after credentials are extracted from the URL, matching the sync client behaviour. -
Rate-limit handling asymmetryFIXED --AsyncDAVClient.request()now hasrate_limit_time_slept: float = 0, the same adaptive backoff loop as the sync client (each retry adds half the already-slept time), and the same features-based auto-detect ofrate_limit_handle. Retries recursively viaself.request()instead of calling_async_request()directly. -
_auto_urlblocks the event loop -- RFC6764 discovery performs synchronous DNS lookups and HTTP requests insideAsyncDAVClient.__init__(), which is called fromasync get_davclient(). This blocks the event loop. -
Sync import dependency --
async_davclient.py:198imports fromcaldav.davclient, pulling the sync HTTP stack into async contexts._auto_urlshould live incaldav/config.pyorcaldav/discovery.py. -
Password encoding asymmetry -- Sync client encodes password to bytes eagerly (
davclient.py:329), async does not. This creates different code paths for auth building. -
Response parsing boilerplate -- The pattern
if response.status in (200, 207) and response._raw: ...is repeated in ~5 methods. Should be a helper.
Rating: 6/10 -- Mature but has accumulated technical debt.
Issues found:
-
BareFIXED -- Narrowed toexcept:clauses (lines 367, 679)except TypeErrorfor the test teardown fallback (the expected exception whenteardown()takes an argument), andexcept Exceptionforcheck_dav_support(which intentionally catches anything from principal lookup and falls back to root URL). -
Rate-limit auto-detect always enabledFIXED --is_supported('rate-limit', dict)returns{'enable': False}(a truthy non-empty dict) for any unconfigured client; theif rate_limit:check was incorrectly auto-enabling rate limiting for everyone. Changed toif rate_limit and rate_limit.get('enable'):. Also fixed aTypeErrorcrash whenrate_limit_max_sleep is Noneand the>comparison was attempted. -
ALREADY FIXED -- Code already callsNotImplementedErrorfor auth failures (line 996)self._raise_authorization_error()which raisesAuthorizationError. Issue was stale at time of review. -
Type annotation gaps -- Multiple
headers: Mapping[str, str] = Noneparameters whereNoneis not in the union type. -
propfindAPI divergence -- Sync version (line 754) takesprops=Nonewhich can be either XML string or property list. Async version (line 512) has separatebodyandpropsparameters.
Rating: 8/10 -- Clean PEP 562 implementation. import caldav is now fast.
Minor: _LAZY_SUBMODULES could be frozenset. No DAVResponse export (probably intentional).
Rating: 7/10 -- Clean re-export module with backward-compat Async* aliases.
Issue: No get_calendars/get_calendar re-export -- users must import from async_davclient directly.
Rating: 8/10 -- Clean, pure functions with good type annotations.
Minor: WWW-Authenticate parsing (line 31) splits on commas, which fails for headers with commas inside quoted strings (e.g., Bearer challenges with error_description).
Rating: 7/10 -- Solid dual-mode foundation.
Issues:
Production-unsafe assert (FIXED -- Replacedline 99)assert " " not in str(self.url)with an explicitValueError(also fixed theurl=Nonecase that the assert silently passed).- Return type lies for async -- Methods like
get_property(),get_properties(),delete()return coroutines when used with async clients, but annotations saystr | None,dict,None. set_propertiesregression -- Changed from per-property status checking to HTTP status-only checking, losing ability to detect partial PROPPATCH failures.
Rating: 6/10 -- Functional but at 2,054 lines is the largest file and could benefit from extraction.
Issues:
- Missing deprecation warnings --
calendars(),events(),todos()etc. have docstring notes but nowarnings.warn()calls (unlikedate_searchanddavobject.namewhich do emit). Comment: This is intentional. Those are "core features" that was suddenly changed between v2 and v3 - we want people to upgrade to v3 without getting a lot of deprecation warnings thrown in their face. Those warnings will be added in v4. FIXED -- Added_generate_fake_sync_tokenuses MD5 (line 1655)usedforsecurity=False.FIXED -- Deleted the override; parentPrincipal._async_get_propertyoverrides parent (line 352) with incompatible implementation.DAVObject._async_get_propertyhandles it correctly.
Rating: 7/10 -- Good DataState integration but large (1,919 lines).
Issues:
BUG:FIXED -- Was calling_set_deprecated_vobject_instance(line 1248)_get_vobject_instance(inst)(getter, wrong number of arguments); fixed to call_set_vobject_instance(inst).idsetter is a no-op (line 123) -- Intentional design: UID lives in the iCalendar data, not as a separate field. The setter exists to satisfy the parent class__init__without side effects; removing it would cause silent writes that don't take effect. Not a bug.FIXED -- Added_async_loadmissing multiget fallback_async_multiget()toCalendarand_async_load_by_multiget()toCalendarObjectResource;_async_loadnow has the same fallback chain as syncload().- Dual data model risk -- Old
_data/_vobject_instance/_icalendar_instancecoexist with new_state. Manual sync at lines 1206, 1279 could desynchronize.
Rating: 7/10 -- Excellent generator-based Sans-I/O pattern.
Issues:
- Generator error handling (lines 516-545) --
except StopIteration: return []silently swallows premature generator exits, masking bugs. - Double-loading (lines 448-467) -- Objects loaded twice as "partial workaround for #201" with
except Exception: passmasking errors. TypesFactoryshadowing (line 25) -- Class shadowed by instance at module level.
Rating: 7/10 -- Good centralized configuration with clear priority chain.
Issues:
config_sectionname shadowing (line 329) -- Parameter shadows module-level function._extract_conn_params_from_sectionrejects URL-less configs (line 395) -- ReturnsNonefor sections without URL, conflicting with feature-based auto-connect.read_configinconsistency -- ReturnsNonewhen searching defaults (line 91) but{}when explicit file not found (line 116).
Rating: 7/10 -- Comprehensive server database.
Issues:
FIXED -- Replaced withbreakpoint()in production code (line 443)raise ValueError(f"Unknown feature type: {feature_type!r}").incompatibility_descriptiondict still present (lines 660-778) -- Marked "TO BE REMOVED" but still referenced bytest_caldav.py(lines 722, 727, 754). Cannot be deleted until the test code is updated.# fmt: offfor entire 1,366-line file -- Should scope it to just the dict definitions.
| Code Section | davclient.py | async_davclient.py | Similarity | Status |
|---|---|---|---|---|
search_principals |
376-435 | 1107-1168 | ~95% (copy-paste + await) | FIXED — build/parse hoisted to BaseDAVClient |
_get_calendar_home_set |
548-568 | 974-994 | ~95% | open |
get_events |
570-597 | 996-1023 | ~95% | FIXED — hoisted to BaseDAVClient |
get_todos |
599-613 | 1025-1039 | ~95% | FIXED — hoisted to BaseDAVClient |
propfind response parsing |
280-320 | 750-790 | ~90% | open |
| Auth type extraction | 180-210 | 420-450 | ~100% | already in BaseDAVClient |
| Factory functions | 1015-1078 | 1312-1431 | ~80% | open |
| File | Duplicated pairs | Approx. lines | Notes |
|---|---|---|---|
| davobject.py | 6 method pairs | _build_xml_body / _build_propfind_root extracted; _query/_async_query and set_properties/_async_set_properties shortened; _query_properties/_async_query_properties each now 1 line |
|
| collection.py | 8 method pairs | CalendarSet.get_calendars sync migrated to protocol layer; shared _calendars_from_results eliminates 30+ lines of duplication |
|
| calendarobjectresource.py | 4 method pairs | ~100 | open |
| search.py | 2 method pairs | ~50 | open |
response.py and protocol/xml_parsers.py originally shared five pieces of nearly identical logic. Three have been consolidated:
| Piece | Status |
|---|---|
| Multistatus stripping | FIXED — response._strip_to_multistatus now delegates to xml_parsers._strip_to_multistatus |
| Status validation | FIXED — response.validate_status now delegates to xml_parsers._validate_status |
%2540 Confluence workaround + absolute-URL-to-path |
FIXED — extracted as xml_parsers._normalize_href; called from both _parse_response_element and response._parse_response |
Response element parsing (_parse_response vs _parse_response_element) |
open — too much divergence in error handling to merge without risk |
Propstat loops (_find_objects_and_props vs _extract_properties) |
open — _find_objects_and_props must return raw _Element objects for expand_simple_props(); merging requires a public API change |
| Module | Coverage | Rating | Notes |
|---|---|---|---|
caldav/protocol/ |
Excellent | 9/10 | Pure unit tests in test_protocol.py |
caldav/operations/ |
Excellent | 9/10 | 6 dedicated test files |
caldav/async_davclient.py |
Good | 8/10 | test_async_davclient.py; adaptive rate-limit tests added |
caldav/datastate.py |
Good | 7/10 | Covered through calendarobject tests |
caldav/search.py |
Good | 7/10 | test_search.py + integration tests |
caldav/davclient.py |
Low | 5/10 | Rate-limit unit tests added to test_caldav_unit.py; rest still integration-only |
caldav/collection.py |
Moderate | 6/10 | Integration tests cover most paths |
caldav/discovery.py |
None | 0/10 | Zero dedicated tests |
caldav/config.py |
Poor | 3/10 | Module docstring says "test coverage is poor" |
- Error handling scenarios -- No tests for malformed XML, network timeouts, partial responses
- Sync DAVClient unit tests -- No
test_davclient.pymirroringtest_async_davclient.py - Discovery module -- DNS-based discovery has zero test coverage despite security implications
- Deprecation warnings -- No tests verify that deprecated methods emit warnings
| # | Severity | Location | Description | Status |
|---|---|---|---|---|
| 1 | HIGH | xml_parsers.py:260 |
NameError: calls parse_calendar_query_response (missing underscore) |
FIXED |
| 2 | HIGH | async_davclient.py |
HTTPBearerAuth incompatible with httpx -- bearer auth broken on httpx path |
FIXED |
| 3 | MEDIUM | calendarobjectresource.py:1248 |
_set_deprecated_vobject_instance passes arg to no-arg getter |
FIXED |
| 4 | MEDIUM | compatibility_hints.py:443 |
breakpoint() in production code path |
FIXED |
| 5 | MEDIUM | async_davclient.py |
Missing url.unauth() -- credential leak in logs |
FIXED |
| 6 | MEDIUM | calendarobject_ops.py:55 |
UUID1 leaks MAC address in calendar UIDs | FIXED |
| 7 | LOW | davclient.py:367,679 |
Bare except: catches SystemExit/KeyboardInterrupt |
FIXED |
| 8 | LOW | response.py:169 |
Unguarded tree[0] access |
FIXED |
| 9 | LOW | davobject.py:99 |
Production-unsafe assert for URL validation |
FIXED |
Fix the NameError inxml_parsers.py:260(add underscore)Removebreakpoint()fromcompatibility_hints.py:443Fix_set_deprecated_vobject_instanceto call setter not getterReplaceuuid.uuid1()withuuid.uuid4()incalendarobject_ops.py:55Fix bareexcept:to narrower exception types indavclient.pyAddurl.unauth()call toAsyncDAVClient.__init__
FixHTTPBearerAuthfor httpx pathAdd guard fortree[0]access inresponse.py:169Replace productionassertwith proper validation indavobject.py:99Addusedforsecurity=Falseto MD5 calls for FIPS compliance
Reduce sync/async client duplication (DONE — all three hoisted tosearch_principals,get_events,get_todos)BaseDAVClient; XML build/parse extracted to_build_principal_search_query/_parse_principal_search_response;_build_xml_body/_build_propfind_roothelpers added toDAVObject;CalendarSet.get_calendarssync path migrated to protocol layerConsolidatePARTIALLY DONE — three of five duplicated pieces consolidated (see §5.3); response-element parsing and propstat loops remainresponse.pyandprotocol/xml_parsers.pyduplication- Add sync DAVClient unit tests mirroring async test structure
- Add discovery module tests
- Add missing
warnings.warn()to all deprecated methods Remove dead code inDONE — deletedxml_builders.py_build_freebusy_query_body,_build_mkcol_body,_to_utc_date_string- Move
_auto_urlfromdavclient.pyto shared module (also fixes event-loop blocking in async client) - Make
search_ops._build_search_xml_querynot mutate its input - Fix
NotImplementedErrorfor auth failures indavclient.py-- raiseAuthorizationErrorinstead - Fix
_auto_urlblocking the event loop inAsyncDAVClient.__init__
- Address implicit data conversion side effects (issue #613)
- Consider splitting
collection.py(2,054 lines) andcalendarobjectresource.py(1,919 lines) - Fix return type annotations for async-capable methods (use
@overload) - Remove
incompatibility_descriptiondict from compatibility_hints.py
| File | Lines | Purpose |
|---|---|---|
caldav/async_davclient.py |
1,431 | Async HTTP client |
caldav/base_client.py |
480 | Shared client ABC |
caldav/response.py |
Shared response parsing | |
caldav/datastate.py |
246 | Data representation state machine |
caldav/aio.py |
93 | Async entry point |
caldav/lib/auth.py |
69 | Shared auth utilities |
caldav/protocol/types.py |
Request/response dataclasses | |
caldav/protocol/xml_builders.py |
XML construction | |
caldav/protocol/xml_parsers.py |
XML parsing | |
caldav/operations/base.py |
189 | Query specifications |
caldav/operations/search_ops.py |
445 | Search query building |
caldav/operations/calendarobject_ops.py |
531 | Calendar object ops |
caldav/operations/davobject_ops.py |
293 | DAV object ops |
caldav/operations/calendar_ops.py |
261 | Calendar search/sync ops |
caldav/operations/calendarset_ops.py |
245 | Calendar set ops |
caldav/operations/principal_ops.py |
162 | Principal ops |
| File | Lines | Recommendation |
|---|---|---|
collection.py |
2,054 | Extract SynchronizableCalendarObjectCollection, ScheduleMailbox |
calendarobjectresource.py |
1,919 | Extract Todo.complete() and recurring task logic |
async_davclient.py |
1,431 | Reduce by moving shared code to operations layer |
compatibility_hints.py |
1,366 | Consider YAML/JSON for server profiles |
davclient.py |
1,089 | Reduce by moving shared code to operations layer |
| File | Lines | Purpose |
|---|---|---|
test_async_davclient.py |
821 | Async client unit tests |
test_async_integration.py |
466 | Async integration tests |
test_operations_calendarobject.py |
529 | CalendarObject ops tests |
test_protocol.py |
319 | Protocol layer tests |
test_operations_calendarset.py |
277 | CalendarSet ops tests |
test_operations_davobject.py |
288 | DAVObject ops tests |
test_operations_principal.py |
242 | Principal ops tests |
test_operations_calendar.py |
329 | Calendar ops tests |
test_operations_base.py |
192 | Base ops tests |
test_lazy_import.py |
141 | Lazy import verification |