Skip to content

Conversation

@cbcoutinho
Copy link

@cbcoutinho cbcoutinho commented Nov 22, 2025

Summary

  • Proposes async-first architecture for caldav v3.0 using HTTPX
  • All core logic written as async, with thin sync wrappers using anyio.from_thread.run()
  • Backward compatible sync API (no changes for existing users)
  • New async API via caldav.aio module

This ADR supersedes the approach in #555, taking a simpler "thin wrapper" approach rather than code generation (unasync).

Key Design Decisions

  1. HTTPX + AnyIO: Modern HTTP client with native async support, anyio for clean sync-from-async bridging
  2. Single Source of Truth: Only async code is maintained; sync wrappers are minimal delegation
  3. No Code Generation: Runtime wrapping is simpler than unasync-style generation
  4. Backward Compatible: Sync API remains identical for existing users

Test plan

  • Review ADR document for technical accuracy
  • Discuss approach with maintainers
  • If accepted, implement in phases as outlined

This PR was generated with the help of AI, and reviewed by a Human

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>
@cbcoutinho
Copy link
Author

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?

cbcoutinho and others added 6 commits November 22, 2025 16:34
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>
@tobixen
Copy link
Member

tobixen commented Nov 22, 2025

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>
@tobixen
Copy link
Member

tobixen commented Nov 27, 2025

Does httpx support DNSSEC?

@cbcoutinho
Copy link
Author

What does supporting DNSSEC mean? Would this be part of the caldav library itself?

httpx uses the DNS resolver of the host system, and you can override this to use a specific resolver by creating a subclass of httpcore.SyncHTTPTransport similar to the following:

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}")

@tobixen
Copy link
Member

tobixen commented Nov 28, 2025

What does supporting DNSSEC mean? Would this be part of the caldav library itself?

httpx uses the DNS resolver of the host system, and you can override this to use a specific resolver by creating a subclass of httpcore.SyncHTTPTransport similar to the following:

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.

@tobixen
Copy link
Member

tobixen commented Dec 5, 2025

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.
Copy link
Member

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)
Copy link
Member

@tobixen tobixen Dec 5, 2025

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
Copy link
Member

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
Copy link
Member

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.
Copy link
Member

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.
Copy link
Member

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?

@tobixen
Copy link
Member

tobixen commented Dec 5, 2025

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

@tobixen
Copy link
Member

tobixen commented Dec 6, 2025

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.

@tobixen
Copy link
Member

tobixen commented Dec 6, 2025

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants