Skip to content

Commit a2688ae

Browse files
committed
feat: ship the test-server registry in the package
The caldav source code has rather a lot of testing logic, including allowing ordinary configuration files to enable testing towards the configured servers, allowing special test server configuration files to be read, and also start/stop-scripts that can instantiate docker test servers if docker or podman is installed. There is a legitimate need for testing other packages (like plann, calendar-cli, calendaring, calendaring-sync) towards a wide range of test servers, hence we're bringing the test server registry logic out as a public part of the package. Arguably it would better belong in a separate package (or in the caldav-server-tester package). As for now the costs of spawning Yet Another Package have been considered bigger than the costs of adding some few kilobytes of pure-Python logic that ordinary users of the caldav package do not need - but this may be considered in the future - filed as #823 The registry, config loader, locks and the embedded, Docker and external server classes moved from tests/test_servers into the installed caldav.testing package, so other projects can use them without a source checkout. tests/test_servers re-exports them, with each submodule aliased to its new home so existing imports and monkeypatching keep working. The docker-compose files stay in the repository; CALDAV_DOCKER_TEST_SERVERS_DIR names them when caldav is installed rather than checked out. get_davclient() on a test server now uses that registry on an installed caldav too, in place of the embedded-plus-test_allowed fallback; a config file named explicitly or through CALDAV_CONFIG_FILE still adds its test_allowed sections. #817 Prompt: The other session reports that #817, closed by #820, has only been partially done. Please investigate [#817: move the test-server registry into the installed caldav.testing; #820 merged only the follow-up comment] Followup-Prompt: Please just start working on a new PR instead [rather than commenting on #817] Prompts (comments made inline in the document while reviewing it): > (on this commit message, after rewriting its first paragraphs) Please do another QA of my editing here Assisted-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assisted-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Reviewed-by: Tobias Brox <tobias@redpill-linpro.com>
1 parent 7c99da5 commit a2688ae

15 files changed

Lines changed: 182 additions & 113 deletions

‎caldav/config.py‎

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ def _caldav_connections(
364364

365365
## TODO: the test server selection below, get_test_setting and
366366
## get_all_test_servers are test tooling, not config; move them to
367-
## caldav/testing.py (planned for 3.6.1).
367+
## the caldav.testing package.
368368
def _get_test_server_config(
369369
name: str | None, environment: bool, config_file: str | None = None
370370
) -> dict[str, Any] | None:
@@ -376,15 +376,15 @@ def _get_test_server_config(
376376
(20) and configured / test_allowed servers (30) by default. Any server
377377
can override its priority via ``test_priority: <int>`` in its config.
378378
379-
When running from the source tree the full registry is used (embedded +
380-
docker + external). When caldav is pip-installed without the test
381-
infrastructure, only embedded servers from ``caldav.testing`` plus any
382-
``test_allowed`` config-file sections are available.
379+
The servers are those of the ``caldav.testing`` registry (embedded,
380+
docker and external), plus the ``test_allowed`` sections of
381+
``config_file``.
383382
384383
Args:
385384
name: Optional server name or server_type to restrict selection.
386-
environment: Whether to read PYTHON_CALDAV_TEST_SERVER_NAME.
387-
config_file: Explicit config file path (for test_allowed fallback).
385+
environment: Whether to read PYTHON_CALDAV_TEST_SERVER_NAME, and to
386+
find the config file through CALENDARING_CONFIG_FILE / CALDAV_CONFIG_FILE.
387+
config_file: Explicit config file path, for its test_allowed sections.
388388
389389
Returns:
390390
Connection parameters dict, or None if no server could be started.
@@ -401,7 +401,7 @@ def _get_test_server_config(
401401

402402
for server in _collect_test_servers(name, config_file):
403403
was_already_started = server._started
404-
# Servers from caldav.testing (pip-installed caldav) have no session lock
404+
# A _ConfiguredServer has no session lock
405405
claim = getattr(server, "claim_server", None)
406406
claimed = False
407407
try:
@@ -427,59 +427,32 @@ def _collect_test_servers(name: str | None, config_file: str | None) -> list[Any
427427
"""
428428
Return a priority-ordered list of available test servers.
429429
430-
Tries the full registry first (source tree); falls back to embedded
431-
servers from ``caldav.testing`` plus ``test_allowed`` config sections
432-
(pip-installed users).
430+
The registry reads the default config file, so ``config_file`` adds
431+
only what it holds beyond that: a file named explicitly or through
432+
CALENDARING_CONFIG_FILE / CALDAV_CONFIG_FILE.
433433
"""
434-
try:
435-
from tests.test_servers.registry import get_registry
436-
437-
servers: list[Any] = list(get_registry().enabled_servers())
438-
except ImportError:
439-
servers = _get_pip_test_servers(config_file)
434+
from caldav.testing.registry import get_registry
435+
436+
servers: list[Any] = list(get_registry().enabled_servers())
437+
if config_file:
438+
known = {s.name.lower() for s in servers}
439+
servers += [
440+
_ConfiguredServer(section_name, params)
441+
for section_name, params in get_all_test_servers(config_file).items()
442+
if section_name.lower() not in known
443+
]
444+
servers.sort(key=lambda s: s.priority)
440445

441446
if name is not None:
442447
servers = [s for s in servers if name.lower() in (s.name.lower(), s.server_type.lower())]
443448
return servers
444449

445450

446-
def _get_pip_test_servers(config_file: str | None) -> list[Any]:
447-
"""
448-
Build a server list for pip-installed users (no tests/ available).
449-
450-
Includes embedded servers from caldav.testing and any test_allowed
451-
sections from config files, all sorted by priority.
452-
"""
453-
from caldav.testing import RadicaleServer, XandikosServer
454-
455-
servers: list[Any] = []
456-
457-
try:
458-
import xandikos # noqa: F401
459-
460-
servers.append(XandikosServer())
461-
except ImportError:
462-
pass
463-
464-
try:
465-
import radicale # noqa: F401
466-
467-
servers.append(RadicaleServer())
468-
except ImportError:
469-
pass
470-
471-
for section_name, params in get_all_test_servers(config_file).items():
472-
servers.append(_ConfiguredServer(section_name, params))
473-
474-
return sorted(servers, key=lambda s: s.priority)
475-
476-
477451
class _ConfiguredServer:
478452
"""
479-
Thin wrapper around a ``test_allowed`` config-file section.
480-
481-
Used only in pip-installed mode; in the source tree these sections are
482-
loaded into the registry as ``ExternalTestServer`` instances instead.
453+
Thin wrapper around a ``test_allowed`` section of a config file that
454+
the registry does not read (see ``_collect_test_servers``); the
455+
registry loads the default file's sections as ``ExternalTestServer``.
483456
"""
484457

485458
server_type = "external"
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""
2-
Lightweight embedded CalDAV test servers.
2+
CalDAV test servers, shipped with the package.
33
4-
Provides XandikosServer and RadicaleServer that are part of the installed
5-
package so that pip-installed users can use get_davclient() with
6-
PYTHON_CALDAV_USE_TEST_SERVER=1 without needing the full test infrastructure
7-
from the source tree.
4+
This module holds the lightweight embedded XandikosServer and RadicaleServer,
5+
used by get_davclient() with PYTHON_CALDAV_USE_TEST_SERVER=1.
86
9-
Docker and external server support lives in tests/test_servers/ (source only).
7+
The registry, config loader and the Docker and external server classes are
8+
submodules of this package (base, registry, config_loader, docker, ...); the
9+
docker-compose files stay in the source repository.
1010
"""
1111

1212
import copy
Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,22 @@
1717

1818
from caldav.config import get_test_setting
1919

20-
try:
21-
import niquests as requests
22-
except ImportError:
23-
import requests # type: ignore
20+
21+
def docker_servers_dir() -> Path:
22+
"""Directory holding the per-server ``start.sh``/``docker-compose.yml`` files.
23+
24+
These stay in the source repository, not in the installed package. Point
25+
``CALDAV_DOCKER_TEST_SERVERS_DIR`` at ``tests/docker-test-servers`` of a
26+
checkout to use them from an installed caldav; without it, the directory is
27+
looked up relative to the module (found when caldav is a source checkout).
28+
"""
29+
env = os.environ.get("CALDAV_DOCKER_TEST_SERVERS_DIR")
30+
if env:
31+
return Path(env).expanduser()
32+
return Path(__file__).resolve().parent.parent.parent / "tests" / "docker-test-servers"
33+
34+
35+
from caldav.lib.http_sync import requests
2436

2537
# Constants - extracted from magic numbers in conf.py
2638
DEFAULT_HTTP_TIMEOUT = 5
@@ -402,9 +414,7 @@ def __init__(self, config: dict[str, Any] | None = None) -> None:
402414
# Docker directory - default to tests/docker-test-servers/<name>
403415
from pathlib import Path
404416

405-
default_docker_dir = (
406-
Path(__file__).parent.parent / "docker-test-servers" / self.name.lower()
407-
)
417+
default_docker_dir = docker_servers_dir() / self.name.lower()
408418
self.docker_dir = Path(self.config.get("docker_dir", default_docker_dir))
409419
# Set when start() fails, so later tests fail fast instead of
410420
# re-running start.sh and waiting for the full timeout every time.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# Test-specific config file locations (don't need test_allowed). The
2020
# checkout's file is found relative to this module, not to the current
2121
# directory, so projects running from elsewhere see it too.
22-
_TESTS_DIR = Path(__file__).resolve().parent.parent
22+
_TESTS_DIR = Path(__file__).resolve().parent.parent.parent / "tests"
2323
TEST_CONFIG_LOCATIONS = [
2424
str(_TESTS_DIR / "caldav_test_servers.yaml"),
2525
str(_TESTS_DIR / "caldav_test_servers.json"),
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@
1010
from typing import Any
1111
from urllib.parse import quote
1212

13-
try:
14-
import niquests as requests
15-
except ImportError:
16-
import requests # type: ignore
17-
1813
from caldav import compatibility_hints
14+
from caldav.lib.http_sync import requests
1915

2016
from .base import DEFAULT_HTTP_TIMEOUT, DockerTestServer
2117
from .registry import register_server_class
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def client_context(server_index: int = 0, server_name: str | None = None):
3636
3737
Usage::
3838
39-
from tests.test_servers import client_context
39+
from caldav.testing.helpers import client_context
4040
4141
with client_context() as client:
4242
principal = client.principal()
Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,17 +302,16 @@ def _discover_embedded_servers(self) -> None:
302302

303303
def _discover_docker_servers(self) -> None:
304304
"""Discover available Docker servers."""
305-
from pathlib import Path
306-
307305
from .base import DockerTestServer
308-
309-
docker_available = DockerTestServer.verify_docker()
306+
from .base import docker_servers_dir as _docker_servers_dir
310307

311308
# Look for docker-test-servers directories
312-
docker_servers_dir = Path(__file__).parent.parent / "docker-test-servers"
309+
docker_servers_dir = _docker_servers_dir()
313310
if not docker_servers_dir.exists():
314311
return
315312

313+
docker_available = DockerTestServer.verify_docker()
314+
316315
# Check each subdirectory for a start.sh script
317316
for server_dir in docker_servers_dir.iterdir():
318317
if server_dir.is_dir() and (server_dir / "start.sh").exists():
@@ -378,7 +377,7 @@ def get_registry() -> ServerRegistry:
378377

379378
warnings.warn(
380379
f"Failed to load test server configuration: {e}. "
381-
"Check tests/caldav_test_servers.yaml for errors.",
380+
"Check the test server configuration for errors.",
382381
UserWarning,
383382
stacklevel=2,
384383
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The test-server framework - `ServerRegistry`, `get_registry()`, the config loader and the Docker and external server classes - is now part of the installed package, as submodules of `caldav.testing`, so other projects can run their tests against caldav's test servers without a source checkout (https://github.com/python-caldav/caldav/issues/817). `tests/test_servers` re-exports it. The docker-compose files stay in the repository: set `CALDAV_DOCKER_TEST_SERVERS_DIR` to `tests/docker-test-servers` of a checkout to start the Docker servers from an installed caldav. `get_davclient()` with `PYTHON_CALDAV_USE_TEST_SERVER=1` now picks from that registry on an installed caldav too, so the servers in `~/.config/caldav/test_servers.yaml` are candidates there as well.

‎docs/design/TODO.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ A response whose `Content-Type` says XML but whose body is not XML (an HTML erro
4242

4343
## A waiting session-lock claim blocks other threads
4444

45-
`TestServer.claim_server()` in `tests/test_servers/base.py` polls for the flock (up to `PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT`, 30 minutes by default) while holding the module-wide `_session_locks_mutex`. A thread waiting for one busy server therefore blocks every other thread claiming a different server, which contradicts "runs against different servers proceed in parallel". Harmless for single-threaded pytest. Fix: hold the mutex only around the `_session_locks` checks and inserts, with a per-name lock against double claims. Found in the review of https://github.com/python-caldav/caldav/pull/776.
45+
`TestServer.claim_server()` in `caldav/testing/base.py` polls for the flock (up to `PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT`, 30 minutes by default) while holding the module-wide `_session_locks_mutex`. A thread waiting for one busy server therefore blocks every other thread claiming a different server, which contradicts "runs against different servers proceed in parallel". Harmless for single-threaded pytest. Fix: hold the mutex only around the `_session_locks` checks and inserts, with a per-name lock against double claims. Found in the review of https://github.com/python-caldav/caldav/pull/776.
4646

4747
## calendar_multiget() fails on an async client
4848

@@ -72,3 +72,7 @@ On Nextcloud a deleted calendar moves to the trashbin, keeps its cal_id occupied
7272
The tests and the caldav-server-tester leave calendars behind, and on servers that cap the number of calendars per principal (ecloud, see above) that eventually blocks all creation. On 2026-10-23 ecloud held 15 empty calendars with bare-uuid cal_ids and the uuid as display name; which code made them was not found. Every calendar the tests or the tester create should carry a cal_id prefix, like the caldav-server-tester's `csc_` UIDs, so a cleanup step can delete leftovers safely - with the purge from the section above where the server has a trashbin.
7373
* caldav tests: there is no common cal_id prefix on every calendar, and no sweep. Several tests create calendars by name only (`test_caldav.py` "CalConfigA"/"CalConfigB", "XandikosMetaCal"/"RadicaleMetaCal", `test_get_calendars_sections.py`, `test_examples.py`), so `make_calendar()` gives them a bare uuid. Give all of them a `pythoncaldav-test` prefixed cal_id and add a session-level sweep.
7474
* caldav-server-tester: has `PROBE_CALENDAR_PREFIXES` and a sweep matching cal_id or display name, but `CheckDuplicateUID` creates its second calendar by name only, so its cal_id is a bare uuid that the sweep cannot recognise when the server drops the display name. Pass a prefixed cal_id. Its sweep also only moves calendars to the Nextcloud trashbin.
75+
76+
## An installed caldav.testing looks for the checkout in site-packages
77+
78+
`caldav/testing/config_loader.py` (`_TESTS_DIR`) and `docker_servers_dir()` in `caldav/testing/base.py` look for a checkout's `tests/` three levels up from the module. For an installed caldav that is `site-packages/tests`, so a stray top-level `tests` directory left by another badly packaged wheel would be searched for `caldav_test_servers.yaml` and `docker-test-servers/`. Also, while `CALDAV_DOCKER_TEST_SERVERS_DIR` locates the compose files of a checkout, nothing locates its `caldav_test_servers.yaml`, so an installed caldav reads only `~/.config/caldav/test_servers.yaml`. Fix: use the `tests/` path only when it belongs to a caldav checkout (e.g. a sibling `pyproject.toml` naming caldav), and perhaps add a `CALDAV_TEST_SERVERS_CONFIG` variable. Found in the review of https://github.com/python-caldav/caldav/issues/817.

0 commit comments

Comments
 (0)