Source code for calendaring_jmap.objects.calendar_object
# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
JMAP calendar resource object.
Wraps a raw JSCalendar CalendarEvent dict with a minimal interface:
``.id``, ``.parent``, ``get_data``, ``get_icalendar_instance``,
``edit_icalendar_instance``, and ``save``.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import icalendar
from calendaring_jmap.convert import jscal_to_ical
from calendaring_jmap.error import JMAPMethodError
if TYPE_CHECKING:
from calendaring_jmap.objects.calendar import JMAPCalendar
[docs]
@dataclass
class JMAPCalendarObject:
"""Thin wrapper around a raw JSCalendar CalendarEvent dict.
Stores the server's JSON response as-is. No JMAP field names are mapped
to typed attributes: callers work with the dict directly via
:meth:`get_data`, or convert to iCalendar via :meth:`get_icalendar_instance`.
Attributes:
data: Raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``.
parent: The :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` this object
belongs to, or ``None`` when fetched without a calendar context
(e.g. via :meth:`~calendaring_jmap.client.JMAPClient.get_event`).
"""
data: dict
parent: JMAPCalendar | None
_ical_cache: icalendar.Calendar | None = field(
default=None, init=False, repr=False, compare=False
)
@property
def id(self) -> str:
"""Server-assigned JMAP event ID."""
return self.data["id"]
[docs]
def get_data(self) -> dict:
"""Return the raw JSCalendar dict as returned by ``CalendarEvent/get``."""
return self.data
[docs]
def get_icalendar_instance(self) -> icalendar.Calendar:
"""Return an :class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for this object.
The result is cached after the first conversion. Treat it as
read-only; use :meth:`edit_icalendar_instance` to make and persist
changes.
"""
if self._ical_cache is None:
self._ical_cache = icalendar.Calendar.from_ical(jscal_to_ical(self.data))
return self._ical_cache
[docs]
@contextmanager
def edit_icalendar_instance(self):
"""Borrow an editable
:class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for this object.
Yields the cached
:class:`icalendar.Calendar <icalendar.cal.calendar.Calendar>` for in-place editing.
Call :meth:`save` after the ``with`` block to persist changes to the server.
Note: :meth:`save` is sync-only. Async-backed calendars cannot use
this path yet.
Example::
with obj.edit_icalendar_instance() as cal:
cal.subcomponents[0]["SUMMARY"] = vText("New title")
obj.save()
"""
cal = self.get_icalendar_instance()
yield cal
[docs]
def save(self) -> None:
"""Persist changes made via :meth:`edit_icalendar_instance` to the server.
Serialises the (possibly edited) icalendar object back to an iCalendar
string and calls ``update_event()`` on the parent calendar's client.
Raises:
JMAPMethodError: If no parent calendar is set (``parent`` is ``None``).
RuntimeError: If called on an async-backed calendar.
ValueError: If an edit made via :meth:`edit_icalendar_instance`
leaves the object in a shape rejected by
:meth:`~calendaring_jmap.client.JMAPClient.update_event`
(e.g. removing ``UID``/``DTSTART``, or moving ``DTEND``
before ``DTSTART``).
"""
if self.parent is None:
raise JMAPMethodError(url="N/A", reason="Cannot save: no parent calendar is set")
if self.parent._is_async:
raise RuntimeError(
"save() is not supported for async-backed calendars. "
"Use await parent._client.update_event() directly."
)
ical_str = (
self._ical_cache.to_ical().decode()
if self._ical_cache is not None
else jscal_to_ical(self.data)
)
self.parent._bound_client.update_event(
self.id, ical_str, account_id=self.parent._account_id
)