Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
sogo_pass: testpass
sogo_name: Test User
sogo_fqhn: example.com
bedework:
bedework3:
image: ioggstream/bedework:latest
ports:
- 8804:8080
Expand Down Expand Up @@ -301,7 +301,7 @@ jobs:
echo "✗ Error: SOGo CalDAV access failed"
exit 1
fi
- name: Configure Bedework
- name: Configure Bedework 3.10.3
run: |
echo "Waiting for Bedework..."
# Bedework/JBoss takes longer to start up
Expand Down Expand Up @@ -330,7 +330,7 @@ jobs:
BAIKAL_URL: http://localhost:8800
CYRUS_URL: http://localhost:8802
SOGO_URL: http://localhost:8803
BEDEWORK_URL: http://localhost:8804
BEDEWORK3_URL: http://localhost:8804
docs:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 2 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ http://oxpedia\.org/.*
http://httpd\.apache\.org/.*
# GitHub URL template in sphinx conf (contains encoded braces, always 404)
https://github\.com/python-caldav/caldav/blob/master/%7B.*
# Shell/Dockerfile URL templates with an unexpanded ${VAR} (encoded as $%7B)
.*\$%7B.*

# Other junk that was never meant to be followed
file://.*/scheme:.*
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ Changelogs prior to v3.0 are pruned, but are 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.

## [3.3.1] - 2026-09-16

The two main things in this release:
* Changes in the compatibility_hints.py needed for the upcoming caldav-server-tester 1.3.0 release.
* Workarounds for broken behaviour in the Bedework 5 calendaring server.

### Added

* Support for Bedework 5.0. The earlier Bedework tests were targeting the docker image `ioggstream/bedework:latest` which has been locked towards Bedework 3.10.3 since 2018. Now there is a script for building a bedework container in the docker test servers. The old Bedework docker image has been kept, but renamed into bedework3. Workarounds for various server quirks have been implemented.

### Changed

* **Breaking:** the `bedework` compatibility profile is renamed `bedework_3_10_3`, and `bedework_5_0_0` is added. `features: bedework` or `base: bedework` in a config now raises a `ValueError` naming both.
* `compatibility_hints`: the `write-delay` server-peculiarity is turned around into the `synchronous-write` server-feature. While the caldav-server-tester does probe it, the delay (relevant for tests and the caldav server tester) should still be hand-configured.
* `compatibility_hints`: new caldav-server-tester probes caused the Xandikos, SOGo, Radicale, OX, Zimbra, Bedework and Cyrus profiles to be regraded. With `features: <server>` configured, `is_supported()` may give a different answer than in 3.3.0.

### Fixed

* `make_calendar()` raised `MkcalendarError` when the server answered MKCALENDAR with `207 Multi-Status` instead of `201 Created`, even though the calendar was created. A multistatus reporting no failure is now accepted (seen on Bedework 5).
* An ETag delivered percent-encoded (`%22...%22`) in a PUT response is now decoded; previously every second `save()` of an object raised `ETagMismatchError` (seen on Bedework 5).
* Expanded searches lost all but the last occurrence when the server answered with one `DAV:response` per recurrence instance, all under the same href. The instances are now merged (seen on Bedework 5).

* A bare `icalendar.Event`/`Todo`/`Journal` handed to caldav is wrapped in a `VCALENDAR` - that wrapper no longer gets a random RFC 7986 `UID` of its own (`icalendar.Calendar.new()` adds one). Servers taking the calendar-level `UID` to be the identity of the calendar object resource (i.e. Stalwart) saw a brand new UID on every save and rejected it with `412 no-uid-conflict`.

## [3.3.0] - 2026-09-03

3.3.0 is mostly a maintenance and QA release. The major news here is that we've done an AI-based (Claude Fable) review of all the code, this has resulted in quite a lot of hammering on the code to get all the issues found smoothened out.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,4 @@ Consider this procedures to be a more of a guideline than a rigid procedure. Us

## Code of Conduct

Code of Conduct has been moved to a [separate document](CODE_OF_CONDUCT]
Code of Conduct has been moved to a [separate document](CODE_OF_CONDUCT)
29 changes: 20 additions & 9 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, ClassVar, Optional
from urllib.parse import ParseResult, SplitResult, quote
from urllib.parse import ParseResult, SplitResult, quote, unquote

import icalendar
from dateutil.rrule import rrulestr
Expand Down Expand Up @@ -1196,7 +1196,13 @@ def _update_tag_props(self, r) -> None:
if not r.headers:
return
if "Etag" in r.headers:
self.props[dav.GetEtag.tag] = r.headers["Etag"]
etag = r.headers["Etag"]
## Bedework 5 percent-encodes the quotes in a PUT response and then
## refuses that form in If-Match. RFC 9110 has an entity-tag start
## with '"' or 'W/"', so a leading %22 can only be that encoding.
if etag.startswith(("%22", "W/%22")):
etag = unquote(etag)
self.props[dav.GetEtag.tag] = etag
if r.headers.get("Schedule-Tag"):
self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"]

Expand Down Expand Up @@ -1362,6 +1368,10 @@ def save(
* self

"""

# TODO: the overwrite/no-overwrite logic can be handled server-side for
# servers that adheres to the RFC: "If-Match: *" and "If-None-Match: *"

# Early return if there's no data (no-op case)
if not self.is_loaded():
return self
Expand Down Expand Up @@ -1696,13 +1706,14 @@ def _set_icalendar_instance(self, inst):
if not isinstance(inst, icalendar.Calendar):
## assume inst is an Event, Journal or Todo.
## TODO: perhaps a bit better sanity checking here?
try: ## DEPRECATION TODO: remove this try/except the future
## icalendar 7.x behaviour (not released yet as of 2025-09
cal = icalendar.Calendar.new()
except AttributeError:
cal = icalendar.Calendar()
cal.add("prodid", "-//python-caldav//caldav//en_DK")
cal.add("version", "2.0")
## Deliberately not using icalendar.Calendar.new() here - it adds a
## random RFC 7986 UID to the VCALENDAR wrapper, and some servers
## (i.e. Stalwart) take that UID to be the identity of the calendar
## object resource. A fresh random UID on every wrap then makes
## the second save of the same object fail with 412 no-uid-conflict.
cal = icalendar.Calendar()
cal.add("prodid", "-//python-caldav//caldav//en_DK")
cal.add("version", "2.0")
cal.add_component(inst)
inst = cal
self._icalendar_instance = inst
Expand Down
32 changes: 30 additions & 2 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,34 @@
from .davobject import DAVObject
from .elements import cdav, dav
from .lib import error, vcal
from .lib.error import errmsg
from .lib.python_utilities import to_wire
from .lib.url import URL, normalise_path, requote_path

_CC = TypeVar("_CC", bound="CalendarObjectResource")
log = logging.getLogger("caldav")

## RFC 4791 §5.3.1 (MKCALENDAR) and RFC 5689 §3 (extended MKCOL) have the
## server answer 201 Created when the collection was made and every property
## set, and reserve the multistatus for reporting what went wrong. Bedework 5
## nevertheless answers 207 as soon as the request carries properties, listing
## each of them as 200 ok. Both are accepted here; _assert_created() then
## sorts a 207 that spells out a success from one reporting a failure.
_CREATED_STATUSES = (201, 207)


def _assert_created(response, method: str) -> None:
"""Raise unless the server really did create the collection.

A 207 counts as success only when every status in it is a 2xx - a
multistatus reporting that a property could not be set (or that the
collection could not be made) is the failure the RFCs use it for, and must
raise the same way any other unexpected answer does.
"""
if response.status == 201 or response.all_statuses_ok():
return
raise error.exception_by_method[method](errmsg(response))


# ---------------------------------------------------------------------------
# Helpers for extracting calendar / principal info from PROPFIND results.
Expand Down Expand Up @@ -930,7 +952,10 @@ def _create(
if self.is_async_client:
return self._async_create(path, mkcol, method, name, display_name, stable_url)

self._query(root=mkcol, query_method=method, url=path, expected_return_value=201)
response = self._query(
root=mkcol, query_method=method, url=path, expected_return_value=_CREATED_STATUSES
)
_assert_created(response, method)

# COMPATIBILITY ISSUE
# name should already be set, but we've seen caldav servers failing
Expand Down Expand Up @@ -1030,7 +1055,10 @@ def _adopt_relocated_url(self, name, relocated: list) -> None:

async def _async_create(self, path, mkcol, method, name, display_name, stable_url) -> None:
"""Async implementation of _create (call via _create, not directly)."""
await self._query(root=mkcol, query_method=method, url=path, expected_return_value=201)
response = await self._query(
root=mkcol, query_method=method, url=path, expected_return_value=_CREATED_STATUSES
)
_assert_created(response, method)

# COMPATIBILITY ISSUE - try to set display name explicitly
if display_name:
Expand Down
Loading
Loading