diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 19d06680..80498af2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -154,6 +154,11 @@ jobs: docker exec -e OC_PASS="testpass${i}" ${{ job.services.nextcloud.id }} php occ user:add --password-from-env --display-name="User ${i}" "user${i}" || echo "user${i} may already exist" done + # Set email addresses for scheduling users (required for calendar-user-address-set) + for i in 1 2 3; do + docker exec ${{ job.services.nextcloud.id }} php occ user:setting "user${i}" settings email "user${i}@localhost" || true + done + # Enable calendar and contacts apps docker exec ${{ job.services.nextcloud.id }} php occ app:enable calendar || true docker exec ${{ job.services.nextcloud.id }} php occ app:enable contacts || true @@ -180,6 +185,17 @@ jobs: " || true echo "Nextcloud is configured!" + - name: Configure Cyrus + run: | + # Copy imapd.conf with virtdomains: off (required for iTIP scheduling delivery). + # The default virtdomains: userid setting causes caladdress_lookup() to preserve + # the full email form (user2@example.com) while mailbox ACLs use the short form + # (user2), resulting in 403 errors when delivering iTIP invites. + sed 's/{{DEFAULTDOMAIN}}/example.com/g; s/{{SERVERNAME}}/cyrus-test/g' \ + tests/docker-test-servers/cyrus/imapd.conf > /tmp/imapd_expanded.conf + docker cp /tmp/imapd_expanded.conf ${{ job.services.cyrus.id }}:/srv/cyrus-docker-test-server.git/imapd.conf + docker restart ${{ job.services.cyrus.id }} + echo "✓ Cyrus reconfigured with virtdomains: off" - name: Wait for Cyrus to be ready run: | echo "Waiting for Cyrus server..." @@ -334,33 +350,72 @@ jobs: key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - run: pip install tox - run: tox -e deptry - async-niquests: - # Test that async code works with niquests when httpx is not installed - name: async (niquests fallback) + async-httpx: + # Test that async code works with httpx when niquests is not installed + name: async (httpx fallback) runs-on: ubuntu-latest + services: + baikal: + image: ckulka/baikal:nginx + ports: + - 8800:80 + options: >- + --health-cmd "curl -f http://localhost/ || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --health-start-period 30s steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install dependencies without httpx + - name: Install dependencies without niquests run: | pip install --editable .[test] - pip uninstall -y httpx - - name: Verify niquests is used + pip uninstall -y niquests + - name: Configure Baikal with pre-seeded database + run: | + docker cp tests/docker-test-servers/baikal/Specific/. ${{ job.services.baikal.id }}:/var/www/baikal/Specific/ + docker cp tests/docker-test-servers/baikal/config/. ${{ job.services.baikal.id }}:/var/www/baikal/config/ + docker exec ${{ job.services.baikal.id }} chown -R nginx:nginx /var/www/baikal/Specific /var/www/baikal/config + docker exec ${{ job.services.baikal.id }} chmod -R 770 /var/www/baikal/Specific + docker restart ${{ job.services.baikal.id }} + - name: Wait for Baikal to be ready + run: | + if timeout 60 bash -c 'until curl -f http://localhost:8800/ 2>/dev/null; do echo "Waiting..."; sleep 2; done'; then + echo "✓ Baikal is ready!" + else + echo "✗ Error: Baikal did not become ready within 60 seconds" + exit 1 + fi + - name: Verify httpx is used run: | python -c " from caldav.async_davclient import _USE_HTTPX, _USE_NIQUESTS - assert not _USE_HTTPX, 'httpx should not be available' - assert _USE_NIQUESTS, 'niquests should be used' - print('✓ Using niquests for async HTTP') + assert _USE_HTTPX, 'httpx should be available' + assert not _USE_NIQUESTS, 'niquests should not be available' + print('✓ Using httpx for async HTTP') " - - name: Run async tests with niquests - run: pytest tests/test_async_davclient.py -v + - name: Run async tests with httpx + run: pytest tests/test_async_davclient.py tests/test_async_integration.py -v -k baikal + env: + BAIKAL_URL: http://localhost:8800 sync-requests: # Test that sync code works with requests when niquests is not installed name: sync (requests fallback) runs-on: ubuntu-latest + services: + baikal: + image: ckulka/baikal:nginx + ports: + - 8800:80 + options: >- + --health-cmd "curl -f http://localhost/ || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --health-start-period 30s steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -371,6 +426,21 @@ jobs: pip install --editable .[test] pip uninstall -y niquests pip install requests + - name: Configure Baikal with pre-seeded database + run: | + docker cp tests/docker-test-servers/baikal/Specific/. ${{ job.services.baikal.id }}:/var/www/baikal/Specific/ + docker cp tests/docker-test-servers/baikal/config/. ${{ job.services.baikal.id }}:/var/www/baikal/config/ + docker exec ${{ job.services.baikal.id }} chown -R nginx:nginx /var/www/baikal/Specific /var/www/baikal/config + docker exec ${{ job.services.baikal.id }} chmod -R 770 /var/www/baikal/Specific + docker restart ${{ job.services.baikal.id }} + - name: Wait for Baikal to be ready + run: | + if timeout 60 bash -c 'until curl -f http://localhost:8800/ 2>/dev/null; do echo "Waiting..."; sleep 2; done'; then + echo "✓ Baikal is ready!" + else + echo "✗ Error: Baikal did not become ready within 60 seconds" + exit 1 + fi - name: Verify requests is used run: | python -c " @@ -380,4 +450,6 @@ jobs: print('✓ Using requests for sync HTTP') " - name: Run sync tests with requests - run: pytest tests/test_caldav.py -v -k "Radicale" --ignore=tests/test_async_integration.py + run: pytest tests/test_caldav.py -v -k "Baikal or Radicale" --ignore=tests/test_async_integration.py + env: + BAIKAL_URL: http://localhost:8800 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fed5540..9a5cf176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Changelogs prior to v3.0 is pruned, but was available in the v3.1 release This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence. +## [Unreleased] + +### Added + +* `Calendar.delete(wipe=None)` now accepts a `wipe` parameter. `wipe=True` wipes all objects from the calendar without deleting the calendar itself — useful for servers like Nextcloud where calendar deletion moves the calendar to a trashbin without freeing the URL namespace. `wipe=False` always attempts a HTTP DELETE regardless of server support. The existing `None` default preserves current auto-detect behaviour. + ## [3.2.0] - 2026-04-24 The two most significant news in v3.2 are **relatively well-tested support for scheduling** (RFC6638) and **better-tested support for async**. Care should still be taken, those features are backed by many tests, but lacks testing for how well they support real-world use-case scenarios. While async support was added in version 3.0, it was not well-enough tested. Still only a fraction of all the integration tests for sync usage has been duplicated in the async integration test, I expect to release 3.2.1 with symmetric async integration tests before 2025-07. diff --git a/caldav/collection.py b/caldav/collection.py index ee2a8521..f0b28a06 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -786,44 +786,70 @@ async def _async_create(self, path, mkcol, method, name, display_name) -> None: exc_info=True, ) - def delete(self): + def delete(self, wipe=None): """Delete the calendar. For async clients, returns a coroutine that must be awaited. + + wipe: tristate controlling cleanup behaviour + None (default) – wipe all objects instead of deleting if the server + doesn't support calendar deletion + True – wipe all objects and return without deleting the + calendar itself (useful for servers where deletion + moves calendars to a trashbin) + False – always attempt to delete the calendar via HTTP DELETE """ if self.is_async_client: - return self._async_delete() + return self._async_delete(wipe=wipe) + + if wipe is True: + for obj in self.search(): + try: + obj.delete() + except error.NotFoundError: + pass + return ## TODO: remove quirk handling from the functional tests ## TODO: this needs test code quirk_info = self.client.features.is_supported("delete-calendar", dict) - wipe = not self.client.features.is_supported("delete-calendar") + if wipe is None: + wipe = not self.client.features.is_supported("delete-calendar") if quirk_info["support"] == "fragile": ## Do some retries on deleting the calendar - for x in range(0, 20): + for _ in range(0, 20): try: super().delete() except error.DeleteError: pass try: - x = self.get_events() + self.get_events() sleep(0.3) except error.NotFoundError: wipe = False break if wipe: - for x in self.search(): - x.delete() + for obj in self.search(): + obj.delete() else: super().delete() - async def _async_delete(self): + async def _async_delete(self, wipe=None): """Async implementation of Calendar.delete().""" import asyncio + if wipe is True: + for obj in await self.search(): + try: + await obj.delete() + except error.NotFoundError: + pass + return + quirk_info = self.client.features.is_supported("delete-calendar", dict) - wipe = not self.client.features.is_supported("delete-calendar") + if wipe is None: + wipe = not self.client.features.is_supported("delete-calendar") if quirk_info["support"] == "fragile": # Do some retries on deleting the calendar diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 65616dc4..c8033c59 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -907,9 +907,8 @@ def dotted_feature_set_list(self, compact=False): ## Principal property search returns 403 (not implemented) "principal-search": "ungraceful", - ## Server-side recurrence expansion for event exceptions is still broken; ## VTODO RRULE expansion was fixed in xandikos PR #627 (released in 0.3.7). - "search.recurrences.expanded.exception": "unsupported", + ## Exception expansion (CALDAV:expand with EXDATE/RECURRENCE-ID) is now also supported. ## Open-start time-range searches (no lower bound) crash xandikos 0.3.7 with a ## 500 Internal Server Error (OverflowError: date value out of range in icalendar.py @@ -959,6 +958,9 @@ def dotted_feature_set_list(self, compact=False): 'behaviour': "deleting a calendar moves it to a trashbin, thrashbin has to be manually 'emptied' from the web-ui before the namespace is freed up", 'support': 'fragile', }, + # Calendar deletion goes to trashbin so delete-and-recreate doesn't give a + # fresh empty calendar. Wipe objects instead of deleting the calendar itself. + "test-calendar": {"cleanup-regime": "wipe-calendar"}, 'search.recurrences.includes-implicit.todo': {'support': 'unsupported'}, #'save-load.todo.mixed-calendar': {'support': 'unsupported'}, ## Why? It started complaining about this just recently. 'principal-search.by-name.self': {'support': 'unsupported'}, @@ -1145,7 +1147,7 @@ def dotted_feature_set_list(self, compact=False): # Cyrus changes the Schedule-Tag even on attendee PARTSTAT-only updates, # violating RFC6638 section 3.2 which requires the tag to remain stable. "scheduling.schedule-tag.stable-partstat": {"support": "unsupported"}, - # Cyrus may not properly reject wrong passwords in some configurations + # Cyrus may not properly reject wrong passwords in some configurations. # Cyrus implements server-side automatic scheduling: for cross-user invites, # the server both auto-processes the invite into the attendee's calendar # AND delivers an iTIP notification copy to the attendee's schedule-inbox. @@ -1420,10 +1422,7 @@ def dotted_feature_set_list(self, compact=False): ## Stalwart returns the recurring todo in search results but doesn't return the ## RRULE intact, so client-side expansion can't expand it to specific occurrences. 'search.recurrences.includes-implicit.todo': {'support': 'fragile'}, - ## Stalwart doesn't handle exceptions properly in server-side CALDAV:expand: - ## returns 3 items instead of 2 for a recurring event with one exception - ## (the exception is stored as a separate object and returned twice). - 'search.recurrences.expanded.exception': False, + ## Stalwart correctly handles exceptions in server-side CALDAV:expand (observed supported). ## Stalwart stores master+exception VEVENTs as a single resource with 2 VEVENTs. 'save-load.event.recurrences.exception': {'support': 'full'}, 'search.time-range.open': True, diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py index fcedc0d3..c24e9f63 100644 --- a/tests/test_async_integration.py +++ b/tests/test_async_integration.py @@ -221,80 +221,85 @@ async def async_principal(self, async_client: Any) -> Any: @pytest_asyncio.fixture async def async_calendar(self, async_client: Any) -> Any: - """Create a test calendar or use an existing one if creation not supported.""" + """Create or find a stable test calendar, wiping it before and after use. + + Uses a stable cal_id so the calendar is reused across tests. For servers + where deletion moves calendars to a trashbin (e.g. Nextcloud), we wipe + objects only rather than deleting the calendar, keeping the trashbin empty. + """ from caldav.aio import AsyncPrincipal from caldav.lib.error import AuthorizationError, NotFoundError - from .fixture_helpers import aget_or_create_test_calendar + from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects + + feats = getattr(async_client, "features", None) + + def _feat(name: str) -> bool: + return feats.is_supported(name) if feats else True - calendar_name = f"async-test-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + delete_frees_namespace = _feat("delete-calendar.free-namespace") - # Try to get principal for calendar operations principal = None try: principal = await AsyncPrincipal.create(async_client) except (NotFoundError, AuthorizationError): pass - # Use shared helper for calendar setup calendar, created = await aget_or_create_test_calendar( - async_client, principal, calendar_name=calendar_name + async_client, + principal, + calendar_name="pythoncaldav-async-test", + cal_id="pythoncaldav-async-test", ) if calendar is None: pytest.skip("Could not create or find a calendar for testing") + await cleanup_calendar_objects(calendar) + yield calendar - # Only cleanup if we created the calendar - if created: + if delete_frees_namespace and created: try: await calendar.delete() except Exception: pass + else: + await cleanup_calendar_objects(calendar) @pytest_asyncio.fixture async def async_task_list(self, async_client: Any) -> Any: - """Create a task list for todo tests. - - For servers that don't support mixed calendars (like Zimbra), todos must - be stored in a separate task list with supported_calendar_component_set=["VTODO"]. + """Create or find a stable task-list calendar, wiping it before and after use. - Uses the same stable cal_id ("pythoncaldav-test-tasks") as the sync test suite - so that both share state rather than accumulate duplicate-UID conflicts on - servers with cross-calendar UID uniqueness (e.g. OX). Objects are wiped - before each test for isolation. + For servers that don't support mixed calendars (e.g. Zimbra), a VTODO-only + calendar is used. The calendar is reused across tests via a stable cal_id + rather than being deleted and recreated, avoiding trashbin accumulation on + servers like Nextcloud. """ from caldav.aio import AsyncPrincipal from caldav.lib.error import AuthorizationError, NotFoundError from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects - # Check if server supports mixed calendars - supports_mixed = True - if hasattr(async_client, "features") and async_client.features: - supports_mixed = async_client.features.is_supported("save-load.todo.mixed-calendar") + feats = getattr(async_client, "features", None) + + def _feat(name: str) -> bool: + return feats.is_supported(name) if feats else True + + supports_mixed = _feat("save-load.todo.mixed-calendar") + delete_frees_namespace = _feat("delete-calendar.free-namespace") + + component_set: list[str] | None = ["VTODO"] if not supports_mixed else None + cal_id = "pythoncaldav-async-test-tasks" + supports_displayname = _feat("create-calendar.set-displayname") + calendar_name = cal_id if supports_displayname else None - # Try to get principal for calendar operations principal = None try: principal = await AsyncPrincipal.create(async_client) except (NotFoundError, AuthorizationError): pass - # For servers without mixed calendar support, create a dedicated task list. - # Use the same stable cal_id as the sync test suite so servers with - # cross-calendar duplicate-UID detection (e.g. OX) don't reject objects - # that also exist in the sync test's calendar. - component_set = ["VTODO"] if not supports_mixed else None - cal_id = "pythoncaldav-test-tasks" if not supports_mixed else "pythoncaldav-async-test" - supports_displayname = ( - async_client.features.is_supported("create-calendar.set-displayname") - if hasattr(async_client, "features") and async_client.features - else True - ) - calendar_name = cal_id if supports_displayname else None - calendar, created = await aget_or_create_test_calendar( async_client, principal, @@ -310,22 +315,28 @@ async def async_task_list(self, async_client: Any) -> Any: yield calendar - # Only cleanup if we created the calendar - if created: + if delete_frees_namespace and created: try: await calendar.delete() except Exception: pass + else: + await cleanup_calendar_objects(calendar) @pytest_asyncio.fixture async def async_calendar2(self, async_client: Any) -> Any: - """Create a second test calendar for tests that need two distinct calendars.""" + """Create or find a stable second test calendar for tests needing two calendars.""" from caldav.aio import AsyncPrincipal from caldav.lib.error import AuthorizationError, NotFoundError - from .fixture_helpers import aget_or_create_test_calendar + from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects + + feats = getattr(async_client, "features", None) - calendar_name = f"async-test2-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + def _feat(name: str) -> bool: + return feats.is_supported(name) if feats else True + + delete_frees_namespace = _feat("delete-calendar.free-namespace") principal = None try: @@ -334,29 +345,44 @@ async def async_calendar2(self, async_client: Any) -> Any: pass calendar, created = await aget_or_create_test_calendar( - async_client, principal, calendar_name=calendar_name + async_client, + principal, + calendar_name="pythoncaldav-async-test-2", + cal_id="pythoncaldav-async-test-2", ) if calendar is None: pytest.skip("Could not create or find a second calendar for testing") + await cleanup_calendar_objects(calendar) + yield calendar - if created: + if delete_frees_namespace and created: try: await calendar.delete() except Exception: pass + else: + await cleanup_calendar_objects(calendar) @pytest_asyncio.fixture async def async_journal_list(self, async_client: Any) -> Any: - """Create a VJOURNAL calendar for journal tests.""" + """Create or find a stable VJOURNAL calendar, wiping it before and after use.""" from caldav.aio import AsyncPrincipal from caldav.lib.error import AuthorizationError, NotFoundError - from .fixture_helpers import aget_or_create_test_calendar + from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects + + feats = getattr(async_client, "features", None) - calendar_name = f"async-journal-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + def _feat(name: str) -> bool: + return feats.is_supported(name) if feats else True + + delete_frees_namespace = _feat("delete-calendar.free-namespace") + supports_displayname = _feat("create-calendar.set-displayname") + cal_id = "pythoncaldav-async-journal" + calendar_name = cal_id if supports_displayname else None principal = None try: @@ -368,19 +394,24 @@ async def async_journal_list(self, async_client: Any) -> Any: async_client, principal, calendar_name=calendar_name, + cal_id=cal_id, supported_calendar_component_set=["VJOURNAL"], ) if calendar is None: pytest.skip("Could not create or find a journal list for testing") + await cleanup_calendar_objects(calendar) + yield calendar - if created: + if delete_frees_namespace and created: try: await calendar.delete() except Exception: pass + else: + await cleanup_calendar_objects(calendar) async def _make_async_client_with_params(self, **overrides: Any) -> Any: """Build a fresh async client from this server's config with kwargs overridden. diff --git a/tests/test_caldav.py b/tests/test_caldav.py index f12e0329..cd45d2c6 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1383,25 +1383,15 @@ def _cleanup(self, mode=None): return ## no cleanup needed if self.cleanup_regime == "wipe-calendar": for cal in self.calendars_used: - ## do we need a try-except-pass? - try: - for x in cal.search(): - x.delete() - except error.NotFoundError: - pass + cal.delete(wipe=True) elif not self.is_supported("create-calendar") or self.cleanup_regime == "thorough": for cal in self.calendars_used: - for x in cal.search(): - x.delete() + cal.delete(wipe=True) return for cal in self.calendars_used: if str(cal.url) in self._preconfigured_calendar_urls: ## Pre-configured calendar: wipe objects, don't delete the calendar - try: - for x in cal.search(): - x.delete() - except error.NotFoundError: - pass + cal.delete(wipe=True) else: cal.delete() for calid in (self.testcal_id, self.testcal_id2, self.testcal_id + "-tasks"): @@ -1438,10 +1428,7 @@ def _teardownCalendar(self, name=None, cal_id=None): def _fixCalendar(self, **kwargs): cal = self._fixCalendar_(**kwargs) if self.cleanup_regime == "wipe-calendar": - ## do we need a try-except-pass? - ## (if so, consolidate) - for x in cal.search(): - x.delete() + cal.delete(wipe=True) return cal def _fixCalendar_(self, **kwargs): @@ -1548,6 +1535,10 @@ def testCheckCompatibility(self, request) -> None: fe = self.caldav.features ## dotted list expected and observed + ## Snapshot checked features before compact=True calls collapse(), which + ## mutates _server_features by removing subfeatures that collapse into + ## their parent — making tested features look like untested ones. + checked_features = set(fo._server_features.keys()) observed = fo.dotted_feature_set_list(compact=True) expected = fe.dotted_feature_set_list(compact=True) @@ -1560,7 +1551,7 @@ def testCheckCompatibility(self, request) -> None: continue ## Skip features the checker never explicitly tested - ## the observation would just be a default, not a real result - if feature not in observed and feature not in fo._server_features: + if feature not in observed and feature not in checked_features: continue type_ = fo.find_feature(feature).get("type", "server-feature") if type_ in ( @@ -3969,7 +3960,12 @@ def testRecurringDateWithExceptionSearch(self): ): assert len(rs) == 2 - asserts_on_results = [r] + asserts_on_results = [] + # Client-side expansion only produces correct RECURRENCE-IDs when the + # server keeps master VEVENT + exception VEVENT in the same calendar + # object resource. If the server splits them, skip this assertion. + if self.is_supported("save-load.event.recurrences.exception"): + asserts_on_results.append(r) if self.is_supported("search.recurrences.expanded.exception"): asserts_on_results.append(rs) diff --git a/tests/test_servers/base.py b/tests/test_servers/base.py index 4a61327b..8ee1e7c7 100644 --- a/tests/test_servers/base.py +++ b/tests/test_servers/base.py @@ -308,30 +308,22 @@ def verify_docker() -> bool: Check if docker and docker-compose are available. Returns: - True if docker-compose is available and docker daemon is running + True if docker compose is available and docker daemon is running """ import subprocess - try: - subprocess.run( - ["docker-compose", "--version"], - capture_output=True, - check=True, - timeout=5, - ) - subprocess.run( - ["docker", "ps"], - capture_output=True, - check=True, - timeout=5, - ) - return True - except ( - subprocess.CalledProcessError, - FileNotFoundError, - subprocess.TimeoutExpired, - ): - return False + def _run(*cmd: str) -> bool: + try: + subprocess.run(list(cmd), capture_output=True, check=True, timeout=5) + return True + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + return False + + # start.sh scripts use the standalone `docker-compose` binary, so we + # only return True when that binary is actually present. The `docker + # compose` plugin form is NOT sufficient — start.sh will exit 127 if + # only the plugin is available (e.g. on GitHub Actions runners). + return _run("docker-compose", "--version") and _run("docker", "ps") def start(self) -> None: """ diff --git a/tests/test_servers/registry.py b/tests/test_servers/registry.py index 384b340f..4ebd8bea 100644 --- a/tests/test_servers/registry.py +++ b/tests/test_servers/registry.py @@ -297,8 +297,7 @@ def _discover_docker_servers(self) -> None: from .base import DockerTestServer - if not DockerTestServer.verify_docker(): - return + docker_available = DockerTestServer.verify_docker() # Look for docker-test-servers directories docker_servers_dir = Path(__file__).parent.parent / "docker-test-servers" @@ -312,7 +311,11 @@ def _discover_docker_servers(self) -> None: server_class = get_server_class(server_name) if server_class is not None and server_name not in self._servers: - self.register(server_class({"docker_dir": str(server_dir)})) + server = server_class({"docker_dir": str(server_dir)}) + # Register if Docker is available (can start containers) OR if + # the server is already running (e.g. a CI service container). + if docker_available or server.is_accessible(): + self.register(server) def get_caldav_servers_list(self) -> list[dict]: """