Skip to content

Commit 71f3066

Browse files
tobixenclaude
andcommitted
Refactor domain objects and core library; remove deprecated objects module
- Refined dual-mode sync/async API consistency across collection.py, calendarobjectresource.py, davobject.py - Remove caldav/objects.py (deprecated aliases module) - Update caldav/__init__.py exports accordingly - Various fixes in config.py, lib/url.py, lib/vcal.py discovered during testing against multiple servers Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d0f1cfa commit 71f3066

8 files changed

Lines changed: 298 additions & 147 deletions

File tree

caldav/__init__.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
#!/usr/bin/env python
2+
"""
3+
caldav — CalDAV client library for Python.
4+
5+
Heavy dependencies (niquests, icalendar, lxml) are loaded lazily on first
6+
use via PEP 562 module-level ``__getattr__``. This keeps ``import caldav``
7+
fast even on constrained hardware.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import importlib
213
import logging
314

415
try:
@@ -8,16 +19,10 @@
819
import warnings
920

1021
warnings.warn(
11-
"You need to install the `build` package and do a `python -m build` to get caldav.__version__ set correctly"
22+
"You need to install the `build` package and do a `python -m build` "
23+
"to get caldav.__version__ set correctly"
1224
)
13-
from .davclient import DAVClient, get_calendar, get_calendars, get_davclient
1425

15-
## TODO: this should go away in some future version of the library.
16-
from .objects import *
17-
from .search import CalDAVSearcher
18-
19-
## We should consider if the NullHandler-logic below is needed or not, and
20-
## if there are better alternatives?
2126
# Silence notification of no default logging handler
2227
log = logging.getLogger("caldav")
2328

@@ -29,4 +34,65 @@ def emit(self, record) -> None:
2934

3035
log.addHandler(NullHandler())
3136

32-
__all__ = ["__version__", "DAVClient", "get_davclient", "get_calendars", "get_calendar"]
37+
# ---------------------------------------------------------------------------
38+
# Lazy import machinery (PEP 562)
39+
# ---------------------------------------------------------------------------
40+
# Maps public attribute names to the *caldav* submodule that provides them.
41+
_LAZY_IMPORTS: dict[str, str] = {
42+
# davclient
43+
"DAVClient": "caldav.davclient",
44+
"get_calendar": "caldav.davclient",
45+
"get_calendars": "caldav.davclient",
46+
"get_davclient": "caldav.davclient",
47+
# base_client
48+
"CalendarCollection": "caldav.base_client",
49+
"CalendarResult": "caldav.base_client",
50+
# collection
51+
"Calendar": "caldav.collection",
52+
"CalendarSet": "caldav.collection",
53+
"Principal": "caldav.collection",
54+
"ScheduleMailbox": "caldav.collection",
55+
"ScheduleInbox": "caldav.collection",
56+
"ScheduleOutbox": "caldav.collection",
57+
"SynchronizableCalendarObjectCollection": "caldav.collection",
58+
# davobject
59+
"DAVObject": "caldav.davobject",
60+
# calendarobjectresource
61+
"CalendarObjectResource": "caldav.calendarobjectresource",
62+
"Event": "caldav.calendarobjectresource",
63+
"Todo": "caldav.calendarobjectresource",
64+
"Journal": "caldav.calendarobjectresource",
65+
"FreeBusy": "caldav.calendarobjectresource",
66+
# search
67+
"CalDAVSearcher": "caldav.search",
68+
}
69+
70+
# Submodules accessible as attributes (e.g. ``caldav.error``).
71+
_LAZY_SUBMODULES: set[str] = {"error"}
72+
73+
__all__ = [
74+
"__version__",
75+
*_LAZY_IMPORTS,
76+
]
77+
78+
79+
def __getattr__(name: str) -> object:
80+
if name in _LAZY_IMPORTS:
81+
module = importlib.import_module(_LAZY_IMPORTS[name])
82+
attr = getattr(module, name)
83+
# Cache on the module so __getattr__ is not called again.
84+
globals()[name] = attr
85+
return attr
86+
87+
if name in _LAZY_SUBMODULES:
88+
module = importlib.import_module(f"caldav.lib.{name}")
89+
globals()[name] = module
90+
return module
91+
92+
raise AttributeError(f"module 'caldav' has no attribute {name!r}")
93+
94+
95+
def __dir__() -> list[str]:
96+
# Expose lazy names alongside the eagerly-defined ones.
97+
eager = list(globals())
98+
return sorted(set(eager + list(_LAZY_IMPORTS) + list(_LAZY_SUBMODULES)))

caldav/calendarobjectresource.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,28 @@ def load(self, only_if_unloaded: bool = False) -> Self:
704704
raise error.NotFoundError(errmsg(r))
705705
self.data = r.raw # type: ignore
706706
except error.NotFoundError:
707+
# Only attempt fallbacks if the object was previously loaded
708+
# (has a UID), indicating the server may have changed the URL.
709+
# Without a UID, the 404 is definitive.
710+
uid = self.id
711+
if uid:
712+
# Fallback 1: try multiget (REPORT may work even when GET fails)
713+
try:
714+
return self.load_by_multiget()
715+
except Exception:
716+
pass
717+
# Fallback 2: re-fetch by UID (server may have changed the URL)
718+
if self.parent and hasattr(self.parent, "get_object_by_uid"):
719+
try:
720+
obj = self.parent.get_object_by_uid(uid)
721+
if obj:
722+
self.url = obj.url
723+
self.data = obj.data
724+
if hasattr(obj, "props"):
725+
self.props.update(obj.props)
726+
return self
727+
except error.NotFoundError:
728+
pass
707729
raise
708730
except Exception:
709731
return self.load_by_multiget()
@@ -730,6 +752,19 @@ async def _async_load(self, only_if_unloaded: bool = False) -> Self:
730752
raise error.NotFoundError(errmsg(r))
731753
self.data = r.raw # type: ignore
732754
except error.NotFoundError:
755+
# Fallback: re-fetch by UID (server may have changed the URL)
756+
uid = self.id
757+
if uid and self.parent and hasattr(self.parent, "get_object_by_uid"):
758+
try:
759+
obj = await self.parent.get_object_by_uid(uid)
760+
if obj:
761+
self.url = obj.url
762+
self.data = obj.data
763+
if hasattr(obj, "props"):
764+
self.props.update(obj.props)
765+
return self
766+
except error.NotFoundError:
767+
pass
733768
raise
734769
except Exception:
735770
# Note: load_by_multiget is sync-only, not supported in async mode yet
@@ -862,7 +897,9 @@ def _generate_url(self):
862897
## See https://github.com/python-caldav/caldav/issues/143 for the rationale behind double-quoting slashes
863898
## TODO: should try to wrap my head around issues that arises when id contains weird characters. maybe it's
864899
## better to generate a new uuid here, particularly if id is in some unexpected format.
865-
return self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics")
900+
url = self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics")
901+
assert " " not in str(url)
902+
return url
866903

867904
def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None:
868905
"""
@@ -950,11 +987,7 @@ def save(
950987
951988
"""
952989
# Early return if there's no data (no-op case)
953-
if (
954-
self._vobject_instance is None
955-
and self._data is None
956-
and self._icalendar_instance is None
957-
):
990+
if not self.is_loaded():
958991
return self
959992

960993
# Helper function to get the full object by UID

caldav/collection.py

Lines changed: 36 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,10 @@ async def _async_get_calendar_home_set(self) -> "CalendarSet":
421421
self.calendar_home_set = calendar_home_set_url
422422
return self._calendar_home_set
423423

424+
## TODO: the parameter names name, cal_id and cal_url is quite inconsistent
425+
## I think it was made so to make sure the calendar URL was not mixed up
426+
## with the caldav base URL - but I still think we should reconsider this
427+
## parameter naming
424428
def calendar(
425429
self,
426430
name: str | None = None,
@@ -754,7 +758,7 @@ def delete(self):
754758
## TODO: remove quirk handling from the functional tests
755759
## TODO: this needs test code
756760
quirk_info = self.client.features.is_supported("delete-calendar", dict)
757-
wipe = quirk_info["support"] in ("unsupported", "fragile")
761+
wipe = not self.client.features.is_supported("delete-calendar")
758762
if quirk_info["support"] == "fragile":
759763
## Do some retries on deleting the calendar
760764
for x in range(0, 20):
@@ -776,42 +780,31 @@ def delete(self):
776780
super().delete()
777781

778782
async def _async_calendar_delete(self):
779-
"""Async implementation of Calendar.delete().
783+
"""Async implementation of Calendar.delete()."""
784+
import asyncio
780785

781-
Note: Server quirk handling (fragile/wipe modes) is simplified for async.
782-
Most modern servers support proper calendar deletion.
783-
"""
784786
quirk_info = self.client.features.is_supported("delete-calendar", dict)
787+
wipe = not self.client.features.is_supported("delete-calendar")
785788

786-
# For fragile servers, try simple delete first
787789
if quirk_info["support"] == "fragile":
788-
for _ in range(0, 5):
790+
# Do some retries on deleting the calendar
791+
for _ in range(0, 20):
789792
try:
790793
await self._async_delete()
791-
return
792794
except error.DeleteError:
793-
import asyncio
794-
795+
pass
796+
try:
797+
await self.search(event=True)
795798
await asyncio.sleep(0.3)
796-
# If still failing after retries, fall through to wipe
797-
798-
if quirk_info["support"] in ("unsupported", "fragile"):
799-
# Need to delete all objects first
800-
# Use the async client's get_events method
801-
try:
802-
events = await self.client.get_events(self)
803-
for event in events:
804-
await event._async_delete()
805-
except Exception:
806-
pass # Best effort
807-
try:
808-
todos = await self.client.get_todos(self)
809-
for todo in todos:
810-
await todo._async_delete()
811-
except Exception:
812-
pass # Best effort
799+
except error.NotFoundError:
800+
wipe = False
801+
break
813802

814-
await self._async_delete()
803+
if wipe:
804+
for obj in await self.search():
805+
await obj._async_delete()
806+
else:
807+
await self._async_delete()
815808

816809
def get_supported_components(self) -> list[Any]:
817810
"""
@@ -1036,9 +1029,11 @@ def multiget(self, event_urls: Iterable[URL], raise_notfound: bool = False) -> I
10361029
"""
10371030
results = self._multiget(event_urls, raise_notfound=raise_notfound)
10381031
for url, data in results:
1032+
# Quote path to handle servers returning unencoded spaces (e.g., Zimbra)
1033+
quoted_url = quote(unquote(str(url)), safe="/:@")
10391034
yield self._calendar_comp_class_by_data(data)(
10401035
self.client,
1041-
url=self.url.join(url),
1036+
url=self.url.join(quoted_url),
10421037
data=data,
10431038
parent=self,
10441039
)
@@ -1526,21 +1521,18 @@ def get_object_by_uid(
15261521
Returns:
15271522
CalendarObjectResource (Event, Todo, or Journal)
15281523
"""
1529-
## late import to avoid cyclic dependencies
1530-
from .search import CalDAVSearcher
1531-
1532-
## 2025-11: some logic validating the comp_filter and
1533-
## comp_class has been removed, and replaced with the
1534-
## recommendation not to use comp_filter. We're still using
1535-
## comp_filter internally, but it's OK, it doesn't need to be
1536-
## validated.
1537-
1538-
## Lots of old logic has been removed, the new search logic
1539-
## can do the things for us:
1540-
searcher = CalDAVSearcher(comp_class=comp_class)
1541-
## Default is substring
1542-
searcher.add_property_filter("uid", uid, "==")
1543-
items_found = searcher.search(self, xml=comp_filter, _hacks="insist", post_filter=True)
1524+
## Use self.search() rather than CalDAVSearcher directly, so that any
1525+
## monkey-patching of Calendar.search (e.g. the search-cache delay for
1526+
## servers with lazy search indexes) is respected. This mirrors the
1527+
## pattern used in get_todos() and get_events().
1528+
##
1529+
## search(uid=...) does substring matching on the server side, so we
1530+
## apply an exact match filter afterwards to preserve the semantics of
1531+
## this method (see testObjectByUID).
1532+
items_found = self.search(
1533+
uid=uid, comp_class=comp_class, xml=comp_filter, post_filter=True, _hacks="insist"
1534+
)
1535+
items_found = [o for o in items_found if o.id == uid]
15441536

15451537
if not items_found:
15461538
raise error.NotFoundError("%s not found on server" % uid)

0 commit comments

Comments
 (0)