Skip to content

Commit 04f4072

Browse files
committed
fix: wipe async test calendars instead of deleting to avoid Nextcloud trashbin slowdown
Calendar.delete() now accepts a wipe parameter (None/True/False tristate): - True: wipe all objects, keep the calendar itself (never sends HTTP DELETE) - False: always attempt HTTP DELETE - None (default): existing auto-detect behaviour (wipe if delete unsupported) The four async test fixtures (async_calendar, async_task_list, async_calendar2, async_journal_list) are refactored to use stable cal_ids instead of unique timestamped names. At fixture teardown, servers where delete-calendar.free- namespace is not supported (Nextcloud trashbin) now wipe objects via calendar.delete(wipe=True) rather than HTTP-deleting the calendar. Root cause of the >1-hour CI runs: each async test created a uniquely-named calendar and deleted it; on Nextcloud every deletion moves the calendar to the trashbin. After ~30 async tests, Nextcloud's SQLite database held 30+ trashed calendars, making every subsequent request from the sync test suite take ~50 s instead of <1 s. Reusing a single stable calendar per fixture and wiping its objects keeps the trashbin empty and the database small. The async_task_list fixture is also simplified: the previous logic that shared the sync suite's pythoncaldav-test-tasks calendar with Cyrus (to avoid cross- calendar UID conflicts) is no longer needed because the wipe-at-teardown guarantees the UID is absent before the sync suite runs. prompt: the github runs takes more than an hour now. It's not expected to take more than 15-30 minutes followup-prompt: Can we wipe the calendar instead of deleting it, and use the same calendar for all the tests? The delete-function already supports wiping, but only when the calendar does not support delete. Perhaps the delete-function could have a wipe-parameter, tristate, False = don't wipe, True = wipe instead of delete, None => wipe the calendar if deletion is not supported. And then we should mark up somehow in the feature setup that the tests should wipe nextcloud rather than delete. Also, why the need of having different logic in the async and sync? If the sync tests don't need unique calendars, why do the async test need it? Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> AI Prompts: claude-sonnet-4-6: the gihub runs takes more than an hour now. It's not expected to take more than 15-30 minutes claude-sonnet-4-6: Can we wipe the calendar instead of deleting it, and use the same calendar for all the tests? The delete-function already supports wiping, but only when the calendar does not support delete. Perhaps the delete-function could have a wipe-parameter, tristate, False = don't wipe, True = wipe isntead of delete, None => wipe the calendar if deletion is not supported. And then we should mark up somehow in the feature setup that the tests should wipe nextcloud rather than delete. Also, why the need of having different logic in the async and sync? If the sync tests don't need unique calendars, why do the async test need it? claude-sonnet-4-6: github runs still fail claude-sonnet-4-6: I think we're on the wrong track here. The caldav-server-tester reports full support for many of the features now set to "unknown"
1 parent d09ec45 commit 04f4072

3 files changed

Lines changed: 103 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Changelogs prior to v3.0 is pruned, but was available in the v3.1 release
1212

1313
This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence.
1414

15+
## [Unreleased]
16+
17+
### Added
18+
19+
* `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.
20+
1521
## [3.2.0] - 2026-04-24
1622

1723
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.

caldav/collection.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -786,44 +786,64 @@ async def _async_create(self, path, mkcol, method, name, display_name) -> None:
786786
exc_info=True,
787787
)
788788

789-
def delete(self):
789+
def delete(self, wipe=None):
790790
"""Delete the calendar.
791791
792792
For async clients, returns a coroutine that must be awaited.
793+
794+
wipe: tristate controlling cleanup behaviour
795+
None (default) – wipe all objects instead of deleting if the server
796+
doesn't support calendar deletion
797+
True – wipe all objects and return without deleting the
798+
calendar itself (useful for servers where deletion
799+
moves calendars to a trashbin)
800+
False – always attempt to delete the calendar via HTTP DELETE
793801
"""
794802
if self.is_async_client:
795-
return self._async_delete()
803+
return self._async_delete(wipe=wipe)
804+
805+
if wipe is True:
806+
for obj in self.search():
807+
obj.delete()
808+
return
796809

797810
## TODO: remove quirk handling from the functional tests
798811
## TODO: this needs test code
799812
quirk_info = self.client.features.is_supported("delete-calendar", dict)
800-
wipe = not self.client.features.is_supported("delete-calendar")
813+
if wipe is None:
814+
wipe = not self.client.features.is_supported("delete-calendar")
801815
if quirk_info["support"] == "fragile":
802816
## Do some retries on deleting the calendar
803-
for x in range(0, 20):
817+
for _ in range(0, 20):
804818
try:
805819
super().delete()
806820
except error.DeleteError:
807821
pass
808822
try:
809-
x = self.get_events()
823+
self.get_events()
810824
sleep(0.3)
811825
except error.NotFoundError:
812826
wipe = False
813827
break
814828

815829
if wipe:
816-
for x in self.search():
817-
x.delete()
830+
for obj in self.search():
831+
obj.delete()
818832
else:
819833
super().delete()
820834

821-
async def _async_delete(self):
835+
async def _async_delete(self, wipe=None):
822836
"""Async implementation of Calendar.delete()."""
823837
import asyncio
824838

839+
if wipe is True:
840+
for obj in await self.search():
841+
await obj.delete()
842+
return
843+
825844
quirk_info = self.client.features.is_supported("delete-calendar", dict)
826-
wipe = not self.client.features.is_supported("delete-calendar")
845+
if wipe is None:
846+
wipe = not self.client.features.is_supported("delete-calendar")
827847

828848
if quirk_info["support"] == "fragile":
829849
# Do some retries on deleting the calendar

tests/test_async_integration.py

Lines changed: 68 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -221,88 +221,79 @@ async def async_principal(self, async_client: Any) -> Any:
221221

222222
@pytest_asyncio.fixture
223223
async def async_calendar(self, async_client: Any) -> Any:
224-
"""Create a test calendar or use an existing one if creation not supported."""
224+
"""Create or find a stable test calendar, wiping it before and after use.
225+
226+
Uses a stable cal_id so the calendar is reused across tests. For servers
227+
where deletion moves calendars to a trashbin (e.g. Nextcloud), we wipe
228+
objects only rather than deleting the calendar, keeping the trashbin empty.
229+
"""
225230
from caldav.aio import AsyncPrincipal
226231
from caldav.lib.error import AuthorizationError, NotFoundError
227232

228-
from .fixture_helpers import aget_or_create_test_calendar
233+
from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects
234+
235+
feats = getattr(async_client, "features", None)
229236

230-
calendar_name = f"async-test-{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
237+
def _feat(name: str) -> bool:
238+
return feats.is_supported(name) if feats else True
239+
240+
delete_frees_namespace = _feat("delete-calendar.free-namespace")
231241

232-
# Try to get principal for calendar operations
233242
principal = None
234243
try:
235244
principal = await AsyncPrincipal.create(async_client)
236245
except (NotFoundError, AuthorizationError):
237246
pass
238247

239-
# Use shared helper for calendar setup
240248
calendar, created = await aget_or_create_test_calendar(
241-
async_client, principal, calendar_name=calendar_name
249+
async_client,
250+
principal,
251+
calendar_name="pythoncaldav-async-test",
252+
cal_id="pythoncaldav-async-test",
242253
)
243254

244255
if calendar is None:
245256
pytest.skip("Could not create or find a calendar for testing")
246257

258+
await cleanup_calendar_objects(calendar)
259+
247260
yield calendar
248261

249-
# Only cleanup if we created the calendar
250-
if created:
262+
if delete_frees_namespace and created:
251263
try:
252264
await calendar.delete()
253265
except Exception:
254266
pass
267+
else:
268+
await cleanup_calendar_objects(calendar)
255269

256270
@pytest_asyncio.fixture
257271
async def async_task_list(self, async_client: Any) -> Any:
258-
"""Create a task list for todo tests.
259-
260-
For servers that don't support mixed calendars (like Zimbra), todos must
261-
be stored in a separate task list with supported_calendar_component_set=["VTODO"].
262-
263-
Calendar naming strategy:
264-
- Servers with cross-calendar UID uniqueness (Cyrus, OX) or no mixed-calendar
265-
support: use "pythoncaldav-test-tasks" (shared with sync suite) to avoid
266-
duplicate-UID conflicts.
267-
- Servers where calendar deletion doesn't free the namespace (Nextcloud trashbin):
268-
use unique timestamped names to avoid stale state from previous runs.
269-
- All other servers: use stable "pythoncaldav-async-test".
272+
"""Create or find a stable task-list calendar, wiping it before and after use.
273+
274+
For servers that don't support mixed calendars (e.g. Zimbra), a VTODO-only
275+
calendar is used. The calendar is reused across tests via a stable cal_id
276+
rather than being deleted and recreated, avoiding trashbin accumulation on
277+
servers like Nextcloud.
270278
"""
271279
from caldav.aio import AsyncPrincipal
272280
from caldav.lib.error import AuthorizationError, NotFoundError
273281

274282
from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects
275283

276-
feats = getattr(async_client, "features", None) or None
284+
feats = getattr(async_client, "features", None)
277285

278286
def _feat(name: str) -> bool:
279287
return feats.is_supported(name) if feats else True
280288

281289
supports_mixed = _feat("save-load.todo.mixed-calendar")
282-
cross_cal_uid_issues = not _feat("save.duplicate-uid.cross-calendar")
283290
delete_frees_namespace = _feat("delete-calendar.free-namespace")
284291

285-
# Determine cal_id and whether we share state with the sync test suite
286-
if not supports_mixed or cross_cal_uid_issues:
287-
# Must share with sync suite to avoid cross-calendar UID conflicts
288-
component_set: list[str] | None = ["VTODO"] if not supports_mixed else None
289-
cal_id = "pythoncaldav-test-tasks"
290-
shared_with_sync = True
291-
elif not delete_frees_namespace:
292-
# Deletion goes to trashbin (e.g. Nextcloud): use unique name so
293-
# stale objects from a previous run don't cause duplicate-UID errors.
294-
component_set = None
295-
cal_id = f"pythoncaldav-async-test-{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
296-
shared_with_sync = False
297-
else:
298-
component_set = None
299-
cal_id = "pythoncaldav-async-test"
300-
shared_with_sync = False
301-
292+
component_set: list[str] | None = ["VTODO"] if not supports_mixed else None
293+
cal_id = "pythoncaldav-async-test-tasks"
302294
supports_displayname = _feat("create-calendar.set-displayname")
303295
calendar_name = cal_id if supports_displayname else None
304296

305-
# Try to get principal for calendar operations
306297
principal = None
307298
try:
308299
principal = await AsyncPrincipal.create(async_client)
@@ -324,24 +315,28 @@ def _feat(name: str) -> bool:
324315

325316
yield calendar
326317

327-
# Delete only if we created it and it's not shared with the sync suite.
328-
# For shared calendars, objects were already wiped at setup; deleting the
329-
# calendar here would break sync tests that run later in the same session.
330-
if created and not shared_with_sync:
318+
if delete_frees_namespace and created:
331319
try:
332320
await calendar.delete()
333321
except Exception:
334322
pass
323+
else:
324+
await cleanup_calendar_objects(calendar)
335325

336326
@pytest_asyncio.fixture
337327
async def async_calendar2(self, async_client: Any) -> Any:
338-
"""Create a second test calendar for tests that need two distinct calendars."""
328+
"""Create or find a stable second test calendar for tests needing two calendars."""
339329
from caldav.aio import AsyncPrincipal
340330
from caldav.lib.error import AuthorizationError, NotFoundError
341331

342-
from .fixture_helpers import aget_or_create_test_calendar
332+
from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects
333+
334+
feats = getattr(async_client, "features", None)
335+
336+
def _feat(name: str) -> bool:
337+
return feats.is_supported(name) if feats else True
343338

344-
calendar_name = f"async-test2-{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
339+
delete_frees_namespace = _feat("delete-calendar.free-namespace")
345340

346341
principal = None
347342
try:
@@ -350,29 +345,44 @@ async def async_calendar2(self, async_client: Any) -> Any:
350345
pass
351346

352347
calendar, created = await aget_or_create_test_calendar(
353-
async_client, principal, calendar_name=calendar_name
348+
async_client,
349+
principal,
350+
calendar_name="pythoncaldav-async-test-2",
351+
cal_id="pythoncaldav-async-test-2",
354352
)
355353

356354
if calendar is None:
357355
pytest.skip("Could not create or find a second calendar for testing")
358356

357+
await cleanup_calendar_objects(calendar)
358+
359359
yield calendar
360360

361-
if created:
361+
if delete_frees_namespace and created:
362362
try:
363363
await calendar.delete()
364364
except Exception:
365365
pass
366+
else:
367+
await cleanup_calendar_objects(calendar)
366368

367369
@pytest_asyncio.fixture
368370
async def async_journal_list(self, async_client: Any) -> Any:
369-
"""Create a VJOURNAL calendar for journal tests."""
371+
"""Create or find a stable VJOURNAL calendar, wiping it before and after use."""
370372
from caldav.aio import AsyncPrincipal
371373
from caldav.lib.error import AuthorizationError, NotFoundError
372374

373-
from .fixture_helpers import aget_or_create_test_calendar
375+
from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects
376+
377+
feats = getattr(async_client, "features", None)
374378

375-
calendar_name = f"async-journal-{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
379+
def _feat(name: str) -> bool:
380+
return feats.is_supported(name) if feats else True
381+
382+
delete_frees_namespace = _feat("delete-calendar.free-namespace")
383+
supports_displayname = _feat("create-calendar.set-displayname")
384+
cal_id = "pythoncaldav-async-journal"
385+
calendar_name = cal_id if supports_displayname else None
376386

377387
principal = None
378388
try:
@@ -384,19 +394,24 @@ async def async_journal_list(self, async_client: Any) -> Any:
384394
async_client,
385395
principal,
386396
calendar_name=calendar_name,
397+
cal_id=cal_id,
387398
supported_calendar_component_set=["VJOURNAL"],
388399
)
389400

390401
if calendar is None:
391402
pytest.skip("Could not create or find a journal list for testing")
392403

404+
await cleanup_calendar_objects(calendar)
405+
393406
yield calendar
394407

395-
if created:
408+
if delete_frees_namespace and created:
396409
try:
397410
await calendar.delete()
398411
except Exception:
399412
pass
413+
else:
414+
await cleanup_calendar_objects(calendar)
400415

401416
async def _make_async_client_with_params(self, **overrides: Any) -> Any:
402417
"""Build a fresh async client from this server's config with kwargs overridden.

0 commit comments

Comments
 (0)