diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index 236abd42..aadcc109 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 7fdc35b1..a887b1ad 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 e5e2c506..b82ef53e 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/caldav/testing.py b/caldav/testing.py index 65bab272..527b8012 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/793.fixed.md b/changes/793.fixed.md new file mode 100644 index 00000000..c4cac8fe --- /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 00000000..c59706d5 --- /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/changes/797.fixed.md b/changes/797.fixed.md new file mode 100644 index 00000000..d391d52e --- /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_async_davclient.py b/tests/test_async_davclient.py index b8ecee70..1a96f30b 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 00000000..ccdda3e4 --- /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 b0a35f20..41be3c81 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: diff --git a/tests/test_servers/helpers.py b/tests/test_servers/helpers.py index 6d783424..02b396fc 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 00000000..16163ebd --- /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()