Skip to content
1 change: 1 addition & 0 deletions docs/how-to/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ How to guides
configure
upgrade
usage
parse
parse-errors
attendees
custom-components
Expand Down
95 changes: 95 additions & 0 deletions docs/how-to/parse.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
.. _how-to-parse:

=========================
Comment thread
niccokunzmann marked this conversation as resolved.
Resolved
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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 <xml...> prefix, it should work. Have a look: #2045

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I read #2045. For now parse still raises a TypeError for text, and I left the input types for that issue.



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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. code-block:: pycon
The example below demonstrates the iCalendar stream.
The ``ical`` variable contains only one calendar.
``icale + ical`` parses into two calendars.
.. code-block:: pycon

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, with ical + ical in the last line.


>>> ical = b"BEGIN:VCALENDAR\r\nPRODID:example\r\nEND:VCALENDAR\r\n"
>>> calendars = parse(ical + ical, multiple=True)
>>> len(calendars)
2
1 change: 1 addition & 0 deletions news/1917.feature
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
8 changes: 8 additions & 0 deletions src/icalendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TimezoneDaylight,
TimezoneStandard,
Todo,
parse,
)
from icalendar.enums import (
BUSYTYPE,
Expand All @@ -34,6 +35,7 @@
)
from icalendar.error import (
BrokenCalendarProperty,
CalendarInformationLost,
ComponentEndMissing,
ComponentStartMissing,
FeatureWillBeRemovedInFutureVersion,
Expand All @@ -45,6 +47,8 @@
InvalidToken,
JCalParsingError,
LocalTimezoneMissing,
NoCalendarsFound,
UnknownCalendarFormat,
XCalParsingError,
)

Expand Down Expand Up @@ -124,6 +128,7 @@
"Available",
"BrokenCalendarProperty",
"Calendar",
"CalendarInformationLost",
"Component",
"ComponentEndMissing",
"ComponentFactory",
Expand All @@ -144,16 +149,19 @@
"LazyCalendar",
"LocalTimezoneMissing",
"NFields",
"NoCalendarsFound",
"Parameters",
"Timezone",
"TimezoneDaylight",
"TimezoneStandard",
"Todo",
"TypesFactory",
"UnknownCalendarFormat",
"XCalParsingError",
"__version__",
"__version_tuple__",
"is_utc",
"parse",
"q_join",
"q_split",
"use_pytz",
Expand Down
3 changes: 2 additions & 1 deletion src/icalendar/cal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,4 +32,5 @@
"TimezoneDaylight",
"TimezoneStandard",
"Todo",
"parse",
]
130 changes: 127 additions & 3 deletions src/icalendar/cal/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -697,4 +704,121 @@ def validate(self):
)


__all__ = ["Calendar"]
FORMAT_START = re.compile(rb"(?:\xef\xbb\xbf|\xff\xfe|\xfe\xff)?[\s\x00]*([Bb\[<])")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 main and the RTD preview can build.

"""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
Comment thread
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"]
22 changes: 22 additions & 0 deletions src/icalendar/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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.

Expand Down Expand Up @@ -399,6 +418,7 @@ def __init__(

__all__ = [
"BrokenCalendarProperty",
"CalendarInformationLost",
"ComponentEndMissing",
"ComponentStartMissing",
"FeatureWillBeRemovedInFutureVersion",
Expand All @@ -409,5 +429,7 @@ def __init__(
"InvalidToken",
"JCalParsingError",
"LocalTimezoneMissing",
"NoCalendarsFound",
"UnknownCalendarFormat",
"XCalParsingError",
]
Loading
Loading