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
5 changes: 4 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ jobs:
--health-retries 5
--health-start-period 60s
cyrus:
image: ghcr.io/cyrusimap/cyrus-docker-test-server:latest
# Pinned to last known-good build (pre-April-2026 multi-stage rebuild
# that broke CalDAV startup). Unpin once upstream is fixed.
# Working digest confirmed in CI run 23748898521 (2026-03-30).
image: ghcr.io/cyrusimap/cyrus-docker-test-server@sha256:d639a9116691a7a1c875073486c419d60843e5ef8e32e65c5ef56283874dbf2c
ports:
- 8802:8080
- 8001:8001
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0

## [Unreleased]

### Removed

* Compatibility feature `search.text.by-uid` has been removed. `get_object_by_uid()` already has a client-side fallback (via `_hacks="insist"`) that works on any server, so the guard was no longer needed. Closes https://github.com/python-caldav/caldav/issues/586

### Fixed

* Reusing a `CalDAVSearcher` across multiple `search()` calls could yield inconsistent results: the first call would return only pending tasks (correct), but subsequent calls would change behaviour because `icalendar_searcher.Searcher.check_component()` mutated the `include_completed` field from `None` to `False` as a side-effect. Fixed by passing a copy with `include_completed` already resolved to `filter_search_results()`, leaving the original searcher object unchanged. Fixes https://github.com/python-caldav/caldav/issues/650
Expand Down
2 changes: 1 addition & 1 deletion caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,7 @@ def save(
def get_self():
from caldav.lib import error

uid = self.id or self.icalendar_component.get("uid")
uid = self.id
if uid and self.parent:
try:
if not obj_type:
Expand Down
13 changes: 9 additions & 4 deletions caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,12 @@ def get_object_by_uid(
## apply an exact match filter afterwards to preserve the semantics of
## this method (see testObjectByUID).
items_found = self.search(
uid=uid, comp_class=comp_class, xml=comp_filter, post_filter=True, _hacks="insist"
uid=uid,
comp_class=comp_class,
xml=comp_filter,
post_filter=True,
_hacks="insist",
include_completed=True,
)
items_found = [o for o in items_found if o.id == uid]

Expand Down Expand Up @@ -1541,7 +1546,7 @@ def get_todo_by_uid(self, uid: str) -> "CalendarObjectResource":
Returns the task with the given uid.
See :meth:`get_object_by_uid` for more details.
"""
return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO"))
return self.get_object_by_uid(uid, comp_class=Todo)

def get_event_by_uid(self, uid: str) -> "CalendarObjectResource":
"""
Expand All @@ -1550,7 +1555,7 @@ def get_event_by_uid(self, uid: str) -> "CalendarObjectResource":
Returns the event with the given uid.
See :meth:`get_object_by_uid` for more details.
"""
return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT"))
return self.get_object_by_uid(uid, comp_class=Event)

def get_journal_by_uid(self, uid: str) -> "CalendarObjectResource":
"""
Expand All @@ -1559,7 +1564,7 @@ def get_journal_by_uid(self, uid: str) -> "CalendarObjectResource":
Returns the journal with the given uid.
See :meth:`get_object_by_uid` for more details.
"""
return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL"))
return self.get_object_by_uid(uid, comp_class=Journal)

## Deprecated aliases - use get_*_by_uid instead

Expand Down
8 changes: 4 additions & 4 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,6 @@ class FeatureSet:
"search.text.category.substring": {
"description": "Substring search for category should work according to the RFC. I.e., search for mil should match family,finance",
},
"search.text.by-uid": {
"description": "The server supports searching for objects by UID property. When unsupported, calendar.get_object_by_uid(uid) will not work. This may be removed in the feature - the checker-script is not checking the right thing (check TODO-comments), probably search by uid is no special case for any server implementations"
},
"search.recurrences": {
"description": "Support for recurrences in search"
},
Expand Down Expand Up @@ -1034,6 +1031,7 @@ def dotted_feature_set_list(self, compact=False):
'old_flags': [
'propfind_allprop_failure',
'duplicates_not_allowed',
'no_relships' ## relships seems to work as long as it's one RELATED-TO-line, but as soon as there are multiple lines the implementation seems broken
],

}
Expand Down Expand Up @@ -1219,7 +1217,6 @@ def dotted_feature_set_list(self, compact=False):
"search.time-range.todo": { "support": "unsupported" },
"search.time-range.alarm": {'support': 'unsupported'},
"search.text": { "support": "unsupported", "behaviour": "a text search ignores the filter and returns all elements" },
"search.text.by-uid": { "support": "fragile", "behaviour": "Probably not supported, but my caldav-server-checker tool has issues with it at the moment" },
"search.comp-type.optional": { "support": "ungraceful" },
"search.recurrences.expanded.todo": { "support": "unsupported" },
"search.recurrences.expanded.event": { "support": "fragile" },
Expand Down Expand Up @@ -1495,6 +1492,9 @@ def dotted_feature_set_list(self, compact=False):
'principal-search.list-all': {'support': 'unsupported'},
## Cross-calendar duplicate UID test fails (AuthorizationError creating second calendar)
'save.duplicate-uid.cross-calendar': {'support': 'ungraceful'},
'old_flags': [
'no_relships',
],
}

# fmt: on
10 changes: 8 additions & 2 deletions caldav/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ def _search_impl(
):
"""Core search implementation as a generator yielding actions.

(TODO: refactoring beyond readability? Is this sane?)

This generator contains all the search logic and yields (action, data) tuples
that the caller (sync or async) executes. Results are sent back via .send().

Expand Down Expand Up @@ -451,7 +453,7 @@ def _search_impl(
## we wouldn't waste so much time on repeated queries
if self.todo and self.include_completed is False:
clone = replace(self, include_completed=True)
clone.include_completed = True
clone.include_completed = True ## Why? Isn't this redundant?
clone.expand = False

if (
Expand Down Expand Up @@ -618,7 +620,7 @@ def search(
:param calendar: Calendar to be searched (optional if searcher was created
from a calendar via ``calendar.searcher()``)
:param server_expand: Ask the CalDAV server to expand recurrences
:param split_expanded: Don't collect a recurrence set in one ical calendar
:param split_expanded: Send expanded recurrences as multiple objects
:param props: CalDAV properties to send in the query
:param xml: XML query to be sent to the server (string or elements)
:param post_filter: Do client-side filtering after querying the server
Expand Down Expand Up @@ -699,6 +701,10 @@ def _search_with_comptypes(
assert self.event is None and self.todo is None and self.journal is None

for comp_class in (Event, Todo, Journal):
if not calendar.client.features.is_supported(
f"save-load.{comp_class.__name__.lower()}"
):
continue
clone = replace(self)
clone.comp_class = comp_class
objects += clone.search(
Expand Down
61 changes: 27 additions & 34 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,7 @@ def testFindCalendarOwner(self):
## TODO: something should probably be asserted about the Owner

def testIssue397(self):
self.skip_unless_support("search.text.by-uid")
self.skip_unless_support("save-load.event.recurrences.exception")
cal = self._fixCalendar()
cal.add_event(
"""BEGIN:VCALENDAR
Expand Down Expand Up @@ -1289,7 +1289,6 @@ def testChangeAttendeeStatusWithEmailGiven(self):
)
event.change_attendee_status(attendee="testuser@example.com", PARTSTAT="ACCEPTED")
event.save()
self.skip_unless_support("search.text.by-uid")
event = c.get_event_by_uid("test1")
## TODO: work in progress ... see https://github.com/python-caldav/caldav/issues/399

Expand Down Expand Up @@ -1456,7 +1455,6 @@ def testObjectByUID(self):
"""
It should be possible to save a task and retrieve it by uid
"""
self.skip_unless_support("search.text.by-uid")
c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
c.add_todo(summary="Some test task with a well-known uid", uid="well_known_1")
foo = c.get_object_by_uid("well_known_1")
Expand Down Expand Up @@ -2228,7 +2226,6 @@ def testWrongPassword(self):
def testCreateChildParent(self):
self.skip_unless_support("save-load.event")
self.skip_on_compatibility_flag("no_relships")
self.skip_unless_support("search.text.by-uid")
c = self._fixCalendar(supported_calendar_component_set=["VEVENT"])
parent = c.add_event(
dtstart=datetime(2022, 12, 26, 19, 15),
Expand Down Expand Up @@ -2356,7 +2353,7 @@ def testSetDue(self):
dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc),
due=datetime(2022, 12, 26, 20, 00, tzinfo=utc),
summary="Some task",
uid="ctuid1",
uid="ctuid5",
)

## setting the due should ... set the due (surprise, surprise)
Expand All @@ -2378,7 +2375,7 @@ def testSetDue(self):
dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc),
duration=timedelta(minutes=15),
summary="Some other task",
uid="ctuid2",
uid="ctuid6",
)
some_other_todo.set_due(datetime(2022, 12, 26, 19, 45, tzinfo=utc), move_dtstart=True)
assert some_other_todo.icalendar_component["DUE"].dt == datetime(
Expand All @@ -2391,19 +2388,22 @@ def testSetDue(self):
some_todo.save()

self.skip_on_compatibility_flag("no_relships")
self.skip_unless_support("search.text.by-uid")

parent = c.add_todo(
dtstart=datetime(2022, 12, 26, 19, 00, tzinfo=utc),
due=datetime(2022, 12, 26, 21, 00, tzinfo=utc),
summary="this is a parent test task",
uid="ctuid3",
uid="ctuid7",
child=[some_todo.id],
)

## The check_reverse_relations method is cheeky,
## returning a list of non-behaving relations
## (so it SHOULD return an empty list)
assert not parent.check_reverse_relations()

## The above updates the some_todo object on the server side, but the local object is not
## updated ... until we reload it
## The above updates the some_todo object on the server side,
## but the local object is not updated ... until we reload it
some_todo.load()

## This should work out (set the children due to some time before the parents due)
Expand Down Expand Up @@ -2468,7 +2468,6 @@ def testCreateJournalListAndJournalEntry(self):
j1 = c.add_journal(journal)
journals = c.get_journals()
assert len(journals) == 1
self.skip_unless_support("search.text.by-uid")
j1_ = c.get_journal_by_uid(j1.id)
## Direct comparison handles different line folding from different fetch methods
assert j1_.get_icalendar_instance() == journals[0].get_icalendar_instance()
Expand Down Expand Up @@ -2836,11 +2835,10 @@ def testTodoCompletion(self):
# The historic todo-item can still be accessed
todos = c.get_todos(include_completed=True)
assert len(todos) == 3
if self.is_supported("search.text.by-uid"):
t3_ = c.get_todo_by_uid(t3.id)
assert t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary
assert t3_.vobject_instance.vtodo.uid == t3.vobject_instance.vtodo.uid
assert t3_.vobject_instance.vtodo.dtstart == t3.vobject_instance.vtodo.dtstart
t3_ = c.get_todo_by_uid(t3.id)
assert t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary
assert t3_.vobject_instance.vtodo.uid == t3.vobject_instance.vtodo.uid
assert t3_.vobject_instance.vtodo.dtstart == t3.vobject_instance.vtodo.dtstart

t2.delete()

Expand Down Expand Up @@ -3073,10 +3071,9 @@ def testLookupEvent(self):
e2 = c.event_by_url(e1.url)
assert e2.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid
assert e2.url == e1.url
if self.is_supported("search.text.by-uid"):
e3 = c.get_event_by_uid("20010712T182145Z-123401@example.com")
assert e3.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid
assert e3.url == e1.url
e3 = c.get_event_by_uid("20010712T182145Z-123401@example.com")
assert e3.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid
assert e3.url == e1.url

# Knowing the URL of an event, we should be able to get to it
# without going through a calendar object
Expand All @@ -3100,10 +3097,9 @@ def testCreateOverwriteDeleteEvent(self):
c = self._fixCalendar()
assert c.url is not None

# attempts on updating/overwriting a non-existing event should fail (unless get_object_by_uid_is_broken):
if self.is_supported("search.text.by-uid"):
with pytest.raises(error.ConsistencyError):
c.add_event(ev1, no_create=True)
# attempts on updating/overwriting a non-existing event should fail:
with pytest.raises(error.ConsistencyError):
c.add_event(ev1, no_create=True)

# no_create and no_overwrite is mutually exclusive, this will always
# raise an error (unless the ical given is blank)
Expand All @@ -3121,11 +3117,9 @@ def testCreateOverwriteDeleteEvent(self):
assert t1.url is not None
if not self.check_compatibility_flag("event_by_url_is_broken"):
assert c.event_by_url(e1.url).url == e1.url
if self.is_supported("search.text.by-uid"):
assert c.get_event_by_uid(e1.id).url == e1.url
assert c.get_event_by_uid(e1.id).url == e1.url

## no_create will not work unless get_object_by_uid works
no_create = self.is_supported("search.text.by-uid")
no_create = True

## add same event again. As it has same uid, it should be overwritten
## (but some calendars may throw a "409 Conflict")
Expand Down Expand Up @@ -3155,13 +3149,12 @@ def testCreateOverwriteDeleteEvent(self):
e3 = c.event_by_url(e1.url)
assert e3.vobject_instance.vevent.summary.value == "Bastille Day Party!"

## "no_overwrite" should throw a ConsistencyError. But it depends on get_object_by_uid.
if self.is_supported("search.text.by-uid"):
## "no_overwrite" should throw a ConsistencyError.
with pytest.raises(error.ConsistencyError):
c.add_event(ev1, no_overwrite=True)
if todo_ok:
with pytest.raises(error.ConsistencyError):
c.add_event(ev1, no_overwrite=True)
if todo_ok:
with pytest.raises(error.ConsistencyError):
c.add_todo(todo, no_overwrite=True)
c.add_todo(todo, no_overwrite=True)

# delete event
e1.delete()
Expand Down
10 changes: 4 additions & 6 deletions tests/test_compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,17 +375,16 @@ def test_hierarchical_vs_independent_subfeatures(self) -> None:

def test_intermediate_feature_derives_from_children(self) -> None:
"""Test that intermediate features (e.g. search.text) derive status from their children"""
# search.text has 5 children: case-sensitive, case-insensitive,
# substring, category, by-uid (none have explicit defaults)
# search.text has 4 direct children: case-sensitive, case-insensitive,
# substring, category (none have explicit defaults)

# All children set with mixed statuses -> derive "unknown"
fs = FeatureSet(
{
"search.text.case-sensitive": {"support": "unsupported"},
"search.text.case-insensitive": {"support": "unsupported"},
"search.text.substring": {"support": "unsupported"},
"search.text.category": {"support": "unsupported"},
"search.text.by-uid": {"support": "fragile"},
"search.text.category": {"support": "fragile"},
}
)
assert not fs.is_supported("search.text")
Expand All @@ -396,7 +395,7 @@ def test_intermediate_feature_derives_from_children(self) -> None:
fs1b = FeatureSet(
{
"search.text.case-sensitive": {"support": "unsupported"},
"search.text.by-uid": {"support": "fragile"},
"search.text.category.substring": {"support": "fragile"},
}
)
assert fs1b.is_supported("search.text")
Expand All @@ -408,7 +407,6 @@ def test_intermediate_feature_derives_from_children(self) -> None:
"search.text.case-insensitive": {"support": "unsupported"},
"search.text.substring": {"support": "unsupported"},
"search.text.category": {"support": "unsupported"},
"search.text.by-uid": {"support": "unsupported"},
}
)
assert not fs2.is_supported("search.text")
Expand Down
Loading