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
1 change: 1 addition & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ file://.*/scheme:.*
http://x/
https://ecloud\.global/remote\.php/.*
https://tobixen@e\.email/remote\.php/dav
https://dav\.qq\.com/.*
243 changes: 165 additions & 78 deletions caldav/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,25 @@ class CalendarCollection(list):
calendars[0].client.close()
"""

def __init__(self, calendars: list | None = None, client: Any = None):
def __init__(
self,
calendars: list | None = None,
client: Any = None,
clients: list | None = None,
):
super().__init__(calendars or [])
self._client = client
if clients is not None:
self._clients: list = list(clients)
elif client is not None:
self._clients = [client]
else:
self._clients = []

@property
def client(self):
"""The underlying DAV client, if available."""
if self._client:
return self._client
if self._clients:
return self._clients[0]
# Fall back to getting client from first calendar
if self:
return self[0].client
Expand All @@ -313,10 +323,13 @@ def __exit__(self, exc_type, exc_val, exc_tb):
return False

def close(self):
"""Close the underlying DAV client connection."""
if self._client:
self._client.close()
elif self:
"""Close all underlying DAV client connections."""
seen: set[int] = set()
for c in self._clients:
if id(c) not in seen:
c.close()
seen.add(id(c))
if not self._clients and self:
self[0].client.close()


Expand Down Expand Up @@ -394,6 +407,67 @@ def _normalize_to_list(obj: Any) -> list:
return list(obj)


def _fetch_calendars_for_client(
client: Any,
calendar_url: Any | None,
calendar_name: Any | None,
raise_errors: bool,
) -> list:
"""
Fetch calendars from a single connected client, optionally filtered.

Returns a (possibly empty) list of Calendar objects. On error the
behaviour is controlled by ``raise_errors``.
"""
import logging

log = logging.getLogger("caldav")

def _try(meth, kwargs, errmsg):
try:
ret = meth(**kwargs)
if ret is None:
raise ValueError(f"Method returned None: {errmsg}")
return ret
except Exception as e:
log.error(f"Problems fetching calendar information: {errmsg} - {e}")
if raise_errors:
raise
return None

principal = _try(client.principal, {}, "getting principal")
if not principal:
return []

calendars = []
calendar_urls = _normalize_to_list(calendar_url)
calendar_names = _normalize_to_list(calendar_name)

for cal_url in calendar_urls:
if "/" in str(cal_url):
calendar = principal.calendar(cal_url=cal_url)
else:
calendar = principal.calendar(cal_id=cal_url)
if _try(calendar.get_display_name, {}, f"calendar {cal_url}"):
calendars.append(calendar)

for cal_name in calendar_names:
calendar = _try(
principal.calendar,
{"name": cal_name},
f"calendar by name '{cal_name}'",
)
if calendar:
calendars.append(calendar)

if not calendars and not calendar_urls and not calendar_names:
all_cals = _try(principal.get_calendars, {}, "getting all calendars")
if all_cals:
calendars = all_cals

return calendars


def get_calendars(
client_class: type,
calendar_url: Any | None = None,
Expand All @@ -408,68 +482,74 @@ def get_calendars(
**config_data,
) -> CalendarCollection:
"""
Get calendars from a CalDAV server with configuration from multiple sources.
Get calendars from one or more CalDAV servers.

This function creates a client, connects to the server, and returns
calendar objects based on the specified criteria. Configuration is read
from various sources (explicit parameters, environment variables, config files).
Configuration is read from multiple sources in priority order:

The returned CalendarCollection can be used as a context manager to ensure
the underlying connection is properly closed.
1. Explicit keyword arguments (``url``, ``username``, ``password``, …)
2. Test server (``testconfig=True`` or ``PYTHON_CALDAV_USE_TEST_SERVER``)
3. Environment variables (``CALDAV_URL``, …)
4. Config file — supports meta-sections so a single ``config_section``
can expand to multiple servers (see below)

**Multi-server / meta-sections**

Sources 1–3 always produce a single connection. When the config file is
used (source 4) the ``config_section`` value is passed through
``expand_config_section``, which supports:

* ``"*"`` – every non-disabled section in the file
* ``"all"`` – a meta-section defined as ``{"contains": ["work", "personal"]}``
* Glob patterns such as ``"work_*"``
* A plain section name (normal single-server behaviour)

Each expanded leaf section can carry its own ``calendar_name`` or
``calendar_url`` to filter which calendars are returned for that server.
Function-level ``calendar_name`` / ``calendar_url`` arguments override
per-section values when provided.

The returned :class:`CalendarCollection` is a list that can be used as a
context manager; on exit **all** underlying connections are closed.

Args:
client_class: The client class to use (DAVClient or AsyncDAVClient).
client_class: The client class to use (``DAVClient`` or ``AsyncDAVClient``).
calendar_url: URL(s) or ID(s) of specific calendars to fetch.
Can be a string or list of strings. If the value contains '/',
it's treated as a URL; otherwise as a calendar ID.
calendar_name: Name(s) of specific calendars to fetch by display name.
Can be a string or list of strings.
check_config_file: Whether to look for config files (default: True).
config_file: Explicit path to config file.
config_section: Section name in config file (default: "default").
config_section: Section name in config file (default: ``"default"``).
Supports ``*``, meta-sections, and glob patterns.
testconfig: Whether to use test server configuration.
environment: Whether to read from environment variables (default: True).
name: Name of test server to use (for testconfig).
raise_errors: If True, raise exceptions on errors; if False, log and skip.
**config_data: Connection parameters (url, username, password, etc.)
**config_data: Explicit connection parameters (url, username, password, …).

Returns:
CalendarCollection of Calendar objects matching the criteria.
If no calendar_url or calendar_name specified, returns all calendars.
:class:`CalendarCollection` of matching calendars (may be empty).

Example::
Example — single server::

from caldav import get_calendars

# As context manager (recommended)
with get_calendars(url="https://...", username="...", password="...") as calendars:
for cal in calendars:
with get_calendars(url="https://...", username="...", password="...") as cals:
for cal in cals:
print(cal.get_display_name())

# Without context manager - connection closed on garbage collection
calendars = get_calendars(url="https://...", username="...", password="...")
"""
import logging

log = logging.getLogger("caldav")
Example — all sections in config file::

def _try(meth, kwargs, errmsg):
"""Try a method call, handling errors based on raise_errors flag."""
try:
ret = meth(**kwargs)
if ret is None:
raise ValueError(f"Method returned None: {errmsg}")
return ret
except Exception as e:
log.error(f"Problems fetching calendar information: {errmsg} - {e}")
if raise_errors:
raise
return None
with get_calendars(config_section="*") as cals:
for cal in cals:
print(cal.get_display_name())
"""
from caldav import config as _config

# Get client using existing config infrastructure
# ── Priority 1-3: explicit params / test mode / env vars ──────────────
# Try without config file first; if a client is resolved we stay
# single-server (existing behaviour unchanged).
client = get_davclient(
client_class=client_class,
check_config_file=check_config_file,
check_config_file=False,
config_file=config_file,
config_section=config_section,
testconfig=testconfig,
Expand All @@ -478,48 +558,55 @@ def _try(meth, kwargs, errmsg):
**config_data,
)

if client is None:
if client is not None:
calendars = _fetch_calendars_for_client(client, calendar_url, calendar_name, raise_errors)
return CalendarCollection(calendars, client=client)

# ── Priority 4: config file (may expand to multiple sections) ─────────
if not check_config_file:
if raise_errors:
raise ValueError("Could not create DAV client - no configuration found")
return CalendarCollection()

# Get principal
principal = _try(client.principal, {}, "getting principal")
if not principal:
return CalendarCollection(client=client)
# Resolve config_file path from env if not given (mirrors get_connection_params)
resolved_config_file = config_file
if environment and not resolved_config_file:
import os

calendars = []
calendar_urls = _normalize_to_list(calendar_url)
calendar_names = _normalize_to_list(calendar_name)
resolved_config_file = os.environ.get("CALDAV_CONFIG_FILE")

# Fetch specific calendars by URL/ID
for cal_url in calendar_urls:
if "/" in str(cal_url):
calendar = principal.calendar(cal_url=cal_url)
else:
calendar = principal.calendar(cal_id=cal_url)
resolved_section = config_section
if environment and not resolved_section:
import os

# Verify the calendar exists by trying to get its display name
if _try(calendar.get_display_name, {}, f"calendar {cal_url}"):
calendars.append(calendar)
resolved_section = os.environ.get("CALDAV_CONFIG_SECTION")

# Fetch specific calendars by name
for cal_name in calendar_names:
calendar = _try(
principal.calendar,
{"name": cal_name},
f"calendar by name '{cal_name}'",
)
if calendar:
calendars.append(calendar)
all_params = _config.get_all_file_connection_params(resolved_config_file, resolved_section)

# If no specific calendars requested, get all calendars
if not calendars and not calendar_urls and not calendar_names:
all_cals = _try(principal.get_calendars, {}, "getting all calendars")
if all_cals:
calendars = all_cals
if not all_params:
if raise_errors:
raise ValueError("Could not create DAV client - no configuration found")
return CalendarCollection()

from caldav.config import CONNKEYS

all_calendars: list = []
all_clients: list = []

for params in all_params:
# Per-section calendar filters — function-level args override them
sec_cal_url = params.pop("calendar_url", None)
sec_cal_name = params.pop("calendar_name", None)
eff_cal_url = calendar_url if calendar_url is not None else sec_cal_url
eff_cal_name = calendar_name if calendar_name is not None else sec_cal_name

conn_params = {k: v for k, v in params.items() if k in CONNKEYS}
c = client_class(**conn_params)
section_cals = _fetch_calendars_for_client(c, eff_cal_url, eff_cal_name, raise_errors)
all_calendars.extend(section_cals)
all_clients.append(c)

return CalendarCollection(calendars, client=client)
return CalendarCollection(all_calendars, clients=all_clients)


def get_davclient(
Expand Down
43 changes: 43 additions & 0 deletions caldav/calendarobjectresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,23 @@ def get_icalendar_instance(self) -> icalendar.Calendar:
"""
return self._ensure_state().get_icalendar_copy()

def get_icalendar_component(self) -> "icalendar.Component":
"""Get a COPY of the inner icalendar component (VEVENT/VTODO/VJOURNAL) for read-only access.

This is safe for inspection - modifications to the returned object
will NOT be saved. For editing, use edit_icalendar_component().

For recurring events with multiple components, returns the first
non-timezone component (the master RRULE component). Use
``search(..., expand=True)`` to get individual expanded occurrences.

Returns:
A copy of the first non-timezone subcomponent.
"""
import copy

return copy.deepcopy(self.icalendar_component)

def get_vobject_instance(self) -> "vobject.base.Component":
"""Get a COPY of the vobject object for read-only access.

Expand Down Expand Up @@ -1401,6 +1418,32 @@ def edit_icalendar_instance(self):
finally:
self._borrowed = False

@contextmanager
def edit_icalendar_component(self):
"""Context manager to borrow the inner icalendar component for editing.

Like :meth:`edit_icalendar_instance` but yields the first
``VEVENT`` / ``VTODO`` / ``VJOURNAL`` subcomponent directly,
rather than the ``VCALENDAR`` wrapper. This is convenient when
you only need to modify a single property on the component itself.

Usage::

with event.edit_icalendar_component() as comp:
comp['SUMMARY'] = 'New Summary'
event.save()

Yields:
The first non-``VTIMEZONE`` subcomponent of the icalendar object.

Raises:
RuntimeError: If another representation is currently borrowed.
StopIteration: If the calendar contains no non-timezone components.
"""
with self.edit_icalendar_instance() as cal:
component = next(c for c in cal.subcomponents if c.name != "VTIMEZONE")
yield component

@contextmanager
def edit_vobject_instance(self):
"""Context manager to borrow the vobject object for editing.
Expand Down
Loading
Loading