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
6 changes: 3 additions & 3 deletions docs/how-to/convert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Convert iCalendar and JSCalendar
================================

``ical_to_jscal`` and ``jscal_to_ical`` are the same conversion functions the client uses internally, available directly if you want to convert between formats without making any JMAP request.
``ical_to_jscal`` and ``jscal_to_ical`` are the same conversion functions :meth:`~calendaring_jmap.objects.calendar.JMAPCalendar.add_event` and :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.save` use internally, available directly if you want to convert between formats without making any JMAP request, or if you're calling :meth:`~calendaring_jmap.client.JMAPClient.create_event`/:meth:`~calendaring_jmap.client.JMAPClient.update_event` directly and starting from iCalendar rather than a JSCalendar dict.

.. code-block:: python

Expand All @@ -14,10 +14,10 @@ Convert iCalendar and JSCalendar
jscal = ical_to_jscal(ical_str)
ical_str_again = jscal_to_ical(jscal)

``ical_to_jscal`` processes the first VEVENT in the string; any sibling VEVENTs sharing the same UID with a RECURRENCE-ID are folded into the result's ``recurrenceOverrides`` map, and EXDATE lines become excluded override entries. Pass ``calendar_id`` if the result is going to a real ``CalendarEvent/set`` call, since the server needs ``calendarIds`` set on it:
``ical_to_jscal`` processes the first VEVENT in the string; any sibling VEVENTs sharing the same UID with a RECURRENCE-ID are folded into the result's ``recurrenceOverrides`` map, and EXDATE lines become excluded override entries. Pass ``calendar_id`` if you need the result's own ``calendarIds`` set and aren't passing it straight to ``create_event``/``send_invite``, which set ``calendarIds`` themselves from their own ``calendar_id`` argument regardless of what the dict already has:

.. code-block:: python

jscal = ical_to_jscal(ical_str, calendar_id=calendar_id)

Both functions raise ``ValueError`` on malformed input, the same errors :doc:`errors` describes for ``create_event`` and ``update_event``, since those methods call ``ical_to_jscal`` internally. See :doc:`../explanation/design` for the conversion layer's own design decisions.
Both functions raise ``ValueError`` on malformed input. ``add_event``/``save`` call ``ical_to_jscal``/``jscal_to_ical`` internally and surface the same errors; ``create_event``/``send_invite``/``update_event`` take a JSCalendar dict directly and raise nothing from this layer, since no conversion happens inside them. See :doc:`../explanation/design` for the conversion layer's own design decisions.
6 changes: 3 additions & 3 deletions docs/how-to/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Catch specific error types
)

try:
event_id = client.create_event(calendar_id, ical)
event_id, old_state, new_state = client.create_event(calendar_id, event)
except JMAPAuthError:
print("Authentication failed (401/403)")
except JMAPCapabilityError:
Expand All @@ -35,12 +35,12 @@ See :doc:`../reference/errors` for what each error class means and the full list
Malformed iCalendar input
=========================

``create_event``, ``send_invite``, ``update_event``, and the calendar/object convenience methods that call them (``JMAPCalendar.add_event``, ``JMAPCalendarObject.save``) convert your iCalendar string to JSCalendar before sending it. If it's missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, they raise a plain ``ValueError``, not a ``JMAPError`` subclass. This happens client-side, 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. 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:

.. code-block:: python

try:
event_id = client.create_event(calendar_id, ical)
event_id = cal.add_event(ical)
except ValueError as e:
print(f"Malformed iCalendar: {e}")
except JMAPMethodError as e:
Expand Down
6 changes: 3 additions & 3 deletions docs/how-to/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Work with events
================

Events are passed as iCalendar strings, so existing iCalendar-producing code works unchanged.
:meth:`~calendaring_jmap.objects.calendar.JMAPCalendar.add_event` and :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.save` take and produce iCalendar strings, so existing iCalendar-producing code works unchanged through them. The lower-level client methods (:meth:`~calendaring_jmap.client.JMAPClient.create_event`, :meth:`~calendaring_jmap.client.JMAPClient.send_invite`, :meth:`~calendaring_jmap.client.JMAPClient.update_event`) take a JSCalendar dict directly instead, the same shape :meth:`~calendaring_jmap.client.JMAPClient.get_event` hands back; convert with ``ical_to_jscal`` first if you're starting from iCalendar.

Add an event
============
Expand Down Expand Up @@ -61,11 +61,11 @@ Edit the object in place, then call :meth:`~calendaring_jmap.objects.calendar_ob

``save()`` needs an object with its ``parent`` calendar set, since it uses that calendar's client to send the update. Objects from ``cal.get_object_by_uid`` and :meth:`~calendaring_jmap.objects.calendar.JMAPCalendar.search` have it set; objects from :meth:`~calendaring_jmap.client.JMAPClient.get_event` don't, since they aren't fetched through a calendar. It's also sync-only; on an async-backed calendar, call :meth:`~calendaring_jmap.async_client.AsyncJMAPClient.update_event` directly instead.

If you already have a full replacement iCalendar string, ``update_event`` also works directly on the client:
If you already have a full replacement iCalendar string, ``update_event`` also works directly on the client, once converted:

.. code-block:: python

client.update_event(event_id, updated_ical_str)
client.update_event(event_id, ical_to_jscal(updated_ical_str))

Delete an event
===============
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ Send an invitation

.. code-block:: python

event_id = client.send_invite(calendar_id, ical_str)
event_id, old_state, new_state = client.send_invite(calendar_id, event)

Identical to :meth:`~calendaring_jmap.client.JMAPClient.create_event`, except the server dispatches iTIP invitations to the event's participants (ATTENDEE/ORGANIZER properties in ``ical_str``). Use :meth:`~calendaring_jmap.client.JMAPClient.create_event` itself for an event with no participants to notify.
Identical to :meth:`~calendaring_jmap.client.JMAPClient.create_event`, except the server dispatches iTIP invitations to the event's participants (ATTENDEE/ORGANIZER properties, as a JSCalendar ``participants`` map). Use :meth:`~calendaring_jmap.client.JMAPClient.create_event` itself for an event with no participants to notify.

Respond to an invitation
========================
Expand Down
1 change: 1 addition & 0 deletions news/67.breaking.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``create_event``, ``send_invite``, and ``update_event`` now take a JSCalendar dict instead of an iCalendar string. ``JMAPCalendar.add_event`` and ``JMAPCalendarObject.save`` are unaffected: they still take and produce iCalendar strings.
1 change: 1 addition & 0 deletions news/67.breaking.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``create_event`` and ``send_invite`` now return a 3-tuple ``(event_id, old_state, new_state)`` instead of just the event id.
1 change: 1 addition & 0 deletions news/67.breaking.3.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``update_event`` now returns ``(old_state, new_state)`` instead of ``None``.
91 changes: 56 additions & 35 deletions src/calendaring_jmap/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
PARTICIPATION_STATUS_DECLINED,
PARTICIPATION_STATUS_TENTATIVE,
)
from calendaring_jmap.convert import ical_to_jscal
from calendaring_jmap.error import (
_DEFAULT_ERROR_TYPE,
JMAPAuthError,
Expand Down Expand Up @@ -439,13 +438,14 @@ async def set_default_alerts(
async def _create_event_impl(
self,
calendar_id: str,
ical_str: str,
event: dict,
account_id: str | None,
send_scheduling_messages: bool,
if_in_state: str | None = None,
) -> str:
) -> tuple[str, str, str]:
session = await self._get_session()
jscal = ical_to_jscal(ical_str, calendar_id=calendar_id)
jscal = self._strip_server_managed_event_properties(event)
jscal["calendarIds"] = {calendar_id: True}
target_account = self._resolve_account(session, account_id)
call = build_event_set_create(
target_account,
Expand All @@ -454,22 +454,30 @@ async def _create_event_impl(
if_in_state=if_in_state,
)
responses = await self._request([call])
return self._parse_create_response(
event_id = self._parse_create_response(
responses, session.api_url, "CalendarEvent/set", parse_event_set
)
old_state, new_state = self._parse_set_state(responses, "CalendarEvent/set")
return event_id, old_state, new_state

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

Args:
calendar_id: The JMAP calendar ID to create the event in.
ical_str: A VCALENDAR string representing the event.
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.
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 @@ -482,17 +490,19 @@ async def create_event(
unconditionally.

Returns:
The server-assigned JMAP event ID.
A 3-tuple ``(event_id, old_state, new_state)``: the server-assigned
JMAP event ID, and the account's state immediately before and
after this write (: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 create request.
ValueError: If ``ical_str`` is malformed in a way ``ical_to_jscal``
rejects (missing ``UID``/``DTSTART``, a negative duration,
mismatched ``DTSTART``/``DTEND`` value types).
"""
return await self._create_event_impl(
calendar_id,
ical_str,
event,
account_id,
send_scheduling_messages=False,
if_in_state=if_in_state,
Expand All @@ -501,37 +511,36 @@ async def create_event(
async def send_invite(
self,
calendar_id: str,
ical_str: str,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
) -> str:
) -> tuple[str, str, str]:
"""Create a calendar event and notify its participants.

Identical to :meth:`create_event`, except the server actually
dispatches iTIP invitations to the event's participants (an iCalendar
string with ATTENDEE/ORGANIZER properties). ``create_event`` itself
never sends scheduling messages; use this instead when the event has
dispatches iTIP invitations to the event's participants (a JSCalendar
dict with ``participants`` set). ``create_event`` itself never sends
scheduling messages; use this instead when the event has
participants who need to be notified.

Args:
calendar_id: The JMAP calendar ID to create the event in.
ical_str: A VCALENDAR string representing the event.
event: A JSCalendar CalendarEvent dict. See :meth:`create_event`.
account_id: The account owning ``calendar_id``, same as
:meth:`create_event`.
if_in_state: See :meth:`create_event`.

Returns:
The server-assigned JMAP event ID.
See :meth:`create_event`.

Raises:
JMAPMethodError: If the server rejects the create request, or
with ``error_type == "noSupportedScheduleMethods"`` if a
participant has no usable delivery method.
ValueError: Same conditions as :meth:`create_event`.
"""
return await self._create_event_impl(
calendar_id,
ical_str,
event,
account_id,
send_scheduling_messages=True,
if_in_state=if_in_state,
Expand Down Expand Up @@ -722,15 +731,18 @@ async def tentatively_accept(
async def update_event(
self,
event_id: str,
ical_str: str,
event: dict,
account_id: str | None = None,
if_in_state: str | None = None,
) -> None:
"""Update a calendar event from an iCalendar string.
) -> tuple[str, str]:
"""Update a calendar event from a JSCalendar dict.

Args:
event_id: The JMAP event ID to update.
ical_str: A VCALENDAR string with the updated event data.
event: A JSCalendar CalendarEvent dict with the updated event
data. See :meth:`~calendaring_jmap.client.JMAPClient.update_event`
for how absent properties are cleared, and for how
server-managed properties and ``calendarIds`` are handled.
account_id: The account owning ``event_id``; pass the owner's
account here to update an event on a calendar shared with
you.
Expand All @@ -744,15 +756,16 @@ async def update_event(
null-key-dropping loop below, since those retries narrow the
same patch rather than re-reading the account's state.

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

Raises:
JMAPMethodError: If the server rejects the update.
ValueError: If ``ical_str`` is malformed in a way ``ical_to_jscal``
rejects (missing ``UID``/``DTSTART``, a negative duration,
mismatched ``DTSTART``/``DTEND`` value types).
"""
session = await self._get_session()
target_account = self._resolve_account(session, account_id)
patch, nulled = self._build_event_update_patch(ical_str)
patch, nulled = self._build_event_update_patch(event)
while True:
responses = await self._request(
[build_event_set_update(target_account, {event_id: patch}, if_in_state=if_in_state)]
Expand All @@ -765,6 +778,7 @@ async def update_event(
self._parse_update_response(
responses, session.api_url, "CalendarEvent/set", parse_event_set, event_id
)
return self._parse_set_state(responses, "CalendarEvent/set")

async def _search(
self,
Expand Down Expand Up @@ -1062,10 +1076,17 @@ async def get_objects_by_sync_token(
server. Compare this against ``sync_token`` to detect a server
that silently accepted an invalid or stale state instead of
reporting an error (:rfc:`8620#section-5.2` does not require a
server to validate ``sinceState`` against a real prior state;
confirmed live that Cyrus echoes back whatever value it was
given, including one that was never actually issued, rather
than rejecting it).
server to validate ``sinceState`` against a real prior state).
Confirmed live that neither server validates consistently:
Cyrus rejects a non-numeric or empty ``sinceState`` with
``cannotCalculateChanges``, but silently accepts and echoes
back any numeric-looking string, including one that was never
actually issued. Stalwart rejects nearly everything invalid
with ``invalidArguments``, except a string starting with the
literal character ``n``, which it accepts and normalizes
``oldState`` down to just ``"n"`` regardless of what follows,
a quirk of its own internal state encoding rather than general
leniency.
- ``new_state``: Pass to the next call to this method as
``sync_token``, whether or not ``has_more_changes`` is set.
Named to match ``oldState``/``newState`` as a pair
Expand Down
Loading
Loading