From 3c70237994a5a591c61f432b8bdf2eda942d7c77 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 19 Oct 2026 17:42:45 +0200 Subject: [PATCH 1/2] fix: caldav.testing server port and stop() leaks RadicaleServer passed no server section to Radicale, so it always listened on 5232. stop() left the server end of the socketpair open, and XandikosServer.stop() never closed its event loop; both raised warnings under -W error. Fixes https://github.com/python-caldav/caldav/issues/797 Prompt: ... start looking into github issues, prioritize issues that are tagged in my calendar, issues recently posted by third parties, but most importantly: issues relevant for [the calendaring library, https://github.com/pycalendar/calendaring] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- caldav/testing.py | 9 +++++++- changes/797.fixed.md | 1 + tests/test_servers/helpers.py | 8 +++++++ tests/test_testing.py | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 changes/797.fixed.md create mode 100644 tests/test_testing.py diff --git a/caldav/testing.py b/caldav/testing.py index 65bab2726..527b80124 100644 --- a/caldav/testing.py +++ b/caldav/testing.py @@ -235,6 +235,9 @@ async def cleanup() -> None: self.thread.join(timeout=5) self.thread = None + if self.xapp_loop and not self.xapp_loop.is_running(): + self.xapp_loop.close() + if self.serverdir: self.serverdir.__exit__(None, None, None) self.serverdir = None @@ -310,6 +313,7 @@ def start(self) -> None: configuration = radicale.config.load("") configuration.update( { + "server": {"hosts": f"{self.host}:{self.port}"}, "storage": {"filesystem_folder": self.serverdir.name}, "auth": {"type": "none"}, } @@ -339,12 +343,15 @@ def stop(self) -> None: if self.shutdown_socket: self.shutdown_socket.close() self.shutdown_socket = None - self.shutdown_socket_out = None if self.thread: self.thread.join(timeout=5) self.thread = None + if self.shutdown_socket_out: + self.shutdown_socket_out.close() + self.shutdown_socket_out = None + if self.serverdir: self.serverdir.__exit__(None, None, None) self.serverdir = None diff --git a/changes/797.fixed.md b/changes/797.fixed.md new file mode 100644 index 000000000..d391d52ea --- /dev/null +++ b/changes/797.fixed.md @@ -0,0 +1 @@ +`caldav.testing`: `RadicaleServer` ignored its configured port, so Radicale always listened on 5232 and any other port timed out at startup. `RadicaleServer.stop()` now closes both ends of its shutdown socketpair, and `XandikosServer.stop()` closes its event loop, so neither leaves a `ResourceWarning` behind. diff --git a/tests/test_servers/helpers.py b/tests/test_servers/helpers.py index 6d7834246..02b396fcb 100644 --- a/tests/test_servers/helpers.py +++ b/tests/test_servers/helpers.py @@ -6,6 +6,7 @@ """ import os +import socket from contextlib import contextmanager from caldav import DAVClient @@ -13,6 +14,13 @@ from .registry import get_registry +def free_port() -> int: + """A TCP port on localhost that was free a moment ago.""" + with socket.socket() as s: + s.bind(("localhost", 0)) + return s.getsockname()[1] + + @contextmanager def client_context(server_index: int = 0, server_name: str | None = None): """ diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 000000000..16163ebdb --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,42 @@ +""" +Tests for the embedded servers in caldav.testing. + +https://github.com/python-caldav/caldav/issues/797 +""" + +import pytest + +from caldav.lib.http_sync import requests +from caldav.testing import RadicaleServer, XandikosServer + +from .test_servers.helpers import free_port + + +def test_radicale_listens_on_configured_port() -> None: + pytest.importorskip("radicale") + port = free_port() + server = RadicaleServer({"port": port}) + server.start() + try: + r = requests.get(f"http://localhost:{port}/{server.username}/", timeout=5) + assert r.status_code in (200, 207, 401, 403, 404) + finally: + server.stop() + + +def test_radicale_stop_closes_both_socket_ends() -> None: + pytest.importorskip("radicale") + server = RadicaleServer({"port": free_port()}) + server.start() + sockets = (server.shutdown_socket, server.shutdown_socket_out) + server.stop() + assert all(s.fileno() == -1 for s in sockets) + + +def test_xandikos_stop_closes_event_loop() -> None: + pytest.importorskip("xandikos") + server = XandikosServer({"port": free_port()}) + server.start() + loop = server.xapp_loop + server.stop() + assert loop.is_closed() From 6cfc27590f5e5caa4f9dbdc80843d7a4f3798e88 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 19 Oct 2026 20:39:23 +0200 Subject: [PATCH 2/2] fix: async factories: config sections, discovery aio.get_calendars() was a drifted copy of the sync one and resolved a single section only. Client resolution is now one shared helper, run in a thread from async code, as aio.get_davclient() now is too, so the config file and RFC 6764 discovery no longer block the event loop. _auto_url moved to base_client, so an httpx-only install can create a client. Like the sync one, aio.get_calendars() no longer probes with OPTIONS, and a failed principal lookup obeys raise_errors. Fixes https://github.com/python-caldav/caldav/issues/793 Fixes https://github.com/python-caldav/caldav/issues/794 Prompt: Let's bundle small things in a PR, #797, #794, #793 are all small things I think? If you agree, go for it, create a PR with those, and we'll take #598 and #786 in the next session Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- caldav/async_davclient.py | 208 ++++++++++++++++----------- caldav/base_client.py | 145 +++++++++++++++++-- caldav/davclient.py | 72 +--------- changes/793.fixed.md | 1 + changes/794.fixed.md | 1 + tests/test_async_davclient.py | 24 ++++ tests/test_get_calendars_sections.py | 72 ++++++++++ tests/test_http_libraries.py | 30 ++++ 8 files changed, 385 insertions(+), 168 deletions(-) create mode 100644 changes/793.fixed.md create mode 100644 changes/794.fixed.md create mode 100644 tests/test_get_calendars_sections.py diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index 236abd424..aadcc1093 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -7,6 +7,7 @@ """ import asyncio +import contextlib import importlib import inspect import logging @@ -96,7 +97,7 @@ def auth_flow(self, request): raise ImportError(_NO_ASYNC_LIBRARY_ERROR) from caldav import __version__ -from caldav.base_client import BaseDAVClient +from caldav.base_client import BaseDAVClient, _auto_url from caldav.base_client import get_davclient as _base_get_davclient from caldav.compatibility_hints import FeatureSet, at_spellings_are_aliased from caldav.lib import error @@ -153,6 +154,8 @@ def __init__( Args: url: CalDAV server URL, domain, or email address. + A domain or email address triggers RFC 6764 discovery, which + blocks; use :func:`get_davclient` to run it outside the event loop. proxy: Proxy server (e.g. http://proxy.example.com:8080). username: Username for authentication. password: Password for authentication. @@ -214,8 +217,6 @@ def __init__( self._create_session() # Auto-construct URL if needed (RFC6764 discovery, etc.) - from caldav.davclient import _auto_url - url_str, discovered_username = _auto_url( url, self.features, @@ -1208,7 +1209,11 @@ async def get_davclient(probe: bool = True, **kwargs: Any) -> AsyncDAVClient: async with await get_davclient(url="...", username="...", password="...") as client: principal = await client.principal() """ - client = _base_get_davclient(AsyncDAVClient, **kwargs) + ## Reading the config file and RFC 6764 discovery (done by the client + ## constructor for a bare domain or e-mail address) are blocking, so the + ## client is created in a thread, ref + ## https://github.com/python-caldav/caldav/issues/794 + client = await asyncio.to_thread(_base_get_davclient, AsyncDAVClient, **kwargs) if client is None: raise ValueError( @@ -1261,11 +1266,51 @@ async def get_calendars( for cal in calendars: print(await cal.get_display_name()) """ - from caldav.base_client import ( - CalendarCollection, - _normalize_to_list, - _warn_unreadable_display_name, - ) + from caldav.base_client import CalendarCollection, _get_calendars_targets + + ## Reading the config file and RFC 6764 discovery are blocking, so the + ## clients are resolved in a thread, ref + ## https://github.com/python-caldav/caldav/issues/794 + try: + targets = await asyncio.to_thread( + _get_calendars_targets, + AsyncDAVClient, + calendar_url=calendar_url, + calendar_name=calendar_name, + raise_errors=raise_errors, + **kwargs, + ) + except Exception as e: + if raise_errors: + raise + log.error(f"Failed to create async client: {e}") + return CalendarCollection() + + clients = [t[0] for t in targets] + calendars: list = [] + try: + for client, eff_cal_url, eff_cal_name in targets: + calendars.extend( + await _fetch_calendars_for_async_client( + client, eff_cal_url, eff_cal_name, raise_errors + ) + ) + except Exception: + for client in clients: + with contextlib.suppress(Exception): + await client.close() + raise + return CalendarCollection(calendars, clients=clients) + + +async def _fetch_calendars_for_async_client( + client: AsyncDAVClient, + calendar_url: Any | None, + calendar_name: Any | None, + raise_errors: bool, +) -> list: + """Async counterpart of :func:`caldav.base_client._fetch_calendars_for_client`.""" + from caldav.base_client import _normalize_to_list, _warn_unreadable_display_name def _try(coro_result, errmsg): """Handle errors based on raise_errors flag.""" @@ -1276,90 +1321,83 @@ def _try(coro_result, errmsg): return coro_result try: - client = await get_davclient(probe=True, **kwargs) + principal = await client.get_principal() except Exception as e: + log.error(f"Problems fetching calendar information: getting principal - {e}") if raise_errors: raise - log.error(f"Failed to create async client: {e}") - return CalendarCollection() - - try: - principal = await client.get_principal() - if not principal: - _try(None, "getting principal") - return CalendarCollection(client=client) - - calendars = [] - calendar_urls = _normalize_to_list(calendar_url) - calendar_names = _normalize_to_list(calendar_name) - - # Fetch specific calendars by URL/ID - for cal_url in calendar_urls: - if "/" in str(cal_url): - calendar = principal.calendar(cal_url=cal_url) - else: - calendar = principal.calendar(cal_id=cal_url) - ## A bare cal_id has to resolve the calendar home set first, so - ## principal.calendar() hands back a coroutine here. Without the - ## await the AttributeError below was caught by the broad except - ## and the calendar was silently dropped from the collection. - if inspect.isawaitable(calendar): - calendar = await calendar - - try: - display_name = await calendar.get_display_name() - if display_name is not None: - calendars.append(calendar) - except Exception as e: - log.error(f"Problems fetching calendar {cal_url}: {e}") - if raise_errors: - raise + return [] + if not principal: + _try(None, "getting principal") + return [] + + calendars = [] + calendar_urls = _normalize_to_list(calendar_url) + calendar_names = _normalize_to_list(calendar_name) + + # Fetch specific calendars by URL/ID + for cal_url in calendar_urls: + if "/" in str(cal_url): + calendar = principal.calendar(cal_url=cal_url) + else: + calendar = principal.calendar(cal_id=cal_url) + ## A bare cal_id has to resolve the calendar home set first, so + ## principal.calendar() hands back a coroutine here. Without the + ## await the AttributeError below was caught by the broad except + ## and the calendar was silently dropped from the collection. + if inspect.isawaitable(calendar): + calendar = await calendar - # Fetch specific calendars by name - if calendar_names: - try: - all_cals_for_name = await principal.get_calendars() - for cal_name in calendar_names: - for cal in all_cals_for_name: - try: - display_name = await cal.get_display_name() - if display_name == cal_name: - calendars.append(cal) - break - except Exception as e: - # Skip calendars whose display name can't be read; warn - # only when the failure is unexpected (see helper). - # Continuing ensures one unreadable calendar doesn't abort - # the whole name lookup. - _warn_unreadable_display_name(client, cal, cal_name, e) - continue - else: - log.error(f"No calendar with name '{cal_name}' found") - if raise_errors: - raise error.NotFoundError(f"No calendar with name '{cal_name}' found") - except error.NotFoundError: + try: + display_name = await calendar.get_display_name() + if display_name is not None: + calendars.append(calendar) + except Exception as e: + log.error(f"Problems fetching calendar {cal_url}: {e}") + if raise_errors: raise - except Exception as e: - log.error(f"Problems fetching calendars by name: {e}") - if raise_errors: - raise - # If no specific calendars requested, get all calendars - if not calendars and not calendar_urls and not calendar_names: - try: - all_cals = await principal.get_calendars() - if all_cals: - calendars = all_cals - except Exception as e: - log.error(f"Problems fetching all calendars: {e}") - if raise_errors: - raise + # Fetch specific calendars by name + if calendar_names: + try: + all_cals_for_name = await principal.get_calendars() + for cal_name in calendar_names: + for cal in all_cals_for_name: + try: + display_name = await cal.get_display_name() + if display_name == cal_name: + calendars.append(cal) + break + except Exception as e: + # Skip calendars whose display name can't be read; warn + # only when the failure is unexpected (see helper). + # Continuing ensures one unreadable calendar doesn't abort + # the whole name lookup. + _warn_unreadable_display_name(client, cal, cal_name, e) + continue + else: + log.error(f"No calendar with name '{cal_name}' found") + if raise_errors: + raise error.NotFoundError(f"No calendar with name '{cal_name}' found") + except error.NotFoundError: + raise + except Exception as e: + log.error(f"Problems fetching calendars by name: {e}") + if raise_errors: + raise - return CalendarCollection(calendars, client=client) + # If no specific calendars requested, get all calendars + if not calendars and not calendar_urls and not calendar_names: + try: + all_cals = await principal.get_calendars() + if all_cals: + calendars = all_cals + except Exception as e: + log.error(f"Problems fetching all calendars: {e}") + if raise_errors: + raise - except Exception: - await client.__aexit__(None, None, None) - raise + return calendars async def get_calendar(**kwargs: Any) -> "CalendarResult": diff --git a/caldav/base_client.py b/caldav/base_client.py index 7fdc35b12..a887b1ad8 100644 --- a/caldav/base_client.py +++ b/caldav/base_client.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import logging from abc import ABC, abstractmethod from collections.abc import Callable, Mapping @@ -83,6 +84,83 @@ def _raise_unless_propfind_ok(response: Any) -> None: raise error.error_from_response(error.PropfindError, response) +def _auto_url( + url, + features, + timeout=10, + ssl_verify_cert=True, + enable_rfc6764=True, + username=None, + require_tls=True, +): + """ + Auto-construct URL from domain and features, with optional RFC6764 discovery. + + Args: + url: User-provided URL, domain, or email address + features: FeatureSet object or dict + timeout: Timeout for RFC6764 well-known URI lookups + ssl_verify_cert: SSL verification setting + enable_rfc6764: Whether to attempt RFC6764 discovery + username: Username to use for discovery if URL is not provided + require_tls: Only accept TLS connections during discovery (default: True) + + Returns: + A tuple of (url_string, discovered_username_or_None) + The discovered_username will be extracted from email addresses like user@example.com + """ + if isinstance(features, dict): + from caldav.compatibility_hints import FeatureSet + + features = FeatureSet(features) + + # If URL already has a path component, don't do discovery + if url and "/" in str(url): + return (url, None) + + # If no URL provided but username contains @, use username for discovery + if not url and username and "@" in str(username) and enable_rfc6764: + log.debug(f"No URL provided, using username for RFC6764 discovery: {username}") + url = username + + # Try RFC6764 discovery first if enabled and we have a bare domain/email + if enable_rfc6764 and url: + ## caldav.discovery needs the sync HTTP library, which an + ## httpx-only install lacks. Fall back as on a failed discovery. + try: + from caldav.discovery import DiscoveryError, discover_caldav + except ImportError: + log.warning("RFC6764 discovery needs niquests or requests; skipping it") + else: + try: + service_info = discover_caldav( + identifier=url, + timeout=timeout, + ssl_verify_cert=ssl_verify_cert if isinstance(ssl_verify_cert, bool) else True, + require_tls=require_tls, + ) + if service_info: + log.info( + f"RFC6764 discovered service: {service_info.url} (source: {service_info.source})" + ) + if service_info.username: + log.debug(f"Username discovered from email: {service_info.username}") + return (service_info.url, service_info.username) + except DiscoveryError as e: + log.debug(f"RFC6764 discovery failed: {e}") + except Exception as e: + log.debug(f"RFC6764 discovery error: {e}") + + # Fall back to feature-based URL construction + url_hints = features.is_supported("auto-connect.url", dict) + # If URL is still empty or looks like an email (from failed discovery attempt), + # replace it with the domain from hints + if (not url or (url and "@" in str(url))) and "domain" in url_hints: + url = url_hints["domain"] + url = f"{url_hints.get('scheme', 'https')}://{url}{url_hints.get('basepath', '')}" + return (url, None) + + class BaseDAVClient(ABC): """ Base class for DAV clients providing shared authentication and configuration logic. @@ -1061,6 +1139,55 @@ def get_calendars( for cal in cals: print(cal.get_display_name()) """ + targets = _get_calendars_targets( + client_class, + calendar_url=calendar_url, + calendar_name=calendar_name, + check_config_file=check_config_file, + config_file=config_file, + config_section=config_section, + testconfig=testconfig, + environment=environment, + name=name, + raise_errors=raise_errors, + **config_data, + ) + clients = [t[0] for t in targets] + calendars: list = [] + try: + for client, eff_cal_url, eff_cal_name in targets: + calendars.extend( + _fetch_calendars_for_client(client, eff_cal_url, eff_cal_name, raise_errors) + ) + except Exception: + for client in clients: + with contextlib.suppress(Exception): + client.close() + raise + return CalendarCollection(calendars, clients=clients) + + +def _get_calendars_targets( + client_class: type, + calendar_url: Any | None = None, + calendar_name: Any | None = None, + check_config_file: bool = True, + config_file: str | None = None, + config_section: str | None = None, + testconfig: bool = False, + environment: bool = True, + name: str | None = None, + raise_errors: bool = False, + **config_data, +) -> list[tuple[Any, Any, Any]]: + """ + Resolve the configuration of :func:`get_calendars` into + ``(client, calendar_url, calendar_name)`` triples, one per server. + + Shared by the sync and the async ``get_calendars()``. It does + blocking I/O (config file, RFC 6764 discovery), so the async caller + runs it in a thread. + """ from caldav import config as _config # ── Priority 1-3: explicit params / test mode / env vars ────────────── @@ -1078,14 +1205,13 @@ def get_calendars( ) if client is not None: - calendars = _fetch_calendars_for_client(client, calendar_url, calendar_name, raise_errors) - return CalendarCollection(calendars, client=client) + return [(client, calendar_url, calendar_name)] # ── Priority 4: config file (may expand to multiple sections) ───────── if not check_config_file: if raise_errors: raise ValueError("Could not create DAV client - no configuration found") - return CalendarCollection() + return [] # Resolve config_file path from env if not given (mirrors get_connection_params) resolved_config_file = config_file @@ -1105,13 +1231,11 @@ def get_calendars( if not all_params: if raise_errors: raise ValueError("Could not create DAV client - no configuration found") - return CalendarCollection() + return [] from caldav.config import CONNKEYS - all_calendars: list = [] - all_clients: list = [] - + targets: list[tuple[Any, Any, Any]] = [] for params in all_params: # Per-section calendar filters — function-level args override them sec_cal_url = params.pop("calendar_url", None) @@ -1120,12 +1244,9 @@ def get_calendars( eff_cal_name = calendar_name if calendar_name is not None else sec_cal_name conn_params = {k: v for k, v in params.items() if k in CONNKEYS} - c = client_class(**conn_params) - section_cals = _fetch_calendars_for_client(c, eff_cal_url, eff_cal_name, raise_errors) - all_calendars.extend(section_cals) - all_clients.append(c) + targets.append((client_class(**conn_params), eff_cal_url, eff_cal_name)) - return CalendarCollection(all_calendars, clients=all_clients) + return targets def get_davclient( diff --git a/caldav/davclient.py b/caldav/davclient.py index e5e2c5064..b82ef53e2 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -18,7 +18,7 @@ from urllib.parse import unquote from caldav import __version__, config -from caldav.base_client import BaseDAVClient +from caldav.base_client import BaseDAVClient, _auto_url from caldav.base_client import get_calendars as _base_get_calendars from caldav.base_client import get_davclient as _base_get_davclient from caldav.collection import Calendar, Principal @@ -83,76 +83,6 @@ from caldav.config import resolve_features as _resolve_features -def _auto_url( - url, - features, - timeout=10, - ssl_verify_cert=True, - enable_rfc6764=True, - username=None, - require_tls=True, -): - """ - Auto-construct URL from domain and features, with optional RFC6764 discovery. - - Args: - url: User-provided URL, domain, or email address - features: FeatureSet object or dict - timeout: Timeout for RFC6764 well-known URI lookups - ssl_verify_cert: SSL verification setting - enable_rfc6764: Whether to attempt RFC6764 discovery - username: Username to use for discovery if URL is not provided - require_tls: Only accept TLS connections during discovery (default: True) - - Returns: - A tuple of (url_string, discovered_username_or_None) - The discovered_username will be extracted from email addresses like user@example.com - """ - if isinstance(features, dict): - features = FeatureSet(features) - - # If URL already has a path component, don't do discovery - if url and "/" in str(url): - return (url, None) - - # If no URL provided but username contains @, use username for discovery - if not url and username and "@" in str(username) and enable_rfc6764: - log.debug(f"No URL provided, using username for RFC6764 discovery: {username}") - url = username - - # Try RFC6764 discovery first if enabled and we have a bare domain/email - if enable_rfc6764 and url: - from caldav.discovery import DiscoveryError, discover_caldav - - try: - service_info = discover_caldav( - identifier=url, - timeout=timeout, - ssl_verify_cert=ssl_verify_cert if isinstance(ssl_verify_cert, bool) else True, - require_tls=require_tls, - ) - if service_info: - log.info( - f"RFC6764 discovered service: {service_info.url} (source: {service_info.source})" - ) - if service_info.username: - log.debug(f"Username discovered from email: {service_info.username}") - return (service_info.url, service_info.username) - except DiscoveryError as e: - log.debug(f"RFC6764 discovery failed: {e}") - except Exception as e: - log.debug(f"RFC6764 discovery error: {e}") - - # Fall back to feature-based URL construction - url_hints = features.is_supported("auto-connect.url", dict) - # If URL is still empty or looks like an email (from failed discovery attempt), - # replace it with the domain from hints - if (not url or (url and "@" in str(url))) and "domain" in url_hints: - url = url_hints["domain"] - url = f"{url_hints.get('scheme', 'https')}://{url}{url_hints.get('basepath', '')}" - return (url, None) - - class DAVClient(BaseDAVClient): """ Basic client for webdav, uses the niquests lib; gives access to diff --git a/changes/793.fixed.md b/changes/793.fixed.md new file mode 100644 index 000000000..c4cac8fe7 --- /dev/null +++ b/changes/793.fixed.md @@ -0,0 +1 @@ +`aio.get_calendars()` ignored meta-sections, `*` and glob patterns in `config_section`, as well as the `calendar_name` and `calendar_url` filters of a config section. It now finds the same calendars as the sync `get_calendars()`, and behaves like it on errors too: it no longer sends an OPTIONS probe first, so an unreachable server raises the error from the principal lookup rather than a `DAVError` saying "Failed to connect", and with `raise_errors=False` such an error is logged and the server skipped rather than raised. diff --git a/changes/794.fixed.md b/changes/794.fixed.md new file mode 100644 index 000000000..c59706d5c --- /dev/null +++ b/changes/794.fixed.md @@ -0,0 +1 @@ +Creating an `AsyncDAVClient` failed with an `ImportError` when only an httpx-family library was installed. RFC 6764 discovery still needs niquests or requests, and is skipped with a warning without them. `aio.get_davclient()` and `aio.get_calendars()` now read the config file and do RFC 6764 discovery in a worker thread, so they no longer block the event loop. diff --git a/tests/test_async_davclient.py b/tests/test_async_davclient.py index b8ecee709..1a96f30bd 100644 --- a/tests/test_async_davclient.py +++ b/tests/test_async_davclient.py @@ -634,6 +634,30 @@ async def test_get_davclient_without_probe(self) -> None: assert client is not None assert isinstance(client, AsyncDAVClient) + @pytest.mark.asyncio + async def test_get_davclient_discovers_off_the_event_loop(self) -> None: + """RFC 6764 discovery does blocking DNS and HTTP, so it must not run + on the event loop's thread, ref + https://github.com/python-caldav/caldav/issues/794""" + import threading + + from caldav.discovery import DiscoveryError + + threads = [] + + def fake_discover(**kwargs): + threads.append(threading.current_thread()) + raise DiscoveryError("no discovery in unit tests") + + with patch("caldav.discovery.discover_caldav", fake_discover): + client = await get_davclient( + url="caldav.example.com", username="user", password="pass", probe=False + ) + + assert threads and threads[0] is not threading.current_thread() + assert client.url.hostname == "caldav.example.com" + await client.close() + @pytest.mark.asyncio async def test_get_davclient_env_vars(self) -> None: """Test get_davclient with environment variables.""" diff --git a/tests/test_get_calendars_sections.py b/tests/test_get_calendars_sections.py new file mode 100644 index 000000000..ccdda3e4c --- /dev/null +++ b/tests/test_get_calendars_sections.py @@ -0,0 +1,72 @@ +""" +get_calendars() with config-file meta-sections, globs and per-section +calendar filters, in both sync and async mode, against an embedded Radicale. + +https://github.com/python-caldav/caldav/issues/793 +""" + +import asyncio +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from caldav import DAVClient, aio, get_calendars +from caldav.testing import RadicaleServer + +from .test_servers.helpers import free_port + +pytest.importorskip("radicale") + + +@pytest.fixture(scope="module") +def config_file(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Path]: + server = RadicaleServer({"port": free_port()}) + server.start() + try: + with DAVClient(url=server.url, username=server.username, password="") as client: + principal = client.principal() + for name in ("Work", "Home"): + principal.make_calendar(name=name) + section = f" caldav_url: {server.url}/\n caldav_username: {server.username}\n caldav_password: x\n" + path = tmp_path_factory.mktemp("config") / "calendar.conf" + path.write_text( + f"work:\n{section} calendar_name: Work\n" + f"home:\n{section} calendar_name: Home\n" + "both:\n contains: [work, home]\n" + ) + yield path + finally: + server.stop() + + +async def _async_names(**kwargs) -> list[str]: + async with await aio.get_calendars(**kwargs) as calendars: + return sorted([await c.get_display_name() for c in calendars]) + + +def _names(mode: str, **kwargs) -> list[str]: + kwargs.setdefault("environment", False) + if mode == "async": + return asyncio.run(_async_names(**kwargs)) + with get_calendars(**kwargs) as calendars: + return sorted(c.get_display_name() for c in calendars) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +class TestConfigSections: + def test_section_calendar_filter(self, mode: str, config_file: Path) -> None: + assert _names(mode, config_file=str(config_file), config_section="work") == ["Work"] + + def test_meta_section(self, mode: str, config_file: Path) -> None: + names = _names(mode, config_file=str(config_file), config_section="both") + assert names == ["Home", "Work"] + + def test_glob(self, mode: str, config_file: Path) -> None: + assert _names(mode, config_file=str(config_file), config_section="h*") == ["Home"] + + def test_argument_overrides_section_filter(self, mode: str, config_file: Path) -> None: + names = _names( + mode, config_file=str(config_file), config_section="work", calendar_name="Home" + ) + assert names == ["Home"] diff --git a/tests/test_http_libraries.py b/tests/test_http_libraries.py index b0a35f20f..41be3c815 100644 --- a/tests/test_http_libraries.py +++ b/tests/test_http_libraries.py @@ -253,6 +253,36 @@ def find_spec(self, fullname, path=None, target=None): + (result.stderr or result.stdout) ) + def test_constructing_a_client_works(self) -> None: + ## https://github.com/python-caldav/caldav/issues/794 + code = textwrap.dedent( + """ + import sys + + class Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] in ("niquests", "requests"): + raise ImportError("blocked by the test: " + fullname) + return None + + sys.meta_path.insert(0, Blocker()) + from caldav.async_davclient import AsyncDAVClient + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + assert str(client.url) == "https://caldav.example.com/dav/" + ## A bare domain needs RFC 6764 discovery, which uses the sync + ## HTTP library; without it the client falls back to the + ## feature-based URL rather than failing. + client = AsyncDAVClient(url="caldav.example.com") + assert "caldav.example.com" in str(client.url) + print("ok") + """ + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, ( + "an httpx-only install must be able to create a client:\n" + + (result.stderr or result.stdout) + ) + @needs_sync_library class TestBackwardCompatibleReExports: