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
96 changes: 84 additions & 12 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 @@ -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
Expand All @@ -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 "
Expand All @@ -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
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
44 changes: 35 additions & 9 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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