Skip to content

Commit e5a3bb4

Browse files
cbcoutinhoclaude
andcommitted
fix: Wire up async-first implementation to public API
Key changes: - Update caldav/__init__.py to import DAVClient from _sync.davclient (the new async-first sync wrapper) - Fix httpx connection pool issues by creating a fresh client per request with connection pooling disabled (max_keepalive_connections=0) - Use async context manager for httpx client to ensure proper cleanup - Update search.py to recognize both sync and async component classes (Event/AsyncEvent, Todo/AsyncTodo, Journal/AsyncJournal) This enables the library to use the new async-first httpx implementation while maintaining full backward compatibility with existing sync code. Tested against live CalDAV server (Nextcloud): - Sync client: principal(), calendars(), events() all work - Async client: same operations work with async/await 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 41be9b5 commit e5a3bb4

4 files changed

Lines changed: 100 additions & 77 deletions

File tree

‎caldav/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
warnings.warn(
1111
"You need to install the `build` package and do a `python -m build` to get caldav.__version__ set correctly"
1212
)
13-
from .davclient import DAVClient
13+
14+
# Import from the new async-first implementation with sync wrappers
15+
from ._sync.davclient import DAVClient
1416
from .search import CalDAVSearcher
1517

1618
## TODO: this should go away in some future version of the library.

‎caldav/_async/davclient.py‎

Lines changed: 74 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -445,20 +445,31 @@ def _auto_url(self, url, features):
445445
return url
446446

447447
async def _get_client(self) -> httpx.AsyncClient:
448-
"""Get or create the httpx AsyncClient."""
449-
if self._client is None:
450-
transport = None
451-
if self._proxy:
452-
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
453-
454-
self._client = httpx.AsyncClient(
455-
auth=self.auth,
456-
timeout=self.timeout,
457-
verify=self.ssl_verify_cert,
458-
cert=self.ssl_cert,
459-
transport=transport,
460-
)
461-
return self._client
448+
"""Get or create the httpx AsyncClient.
449+
450+
When used from sync wrappers (via anyio.run()), we need to create
451+
a fresh client each time because the connection pool gets invalidated
452+
when the event loop closes.
453+
"""
454+
# Always create a fresh client to avoid event loop issues
455+
# The overhead is minimal compared to connection establishment
456+
transport = None
457+
if self._proxy:
458+
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
459+
460+
# Disable connection pooling to avoid stale connections
461+
# when used from sync context with multiple anyio.run() calls
462+
limits = httpx.Limits(max_keepalive_connections=0)
463+
464+
client = httpx.AsyncClient(
465+
auth=self.auth,
466+
timeout=self.timeout,
467+
verify=self.ssl_verify_cert,
468+
cert=self.ssl_cert,
469+
transport=transport,
470+
limits=limits,
471+
)
472+
return client
462473

463474
async def __aenter__(self) -> Self:
464475
await self._get_client()
@@ -518,7 +529,6 @@ async def request(
518529
This is the core method that all other HTTP methods use.
519530
"""
520531
headers = headers or {}
521-
client = await self._get_client()
522532

523533
combined_headers = dict(self.headers)
524534
combined_headers.update(headers or {})
@@ -533,49 +543,57 @@ async def request(
533543
)
534544
)
535545

536-
try:
537-
r = await client.request(
538-
method,
539-
str(url_obj),
540-
content=to_wire(body) if body else None,
541-
headers=combined_headers,
542-
)
543-
log.debug("server responded with %i %s" % (r.status_code, r.reason_phrase))
544-
545-
if (
546-
r.status_code == 401
547-
and "text/html" in self.headers.get("Content-Type", "")
548-
and not self.auth
549-
):
550-
msg = (
551-
"No authentication object was provided. "
552-
"HTML was returned when probing the server for supported authentication types."
546+
# Create a fresh client for each request to avoid event loop issues
547+
# when used from sync context with multiple anyio.run() calls
548+
async with await self._get_client() as client:
549+
try:
550+
r = await client.request(
551+
method,
552+
str(url_obj),
553+
content=to_wire(body) if body else None,
554+
headers=combined_headers,
553555
)
554-
if r.headers.get("WWW-Authenticate"):
555-
auth_types = [
556-
t
557-
for t in self.extract_auth_types(r.headers["WWW-Authenticate"])
558-
if t in ["basic", "digest", "bearer"]
559-
]
560-
if auth_types:
561-
msg += "\nSupported authentication types: %s" % (
562-
", ".join(auth_types)
563-
)
564-
log.warning(msg)
565-
response = DAVResponse(r, self)
566-
except httpx.RequestError:
567-
if self.auth or not self.password:
568-
raise
569-
# Workaround for servers that abort connection instead of 401
570-
r = await client.request(
571-
method="GET",
572-
url=str(url_obj),
573-
headers=combined_headers,
574-
)
575-
if r.status_code != 401:
576-
raise
556+
log.debug(
557+
"server responded with %i %s" % (r.status_code, r.reason_phrase)
558+
)
559+
560+
if (
561+
r.status_code == 401
562+
and "text/html" in self.headers.get("Content-Type", "")
563+
and not self.auth
564+
):
565+
msg = (
566+
"No authentication object was provided. "
567+
"HTML was returned when probing the server for supported authentication types."
568+
)
569+
if r.headers.get("WWW-Authenticate"):
570+
auth_types = [
571+
t
572+
for t in self.extract_auth_types(
573+
r.headers["WWW-Authenticate"]
574+
)
575+
if t in ["basic", "digest", "bearer"]
576+
]
577+
if auth_types:
578+
msg += "\nSupported authentication types: %s" % (
579+
", ".join(auth_types)
580+
)
581+
log.warning(msg)
582+
response = DAVResponse(r, self)
583+
except httpx.RequestError:
584+
if self.auth or not self.password:
585+
raise
586+
# Workaround for servers that abort connection instead of 401
587+
r = await client.request(
588+
method="GET",
589+
url=str(url_obj),
590+
headers=combined_headers,
591+
)
592+
if r.status_code != 401:
593+
raise
594+
response = DAVResponse(r, self)
577595

578-
# Handle authentication
596+
# Handle authentication (outside the client context - will create new client if needed)
579597
if (
580598
r.status_code == 401
581599
and "WWW-Authenticate" in r.headers
@@ -591,11 +609,6 @@ async def request(
591609
"supported authentication methods: basic, digest, bearer"
592610
)
593611

594-
# Recreate client with new auth
595-
if self._client:
596-
await self._client.aclose()
597-
self._client = None
598-
599612
return await self.request(url, method, body, headers)
600613

601614
elif (
@@ -613,11 +626,6 @@ async def request(
613626
self.username = None
614627
self.password = None
615628

616-
# Recreate client with new auth
617-
if self._client:
618-
await self._client.aclose()
619-
self._client = None
620-
621629
return await self.request(str(url_obj), method, body, headers)
622630

623631
# Handle authorization errors

‎caldav/objects.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
* CalendarObjectResource base class -> calendarobjectresource.py
1111
* Event/Todo/Journal/FreeBusy -> calendarobjectresource.py
1212
* Everything else (mostly collection objects) -> collection.py
13+
14+
The async-first implementation lives in _async/ and _sync/ subdirectories.
15+
This module exports the sync (backward-compatible) versions.
1316
"""
14-
## For backward compatibility
17+
## For backward compatibility - import from original modules
18+
## These will be gradually migrated to use the _sync wrappers
1519
from .calendarobjectresource import *
1620
from .collection import *
1721
from .davobject import *

‎caldav/search.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -444,27 +444,36 @@ def build_search_xml_query(
444444
## The only thing I don't support is the component name ('VEVENT').
445445
## Anyway, this code section ensures both comp_filter and comp_class
446446
## is given. Or at least, it tries to ensure it.
447-
for flag, comp_name, comp_class_ in (
448-
("event", "VEVENT", Event),
449-
("todo", "VTODO", Todo),
450-
("journal", "VJOURNAL", Journal),
447+
## Import async classes for comparison
448+
from caldav._async.calendarobjectresource import (
449+
AsyncEvent,
450+
AsyncJournal,
451+
AsyncTodo,
452+
)
453+
454+
for flag, comp_name, comp_classes in (
455+
("event", "VEVENT", (Event, AsyncEvent)),
456+
("todo", "VTODO", (Todo, AsyncTodo)),
457+
("journal", "VJOURNAL", (Journal, AsyncJournal)),
451458
):
452459
flagged = getattr(self, flag)
453460
if flagged:
454461
## event/journal/todo is set, we adjust comp_class accordingly
455-
if self.comp_class is not None and self.comp_class is not comp_class_:
462+
if self.comp_class is not None and self.comp_class not in comp_classes:
456463
raise error.ConsistencyError(
457-
f"inconsistent search parameters - comp_class = {self.comp_class}, want {comp_class_}"
464+
f"inconsistent search parameters - comp_class = {self.comp_class}, want one of {comp_classes}"
458465
)
459-
self.comp_class = comp_class_
466+
if self.comp_class is None:
467+
self.comp_class = comp_classes[0]
460468

461469
if comp_filter and comp_filter.attributes["name"] == comp_name:
462-
self.comp_class = comp_class_
470+
if self.comp_class is None:
471+
self.comp_class = comp_classes[0]
463472
if flag == "todo" and not self.todo and self.include_completed is None:
464473
self.include_completed = True
465474
setattr(self, flag, True)
466475

467-
if self.comp_class == comp_class_:
476+
if self.comp_class in comp_classes:
468477
if comp_filter:
469478
assert comp_filter.attributes["name"] == comp_name
470479
else:

0 commit comments

Comments
 (0)