Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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..."
Expand Down Expand Up @@ -335,8 +351,8 @@ jobs:
- run: pip install tox
- run: tox -e deptry
async-niquests:
# Test that async code works with niquests when httpx/httpxyz are not installed
name: async (niquests fallback)
# Test that async code works with niquests (the default/preferred async library)
name: async (niquests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -386,6 +402,17 @@ jobs:
# Test that async code works with plain httpx when niquests and httpxyz are 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
Expand All @@ -395,6 +422,21 @@ jobs:
run: |
pip install --editable .[test]
pip uninstall -y niquests httpxyz
- 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 "
Expand All @@ -405,11 +447,24 @@ jobs:
print('✓ Using httpx for async HTTP')
"
- name: Run async tests with httpx
run: pytest tests/test_async_davclient.py -v
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
Expand All @@ -420,6 +475,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 "
Expand All @@ -429,4 +499,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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,10 @@ def expand_rrule(self, start: datetime, end: datetime, include_completed: bool =
and occurrence.get("STATUS") in ("COMPLETED", "CANCELLED")
):
continue
## TODO: If there are no reports of missing RECURRENCE-ID until 2027,
## the if-statement below may be deleted
## RFC 4791 §9.6.5: server-side expansion MAY omit RECURRENCE-ID on the
## initial instance. This code path uses recurring_ical_events (client-side),
## which always provides RECURRENCE-ID; the assert catches any regression in
## that library, and the fallback handles it gracefully if it ever fires.
error.assert_("RECURRENCE-ID" in occurrence)
if "RECURRENCE-ID" not in occurrence:
occurrence.add("RECURRENCE-ID", occurrence.get("DTSTART").dt)
Expand Down Expand Up @@ -1366,6 +1368,10 @@ def get_self():
existing = get_self()
self._validate_save_constraints(existing, uid, no_overwrite, no_create)

## Note: RFC 4791 §9.6.5 permits servers to omit RECURRENCE-ID on the initial
## expanded instance. If this object is such an instance (no RECURRENCE-ID but
## fetched via server-side expand), only_this_recurrence will silently not merge
## it into the parent; the caller must add RECURRENCE-ID from DTSTART first.
if (
only_this_recurrence or all_recurrences
) and "RECURRENCE-ID" in self.icalendar_component:
Expand Down
52 changes: 43 additions & 9 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,44 +786,78 @@ 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:
try:
objects = list(self.search())
except error.NotFoundError:
return
for obj in objects:
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:
try:
objects = await self.search()
except error.NotFoundError:
return
for obj in objects:
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
Expand Down
13 changes: 6 additions & 7 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'},
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading