Repository navigation
Add icalendar.parse for iCal, jCal and xCal #1977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
816f9c5
8de31ec
37bdf90
fa0a783
b2fbef5
623715b
8b15911
cf3f41b
904f72f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ How to guides | |
| configure | ||
| upgrade | ||
| usage | ||
| parse | ||
| parse-errors | ||
| attendees | ||
| custom-components | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,95 @@ | ||||||||||||||
| .. _how-to-parse: | ||||||||||||||
|
|
||||||||||||||
| ========================= | ||||||||||||||
| Parse any calendar format | ||||||||||||||
| ========================= | ||||||||||||||
|
|
||||||||||||||
| 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: | ||||||||||||||
|
|
||||||||||||||
| - iCalendar, as specified in :rfc:`5545`, parsed by :meth:`Calendar.from_ical() <icalendar.cal.calendar.Calendar.from_ical>` | ||||||||||||||
| - jCal, a JSON representation, as specified in :rfc:`7265`, parsed by :meth:`Calendar.from_jcal() <icalendar.cal.component.Component.from_jcal>` | ||||||||||||||
| - xCal, an XML representation, as specified in :rfc:`6321`, parsed by :meth:`Calendar.from_xcal() <icalendar.cal.component.Component.from_xcal>` | ||||||||||||||
|
|
||||||||||||||
| The function :func:`~icalendar.cal.calendar.parse` detects the format for you and calls the matching parser. | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| Parse :class:`bytes` | ||||||||||||||
| ==================== | ||||||||||||||
|
|
||||||||||||||
| 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 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 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'. | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| Parse a file | ||||||||||||||
| ============ | ||||||||||||||
|
|
||||||||||||||
| Pass a :class:`~pathlib.Path` to read and parse a file. | ||||||||||||||
|
|
||||||||||||||
| .. code-block:: pycon | ||||||||||||||
|
|
||||||||||||||
| >>> from pathlib import Path | ||||||||||||||
| >>> calendar = parse(Path("src/icalendar/tests/calendars/example.ics")) | ||||||||||||||
| >>> len(calendar.events) | ||||||||||||||
| 3 | ||||||||||||||
|
|
||||||||||||||
| Alternatively, pass a file opened in binary mode. | ||||||||||||||
|
|
||||||||||||||
| .. code-block:: pycon | ||||||||||||||
|
|
||||||||||||||
| >>> with open("src/icalendar/tests/calendars/example.ics", "rb") as file: | ||||||||||||||
| ... calendar = parse(file) | ||||||||||||||
| >>> len(calendar.events) | ||||||||||||||
| 3 | ||||||||||||||
|
|
||||||||||||||
| A file opened in text mode returns :class:`str` instead of :class:`bytes`, so :func:`~icalendar.cal.calendar.parse` raises a :class:`TypeError`. | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I opened an issue - since from_ical also works with string, jCal also does and for XML it just need encoding with the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, I read #2045. For now |
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| 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, 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``. | ||||||||||||||
|
|
||||||||||||||
| The example below demonstrates the iCalendar stream. | ||||||||||||||
| The ``ical`` variable contains only one calendar. | ||||||||||||||
| ``ical + ical`` parses into two calendars. | ||||||||||||||
|
|
||||||||||||||
| .. code-block:: pycon | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added, with |
||||||||||||||
|
|
||||||||||||||
| >>> ical = b"BEGIN:VCALENDAR\r\nPRODID:example\r\nEND:VCALENDAR\r\n" | ||||||||||||||
| >>> calendars = parse(ical + ical, multiple=True) | ||||||||||||||
| >>> len(calendars) | ||||||||||||||
| 2 | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added :func:`icalendar.parse <icalendar.cal.calendar.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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,8 +2,10 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| import uuid | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Literal, cast, overload | ||
|
|
||
| from icalendar.attr import ( | ||
|
|
@@ -21,14 +23,19 @@ | |
| 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__ | ||
|
|
||
| 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, | ||
|
|
@@ -697,4 +704,121 @@ def validate(self): | |
| ) | ||
|
|
||
|
|
||
| __all__ = ["Calendar"] | ||
| FORMAT_START = re.compile(rb"(?:\xef\xbb\xbf|\xff\xfe|\xfe\xff)?[\s\x00]*([Bb\[<])") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should have a docstring to explain its purpose and cite the source of the regular expression.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I want to check that this renders OK once this branch is updated with |
||
| """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 <https://github.com/collective/icalendar/pull/1977#discussion_r4336042948>`_. | ||
| """ | ||
|
|
||
|
|
||
| @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 character of the data, ignoring | ||
| 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. | ||
|
|
||
| Parameters: | ||
| 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. | ||
|
|
||
| Returns: | ||
| A calendar, or a list of calendars if ``multiple`` is ``True``. | ||
|
|
||
| Raises: | ||
| TypeError: If the data is not :class:`bytes`, for example, because | ||
| a file was opened in text mode. | ||
| ~icalendar.error.UnknownCalendarFormat: If the format cannot be detected. | ||
| ~icalendar.error.NoCalendarsFound: If ``multiple`` is ``False`` and the data | ||
| contains no calendars. | ||
| ~icalendar.error.CalendarInformationLost: If ``multiple`` is ``False`` and the | ||
| data contains more than 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 | ||
|
stevepiercy marked this conversation as resolved.
Resolved
|
||
|
|
||
| .. seealso:: | ||
| :doc:`/how-to/parse` | ||
| """ | ||
| if isinstance(source, (bytes, str)): | ||
| data = source | ||
| elif isinstance(source, Path): | ||
| data = source.read_bytes() | ||
| else: | ||
| data = source.read() | ||
| if not isinstance(data, bytes): | ||
| raise TypeError( | ||
| f"Expected bytes, but got {type(data).__name__}. " | ||
| 'Open files in binary mode with "rb".' | ||
| ) | ||
| 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-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 {data[:10]!r}." | ||
| ) | ||
| if multiple: | ||
| return calendars | ||
| if len(calendars) > 1: | ||
| raise CalendarInformationLost( | ||
| "There are multiple calendars. Use multiple=True." | ||
| ) | ||
| if len(calendars) == 0: | ||
| raise NoCalendarsFound("No calendars found.") | ||
| return calendars[0] | ||
|
|
||
|
|
||
| __all__ = ["Calendar", "parse"] | ||
Uh oh!
There was an error while loading. Please reload this page.