Source code for calendaring_jmap.objects.calendar

# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

"""
JMAP Calendar object.

Represents a JMAP Calendar resource as returned by ``Calendar/get``.
Properties are defined in the JMAP Calendars specification.
"""

from __future__ import annotations

from collections.abc import Coroutine
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload

from calendaring_jmap.constants import UTC_DATETIME_FORMAT
from calendaring_jmap.objects.calendar_object import JMAPCalendarObject

if TYPE_CHECKING:
    from calendaring_jmap.async_client import AsyncJMAPClient
    from calendaring_jmap.client import JMAPClient

## Phantom type parameter distinguishing a sync-backed JMAPCalendar from an
## async-backed one, so overloads below can key off self's type instead of
## the runtime-only _is_async flag mypy can't see. No code ever constructs
## JMAPCalendar[Literal[True]] or [Literal[False]] explicitly: get_calendars()
## return types (client.py, async_client.py) pin it for callers instead.
_M = TypeVar("_M", bound=bool)


def _to_utcdate(dt: datetime) -> str:
    """Convert a datetime to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ).

    Naive datetimes are assumed to be UTC.  Aware datetimes are converted to
    UTC before formatting.  Microseconds are dropped as JMAP does not allow them.
    """
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).strftime(UTC_DATETIME_FORMAT)


[docs] @dataclass class JMAPCalendar(Generic[_M]): """A JMAP Calendar object. Attributes: id: Server-assigned calendar identifier. name: Display name of the calendar. Per-user: a sharee who sets this gets their own copy, leaving the owner's name unchanged. description: Optional longer description. color: Optional CSS color string (e.g. ``"#ff0000"``). Per-user, same override rule as ``name``. is_subscribed: Whether the user is subscribed to this calendar. my_rights: Dict of right names to bool for the current user. sort_order: Hint for display ordering (lower = first). Per-user. is_visible: Whether the calendar should be displayed. Per-user. time_zone: IANA time zone used to resolve floating events on this calendar (e.g. for alerts, availability). ``None`` falls back to the account's own time zone. Per-user, same override rule as ``name``. share_with: Map of Principal ID to a dict of right names to bool (``mayReadItems``, ``mayWriteAll``, etc.). Only visible to and settable by users with the ``mayShare`` right; the server returns ``None`` here for anyone else. Not per-user: this is the calendar's actual sharing configuration. default_alerts_with_time: Map of alert ID to Alert dict, applied to new timed events on this calendar when ``useDefaultAlerts`` is set. Per-user, not inherited from the owner. default_alerts_without_time: Same as ``default_alerts_with_time``, for all-day events. """ id: str name: str description: str | None = None color: str | None = None is_subscribed: bool = True my_rights: dict = field(default_factory=dict) sort_order: int = 0 is_visible: bool = True time_zone: str | None = None share_with: dict | None = None default_alerts_with_time: dict | None = None default_alerts_without_time: dict | None = None # Injected by JMAPClient.get_calendars() / AsyncJMAPClient.get_calendars() _client: JMAPClient | AsyncJMAPClient | None = field( default=None, init=False, repr=False, compare=False ) _is_async: bool = field(default=False, init=False, repr=False, compare=False) ## The JMAP account this calendar was fetched from, i.e. what was passed ## to get_calendars(account_id=...). Not a Calendar wire property: it is ## what makes .search()/.add_event()/.get_object_by_uid() target the ## calendar's actual owning account instead of the caller's own primary ## account when the calendar was reached via someone else's share. _account_id: str | None = field(default=None, init=False, repr=False, compare=False) @property def _bound_client(self) -> JMAPClient | AsyncJMAPClient: """The client this calendar was bound to by ``get_calendars()``. A ``JMAPCalendar`` only ever reaches user code through ``JMAPClient.get_calendars()`` or ``AsyncJMAPClient.get_calendars()``, both of which set ``_client`` before returning it, so it is always bound by the time any other method runs. """ assert self._client is not None return self._client @property def _bound_async_client(self: JMAPCalendar[Literal[True]]) -> AsyncJMAPClient: """Same as ``_bound_client``, narrowed for the ``_async_*`` helpers. Only called from a method whose public overload already restricted ``self`` to ``JMAPCalendar[Literal[True]]``, i.e. one that ``AsyncJMAPClient.get_calendars()`` produced, so ``_client`` is always an ``AsyncJMAPClient`` here. """ assert self._is_async return cast("AsyncJMAPClient", self._bound_client)
[docs] @classmethod def from_jmap(cls, data: dict) -> JMAPCalendar: """Construct a JMAPCalendar from a raw JMAP Calendar JSON dict. Unknown keys in ``data`` are silently ignored so that forward compatibility is maintained as the spec evolves. """ return cls( id=data["id"], name=data["name"], description=data.get("description"), color=data.get("color"), is_subscribed=data.get("isSubscribed", True), my_rights=data.get("myRights", {}), sort_order=data.get("sortOrder", 0), is_visible=data.get("isVisible", True), time_zone=data.get("timeZone"), share_with=data.get("shareWith"), default_alerts_with_time=data.get("defaultAlertsWithTime"), default_alerts_without_time=data.get("defaultAlertsWithoutTime"), )
[docs] def to_jmap(self) -> dict: """Serialise to a JMAP Calendar JSON dict for ``Calendar/set``. ``id`` and ``myRights`` are intentionally excluded, both are server-set and must not appear in create or update payloads. Optional fields are included only when they hold a non-default value. """ d: dict = { "name": self.name, "isSubscribed": self.is_subscribed, "sortOrder": self.sort_order, "isVisible": self.is_visible, } if self.description is not None: d["description"] = self.description if self.color is not None: d["color"] = self.color if self.time_zone is not None: d["timeZone"] = self.time_zone if self.share_with is not None: d["shareWith"] = self.share_with if self.default_alerts_with_time is not None: d["defaultAlertsWithTime"] = self.default_alerts_with_time if self.default_alerts_without_time is not None: d["defaultAlertsWithoutTime"] = self.default_alerts_without_time return d
@staticmethod def _normalize_search_range(searchargs: dict[str, Any]) -> tuple[Any, Any]: """Return ``(start, end)`` from ``search()``'s kwargs, converting a ``datetime`` value to JMAP's UTCDate string format. A caller-supplied string is passed through unchanged.""" start = searchargs.get("start") end = searchargs.get("end") if isinstance(start, datetime): start = _to_utcdate(start) if isinstance(end, datetime): end = _to_utcdate(end) return start, end @overload def search( self: JMAPCalendar[Literal[False]], **searchargs: Any ) -> list[JMAPCalendarObject]: ... @overload def search( self: JMAPCalendar[Literal[True]], **searchargs: Any ) -> Coroutine[Any, Any, list[JMAPCalendarObject]]: ...
[docs] def search(self: Any, **searchargs: Any): """Search for calendar objects in this calendar. When called on an async-backed calendar, returns a coroutine that must be awaited. Accepted keyword arguments (all optional): - ``start`` (datetime or str): only events ending after this time (maps to JMAP ``after`` filter). - ``end`` (datetime or str): only events starting before this time (maps to JMAP ``before`` filter). - ``text`` (str): free-text search across title, description, locations, and participants. - ``has_attachment`` (bool), ``participant_email`` (str), ``participation_status`` (str), ``participant_role`` (str): see :meth:`~calendaring_jmap.client.JMAPClient.search_events`. Does not support ``position``/``limit``/``calculate_total``: unlike :meth:`~calendaring_jmap.client.JMAPClient.search_events`, this always returns every matching object in one call. Passing them is silently ignored rather than an error, since this accepts ``**searchargs``; use ``search_events`` directly instead if you need to page through a large result set. Returns: List of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` for all matching objects. """ if self._is_async: return self._async_search(**searchargs) start, end = self._normalize_search_range(searchargs) events, _, _ = self._bound_client._search( calendar_id=self.id, start=start, end=end, text=searchargs.get("text"), has_attachment=searchargs.get("has_attachment"), participant_email=searchargs.get("participant_email"), participation_status=searchargs.get("participation_status"), participant_role=searchargs.get("participant_role"), parent=self, account_id=self._account_id, ) return events
async def _async_search( self: JMAPCalendar[Literal[True]], **searchargs: Any ) -> list[JMAPCalendarObject]: start, end = self._normalize_search_range(searchargs) events, _, _ = await self._bound_async_client._search( calendar_id=self.id, start=start, end=end, text=searchargs.get("text"), has_attachment=searchargs.get("has_attachment"), participant_email=searchargs.get("participant_email"), participation_status=searchargs.get("participation_status"), participant_role=searchargs.get("participant_role"), parent=self, account_id=self._account_id, ) return events @overload def get_object_by_uid( self: JMAPCalendar[Literal[False]], uid: str, comp_class: Any = None ) -> JMAPCalendarObject: ... @overload def get_object_by_uid( self: JMAPCalendar[Literal[True]], uid: str, comp_class: Any = None ) -> Coroutine[Any, Any, JMAPCalendarObject]: ...
[docs] def get_object_by_uid(self: Any, uid: str, comp_class: Any = None): """Get a calendar object by its iCalendar UID. When called on an async-backed calendar, returns a coroutine that must be awaited. Args: uid: The iCalendar UID to search for. comp_class: JMAP ``CalendarEvent/query`` has no native component-type filter, so this argument is currently ignored. Returns: A :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` for the matching object. Raises: JMAPMethodError: If no object with this UID is found. """ if self._is_async: return self._async_get_object_by_uid(uid) return self._bound_client._get_object_by_uid( uid, calendar_id=self.id, parent=self, account_id=self._account_id )
async def _async_get_object_by_uid( self: JMAPCalendar[Literal[True]], uid: str ) -> JMAPCalendarObject: return await self._bound_async_client._get_object_by_uid( uid, calendar_id=self.id, parent=self, account_id=self._account_id ) @overload def add_event(self: JMAPCalendar[Literal[False]], ical_str: str) -> str: ... @overload def add_event(self: JMAPCalendar[Literal[True]], ical_str: str) -> Coroutine[Any, Any, str]: ...
[docs] def add_event(self: Any, ical_str: str): """Add an event to this calendar from an iCalendar string. When called on an async-backed calendar, returns a coroutine that must be awaited. Args: ical_str: A VCALENDAR string representing the event. Returns: The server-assigned JMAP event ID. This returns a string ID rather than a calendar object: the ``CalendarEvent/set`` response does not include the full object, so a follow-up GET would be required. Raises: JMAPMethodError: If the server rejects the create request. ValueError: Same conditions as :meth:`~calendaring_jmap.client.JMAPClient.create_event`. """ if self._is_async: return self._async_add_event(ical_str) return self._bound_client.create_event(self.id, ical_str, account_id=self._account_id)
async def _async_add_event(self: JMAPCalendar[Literal[True]], ical_str: str) -> str: return await self._bound_async_client.create_event( self.id, ical_str, account_id=self._account_id )