diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index afef62e8..20fbe91b 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -10,6 +10,7 @@ Users of the library should not need to construct any of those objects. To add new content to the calendar, use ``calendar.add_event``, ``calendar.add_todo`` or ``calendar.add_journal``. Those methods will return a CalendarObjectResource. To update an existing object, use ``event.save()``. """ +import enum import logging import re import sys @@ -46,7 +47,7 @@ from .elements import cdav, dav from .lib import error, vcal from .lib.error import errmsg -from .lib.python_utilities import to_normal_str, to_unicode, to_wire +from .lib.python_utilities import WireBytes, to_normal_str, to_unicode, to_wire from .lib.url import URL log = logging.getLogger("caldav") @@ -58,6 +59,15 @@ Wildcard = Literal["if-none-match", "if-match"] +class _NotGiven(enum.Enum): + ## save(raw_data=...) tells "not given" from an explicit None, which + ## is what raw_data of an object that was never read holds + NOT_GIVEN = 0 + + +RawData = bytes | bytearray | memoryview + + def _quote_uid(uid: str, features: Any = None) -> str: """URL-quote a UID for use in a CalDAV object URL. @@ -120,6 +130,8 @@ class CalendarObjectResource(DAVObject): _vobject_instance = None _icalendar_instance = None _data = None + ## bytes from a GET, the str as parsed from a REPORT (encoded on read) + _raw_data: bytes | str | None = None # New state management (issue #613) _state: DataState | None = None @@ -134,6 +146,45 @@ def schedule_tag(self) -> str | None: def etag(self) -> str | None: return self.props.get(dav.GetEtag.tag) + @property + def raw_data(self) -> bytes | None: + """The calendar data exactly as the server last sent it, or None. + + A read-only snapshot, taken before ``vcal.fix()`` or any other + normalisation, by ``load()`` and by objects built from a + ``search()``, ``multiget()``, ``get_object_by_uid()`` or sync + REPORT. Editing the object does not change it, so after an edit + it is what was read, not what ``data`` holds now. It is None for + an object built locally or by ``copy()`` - including the + occurrences ``search(expand=True)`` splits out - and cleared by a + successful ``save()``: what the server stores then is only known + after a new load. Where a search filters or expands an object in + place (``post_filter``, ``split_expanded=False``, server-side + expansion), it still holds what the server sent, which after a + server-side expansion is the expanded text, not the stored + resource: load() the object before copying it elsewhere. + + After a GET (``load()``) it is the response body byte for byte + (once any Content-Encoding is undone). From a REPORT it is the + text of the ``calendar-data`` element, encoded as UTF-8: the XML + parser has decoded entities such as ``&`` and turned line + breaks into LF, unless the server escaped the CR as `` ``. + See https://github.com/python-caldav/caldav/issues/763 + """ + if isinstance(self._raw_data, str): + return self._raw_data.encode("utf-8") + return self._raw_data + + def _keep_raw_data(self, raw: Any) -> Self: + """Remember ``raw`` (bytes, or text from a REPORT) as raw_data. + + REPORT text is kept as the str the XML parser made, so a search + does not hold a second, encoded copy of every object's data.""" + if isinstance(raw, (bytes, bytearray)): + raw = bytes(raw) + self._raw_data = raw if isinstance(raw, (bytes, str)) and raw else None + return self + @property def id(self) -> str | None: """Returns the UID of the calendar object. @@ -979,6 +1030,7 @@ def load( if r.status and r.status == 404: raise error.error_from_response(error.NotFoundError, r) self.data = r.raw # type: ignore + self._keep_raw_data(getattr(r, "_raw_body", None)) except error.NotFoundError: # Only attempt fallbacks if the object was previously loaded # (has a UID), indicating the server may have changed the URL. @@ -998,6 +1050,7 @@ def load( if obj: self.url = obj.url self.data = obj.data + self._raw_data = obj._raw_data if hasattr(obj, "props"): self.props.update(obj.props) return self @@ -1029,6 +1082,7 @@ async def _async_load( if r.status and r.status == 404: raise error.error_from_response(error.NotFoundError, r) self.data = r.raw # type: ignore + self._keep_raw_data(getattr(r, "_raw_body", None)) except error.NotFoundError: uid = self.id if uid: @@ -1045,6 +1099,7 @@ async def _async_load( if obj: self.url = obj.url self.data = obj.data + self._raw_data = obj._raw_data if hasattr(obj, "props"): self.props.update(obj.props) return self @@ -1088,7 +1143,9 @@ def _post_load_by_multiget(self, items): ## As of 2025-05-20, this code section is used by ## TestForServerECloud::testCreateOverwriteDeleteEvent raise error.NotFoundError(self.url) - _url, self.data, tags = url_data + _url, data, tags = url_data + self.data = data + self._keep_raw_data(data) self.props.update(tags) error.assert_(self.data) error.assert_(next(items, None) is None) @@ -1375,8 +1432,69 @@ def _post_put( else: raise error.error_from_response(error.PutError, r) self._update_tag_props(r) + ## The server has new data now; raw_data is only what was read + self._raw_data = None return None + def _put_raw( + self, body: bytes, precondition: Precondition = "auto", wildcard: Wildcard | None = None + ) -> "None | Coroutine[Any, Any, None]": + """PUT ``body`` unchanged, for ``save(raw_data=...)``. + + Unlike ``_put()``, a failure is not retried with a cleaned-up + copy of the data: the caller asked for exactly these bytes.""" + if self.url is None: + if not self.id: + raise ValueError( + "save(raw_data=...): the data has no UID and the object no URL - " + "set the URL, as a UID cannot be added without changing the bytes" + ) + self.url = self._generate_url() + headers = self._put_headers(precondition, wildcard) + body = WireBytes(body) + if self.is_async_client: + return self._async_put_raw(body, headers, precondition, wildcard) + try: + r = self.client.put(str(self.url), body, headers) + except error.AuthorizationError as e: + self._raise_uid_conflict(e, wildcard is not None) + raise + return self._post_put(r, False, precondition, wildcard) + + async def _async_put_raw( + self, body: bytes, headers, precondition: Precondition, wildcard: Wildcard | None = None + ) -> None: + try: + r = await self.client.put(str(self.url), body, headers) + except error.AuthorizationError as e: + self._raise_uid_conflict(e, wildcard is not None) + raise + self._post_put(r, False, precondition, wildcard) + + @staticmethod + def _check_raw_data(raw_data: Any) -> bytes | None: + if raw_data is _NotGiven.NOT_GIVEN: + return None + if raw_data is None: + ## Most likely target.save(raw_data=source.raw_data) with a + ## source that was not read from the server: a plain save() + ## here would store something else than asked for + raise ValueError( + "save(raw_data=None): no bytes given (raw_data is None for an object not " + "read from the server, a copy, or after save()) - leave raw_data out for a " + "normal save" + ) + if not isinstance(raw_data, (bytes, bytearray, memoryview)): + raise TypeError(f"save(raw_data=...) takes bytes, not {type(raw_data).__name__}") + raw_data = bytes(raw_data) + if not raw_data: + raise ValueError("save(raw_data=b''): no calendar data to send") + try: + raw_data.decode("utf-8") + except UnicodeDecodeError as e: + raise ValueError(f"save(raw_data=...) needs UTF-8 encoded calendar data: {e}") from e + return raw_data + def _create( self, id=None, @@ -1525,6 +1643,7 @@ def save( only_this_recurrence: bool | None = True, all_recurrences: bool = False, precondition: Precondition = "auto", + raw_data: RawData | None | _NotGiven = _NotGiven.NOT_GIVEN, ) -> "Self | Coroutine[Any, Any, Self]": """Save the object, can be used for creation and update. @@ -1588,6 +1707,21 @@ def save( * False: skip the merge entirely and PUT whatever is in this object directly to the server. + ``raw_data`` sends the given bytes as the PUT body, unchanged: + no ``vcal.fix()``, no re-serialisation, no line ending + normalisation, no SEQUENCE bump and no merging of a recurrence + into its master. The bytes replace whatever the object held, + local edits included, and become its ``data`` (parsed, they + must be UTF-8) before the PUT is sent - also when it then + fails. The precondition headers, ``no_overwrite`` and + ``no_create`` apply as usual. Without a URL, the object's URL + is made from the UID in the bytes. Use it to store what was + read elsewhere, e.g. ``target.save(raw_data=source.raw_data)``; + ``raw_data=None`` (a source never read, a copy, or one saved + since) raises ValueError rather than doing a normal save. + See :attr:`raw_data` and + https://github.com/python-caldav/caldav/issues/763 + Returns: * self @@ -1595,6 +1729,10 @@ def save( ## Checked before anything is sent self._check_precondition(precondition) + raw_data = self._check_raw_data(raw_data) + if raw_data is not None: + ## the bytes win over any edit: the caller asked for them + self.data = raw_data # Early return if there's no data (no-op case) if not self.is_loaded(): @@ -1611,6 +1749,7 @@ def save( only_this_recurrence=only_this_recurrence, all_recurrences=all_recurrences, precondition=precondition, + raw_data=raw_data, ) # Helper function to get the full object by UID @@ -1634,8 +1773,14 @@ def get_self(): return None return None + ## raw bytes are never merged into a master + merging = raw_data is None wildcard, search = self._save_plan( - no_overwrite, no_create, only_this_recurrence, all_recurrences, precondition + no_overwrite, + no_create, + only_this_recurrence if merging else False, + all_recurrences and merging, + precondition, ) if search: uid = self.id or self.icalendar_component.get("uid") @@ -1647,8 +1792,10 @@ def get_self(): ## fetched via server-side expand), only_this_recurrence will silently not merge ## it into the parent; the caller must add RECURRENCE-ID from DTSTART first. if ( - only_this_recurrence is not False or all_recurrences - ) and "RECURRENCE-ID" in self.icalendar_component: + raw_data is None + and (only_this_recurrence is not False or all_recurrences) + and "RECURRENCE-ID" in self.icalendar_component + ): from caldav.lib import error obj = get_self() @@ -1672,6 +1819,9 @@ def get_self(): precondition=precondition, ) + if raw_data is not None: + self._put_raw(raw_data, precondition, wildcard) + return self self._maybe_increment_sequence(increase_seqno) path = self.url.path if self.url else None self._create(id=self.id, path=path, precondition=precondition, wildcard=wildcard) @@ -1842,6 +1992,7 @@ async def _async_save( only_this_recurrence: bool | None = True, all_recurrences: bool = False, precondition: Precondition = "auto", + raw_data: bytes | None = None, ) -> Self: """Async implementation of save() for async clients.""" from caldav.lib import error @@ -1861,8 +2012,14 @@ async def get_self(): return None return None + ## raw bytes are never merged into a master + merging = raw_data is None wildcard, search = self._save_plan( - no_overwrite, no_create, only_this_recurrence, all_recurrences, precondition + no_overwrite, + no_create, + only_this_recurrence if merging else False, + all_recurrences and merging, + precondition, ) if search: uid = self.id or self.icalendar_component.get("uid") @@ -1870,8 +2027,10 @@ async def get_self(): self._validate_save_constraints(existing, uid, no_overwrite, no_create) if ( - only_this_recurrence is not False or all_recurrences - ) and "RECURRENCE-ID" in self.icalendar_component: + raw_data is None + and (only_this_recurrence is not False or all_recurrences) + and "RECURRENCE-ID" in self.icalendar_component + ): obj = await get_self() if obj is None: if only_this_recurrence is True: @@ -1893,6 +2052,9 @@ async def get_self(): precondition=precondition, ) + if raw_data is not None: + await self._put_raw(raw_data, precondition, wildcard) + return self self._maybe_increment_sequence(increase_seqno) path = self.url.path if self.url else None await self._create(id=self.id, path=path, precondition=precondition, wildcard=wildcard) diff --git a/caldav/collection.py b/caldav/collection.py index e8298029..a74335fe 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -1553,7 +1553,7 @@ def _post_multiget(self, results: Iterable[tuple[str, str, dict[str, str]]]) -> data=data, parent=self, props=dict(tags) or None, - ) + )._keep_raw_data(data) for url, data, tags in results ] @@ -1604,7 +1604,9 @@ def _assign_multiget_data( for obj in unloaded: key = unquote(str(obj.url)) if key in url_to_data: - obj.data, tags = url_to_data[key] + data, tags = url_to_data[key] + obj.data = data + obj._keep_raw_data(data) obj.props.update(tags) def _batch_load_objects(self, objects: list) -> None: @@ -1779,7 +1781,7 @@ def _post_request_report_build_resultlist(self, response, comp_class, props_): data=cdata, parent=self, props=pdata, - ) + )._keep_raw_data(cdata) ) return (response, matches) diff --git a/caldav/lib/python_utilities.py b/caldav/lib/python_utilities.py index c668821b..55a1d0a5 100644 --- a/caldav/lib/python_utilities.py +++ b/caldav/lib/python_utilities.py @@ -1,9 +1,17 @@ ## This file was originally made to fascilitate support for both python2 and python3. +class WireBytes(bytes): + """Bytes that are sent exactly as they are: ``to_wire()`` leaves + them alone instead of normalising their line endings to CRLF. Used + by ``save(raw_data=...)``.""" + + def to_wire(text): if text is None: return None + if isinstance(text, WireBytes): + return text if isinstance(text, str): text = bytes(text, "utf-8") text = text.replace(b"\n", b"\r\n") diff --git a/caldav/response.py b/caldav/response.py index d59bb711..29821857 100644 --- a/caldav/response.py +++ b/caldav/response.py @@ -263,6 +263,11 @@ class DAVResponse: headers: Any = None status: int = 0 _raw: Any = "" + ## A body that is not XML, as received (after any Content-Encoding is + ## undone), before the CRLF normalisation applied to ``_raw``. load() + ## keeps it as CalendarObjectResource.raw_data. Empty for XML, whose + ## calendar data is taken from the parsed tree. + _raw_body: bytes = b"" huge_tree: bool = False reason: str = "" davclient: Any = None @@ -331,6 +336,10 @@ def _init_from_response(self, response: "Response", davclient: Any = None) -> No log.debug("response status: " + str(self.status)) self._raw = response.content + if isinstance(self._raw, bytes): + self._raw_body = bytes(self._raw) + elif isinstance(self._raw, str): + self._raw_body = self._raw.encode("utf-8") self.davclient = davclient if davclient: self.huge_tree = davclient.huge_tree @@ -390,6 +399,8 @@ def _init_from_response(self, response: "Response", davclient: Any = None) -> No if expect_xml: raise else: + ## not needed for XML; no reason to hold the body twice + self._raw_body = b"" if log.level <= logging.DEBUG: log.debug(etree.tostring(self.tree, pretty_print=True)) diff --git a/changes/763.added.md b/changes/763.added.md new file mode 100644 index 00000000..95f4c104 --- /dev/null +++ b/changes/763.added.md @@ -0,0 +1 @@ +Calendar objects keep the calendar data exactly as the server sent it, before `vcal.fix()` or any other normalisation, in the new read-only `raw_data` attribute (`bytes`; `None` for objects not read from a server, copies, and after `save()`). After `load()` it is the GET response body byte for byte; from a REPORT (`search()`, `multiget()`) it is the text of the `calendar-data` element, with XML entities decoded and line breaks turned into LF. `save(raw_data=...)` sends the given (UTF-8) bytes as the PUT body unchanged - no `vcal.fix()`, no re-serialisation, no `SEQUENCE` bump - still with `If-Match` / `If-Schedule-Tag-Match`. This lets sync tools compare and copy what servers store. diff --git a/docs/source/howtos.rst b/docs/source/howtos.rst index 61f4fe3d..1a0bcf1e 100644 --- a/docs/source/howtos.rst +++ b/docs/source/howtos.rst @@ -99,6 +99,45 @@ When a single recurrence is saved (see :ref:`tutorial:Modifying Events`), the event. Replying to an invitation (``accept_invite()`` and friends) never increments ``SEQUENCE``; only the organizer revises an event. +The Server's Bytes +~~~~~~~~~~~~~~~~~~ + +``data``, ``wire_data`` and the parsed instances are caldav's normalised copy +of an object: incoming data goes through ``vcal.fix()``, and ``wire_data`` is +re-serialised after an edit. To compare or copy what a server actually +stores, use +:attr:`~caldav.calendarobjectresource.CalendarObjectResource.raw_data` and +``save(raw_data=...)``: + +.. code-block:: python + + source = source_cal.get_event_by_uid(uid).load() + raw = source.raw_data # bytes, as the server sent them + + ## a new object in the target calendar; its URL comes from the UID + Event(parent=target_cal).save(raw_data=raw) + ## or overwrite an object loaded from the target, with its If-Match + target_cal.get_event_by_uid(uid).save(raw_data=raw) + +``raw_data`` is a snapshot of the last read: editing the object does not change +it, a successful ``save()`` clears it (the server's bytes are only known after +the next load), and it is ``None`` for an object built locally or copied, +including the occurrences ``search(expand=True)`` returns. An object a search +filtered or expanded in place keeps what the server sent, which after a +server-side expansion is the expanded text: ``load()`` before copying. +``save(raw_data=None)`` raises ``ValueError`` instead of saving something else. +After ``load()`` (a GET) it is the response body byte for byte. From a +REPORT (``search()``, ``multiget()``, a ``load()`` that falls back to +multiget) it is the text of the ``calendar-data`` XML element, UTF-8 +encoded: the XML parser has decoded entities such as ``&`` and turned +CRLF into LF, so call ``load()`` first when the exact bytes matter. + +``save(raw_data=...)`` sends the bytes without ``vcal.fix()``, re-serialising, +line ending changes, ``SEQUENCE`` bump or merging a recurrence into its master, +and they replace any local edit, even if the PUT fails. The bytes must be +UTF-8. ``If-Match`` / ``If-Schedule-Tag-Match``, +``no_overwrite`` and ``no_create`` apply as for any save. + Backing Up a Calendar --------------------- diff --git a/tests/test_caldav.py b/tests/test_caldav.py index baba7411..49897e55 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -2455,6 +2455,66 @@ def testNoOverwriteSameUidOtherUrl(self): assert isinstance(exc_info.value, error.UidConflictError) assert len(c.get_events()) == 1 + def testRawDataRoundTrip(self): + """ + save(raw_data=...) sends the bytes unchanged, and load() keeps the + body the server sends back in raw_data. Servers may store their + own serialisation, so what comes back is compared with a plain + GET, not with what was sent. + https://github.com/python-caldav/caldav/issues/763 + """ + self.skip_unless_support("save-load.event") + c = self._fixCalendar() + uid = f"raw-{uuid.uuid4()}" + ## an unusual but valid layout: an X- property first, VERSION + ## after PRODID, a folded line, and SEQUENCE (not to be bumped) + body = ( + "BEGIN:VCALENDAR\r\n" + "X-WR-ODD:first\r\n" + "PRODID:-//Example//EN\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "SUMMARY:A folded summary that goes on and on and on and on and o\r\n" + " n until it is folded\r\n" + f"UID:{uid}\r\n" + "SEQUENCE:3\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260102T100000Z\r\n" + "DTEND:20260102T110000Z\r\n" + "X-ODD-PROP;X-PARAM=1:value\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ).encode() + e = Event(client=c.client, parent=c) + e.save(raw_data=body) + assert e.raw_data is None + + def plain_get(): + r = c.client.session.request( + "GET", str(e.url), auth=c.client.auth, verify=c.client.ssl_verify_cert + ) + assert r.status_code == 200 + return r.content + + loaded = Event(client=c.client, url=e.url, parent=c).load() + assert isinstance(loaded.raw_data, bytes) + assert loaded.raw_data == plain_get() + assert loaded.icalendar_component["SEQUENCE"] == 3 + stored = loaded.raw_data + ## the snapshot outlives an edit, and can be sent back as it is + loaded.icalendar_component["SUMMARY"] = "edited" + assert loaded.raw_data == stored + loaded.save(raw_data=stored) + again = Event(client=c.client, url=e.url, parent=c).load() + assert again.raw_data == plain_get() + assert "edited" not in again.data + assert again.icalendar_component["SEQUENCE"] == 3 + ## from a REPORT: servers may serialise it differently from the + ## GET body (Bedework, Purelymail), so only the UID is compared + (found,) = c.multiget([e.url]) + assert found.raw_data is not None + assert f"UID:{uid}".encode() in found.raw_data + def testObjectBySyncToken(self): """ Support for sync-collection reports, ref https://github.com/python-caldav/caldav/issues/87. diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 0bbfc2bb..08c282ac 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -7827,3 +7827,342 @@ def test_hints_of_servers_mishandling_if_match_star(self, server) -> None: fs = FeatureSet(getattr(compatibility_hints, server)) assert not fs.is_supported("save-load.mutable.if-match-wildcard") + + +class TestRawData: + """``raw_data`` keeps the calendar data as the server sent it, and + ``save(raw_data=...)`` sends given bytes unchanged. + https://github.com/python-caldav/caldav/issues/763""" + + URL = "http://cal.example.com/dav/user/mycal/" + ## An unusual but valid layout: X- property first, a folded line, + ## SEQUENCE (no bump expected), a trailing blank that vcal.fix() + ## strips, a duplicated DTSTAMP that vcal.fix() drops, and CRLF. + BODY = ( + b"BEGIN:VCALENDAR\r\n" + b"X-WR-ODD:first\r\n" + b"PRODID:-//Example//EN\r\n" + b"VERSION:2.0\r\n" + b"BEGIN:VEVENT\r\n" + b"SUMMARY:A folded summary that goes on and on and on and on and o\r\n" + b" n until it is folded\r\n" + b"UID:raw-1\r\n" + b"SEQUENCE:3\r\n" + b"DTSTAMP:20260101T000000Z\r\n" + b"DTSTAMP:20260101T000001Z\r\n" + b"DTSTART:20260102T100000Z\r\n" + b"X-TRAILING:blank \r\n" + b"END:VEVENT\r\n" + b"END:VCALENDAR\r\n" + ) + + @pytest.fixture(params=["sync", "async"]) + def base(self, request): + if request.param == "sync": + return DAVClient + from caldav.async_davclient import AsyncDAVClient + + return AsyncDAVClient + + @staticmethod + def _run(result): + import asyncio + + if asyncio.iscoroutine(result): + return asyncio.run(result) + return result + + def _calendar(self, base, body=BODY, put_status=204, features=None, get_status=200): + """A fake server with one object, ``ev1.ics``. Returns the + calendar and the (method, headers, body) triples sent.""" + sent = [] + ## keeps the CR through XML end-of-line normalisation, and + ## & is an entity the XML parser decodes + text = body.decode().replace("\r", " ").replace("&", "&") + text = text.replace("&#13;", " ") + multistatus = ( + '' + "/dav/user/mycal/ev1.ics" + '"etag-r"' + f"{text}" + "HTTP/1.1 200 OK" + "" + ).encode() + client = base(url="http://cal.example.com/dav/", features=features) + + def request(url, method="GET", body="", headers=None): + sent.append((method, {k.lower(): v for k, v in (headers or {}).items()}, body)) + resp = mock.MagicMock() + resp.headers = {} + if method == "GET" and get_status == 404: + resp.status_code, resp.reason = 404, "Not Found" + resp.content = b"" + elif method == "GET": + resp.status_code, resp.reason = 200, "OK" + resp.headers = {"Content-Type": "text/calendar", "Etag": '"etag-g"'} + resp.content = body_get + elif method == "REPORT": + resp.status_code, resp.reason = 207, "Multi-Status" + resp.content = multistatus + else: + resp.status_code, resp.reason = put_status, "whatever" + resp.headers = {"Etag": '"etag-p"'} + resp.content = b"" + return DAVResponse(resp, client) + + body_get = body + if base is DAVClient: + client.request = request + else: + + async def async_request(*args, **kwargs): + return request(*args, **kwargs) + + client.request = async_request + return Calendar(client, url=self.URL), sent + + def _loaded(self, base, **kwargs): + calendar, sent = self._calendar(base, **kwargs) + event = self._run(Event(url=self.URL + "ev1.ics", parent=calendar).load()) + sent.clear() + return event, sent + + def test_load_keeps_the_body(self, base) -> None: + event, _ = self._loaded(base) + assert event.raw_data == self.BODY + assert isinstance(event.raw_data, bytes) + ## the parsed view is still caldav's normalised copy + assert event.wire_data != self.BODY + assert event.etag == '"etag-g"' + + def test_load_keeps_bare_lf_and_odd_bytes(self, base) -> None: + body = self.BODY.replace(b"\r\n", b"\n").replace(b"blank", b"bl\xc3\xa5nk") + event, _ = self._loaded(base, body=body) + assert event.raw_data == body + + def test_raw_data_is_a_snapshot(self, base) -> None: + event, _ = self._loaded(base) + event.icalendar_component["SUMMARY"] = "changed" + event.data = event.data.replace("X-WR-ODD", "X-WR-EVEN") + with event.edit_icalendar_instance() as cal: + cal.subcomponents[0]["SUMMARY"] = "changed again" + assert event.raw_data == self.BODY + + def test_local_object_has_no_raw_data(self) -> None: + assert Event(data=self.BODY.decode()).raw_data is None + assert Event(data=self.BODY).raw_data is None + + def test_raw_data_is_read_only(self) -> None: + with pytest.raises(AttributeError): + Event(data=self.BODY).raw_data = self.BODY + + def test_search_keeps_calendar_data(self, base) -> None: + calendar, _ = self._calendar(base) + (event,) = self._run(calendar.search(event=True)) + ## the text of the calendar-data element, entities decoded + assert event.raw_data == self.BODY + assert event.etag == '"etag-r"' + + def test_entities_are_decoded(self, base) -> None: + body = self.BODY.replace(b"X-WR-ODD:first", b"X-WR-ODD:fish & chips") + calendar, _ = self._calendar(base, body=body) + (event,) = self._run(calendar.search(event=True)) + assert event.raw_data == body + + def test_multiget_keeps_calendar_data(self, base) -> None: + calendar, _ = self._calendar(base) + (event,) = self._run(calendar.multiget([URL(self.URL + "ev1.ics")])) + assert event.raw_data == self.BODY + + def test_load_by_multiget_keeps_calendar_data(self, base) -> None: + calendar, _ = self._calendar(base) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + self._run(event.load_by_multiget()) + assert event.raw_data == self.BODY + + def test_batch_load_keeps_calendar_data(self, base) -> None: + """search() loads objects the server listed without calendar-data + in one multiget""" + calendar, _ = self._calendar(base) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + if base is DAVClient: + calendar._batch_load_objects([event]) + else: + self._run(calendar._async_batch_load_objects([event])) + assert event.raw_data == self.BODY + + def test_copy_has_no_raw_data(self, base) -> None: + """A copy is not read from the server; like the ETag, raw_data + stays with the original""" + event, _ = self._loaded(base) + assert event.copy(keep_uid=True).raw_data is None + assert event.copy().raw_data is None + + def test_xml_body_is_not_kept_twice(self) -> None: + response = DAVResponse.from_bytes(b'') + assert response._raw_body == b"" + response = DAVResponse.from_bytes(self.BODY) + assert response._raw_body == self.BODY + + def test_save_raw_data_sends_the_bytes(self, base) -> None: + event, sent = self._loaded(base) + ## bare LF and CRLF mixed: neither is to be touched + body = self.BODY.replace(b"VERSION:2.0\r\n", b"VERSION:2.0\n") + self._run(event.save(raw_data=body)) + assert [(m, b) for m, _, b in sent] == [("PUT", body)] + assert sent[0][1]["if-match"] == '"etag-g"' + ## the parsed view follows the bytes sent; no SEQUENCE bump + assert event.icalendar_component["SEQUENCE"] == 3 + assert event.etag == '"etag-p"' + + def test_save_raw_data_wins_over_edits(self, base) -> None: + event, sent = self._loaded(base) + event.icalendar_component["SUMMARY"] = "an edit that is discarded" + self._run(event.save(raw_data=self.BODY)) + assert sent[0][2] == self.BODY + assert "discarded" not in event.data + + def test_save_clears_raw_data(self, base) -> None: + event, _ = self._loaded(base) + self._run(event.save(raw_data=self.BODY)) + assert event.raw_data is None + event, _ = self._loaded(base) + self._run(event.save()) + assert event.raw_data is None + + def test_save_raw_data_of_a_new_object(self, base) -> None: + calendar, sent = self._calendar(base) + event = Event(parent=calendar) + self._run(event.save(raw_data=self.BODY)) + assert [(m, b) for m, _, b in sent] == [("PUT", self.BODY)] + assert str(event.url) == self.URL + "raw-1.ics" + assert "if-match" not in sent[0][1] + + def test_save_raw_data_does_not_merge_a_recurrence(self, base) -> None: + calendar, sent = self._calendar(base) + body = self.BODY.replace(b"SEQUENCE:3\r\n", b"RECURRENCE-ID:20260102T100000Z\r\n") + event = Event(url=self.URL + "ev1.ics", parent=calendar) + self._run(event.save(raw_data=body)) + assert [(m, b) for m, _, b in sent] == [("PUT", body)] + + def test_save_raw_data_no_overwrite_sends_if_none_match(self, base) -> None: + features = {"save.no-uid-conflict": {"support": "full"}} + calendar, sent = self._calendar(base, put_status=201, features=features) + event = Event(parent=calendar) + self._run(event.save(raw_data=self.BODY, no_overwrite=True)) + assert [(m, b) for m, _, b in sent] == [("PUT", self.BODY)] + assert sent[0][1]["if-none-match"] == "*" + + def test_save_raw_data_no_create_sends_if_match_star(self, base) -> None: + calendar, sent = self._calendar(base) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + self._run(event.save(raw_data=self.BODY, no_create=True)) + assert [(m, b) for m, _, b in sent] == [("PUT", self.BODY)] + assert sent[0][1]["if-match"] == "*" + + def test_save_raw_data_no_create_412_is_consistency_error(self, base) -> None: + calendar, sent = self._calendar(base, put_status=412) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + with pytest.raises(error.ConsistencyError): + self._run(event.save(raw_data=self.BODY, no_create=True)) + assert [m for m, _, _ in sent] == ["PUT"] + + def test_save_raw_data_recurrence_no_create_skips_search(self, base) -> None: + """No merge into a master, so nothing to search for.""" + calendar, sent = self._calendar(base) + body = self.BODY.replace(b"SEQUENCE:3\r\n", b"RECURRENCE-ID:20260102T100000Z\r\n") + event = Event(url=self.URL + "ev1.ics", parent=calendar) + self._run(event.save(raw_data=body, no_create=True)) + assert [(m, b) for m, _, b in sent] == [("PUT", body)] + assert sent[0][1]["if-match"] == "*" + + def test_save_raw_data_none_is_refused(self, base) -> None: + """save(raw_data=source.raw_data) with nothing read must not + turn into a plain save()""" + event, sent = self._loaded(base) + with pytest.raises(ValueError): + self._run(event.save(raw_data=None)) + assert sent == [] + + def test_save_raw_data_empty_is_refused(self, base) -> None: + event, sent = self._loaded(base) + data = event.data + with pytest.raises(ValueError): + self._run(event.save(raw_data=b"")) + assert sent == [] + assert event.data == data + + def test_save_raw_data_not_utf8_is_refused(self, base) -> None: + event, sent = self._loaded(base) + with pytest.raises(ValueError, match="UTF-8"): + self._run(event.save(raw_data=self.BODY.replace(b"on and on", b"\xe6\xf8\xe5"))) + assert sent == [] + + def test_save_raw_data_sends_cached_schedule_tag(self, base) -> None: + event, sent = self._loaded(base) + event.props[cdav.ScheduleTag.tag] = '"stag-r"' + self._run(event.save(raw_data=self.BODY)) + assert sent[0][1]["if-schedule-tag-match"] == '"stag-r"' + assert "if-match" not in sent[0][1] + + def test_save_raw_data_precondition_etag(self, base) -> None: + event, sent = self._loaded(base) + event.props[cdav.ScheduleTag.tag] = '"stag-r"' + self._run(event.save(raw_data=self.BODY, precondition="etag")) + assert sent[0][1]["if-match"] == '"etag-g"' + assert "if-schedule-tag-match" not in sent[0][1] + + def test_report_text_is_not_kept_twice(self, base) -> None: + """The calendar-data text is kept as parsed and encoded on read.""" + calendar, _ = self._calendar(base) + (event,) = self._run(calendar.multiget([URL(self.URL + "ev1.ics")])) + assert isinstance(event._raw_data, str) + assert isinstance(event.raw_data, bytes) + + def test_load_by_uid_fallback_keeps_raw_data(self) -> None: + calendar, _ = self._calendar(DAVClient, get_status=404) + found = Event(url=self.URL + "moved.ics", parent=calendar)._keep_raw_data(self.BODY) + found.data = self.BODY + calendar.get_object_by_uid = lambda uid: found + event = Event(url=self.URL + "ev1.ics", parent=calendar, data=self.BODY) + event.load(only_if_unloaded=False, multiget_fallback=False) + assert event.raw_data == self.BODY + assert str(event.url) == self.URL + "moved.ics" + + def test_save_raw_data_must_be_bytes(self, base) -> None: + calendar, sent = self._calendar(base) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + with pytest.raises(TypeError): + self._run(event.save(raw_data=self.BODY.decode())) + assert sent == [] + + def test_save_raw_data_without_uid_or_url(self, base) -> None: + calendar, sent = self._calendar(base) + event = Event(parent=calendar) + with pytest.raises(ValueError): + self._run(event.save(raw_data=self.BODY.replace(b"UID:raw-1\r\n", b""))) + assert sent == [] + + def test_save_raw_data_is_not_retried_altered(self, base) -> None: + calendar, sent = self._calendar(base, put_status=500) + event = Event(url=self.URL + "ev1.ics", parent=calendar) + with pytest.raises(error.PutError): + self._run(event.save(raw_data=self.BODY)) + assert [(m, b) for m, _, b in sent] == [("PUT", self.BODY)] + + def test_save_raw_data_reaches_the_http_session_unchanged(self, base) -> None: + """The client's own line ending normalisation is skipped too""" + body = self.BODY.replace(b"VERSION:2.0\r\n", b"VERSION:2.0\n") + client = base(url="http://cal.example.com/dav/") + resp = mock.MagicMock() + resp.status_code, resp.reason = 201, "Created" + resp.headers = {} + resp.content = b"" + if base is DAVClient: + client.session.request = mock.MagicMock(return_value=resp) + else: + client.session.request = mock.AsyncMock(return_value=resp) + event = Event(url=self.URL + "ev1.ics", parent=Calendar(client, url=self.URL)) + self._run(event.save(raw_data=body)) + kwargs = client.session.request.call_args.kwargs + assert (kwargs.get("data") or kwargs.get("content")) == body diff --git a/tests/test_jmap_wrapper.py b/tests/test_jmap_wrapper.py index 8e8178e6..eb679a07 100644 --- a/tests/test_jmap_wrapper.py +++ b/tests/test_jmap_wrapper.py @@ -48,8 +48,10 @@ def test_import_warns(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") import caldav.jmap + # Only the deprecation counts: a source checkout without a + # generated _version.py adds a version UserWarning. + caught = [w for w in caught if issubclass(w.category, DeprecationWarning)] assert len(caught) == 1, caught - assert issubclass(caught[0].category, DeprecationWarning) assert "caldav.jmap is deprecated" in str(caught[0].message) print("ok") """