From 816f9c58bd1722d6620589ef2936c1e950333851 Mon Sep 17 00:00:00 2001 From: Eyal Hefer Date: Tue, 20 Oct 2026 13:24:11 +0300 Subject: [PATCH 1/4] Add icalendar.parse for iCal, jCal and xCal Detect the format from the first non-whitespace character and call the matching parser. Accept bytes, a Path or a binary file. Add tests and docs. Closes #1917 AI: Claude Opus 5.5 (Claude Code) explained the codebase, reviewed my parse() code, and drafted the tests and docs. I reviewed and tested all. Co-Authored-By: Claude Opus 5.5 --- docs/how-to/index.rst | 1 + docs/how-to/parse.rst | 72 ++++++++++++++++ news/1917.feature | 1 + src/icalendar/__init__.py | 2 + src/icalendar/cal/__init__.py | 3 +- src/icalendar/cal/calendar.py | 87 +++++++++++++++++++- src/icalendar/tests/test_issue_1917_parse.py | 77 +++++++++++++++++ 7 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 docs/how-to/parse.rst create mode 100644 news/1917.feature create mode 100644 src/icalendar/tests/test_issue_1917_parse.py diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 93ded7eec..22f08371b 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -9,6 +9,7 @@ How to guides configure upgrade usage + parse parse-errors attendees custom-components diff --git a/docs/how-to/parse.rst b/docs/how-to/parse.rst new file mode 100644 index 000000000..b818f46db --- /dev/null +++ b/docs/how-to/parse.rst @@ -0,0 +1,72 @@ +========================= +Parse any calendar format +========================= + +This chapter describes how to parse calendar data when you do not know in advance whether it is iCalendar, jCal, or xCal. + +icalendar supports three calendar formats: + +- iCalendar, as specified in :rfc:`5545` +- jCal, a JSON representation, as specified in :rfc:`7265` +- xCal, an XML representation, as specified in :rfc:`6321` + +Each format has its own parser, such as :meth:`Calendar.from_ical() `. +The function :func:`~icalendar.cal.calendar.parse` detects the format for you and calls the matching parser. + + +Parse data +========== + +Pass the data as :class:`bytes`. + +.. code-block:: pycon + + >>> from icalendar import parse + >>> data = b'["vcalendar", [["prodid", {}, "text", "example"]], []]' + >>> calendar = parse(data) + >>> calendar["PRODID"] + vText(b'example') + +The format is detected from the first non-whitespace character: + +- ``B``, as in ``BEGIN:VCALENDAR``, means iCalendar. +- ``[`` means jCal. +- ``<`` means xCal. + +If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises a :class:`ValueError`. + + +Parse a file +============ + +Pass a :class:`~pathlib.Path` to read and parse a file. + +.. code-block:: python + + from pathlib import Path + from icalendar import parse + + calendar = parse(Path("calendar.ics")) + +You can also pass a file that you opened in binary mode. + +.. code-block:: python + + with open("calendar.ics", "rb") as file: + calendar = parse(file) + + +Parse several calendars +======================= + +By default, :func:`~icalendar.cal.calendar.parse` returns one calendar. +If the data contains more than one calendar, it raises a :class:`ValueError`. + +To get all calendars as a list, pass ``multiple=True``. + +.. code-block:: pycon + + >>> ical = b"BEGIN:VCALENDAR\r\nPRODID:example\r\nEND:VCALENDAR\r\n" + >>> calendars = parse(ical + ical, multiple=True) + >>> len(calendars) + 2 diff --git a/news/1917.feature b/news/1917.feature new file mode 100644 index 000000000..3ecfe8ca8 --- /dev/null +++ b/news/1917.feature @@ -0,0 +1 @@ +Added :func:`icalendar.parse `, which detects whether data is iCalendar, jCal, or xCal and parses it into calendars. It accepts :class:`bytes`, a :class:`~pathlib.Path`, or a binary file. Added the how-to guide :doc:`/how-to/parse`. I used Claude Code as a learning aid to explain the codebase, review my code, and draft the tests and documentation, then reviewed and tested the result. @EyalHefer diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 6da3d3de5..14343ec0f 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -17,6 +17,7 @@ TimezoneDaylight, TimezoneStandard, Todo, + parse, ) from icalendar.enums import ( BUSYTYPE, @@ -150,6 +151,7 @@ "__version__", "__version_tuple__", "is_utc", + "parse", "q_join", "q_split", "use_pytz", diff --git a/src/icalendar/cal/__init__.py b/src/icalendar/cal/__init__.py index 0a8279344..2cdbcbb3e 100644 --- a/src/icalendar/cal/__init__.py +++ b/src/icalendar/cal/__init__.py @@ -7,7 +7,7 @@ from .alarm import Alarm from .availability import Availability from .available import Available -from .calendar import Calendar +from .calendar import Calendar, parse from .component import Component from .component_factory import ComponentFactory from .event import Event @@ -32,4 +32,5 @@ "TimezoneDaylight", "TimezoneStandard", "Todo", + "parse", ] diff --git a/src/icalendar/cal/calendar.py b/src/icalendar/cal/calendar.py index 7d220e742..31cefe9fb 100644 --- a/src/icalendar/cal/calendar.py +++ b/src/icalendar/cal/calendar.py @@ -4,6 +4,7 @@ import uuid from datetime import timedelta +from pathlib import Path from typing import TYPE_CHECKING, Literal, cast, overload from icalendar.attr import ( @@ -28,7 +29,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence from datetime import date, datetime - from pathlib import Path + from typing import BinaryIO from icalendar.cal import ( Availability, @@ -684,4 +685,86 @@ def validate(self): ) -__all__ = ["Calendar"] +@overload +def parse( + source: bytes | Path | BinaryIO, multiple: Literal[False] = False +) -> Calendar: ... +@overload +def parse( + source: bytes | Path | BinaryIO, multiple: Literal[True] +) -> list[Calendar]: ... + + +def parse( + source: bytes | Path | BinaryIO, multiple: bool = False +) -> Calendar | list[Calendar]: + r"""Parse iCalendar, jCal, or xCal data into calendars. + + The format is detected from the first non-whitespace character of the data: + ``B`` for iCalendar (:rfc:`5545`), ``[`` for jCal (:rfc:`7265`), + and ``<`` for xCal (:rfc:`6321`). + + Parameters: + source: The data to parse, as :class:`bytes`, a :class:`~pathlib.Path` + to a file, or a file opened in binary mode. + multiple: If ``True``, returns a list of calendars. + If ``False``, returns a single calendar. + + Returns: + A calendar, or a list of calendars if ``multiple`` is ``True``. + + Raises: + ValueError: If the format cannot be detected, or if ``multiple`` is + ``False`` and the data does not contain exactly one calendar. + + Example: + + Parse iCalendar and jCal data with the same function. + + .. code-block:: pycon + + >>> from icalendar import parse + >>> ical = b"BEGIN:VCALENDAR\r\nPRODID:example\r\nEND:VCALENDAR\r\n" + >>> parse(ical)["PRODID"] + vText(b'example') + >>> jcal = b'["vcalendar", [["prodid", {}, "text", "example"]], []]' + >>> parse(jcal)["PRODID"] + vText(b'example') + + Use ``multiple=True`` to get a list of all calendars. + + .. code-block:: pycon + + >>> calendars = parse(ical + ical, multiple=True) + >>> len(calendars) + 2 + """ + if isinstance(source, bytes): + data = source + elif isinstance(source, Path): + data = source.read_bytes() + else: + data = source.read() + data = data.lstrip() + first = data[:1] + if first == b"B": + calendars = Calendar.from_ical(data, multiple=True) + elif first == b"[": + calendars = [cast("Calendar", Calendar.from_jcal(data.decode("utf-8")))] + elif first == b"<": + calendars = Calendar.from_xcal(data) + else: + raise ValueError( + f"Could not detect the calendar format: expected iCal, jCal or xCal data, " + f"but the data starts with {first!r}." + ) + if multiple: + return calendars + if len(calendars) > 1: + raise ValueError("There are multiple calendars, please use multiple=True") + if len(calendars) == 0: + raise ValueError("No calendars found.") + return calendars[0] + + +__all__ = ["Calendar", "parse"] diff --git a/src/icalendar/tests/test_issue_1917_parse.py b/src/icalendar/tests/test_issue_1917_parse.py new file mode 100644 index 000000000..32ae1b6b3 --- /dev/null +++ b/src/icalendar/tests/test_issue_1917_parse.py @@ -0,0 +1,77 @@ +"""Tests for icalendar.parse, which detects iCal, jCal and xCal automatically. + +See https://github.com/collective/icalendar/issues/1917 +""" + +import pytest + +from icalendar import Calendar, parse + +ICAL = b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nEND:VCALENDAR\r\n" +JCAL = b'["vcalendar", [["version", {}, "text", "2.0"], ["prodid", {}, "text", "test"]], []]' +XCAL = ( + b'' + b'' + b"2.0test" + b"" +) + + +ALL_FORMATS = pytest.mark.parametrize( + "data", [ICAL, JCAL, XCAL], ids=["ical", "jcal", "xcal"] +) + + +@ALL_FORMATS +def test_parse_returns_one_calendar(data): + """Each format is detected and parsed into a single calendar.""" + calendar = parse(data) + assert isinstance(calendar, Calendar) + assert calendar["PRODID"] == "test" + + +def test_parse_path(tmp_path): + """A path to a file is read and parsed.""" + path = tmp_path / "calendar.ics" + path.write_bytes(ICAL) + assert parse(path)["PRODID"] == "test" + + +def test_empty_data_raises_error(): + """Data with no detectable format raises a ValueError.""" + with pytest.raises(ValueError): + parse(b" ") + + +@ALL_FORMATS +def test_parse_multiple_returns_list(data): + """With multiple=True, each format returns a list of calendars.""" + calendars = parse(data, multiple=True) + assert isinstance(calendars, list) + assert len(calendars) == 1 + assert calendars[0]["PRODID"] == "test" + + +def test_parse_open_file(tmp_path): + """An open binary file is read and parsed.""" + path = tmp_path / "calendar.ics" + path.write_bytes(ICAL) + with path.open("rb") as file: + assert parse(file)["PRODID"] == "test" + + +def test_leading_whitespace_is_ignored(): + """Whitespace before the data does not affect format detection.""" + assert parse(b" \n" + ICAL)["PRODID"] == "test" + + +def test_no_calendars_raises_error(): + """Data without any calendar raises a ValueError.""" + with pytest.raises(ValueError, match="No calendars found"): + parse(b'') + + +def test_multiple_calendars_without_multiple_raises_error(): + """Several calendars with multiple=False raise a ValueError.""" + with pytest.raises(ValueError, match="multiple=True"): + parse(ICAL + ICAL) From 8de31ec7b3851abc215aa412ca471cfbd5418b88 Mon Sep 17 00:00:00 2001 From: Eyal Hefer Date: Sun, 25 Oct 2026 11:52:50 +0200 Subject: [PATCH 2/4] Address review: error classes, TypeError, fixtures Raise UnknownCalendarFormat, NoCalendarsFound and CalendarInformationLost instead of ValueError, and TypeError for non-bytes data. Accept a UTF-8 BOM and lowercase "begin". Use fixtures for all formats in the tests and update the docs. AI: Claude Opus 5.5 (Claude Code) found edge cases and wrote the tests and docs. I wrote the parse() changes and error classes. Co-Authored-By: Claude Opus 5.5 --- docs/how-to/parse.rst | 55 +++++++---- news/1917.feature | 2 +- src/icalendar/__init__.py | 6 ++ src/icalendar/cal/calendar.py | 42 ++++++--- src/icalendar/error.py | 22 +++++ src/icalendar/tests/test_issue_1917_parse.py | 97 ++++++++++++++------ 6 files changed, 164 insertions(+), 60 deletions(-) diff --git a/docs/how-to/parse.rst b/docs/how-to/parse.rst index b818f46db..f849e70f3 100644 --- a/docs/how-to/parse.rst +++ b/docs/how-to/parse.rst @@ -1,21 +1,22 @@ +.. _how-to-parse: + ========================= Parse any calendar format ========================= This chapter describes how to parse calendar data when you do not know in advance whether it is iCalendar, jCal, or xCal. -icalendar supports three calendar formats: +icalendar supports three calendar formats, each with its own parser: -- iCalendar, as specified in :rfc:`5545` -- jCal, a JSON representation, as specified in :rfc:`7265` -- xCal, an XML representation, as specified in :rfc:`6321` +- iCalendar, as specified in :rfc:`5545`, parsed by :meth:`Calendar.from_ical() ` +- jCal, a JSON representation, as specified in :rfc:`7265`, parsed by :meth:`Calendar.from_jcal() ` +- xCal, an XML representation, as specified in :rfc:`6321`, parsed by :meth:`Calendar.from_xcal() ` -Each format has its own parser, such as :meth:`Calendar.from_ical() `. The function :func:`~icalendar.cal.calendar.parse` detects the format for you and calls the matching parser. -Parse data -========== +Parse :class:`bytes` +==================== Pass the data as :class:`bytes`. @@ -27,13 +28,20 @@ Pass the data as :class:`bytes`. >>> calendar["PRODID"] vText(b'example') -The format is detected from the first non-whitespace character: +The format is detected from the first character, ignoring whitespace and a UTF-8 byte order mark: -- ``B``, as in ``BEGIN:VCALENDAR``, means iCalendar. +- ``B`` or ``b``, as in ``BEGIN:VCALENDAR``, means iCalendar. - ``[`` means jCal. - ``<`` means xCal. -If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises a :class:`ValueError`. +If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises :class:`~icalendar.error.UnknownCalendarFormat`. + +.. code-block:: pycon + + >>> parse(b"not a calendar") + Traceback (most recent call last): + ... + icalendar.error.UnknownCalendarFormat: Could not detect the calendar format: expected iCal, jCal or xCal data, but the data starts with b'n'. Parse a file @@ -41,26 +49,35 @@ Parse a file Pass a :class:`~pathlib.Path` to read and parse a file. -.. code-block:: python - - from pathlib import Path - from icalendar import parse +.. code-block:: pycon - calendar = parse(Path("calendar.ics")) + >>> from pathlib import Path + >>> calendar = parse(Path("src/icalendar/tests/calendars/example.ics")) + >>> len(calendar.events) + 3 You can also pass a file that you opened in binary mode. -.. code-block:: python +.. code-block:: pycon + + >>> with open("src/icalendar/tests/calendars/example.ics", "rb") as file: + ... calendar = parse(file) + >>> len(calendar.events) + 3 - with open("calendar.ics", "rb") as file: - calendar = parse(file) +A file opened in text mode returns :class:`str` instead of :class:`bytes`, so :func:`~icalendar.cal.calendar.parse` raises a :class:`TypeError`. Parse several calendars ======================= +iCalendar and xCal data may contain several calendars. +jCal data always contains one calendar. +Most data contains only one calendar. + By default, :func:`~icalendar.cal.calendar.parse` returns one calendar. -If the data contains more than one calendar, it raises a :class:`ValueError`. +If the data contains more than one calendar, returning only one of them would lose the others, so it raises :class:`~icalendar.error.CalendarInformationLost`. +If the data contains no calendars, it raises :class:`~icalendar.error.NoCalendarsFound`. To get all calendars as a list, pass ``multiple=True``. diff --git a/news/1917.feature b/news/1917.feature index 3ecfe8ca8..db995d957 100644 --- a/news/1917.feature +++ b/news/1917.feature @@ -1 +1 @@ -Added :func:`icalendar.parse `, which detects whether data is iCalendar, jCal, or xCal and parses it into calendars. It accepts :class:`bytes`, a :class:`~pathlib.Path`, or a binary file. Added the how-to guide :doc:`/how-to/parse`. I used Claude Code as a learning aid to explain the codebase, review my code, and draft the tests and documentation, then reviewed and tested the result. @EyalHefer +Added :func:`icalendar.parse `, which detects whether data is iCalendar, jCal, or xCal and parses it into calendars. It accepts :class:`bytes`, a :class:`~pathlib.Path`, or a binary file. Added the errors :class:`~icalendar.error.UnknownCalendarFormat`, :class:`~icalendar.error.NoCalendarsFound`, and :class:`~icalendar.error.CalendarInformationLost`. Added the how-to guide :doc:`/how-to/parse`. I used Claude Code as a learning aid to explain the codebase, review my code, and draft the tests and documentation, then reviewed and tested the result. @EyalHefer diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 14343ec0f..9bdaffb54 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -35,6 +35,7 @@ ) from icalendar.error import ( BrokenCalendarProperty, + CalendarInformationLost, ComponentEndMissing, ComponentStartMissing, FeatureWillBeRemovedInFutureVersion, @@ -45,6 +46,8 @@ InvalidCalendar, JCalParsingError, LocalTimezoneMissing, + NoCalendarsFound, + UnknownCalendarFormat, XCalParsingError, ) @@ -122,6 +125,7 @@ "Available", "BrokenCalendarProperty", "Calendar", + "CalendarInformationLost", "Component", "ComponentEndMissing", "ComponentFactory", @@ -141,12 +145,14 @@ "LazyCalendar", "LocalTimezoneMissing", "NFields", + "NoCalendarsFound", "Parameters", "Timezone", "TimezoneDaylight", "TimezoneStandard", "Todo", "TypesFactory", + "UnknownCalendarFormat", "XCalParsingError", "__version__", "__version_tuple__", diff --git a/src/icalendar/cal/calendar.py b/src/icalendar/cal/calendar.py index 31cefe9fb..87f4aa6bf 100644 --- a/src/icalendar/cal/calendar.py +++ b/src/icalendar/cal/calendar.py @@ -22,7 +22,12 @@ from icalendar.cal.component import Component from icalendar.cal.examples import get_example from icalendar.cal.timezone import Timezone -from icalendar.error import IncompleteComponent +from icalendar.error import ( + CalendarInformationLost, + IncompleteComponent, + NoCalendarsFound, + UnknownCalendarFormat, +) from icalendar.parser.ical.calendar import CalendarIcalParser from icalendar.version import __version__ @@ -700,9 +705,10 @@ def parse( ) -> Calendar | list[Calendar]: r"""Parse iCalendar, jCal, or xCal data into calendars. - The format is detected from the first non-whitespace character of the data: - ``B`` for iCalendar (:rfc:`5545`), ``[`` for jCal (:rfc:`7265`), - and ``<`` for xCal (:rfc:`6321`). + The format is detected from the first character of the data, ignoring + whitespace and a UTF-8 byte order mark: ``B`` or ``b`` for iCalendar + (:rfc:`5545`), ``[`` for jCal (:rfc:`7265`), and ``<`` for xCal (:rfc:`6321`). + See :ref:`how-to-parse` for more examples. Parameters: source: The data to parse, as :class:`bytes`, a :class:`~pathlib.Path` @@ -714,8 +720,13 @@ def parse( A calendar, or a list of calendars if ``multiple`` is ``True``. Raises: - ValueError: If the format cannot be detected, or if ``multiple`` is - ``False`` and the data does not contain exactly one calendar. + TypeError: If the data is not :class:`bytes`, for example, because + a file was opened in text mode. + ~error.UnknownCalendarFormat: If the format cannot be detected. + ~error.NoCalendarsFound: If ``multiple`` is ``False`` and the data + contains no calendars. + ~error.CalendarInformationLost: If ``multiple`` is ``False`` and the + data contains more than one calendar. Example: @@ -739,31 +750,38 @@ def parse( >>> len(calendars) 2 """ - if isinstance(source, bytes): + if isinstance(source, (bytes, str)): data = source elif isinstance(source, Path): data = source.read_bytes() else: data = source.read() - data = data.lstrip() + if not isinstance(data, bytes): + raise TypeError( + f"Expected bytes, but got {type(data).__name__}. " + 'Open files in binary mode with "rb".' + ) + data = data.removeprefix(b"\xef\xbb\xbf").lstrip() first = data[:1] - if first == b"B": + if first.upper() == b"B": calendars = Calendar.from_ical(data, multiple=True) elif first == b"[": calendars = [cast("Calendar", Calendar.from_jcal(data.decode("utf-8")))] elif first == b"<": calendars = Calendar.from_xcal(data) else: - raise ValueError( + raise UnknownCalendarFormat( f"Could not detect the calendar format: expected iCal, jCal or xCal data, " f"but the data starts with {first!r}." ) if multiple: return calendars if len(calendars) > 1: - raise ValueError("There are multiple calendars, please use multiple=True") + raise CalendarInformationLost( + "There are multiple calendars, please use multiple=True." + ) if len(calendars) == 0: - raise ValueError("No calendars found.") + raise NoCalendarsFound("No calendars found.") return calendars[0] diff --git a/src/icalendar/error.py b/src/icalendar/error.py index e929922e2..45de10fd2 100644 --- a/src/icalendar/error.py +++ b/src/icalendar/error.py @@ -58,6 +58,25 @@ class BrokenCalendarProperty(InvalidCalendar): """ +class UnknownCalendarFormat(InvalidCalendar): + """The data is not in a calendar format that icalendar can detect. + + The supported formats are iCalendar, jCal and xCal. + """ + + +class NoCalendarsFound(InvalidCalendar): + """The data was parsed, but it contains no calendars.""" + + +class CalendarInformationLost(ValueError): + """The data contains several calendars, but only one was requested. + + Returning one of them would lose the others. + Use ``multiple=True`` to get all calendars as a list. + """ + + class IncompleteComponent(ValueError): """The component is missing attributes. @@ -384,6 +403,7 @@ def __init__( __all__ = [ "BrokenCalendarProperty", + "CalendarInformationLost", "ComponentEndMissing", "ComponentStartMissing", "FeatureWillBeRemovedInFutureVersion", @@ -393,5 +413,7 @@ def __init__( "InvalidCalendar", "JCalParsingError", "LocalTimezoneMissing", + "NoCalendarsFound", + "UnknownCalendarFormat", "XCalParsingError", ] diff --git a/src/icalendar/tests/test_issue_1917_parse.py b/src/icalendar/tests/test_issue_1917_parse.py index 32ae1b6b3..cf96e3147 100644 --- a/src/icalendar/tests/test_issue_1917_parse.py +++ b/src/icalendar/tests/test_issue_1917_parse.py @@ -5,7 +5,13 @@ import pytest -from icalendar import Calendar, parse +from icalendar import ( + Calendar, + CalendarInformationLost, + NoCalendarsFound, + UnknownCalendarFormat, + parse, +) ICAL = b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nEND:VCALENDAR\r\n" JCAL = b'["vcalendar", [["version", {}, "text", "2.0"], ["prodid", {}, "text", "test"]], []]' @@ -17,33 +23,44 @@ ) -ALL_FORMATS = pytest.mark.parametrize( - "data", [ICAL, JCAL, XCAL], ids=["ical", "jcal", "xcal"] -) +@pytest.fixture(params=[ICAL, JCAL, XCAL], ids=["ical", "jcal", "xcal"]) +def data(request): + """Valid data for each supported format.""" + return request.param + + +@pytest.fixture +def data_path(data, tmp_path): + """A path to a file that contains the data.""" + path = tmp_path / "calendar.ics" + path.write_bytes(data) + return path + +@pytest.fixture +def data_file(data_path): + """The data as a file opened in binary mode.""" + with data_path.open("rb") as file: + yield file -@ALL_FORMATS -def test_parse_returns_one_calendar(data): + +def test_parse_bytes(data): """Each format is detected and parsed into a single calendar.""" calendar = parse(data) assert isinstance(calendar, Calendar) assert calendar["PRODID"] == "test" -def test_parse_path(tmp_path): +def test_parse_path(data_path): """A path to a file is read and parsed.""" - path = tmp_path / "calendar.ics" - path.write_bytes(ICAL) - assert parse(path)["PRODID"] == "test" + assert parse(data_path)["PRODID"] == "test" -def test_empty_data_raises_error(): - """Data with no detectable format raises a ValueError.""" - with pytest.raises(ValueError): - parse(b" ") +def test_parse_open_file(data_file): + """An open binary file is read and parsed.""" + assert parse(data_file)["PRODID"] == "test" -@ALL_FORMATS def test_parse_multiple_returns_list(data): """With multiple=True, each format returns a list of calendars.""" calendars = parse(data, multiple=True) @@ -52,26 +69,50 @@ def test_parse_multiple_returns_list(data): assert calendars[0]["PRODID"] == "test" -def test_parse_open_file(tmp_path): - """An open binary file is read and parsed.""" - path = tmp_path / "calendar.ics" - path.write_bytes(ICAL) - with path.open("rb") as file: - assert parse(file)["PRODID"] == "test" +def test_leading_whitespace_is_ignored(data): + """Whitespace before the data does not affect format detection.""" + assert parse(b" \n" + data)["PRODID"] == "test" -def test_leading_whitespace_is_ignored(): - """Whitespace before the data does not affect format detection.""" - assert parse(b" \n" + ICAL)["PRODID"] == "test" +def test_utf8_bom_is_ignored(data): + """A UTF-8 byte order mark before the data does not affect format detection.""" + assert parse(b"\xef\xbb\xbf" + data)["PRODID"] == "test" + + +def test_lowercase_ical_is_detected(): + """iCalendar data that starts with a lowercase begin is detected.""" + assert parse(ICAL.lower())["PRODID"] == "test" + + +@pytest.mark.parametrize("data", [b"", b" ", b"not a calendar"]) +def test_unknown_format_raises_error(data): + """Data with no detectable format raises an error that shows its start.""" + with pytest.raises(UnknownCalendarFormat, match="Could not detect"): + parse(data) def test_no_calendars_raises_error(): - """Data without any calendar raises a ValueError.""" - with pytest.raises(ValueError, match="No calendars found"): + """Data without any calendar raises an error.""" + with pytest.raises(NoCalendarsFound, match="No calendars found"): parse(b'') def test_multiple_calendars_without_multiple_raises_error(): - """Several calendars with multiple=False raise a ValueError.""" - with pytest.raises(ValueError, match="multiple=True"): + """Several calendars with multiple=False raise an error.""" + with pytest.raises(CalendarInformationLost, match="multiple=True"): parse(ICAL + ICAL) + + +def test_str_raises_type_error(): + """Text instead of bytes raises a TypeError.""" + with pytest.raises(TypeError, match="Expected bytes, but got str"): + parse(ICAL.decode()) + + +def test_text_file_raises_type_error(data_path): + """A file opened in text mode raises a TypeError.""" + with ( + data_path.open("r", encoding="utf-8") as file, + pytest.raises(TypeError, match='"rb"'), + ): + parse(file) From b2fbef5d1d568ae5f882ed8764eaeccffb69322e Mon Sep 17 00:00:00 2001 From: Eyal Hefer Date: Fri, 30 Oct 2026 17:11:22 +0200 Subject: [PATCH 3/4] Detect the format with a regex, keep data unchanged Find the first important character with a regex and pass the data to the parser unaltered. This adds UTF-16 xCal support. Link glossary terms in the docs and update the tests. AI: Claude Opus 5.5 (Claude Code) wrote the regex, tests and docs and guided my parse() changes. I reviewed and tested all of it. Co-Authored-By: Claude Opus 5.5 --- docs/how-to/parse.rst | 12 ++++++-- src/icalendar/cal/calendar.py | 21 ++++++++------ src/icalendar/tests/test_issue_1917_parse.py | 29 ++++++++++++++++++-- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/how-to/parse.rst b/docs/how-to/parse.rst index f849e70f3..62e66b361 100644 --- a/docs/how-to/parse.rst +++ b/docs/how-to/parse.rst @@ -4,7 +4,7 @@ Parse any calendar format ========================= -This chapter describes how to parse calendar data when you do not know in advance whether it is iCalendar, jCal, or xCal. +This chapter describes how to parse calendar data when you do not know in advance whether it is :term:`iCalendar`, :term:`jCal`, or :term:`xCal`. icalendar supports three calendar formats, each with its own parser: @@ -28,12 +28,14 @@ Pass the data as :class:`bytes`. >>> calendar["PRODID"] vText(b'example') -The format is detected from the first character, ignoring whitespace and a UTF-8 byte order mark: +The format is detected from the first character, ignoring whitespace and a byte order mark: - ``B`` or ``b``, as in ``BEGIN:VCALENDAR``, means iCalendar. - ``[`` means jCal. - ``<`` means xCal. +The data is passed to the parser unchanged, so :func:`~icalendar.cal.calendar.parse` accepts what the matching parser accepts. + If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises :class:`~icalendar.error.UnknownCalendarFormat`. .. code-block:: pycon @@ -41,7 +43,7 @@ If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises : >>> parse(b"not a calendar") Traceback (most recent call last): ... - icalendar.error.UnknownCalendarFormat: Could not detect the calendar format: expected iCal, jCal or xCal data, but the data starts with b'n'. + icalendar.error.UnknownCalendarFormat: Could not detect the calendar format: expected iCal, jCal or xCal data, but the data starts with b'not a cale'. Parse a file @@ -81,6 +83,10 @@ If the data contains no calendars, it raises :class:`~icalendar.error.NoCalendar To get all calendars as a list, pass ``multiple=True``. +The example below demonstrates the iCalendar stream. +The ``ical`` variable contains only one calendar. +``ical + ical`` parses into two calendars. + .. code-block:: pycon >>> ical = b"BEGIN:VCALENDAR\r\nPRODID:example\r\nEND:VCALENDAR\r\n" diff --git a/src/icalendar/cal/calendar.py b/src/icalendar/cal/calendar.py index b9850ce45..685ff25de 100644 --- a/src/icalendar/cal/calendar.py +++ b/src/icalendar/cal/calendar.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import uuid from datetime import timedelta from pathlib import Path @@ -703,6 +704,9 @@ def validate(self): ) +FORMAT_START = re.compile(rb"(?:\xef\xbb\xbf|\xff\xfe|\xfe\xff)?[\s\x00]*([Bb\[<])") + + @overload def parse( source: bytes | Path | BinaryIO, multiple: Literal[False] = False @@ -719,8 +723,9 @@ def parse( r"""Parse iCalendar, jCal, or xCal data into calendars. The format is detected from the first character of the data, ignoring - whitespace and a UTF-8 byte order mark: ``B`` or ``b`` for iCalendar + whitespace and a byte order mark: ``B`` or ``b`` for iCalendar (:rfc:`5545`), ``[`` for jCal (:rfc:`7265`), and ``<`` for xCal (:rfc:`6321`). + The data is passed to the parser unchanged. See :ref:`how-to-parse` for more examples. Parameters: @@ -735,10 +740,10 @@ def parse( Raises: TypeError: If the data is not :class:`bytes`, for example, because a file was opened in text mode. - ~error.UnknownCalendarFormat: If the format cannot be detected. - ~error.NoCalendarsFound: If ``multiple`` is ``False`` and the data + ~icalendar.error.UnknownCalendarFormat: If the format cannot be detected. + ~icalendar.error.NoCalendarsFound: If ``multiple`` is ``False`` and the data contains no calendars. - ~error.CalendarInformationLost: If ``multiple`` is ``False`` and the + ~icalendar.error.CalendarInformationLost: If ``multiple`` is ``False`` and the data contains more than one calendar. Example: @@ -774,18 +779,18 @@ def parse( f"Expected bytes, but got {type(data).__name__}. " 'Open files in binary mode with "rb".' ) - data = data.removeprefix(b"\xef\xbb\xbf").lstrip() - first = data[:1] + match = FORMAT_START.match(data) + first = b"" if match is None else match.group(1) if first.upper() == b"B": calendars = Calendar.from_ical(data, multiple=True) elif first == b"[": - calendars = [cast("Calendar", Calendar.from_jcal(data.decode("utf-8")))] + calendars = [cast("Calendar", Calendar.from_jcal(data.decode("utf-8-sig")))] elif first == b"<": calendars = Calendar.from_xcal(data) else: raise UnknownCalendarFormat( f"Could not detect the calendar format: expected iCal, jCal or xCal data, " - f"but the data starts with {first!r}." + f"but the data starts with {data[:10]!r}." ) if multiple: return calendars diff --git a/src/icalendar/tests/test_issue_1917_parse.py b/src/icalendar/tests/test_issue_1917_parse.py index cf96e3147..045e7ee79 100644 --- a/src/icalendar/tests/test_issue_1917_parse.py +++ b/src/icalendar/tests/test_issue_1917_parse.py @@ -3,6 +3,8 @@ See https://github.com/collective/icalendar/issues/1917 """ +from xml.etree.ElementTree import ParseError + import pytest from icalendar import ( @@ -69,9 +71,20 @@ def test_parse_multiple_returns_list(data): assert calendars[0]["PRODID"] == "test" -def test_leading_whitespace_is_ignored(data): - """Whitespace before the data does not affect format detection.""" - assert parse(b" \n" + data)["PRODID"] == "test" +def test_leading_whitespace_in_jcal_is_ignored(): + """The jCal parser accepts whitespace before the data.""" + assert parse(b" \n" + JCAL)["PRODID"] == "test" + + +@pytest.mark.parametrize( + ("data", "error"), + [(ICAL, ValueError), (XCAL, ParseError)], + ids=["ical", "xcal"], +) +def test_data_is_passed_to_the_parser_unchanged(data, error): + """Whitespace is not removed, so parsers that reject it raise their error.""" + with pytest.raises(error): + parse(b" \n" + data) def test_utf8_bom_is_ignored(data): @@ -79,6 +92,16 @@ def test_utf8_bom_is_ignored(data): assert parse(b"\xef\xbb\xbf" + data)["PRODID"] == "test" +@pytest.mark.parametrize( + ("bom", "encoding"), + [(b"\xff\xfe", "utf-16-le"), (b"\xfe\xff", "utf-16-be")], + ids=["little-endian", "big-endian"], +) +def test_utf16_xcal(bom, encoding): + """UTF-16 xCal in either byte order is detected and parsed.""" + assert parse(bom + XCAL.decode().encode(encoding))["PRODID"] == "test" + + def test_lowercase_ical_is_detected(): """iCalendar data that starts with a lowercase begin is detected.""" assert parse(ICAL.lower())["PRODID"] == "test" From cf3f41b5a5c26a74a08d1eb247d109dc32592c33 Mon Sep 17 00:00:00 2001 From: Eyal Hefer Date: Thu, 12 Nov 2026 18:05:49 +0200 Subject: [PATCH 4/4] Apply review suggestions and document FORMAT_START Apply the review suggestions: Oxford commas, no "please" in the error message, wording in the how-to guide, the news entry and the test module docstring. Move the link to the how-to guide into a seealso block after the examples. Add a docstring to FORMAT_START that explains what the regular expression skips and cites the review comment it is based on. AI: Claude Opus 5.5 (Claude Code) applied the review suggestions and drafted the FORMAT_START docstring at my request. I reviewed the changes and ran the tests. Co-Authored-By: Claude Opus 5.5 --- docs/how-to/parse.rst | 8 +++--- news/1917.feature | 2 +- src/icalendar/cal/calendar.py | 28 ++++++++++++++++---- src/icalendar/error.py | 2 +- src/icalendar/tests/test_issue_1917_parse.py | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/how-to/parse.rst b/docs/how-to/parse.rst index 62e66b361..ae5a6e002 100644 --- a/docs/how-to/parse.rst +++ b/docs/how-to/parse.rst @@ -4,7 +4,7 @@ Parse any calendar format ========================= -This chapter describes how to parse calendar data when you do not know in advance whether it is :term:`iCalendar`, :term:`jCal`, or :term:`xCal`. +This chapter describes how to parse calendar data when you don't know in advance whether it's :term:`iCalendar`, :term:`jCal`, or :term:`xCal`. icalendar supports three calendar formats, each with its own parser: @@ -36,14 +36,14 @@ The format is detected from the first character, ignoring whitespace and a byte The data is passed to the parser unchanged, so :func:`~icalendar.cal.calendar.parse` accepts what the matching parser accepts. -If the format cannot be detected, :func:`~icalendar.cal.calendar.parse` raises :class:`~icalendar.error.UnknownCalendarFormat`. +If the format can't be detected, :func:`~icalendar.cal.calendar.parse` raises :class:`~icalendar.error.UnknownCalendarFormat`. .. code-block:: pycon >>> parse(b"not a calendar") Traceback (most recent call last): ... - icalendar.error.UnknownCalendarFormat: Could not detect the calendar format: expected iCal, jCal or xCal data, but the data starts with b'not a cale'. + icalendar.error.UnknownCalendarFormat: Could not detect the calendar format: expected iCal, jCal, or xCal data, but the data starts with b'not a cale'. Parse a file @@ -58,7 +58,7 @@ Pass a :class:`~pathlib.Path` to read and parse a file. >>> len(calendar.events) 3 -You can also pass a file that you opened in binary mode. +Alternatively, pass a file opened in binary mode. .. code-block:: pycon diff --git a/news/1917.feature b/news/1917.feature index db995d957..d55103131 100644 --- a/news/1917.feature +++ b/news/1917.feature @@ -1 +1 @@ -Added :func:`icalendar.parse `, which detects whether data is iCalendar, jCal, or xCal and parses it into calendars. It accepts :class:`bytes`, a :class:`~pathlib.Path`, or a binary file. Added the errors :class:`~icalendar.error.UnknownCalendarFormat`, :class:`~icalendar.error.NoCalendarsFound`, and :class:`~icalendar.error.CalendarInformationLost`. Added the how-to guide :doc:`/how-to/parse`. I used Claude Code as a learning aid to explain the codebase, review my code, and draft the tests and documentation, then reviewed and tested the result. @EyalHefer +Added :func:`icalendar.parse `, which detects whether data is iCalendar, jCal, or xCal, and parses it into calendars. It accepts :class:`bytes`, a :class:`~pathlib.Path`, or a binary file. Added the errors :class:`~icalendar.error.UnknownCalendarFormat`, :class:`~icalendar.error.NoCalendarsFound`, and :class:`~icalendar.error.CalendarInformationLost`. Added the how-to guide :doc:`/how-to/parse`. I used Claude Code as a learning aid to explain the codebase, review my code, and draft the tests and documentation, then reviewed and tested the result. @EyalHefer diff --git a/src/icalendar/cal/calendar.py b/src/icalendar/cal/calendar.py index 685ff25de..2d68a5c4d 100644 --- a/src/icalendar/cal/calendar.py +++ b/src/icalendar/cal/calendar.py @@ -705,6 +705,22 @@ def validate(self): FORMAT_START = re.compile(rb"(?:\xef\xbb\xbf|\xff\xfe|\xfe\xff)?[\s\x00]*([Bb\[<])") +"""Find the first character of calendar data, which tells its format. + +:func:`parse` uses this to detect the format without decoding or changing the data. +The expression skips the following before the first character: + +- An optional byte order mark: UTF-8 (``EF BB BF``), UTF-16 little endian + (``FF FE``), or UTF-16 big endian (``FE FF``). +- Whitespace and null bytes. UTF-16 stores each ASCII character with a null + byte, so skipping null bytes lets UTF-16 data match too. + +The first group is the first character: ``B`` or ``b`` for iCalendar, ``[`` +for jCal, or ``<`` for xCal. + +The expression is based on the one suggested in the review of +`pull request 1977 `_. +""" @overload @@ -726,11 +742,10 @@ def parse( whitespace and a byte order mark: ``B`` or ``b`` for iCalendar (:rfc:`5545`), ``[`` for jCal (:rfc:`7265`), and ``<`` for xCal (:rfc:`6321`). The data is passed to the parser unchanged. - See :ref:`how-to-parse` for more examples. Parameters: - source: The data to parse, as :class:`bytes`, a :class:`~pathlib.Path` - to a file, or a file opened in binary mode. + source: The data to parse, as either :class:`bytes`, a + :class:`~pathlib.Path` to a file, or a file opened in binary mode. multiple: If ``True``, returns a list of calendars. If ``False``, returns a single calendar. @@ -767,6 +782,9 @@ def parse( >>> calendars = parse(ical + ical, multiple=True) >>> len(calendars) 2 + + .. seealso:: + :doc:`/how-to/parse` """ if isinstance(source, (bytes, str)): data = source @@ -789,14 +807,14 @@ def parse( calendars = Calendar.from_xcal(data) else: raise UnknownCalendarFormat( - f"Could not detect the calendar format: expected iCal, jCal or xCal data, " + f"Could not detect the calendar format: expected iCal, jCal, or xCal data, " f"but the data starts with {data[:10]!r}." ) if multiple: return calendars if len(calendars) > 1: raise CalendarInformationLost( - "There are multiple calendars, please use multiple=True." + "There are multiple calendars. Use multiple=True." ) if len(calendars) == 0: raise NoCalendarsFound("No calendars found.") diff --git a/src/icalendar/error.py b/src/icalendar/error.py index 3d2bbe026..26b2e4e42 100644 --- a/src/icalendar/error.py +++ b/src/icalendar/error.py @@ -68,7 +68,7 @@ class BrokenCalendarProperty(InvalidCalendar): class UnknownCalendarFormat(InvalidCalendar): """The data is not in a calendar format that icalendar can detect. - The supported formats are iCalendar, jCal and xCal. + The supported formats are iCalendar, jCal, and xCal. """ diff --git a/src/icalendar/tests/test_issue_1917_parse.py b/src/icalendar/tests/test_issue_1917_parse.py index 045e7ee79..f751bd165 100644 --- a/src/icalendar/tests/test_issue_1917_parse.py +++ b/src/icalendar/tests/test_issue_1917_parse.py @@ -1,4 +1,4 @@ -"""Tests for icalendar.parse, which detects iCal, jCal and xCal automatically. +"""Tests for icalendar.parse, which detects iCal, jCal, and xCal automatically. See https://github.com/collective/icalendar/issues/1917 """