-
Notifications
You must be signed in to change notification settings - Fork 107
ADR: HTTPX Async-First Architecture with Thin Sync Wrappers #565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Propose async-first rewrite using HTTPX with thin sync wrappers: - Primary implementation in async using httpx.AsyncClient - Sync API via anyio.from_thread.run() wrappers - Backward compatible sync API (no changes for existing users) - New async API via caldav.aio module Supersedes previous approach in python-caldav#555. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Howdy @tobixen, I'm going to take another crack at this refactor since the recent changes upstream. To kick things off, I wrote an ADR describing the target architecture. Would you be able to take a look and provide any feedback? |
Phase 1 of the async-first architecture (ADR 0001): - Add httpx and anyio dependencies to pyproject.toml - Implement AsyncDAVClient with all core HTTP methods: - request, propfind, proppatch, report, mkcalendar, mkcol - put, post, delete, options - check_dav_support, check_cdav_support, check_scheduling_support - Implement DAVResponse class for parsing server responses - Implement HTTPBearerAuth for httpx - Create sync DAVClient wrapper using anyio.run() - Add placeholder collection modules for Phase 3 The async implementation is the primary source of truth. The sync wrapper delegates all operations via anyio.run(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit implements the async-first object model for the CalDAV library: Async Implementation (caldav/_async/): - davobject.py: AsyncDAVObject base class with async _query, get_properties, set_properties, children, delete methods - collection.py: AsyncCalendarSet, AsyncPrincipal, AsyncCalendar, AsyncScheduleMailbox, AsyncScheduleInbox, AsyncScheduleOutbox - calendarobjectresource.py: AsyncCalendarObjectResource, AsyncEvent, AsyncTodo, AsyncJournal, AsyncFreeBusy Sync Wrappers (caldav/_sync/): - collection.py: Sync wrappers (CalendarSet, Principal, Calendar, etc.) that delegate to async implementations via anyio.run() - calendarobjectresource.py: Sync wrappers (CalendarObjectResource, Event, Todo, Journal, FreeBusy) with same pattern Key design decisions: - Async classes are the primary implementation - Sync wrappers use anyio.run() for each method call - Wrapper classes maintain both _async reference and _sync_client/parent - Factory methods (_from_async) for creating sync objects from async results Note: Search functionality requires CalDAVSearcher.async_search method to be added for full async support. Some Calendar.save_* methods still need implementation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Provides a clean entry point for async users:
from caldav.aio import AsyncDAVClient, AsyncCalendar, AsyncEvent
Exports all async classes from the _async submodules:
- AsyncDAVClient, DAVResponse, HTTPBearerAuth
- AsyncDAVObject
- AsyncCalendar, AsyncCalendarSet, AsyncPrincipal
- AsyncScheduleInbox, AsyncScheduleMailbox, AsyncScheduleOutbox
- AsyncCalendarObjectResource, AsyncEvent, AsyncTodo, AsyncJournal, AsyncFreeBusy
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Add async_search() method to CalDAVSearcher for async calendar operations - Add _async_search_with_comptypes() for searching across component types - Add _request_report_build_resultlist() to AsyncCalendar for async REPORT queries - Add has_component() method to AsyncCalendarObjectResource - Add missing logging import to search.py This enables the async Calendar.search(), events(), todos(), journals(), and *_by_uid() methods to work properly with the async infrastructure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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>
- Add save_event, save_todo, save_journal methods to AsyncCalendar - Add _use_or_create_ics helper method for ical data handling - Wire up sync wrappers to call async implementations - Fix async_search to use async classes instead of sync classes - Add simple httpx integration test suite for sync/async clients 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
The design decisions look good, but I haven't had time to look into the code yet. This will not make it into the caldav 2.x series, it will eventually be caldav 3.x if it looks good and passes all the testing. It does complicate things a bit as I do have some more changes I want to squeeze into 2.x this month before looking into 3..0. |
Skip tests unless: - CALDAV_TEST_URL is explicitly set, OR - A server is reachable at the default URL This prevents test failures in CI/tox when no CalDAV server is running. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Does httpx support DNSSEC? |
|
What does supporting DNSSEC mean? Would this be part of the
import httpx
import httpcore
import dns.resolver
import socket
# --- Custom DNSSEC Transport ---
class DNSSECTransport(httpcore.SyncHTTPTransport):
"""
A custom transport that uses dnspython for DNSSEC-validating resolution.
"""
def __init__(self, transport, dns_servers=None):
self.transport = transport
self.resolver = dns.resolver.Resolver()
# Configure resolver to use specified DNS servers
if dns_servers:
self.resolver.nameservers = dns_servers
# NOTE: For true DNSSEC validation, the configured nameservers
# must be validating resolvers (like 1.1.1.1 or 8.8.8.8).
def request(self, method, url, headers, stream, extensions):
scheme, host_bytes, port, path = url
host = host_bytes.decode("ascii")
# 1. Intercept the host and perform DNS lookup using dnspython
if scheme in (b'http', b'https') and host not in ('localhost', '127.0.0.1'):
try:
# Resolve A (IPv4) records. You might also want to resolve AAAA (IPv6).
# This uses the configured nameservers and performs validation if they support it.
answer = self.resolver.resolve(host, 'A')
ip_address = answer[0].to_text()
# 2. Reconstruct the URL with the IP address instead of the hostname
# The 'Host' header is automatically set to the original hostname by httpx.
# Use socket.getservbyname to get the port if not explicitly given.
if port is None:
port = socket.getservbyname(scheme.decode('ascii'))
# The underlying transport is called with the IP address instead of the host
url_with_ip = (scheme, ip_address.encode('ascii'), port, path)
# 3. Call the underlying transport with the new URL
return self.transport.request(
method,
url_with_ip,
headers,
stream,
extensions
)
except dns.exception.DNSException as e:
# If DNSSEC validation fails, dnspython will likely raise
# a SERVFAIL or similar exception (if configured correctly).
# We can choose to treat this as an unresolvable domain.
raise httpx.ConnectError(f"DNSSEC resolution failed for {host}: {e}")
# For non-HTTP schemes or non-standard hosts, fall back to the base transport
return self.transport.request(method, url, headers, stream, extensions)
# --- Usage Example ---
# Use a well-known validating resolver, e.g., Cloudflare
VALIDATING_DNS = ["1.1.1.1"]
# 1. Create the default transport
default_transport = httpcore.SyncHTTPTransport()
# 2. Wrap it with the custom DNSSEC transport
dnssec_transport = DNSSECTransport(default_transport, dns_servers=VALIDATING_DNS)
# 3. Use the custom transport in the HTTPX Client
with httpx.Client(transport=dnssec_transport) as client:
try:
# Request a domain known to be DNSSEC signed
response = client.get("https://www.dnssec-or-not.com/check/", timeout=10)
print(f"Status for dnssec-or-not.com: {response.status_code}")
# If DNSSEC validation fails in the custom transport, a ConnectError is raised
# Test an unsigned or misconfigured domain (requires finding a suitable test case)
# response_fail = client.get("https://unsigned-domain.com/")
except httpx.ConnectError as e:
print(f"Connection failed: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}") |
I fell into a rabbit hole the other day, I came to realize that the DNS-based service location lookup actually involves a security risk as it may be trivial to highjack the DNS (i.e. on a public hotspot). Validation of DNSSEC records may be the only way to mitigate this risk - but I'm shelving this project as for now, it was too complicated. niquests claim to support DNSSEC validation, but in practice this seems to involve turning on "dns-over-http" and explicitly configuring it to use a doh-provider, I'm not sure if that is a good idea. |
# Conflicts: # caldav/search.py
|
So, version 2.2 has been released, and async support is the next on my list. At the other hand, I will most likely have other priorities than the CalDAV library during December. |
|
|
||
| ## Context | ||
|
|
||
| The caldav library currently uses `requests` (with optional `niquests` support) for HTTP communication. This synchronous-only approach limits the library's usefulness in modern async Python applications. A previous attempt to add async support via parallel implementations created significant code duplication and maintenance burden. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
niquests does support async operations - have any considerations been done to see if it can be utilized?
The biggest problem with niquests seems to be community acceptance. It depends on modifications to the urllib library that was not accepted by the maintainers, so currently niquest depends on an ugly fork of the urllib library.
| ├── collection.py # Principal, Calendar, CalendarSet (~1300 lines) | ||
| ├── calendarobjectresource.py # Event, Todo, Journal, FreeBusy (~1660 lines) | ||
| ├── search.py # CalDAVSearcher (~510 lines) | ||
| ├── requests.py # HTTPBearerAuth (~20 lines) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just had a look at the requests.py file yesterday. The file was introduced by someone else in a pull request as far as I can remember. I'm considering to move request-handling logic from davclient to requests.
| from .collection import Calendar, Principal, CalendarSet | ||
| from .calendarobjectresource import Event, Todo, Journal, FreeBusy | ||
|
|
||
| # caldav/aio.py - Async API |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While we're building a completely new API, it's the perfect time to do major changes. The old API has grown a bit organically and I'm sometimes annoyed by inconsistencies, i.e. not very well thought through were to use properties vs where to use methods. I also heard people disliking the implicit conversions done when accessing event.data vs event.icalendar_instance. Deciding on all those details are of course outside the scope of this ADR, but should be made before releasing CalDAV 3.0.
| [project] | ||
| dependencies = [ | ||
| "httpx>=0.25.0", # Includes anyio as transitive dependency | ||
| "anyio>=4.0.0", # Explicit dependency for sync wrappers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What benefits does anyio give, compared to the latest versions of asyncio?
Anyway, if httpx depends on anyio, then usage of httpx means we'll implicitly depend on anyio, so there is no extra cost involved in depending on anyio here.
|
|
||
| This directory contains Architecture Decision Records (ADRs) for the caldav library. | ||
|
|
||
| ADRs are documents that capture important architectural decisions made during the project's development, along with their context and consequences. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this pattern widely used in other projects? Perhaps it's easier to just use the GitHub tools? At the other hand, it's nice to have things like this recorded in the repository itself, it's nice to avoid depending too much on GItHub.
Next question, where/how is one supposed to discuss the ADRs? Are line-by-line comments through the GitHub review infrastructure a good way to handle it?
| @@ -0,0 +1,38 @@ | |||
| # Architecture Decision Records | |||
|
|
|||
| This directory contains Architecture Decision Records (ADRs) for the caldav library. | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is docs/source the best place to store ADRs? This directory is primarily used for creating user documentation. Will the markdown files be picked up and published by the sphinx-generated documentation? Do we want it to be published with the documentation?
|
Generally, the ADR looks good. Some "food for thoughts" left above. So, the change to async apparently depends on another ADR that hasn't been made yet. When creating a brand new async API, it would be nice to do a complete review of the current API, look into things that obviously doesn't make sense, change things that needs to be changed, etc. I did open an issue on this in #92 |
|
This will affect most of the code. As mentioned in #589 I'd like to replace the "black style" with ruff. I think that ruff should be implemented immediately after the async changeset. |
|
I'm aiming for releasing 3.0 with async by the end of January. I should be able to spend a decent amount of time on the CalDAV library in January. In December and February, probably not so much. |
Summary
anyio.from_thread.run()caldav.aiomoduleThis ADR supersedes the approach in #555, taking a simpler "thin wrapper" approach rather than code generation (unasync).
Key Design Decisions
Test plan
This PR was generated with the help of AI, and reviewed by a Human