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(" ", "
")
+ 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")
"""