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
2 changes: 1 addition & 1 deletion docs/how-to/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ See :doc:`../reference/errors` for what each error class means and the full list
Malformed iCalendar input
=========================

``create_event``, ``send_invite``, and ``update_event`` take a JSCalendar dict directly and raise nothing client-side on a malformed one; whether a missing required property comes back as the server's own ``invalidProperties`` (surfaced as :class:`~calendaring_jmap.error.JMAPMethodError`) depends on the server and the property. Confirmed live that Cyrus rejects a create missing ``uid``/``start`` with ``invalidProperties``, while Stalwart accepts it, generating its own ``uid`` and leaving the event with no ``start`` at all rather than rejecting the request. The ``ValueError`` case below applies to ``ical_to_jscal`` itself, and so to ``JMAPCalendar.add_event``/``JMAPCalendarObject.save``, which call it on your behalf: if the iCalendar string is missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, conversion raises a plain ``ValueError`` before any request reaches the server. Catch it if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you:
``create_event``, ``send_invite``, and ``update_event`` take a JSCalendar dict directly and raise nothing client-side on a malformed one; whether a missing required property comes back as the server's own ``invalidProperties`` (surfaced as :class:`~calendaring_jmap.error.JMAPMethodError`) depends on the server and the property. Confirmed live that Cyrus rejects a create missing ``uid``/``start`` with ``invalidProperties``, while Stalwart accepts it, generating its own ``uid`` and leaving the event with no ``start`` at all rather than rejecting the request. ``JMAPMethodError.properties`` lists which properties an ``invalidProperties`` rejection actually named, when the server included that list, so you can tell a duplicate UID apart from an unrelated rejection sharing the same ``error_type``. The ``ValueError`` case below applies to ``ical_to_jscal`` itself, and so to ``JMAPCalendar.add_event``/``JMAPCalendarObject.save``, which call it on your behalf: if the iCalendar string is missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, conversion raises a plain ``ValueError`` before any request reaches the server. Catch it if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you. ``create_event``/``send_invite`` raise the same exception type for an unrelated reason: passing ``calendar_id=None`` without the event's own ``calendarIds`` set.

.. code-block:: python

Expand Down
4 changes: 3 additions & 1 deletion docs/how-to/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ Delete an event

.. code-block:: python

client.delete_event(event_id)
old_state, new_state = client.delete_event(event_id)

``old_state`` and ``new_state`` are the account's state immediately before and after the delete; pass ``new_state`` as a later write's ``if_in_state`` to chain writes without a separate read in between.

Search events
=============
Expand Down
1 change: 1 addition & 0 deletions news/+create-event-optional-calendar-id.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``create_event``/``send_invite`` now accept ``calendar_id=None`` to send an event's own ``calendarIds`` unchanged, instead of always overwriting it down to one calendar. Raises ``ValueError`` if the event has no ``calendarIds`` at all.
1 change: 1 addition & 0 deletions news/+delete-event-returns-state.breaking.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``delete_event`` now returns ``(old_state, new_state)`` instead of ``None``, matching ``create_event``/``update_event``. Both Cyrus and Stalwart already send this on every destroy; it was just being discarded.
1 change: 1 addition & 0 deletions news/+jmap-method-error-properties.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``JMAPMethodError`` now has a ``properties`` attribute, listing the properties a server's ``invalidProperties`` rejection named, when the server included that list. ``None`` for any other error.
33 changes: 24 additions & 9 deletions src/calendaring_jmap/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,15 @@ async def set_default_alerts(

async def _create_event_impl(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None,
send_scheduling_messages: bool,
if_in_state: str | None = None,
) -> tuple[str, str, str]:
session = await self._get_session()
jscal = self._strip_server_managed_event_properties(event)
jscal["calendarIds"] = {calendar_id: True}
self._resolve_create_calendar_ids(jscal, calendar_id)
target_account = self._resolve_account(session, account_id)
call = build_event_set_create(
target_account,
Expand All @@ -462,22 +462,28 @@ async def _create_event_impl(

async def create_event(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
) -> tuple[str, str, str]:
"""Create a calendar event from a JSCalendar dict.

Args:
calendar_id: The JMAP calendar ID to create the event in.
calendar_id: The JMAP calendar ID to create the event in. If
given, overwrites ``event``'s own ``calendarIds`` with
this one calendar, replacing whatever it already had. If
``None``, ``event``'s own ``calendarIds`` is sent
unchanged, so an event genuinely belonging to more than
one calendar keeps all of them; confirmed live that
Stalwart accepts this, while Cyrus rejects a create
naming more than one calendar with
``invalidProperties``/``properties: ["calendarIds"]``.
event: A JSCalendar CalendarEvent dict (:rfc:`8984#section-2.1`),
e.g. one returned by :meth:`get_event`. Not mutated: a copy is made
before ``id``, ``baseEventId``, and ``isOrigin`` are stripped,
since draft-ietf-jmap-calendars documents all three as
server-set and immutable, and ``calendarIds`` is overwritten
with ``calendar_id``, replacing whatever calendar membership
``event`` already had.
server-set and immutable.
account_id: The account owning ``calendar_id``; pass the owner's
account here to add an event to a calendar shared with you
(see ``get_calendars(account_id=...)``).
Expand All @@ -499,6 +505,8 @@ async def create_event(

Raises:
JMAPMethodError: If the server rejects the create request.
ValueError: If ``calendar_id`` is ``None`` and ``event`` has
no ``calendarIds``, or an empty one.
"""
return await self._create_event_impl(
calendar_id,
Expand All @@ -510,7 +518,7 @@ async def create_event(

async def send_invite(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
Expand All @@ -525,6 +533,7 @@ async def send_invite(

Args:
calendar_id: The JMAP calendar ID to create the event in.
See :meth:`create_event` for ``None``'s meaning.
event: A JSCalendar CalendarEvent dict. See :meth:`create_event`.
account_id: The account owning ``calendar_id``, same as
:meth:`create_event`.
Expand All @@ -537,6 +546,7 @@ async def send_invite(
JMAPMethodError: If the server rejects the create request, or
with ``error_type == "noSupportedScheduleMethods"`` if a
participant has no usable delivery method.
ValueError: Same condition as :meth:`create_event`.
"""
return await self._create_event_impl(
calendar_id,
Expand Down Expand Up @@ -1119,7 +1129,7 @@ async def delete_event(
account_id: str | None = None,
send_scheduling_messages: bool = False,
if_in_state: str | None = None,
) -> None:
) -> tuple[str, str]:
"""Delete a calendar event.

Args:
Expand All @@ -1138,6 +1148,10 @@ async def delete_event(
the account, not just this one. ``None`` applies
unconditionally.

Returns:
A 2-tuple ``(old_state, new_state)``. See
:meth:`~calendaring_jmap.client.JMAPClient.delete_event`.

Raises:
JMAPMethodError: If the server rejects the delete.
"""
Expand All @@ -1156,6 +1170,7 @@ async def delete_event(
self._parse_delete_response(
responses, session.api_url, "CalendarEvent/set", parse_event_set, event_id
)
return self._parse_set_state(responses, "CalendarEvent/set")

async def _get_object_by_uid(
self,
Expand Down
81 changes: 72 additions & 9 deletions src/calendaring_jmap/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,21 @@ def _as_utc_datetime(local_datetime: str) -> str:

@staticmethod
def _raise_set_error(api_url: str, err: dict) -> None:
"""Raise a ``JMAPMethodError`` for one ``SetError`` from a
``notCreated``/``notUpdated``/``notDestroyed`` map.

``properties`` is read unconditionally from ``err``, not only when
``type == "invalidProperties"``: :rfc:`8620#section-5.3` leaves
room for other ``SetError`` types to carry method-specific fields
of their own, so gating on one hardcoded type would silently drop
a field this client doesn't yet know the name of. Absent, it
stays ``None`` on the raised error.
"""
raise JMAPMethodError(
url=api_url,
reason=f"set failed: {err}",
error_type=err.get("type", _DEFAULT_ERROR_TYPE),
properties=err.get("properties"),
)

@staticmethod
Expand Down Expand Up @@ -478,6 +489,39 @@ def _strip_server_managed_event_properties(event: dict) -> dict:
copy.pop(key, None)
return copy

@staticmethod
def _resolve_create_calendar_ids(jscal: dict, calendar_id: str | None) -> None:
"""Set ``jscal["calendarIds"]`` in place for a ``CalendarEvent/set`` create.

``calendar_id`` given: overwrites ``calendarIds`` with that one
calendar, same as every release before this one. ``None``: leaves
``jscal``'s own ``calendarIds`` as the caller built it, so a dict
that genuinely belongs to more than one calendar keeps all of
them; confirmed live that Stalwart accepts a create with two real
calendar ids in ``calendarIds`` at once, while Cyrus rejects it
with ``invalidProperties``/``properties: ["calendarIds"]``, a
per-server difference this client passes through rather than
working around.

Raises:
ValueError: If ``calendar_id`` is ``None`` and ``jscal`` has no
``calendarIds`` key, an empty one, or ``None``.
draft-ietf-jmap-calendars documents an event as belonging
to one or more calendars at all times; confirmed live both
servers reject a create with ``calendarIds`` absent, empty,
or explicitly ``null`` the same way,
``invalidProperties``/``properties: ["calendarIds"]``, so
this raises before sending a request either server would
reject anyway.
"""
if calendar_id is not None:
jscal["calendarIds"] = {calendar_id: True}
elif not jscal.get("calendarIds"):
raise ValueError(
"event has no calendarIds and calendar_id was not given; "
"an event must belong to at least one calendar"
)

@staticmethod
def _build_event_update_patch(event: dict) -> tuple[dict, frozenset[str]]:
"""Build a JSCalendar PatchObject for a ``CalendarEvent/set`` update.
Expand Down Expand Up @@ -1462,15 +1506,15 @@ def set_default_alerts(

def _create_event_impl(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None,
send_scheduling_messages: bool,
if_in_state: str | None = None,
) -> tuple[str, str, str]:
session = self._get_session()
jscal = self._strip_server_managed_event_properties(event)
jscal["calendarIds"] = {calendar_id: True}
self._resolve_create_calendar_ids(jscal, calendar_id)
target_account = self._resolve_account(session, account_id)
call = build_event_set_create(
target_account,
Expand All @@ -1487,22 +1531,28 @@ def _create_event_impl(

def create_event(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
) -> tuple[str, str, str]:
"""Create a calendar event from a JSCalendar dict.

Args:
calendar_id: The JMAP calendar ID to create the event in.
calendar_id: The JMAP calendar ID to create the event in. If
given, overwrites ``event``'s own ``calendarIds`` with
this one calendar, replacing whatever it already had. If
``None``, ``event``'s own ``calendarIds`` is sent
unchanged, so an event genuinely belonging to more than
one calendar keeps all of them; confirmed live that
Stalwart accepts this, while Cyrus rejects a create
naming more than one calendar with
``invalidProperties``/``properties: ["calendarIds"]``.
event: A JSCalendar CalendarEvent dict (:rfc:`8984#section-2.1`),
e.g. one returned by :meth:`get_event`. Not mutated: a copy is made
before ``id``, ``baseEventId``, and ``isOrigin`` are stripped,
since draft-ietf-jmap-calendars documents all three as
server-set and immutable, and ``calendarIds`` is overwritten
with ``calendar_id``, replacing whatever calendar membership
``event`` already had.
server-set and immutable.
account_id: The account owning ``calendar_id``; pass the owner's
account here to add an event to a calendar shared with you
(see ``get_calendars(account_id=...)``).
Expand All @@ -1524,6 +1574,8 @@ def create_event(

Raises:
JMAPMethodError: If the server rejects the create request.
ValueError: If ``calendar_id`` is ``None`` and ``event`` has
no ``calendarIds``, or an empty one.
"""
return self._create_event_impl(
calendar_id,
Expand All @@ -1535,7 +1587,7 @@ def create_event(

def send_invite(
self,
calendar_id: str,
calendar_id: str | None,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
Expand All @@ -1550,6 +1602,7 @@ def send_invite(

Args:
calendar_id: The JMAP calendar ID to create the event in.
See :meth:`create_event` for ``None``'s meaning.
event: A JSCalendar CalendarEvent dict. See :meth:`create_event`.
account_id: The account owning ``calendar_id``, same as
:meth:`create_event`.
Expand All @@ -1562,6 +1615,7 @@ def send_invite(
JMAPMethodError: If the server rejects the create request, or
with ``error_type == "noSupportedScheduleMethods"`` if a
participant has no usable delivery method.
ValueError: Same condition as :meth:`create_event`.
"""
return self._create_event_impl(
calendar_id,
Expand Down Expand Up @@ -2535,7 +2589,7 @@ def delete_event(
account_id: str | None = None,
send_scheduling_messages: bool = False,
if_in_state: str | None = None,
) -> None:
) -> tuple[str, str]:
"""Delete a calendar event.

Args:
Expand All @@ -2554,6 +2608,14 @@ def delete_event(
the account, not just this one. ``None`` applies
unconditionally.

Returns:
A 2-tuple ``(old_state, new_state)``, the account's state
immediately before and after this delete (:rfc:`8620#section-5.3`),
confirmed live present on every ``CalendarEvent/set`` response
on both Cyrus and Stalwart. Pass ``new_state`` as a later
call's ``if_in_state`` to require that no other write has
landed since.

Raises:
JMAPMethodError: If the server rejects the delete.
"""
Expand All @@ -2572,6 +2634,7 @@ def delete_event(
self._parse_delete_response(
responses, session.api_url, "CalendarEvent/set", parse_event_set, event_id
)
return self._parse_set_state(responses, "CalendarEvent/set")

def _get_object_by_uid(
self,
Expand Down
22 changes: 22 additions & 0 deletions src/calendaring_jmap/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ class JMAPMethodError(JMAPError):
Individual methods define further, method-specific error types beyond
this generic set (e.g. ``notFound``, ``calendarHasEvent``,
``expandDurationTooLarge``); see each method builder's own docstring.

Attributes:
properties: The failing properties named by a ``SetError``
(:rfc:`8620#section-5.3`), or ``None`` for an error with no
such list. The RFC documents this field only for
``invalidProperties``, and as a SHOULD there too, so a server
may omit it even then; confirmed live that a method-level
error such as ``stateMismatch`` never has one at all, since it
aborts the whole call before any per-object ``SetError`` is
produced.
"""

error_type = _DEFAULT_ERROR_TYPE
properties: list[str] | None = None

def __init__(
self,
url: str | None = None,
reason: str | None = None,
error_type: str | None = None,
properties: list[str] | None = None,
) -> None:
super().__init__(url=url, reason=reason, error_type=error_type)
if properties is not None:
self.properties = properties
Loading
Loading