Skip to content

Add icalendar.parse for iCal, jCal and xCal - #1977

Open
EyalHefer wants to merge 9 commits into
PyCalendar:mainfrom
EyalHefer:issue-1917-parse
Open

EyalHefer wants to merge 9 commits into
PyCalendar:mainfrom
EyalHefer:issue-1917-parse

Conversation

@EyalHefer

@EyalHefer EyalHefer commented Oct 20, 2026 •

Copy link
Copy Markdown

Linked issue

Description

Adds a parse function that detects whether data is iCalendar, jCal, or xCal from its first non-whitespace character and calls the matching parser.

  • Accepts bytes, a Path, or a binary file.
  • By default returns one calendar and raises ValueError for zero or several calendars. With multiple=True it returns a list.
  • Placed in icalendar.cal.calendar, because it returns calendars and the parser package is a lower layer.

Checklist

  • I added a change log entry, following the instructions in all subsections under Change log requirements.
  • I followed icalendar's Artificial intelligence policy and disclosed my Responsible AI use in my commit messages, if applicable.
  • I added or updated tests, if applicable.
  • I ran and ensured all tests pass locally by following Run tests.
  • I added or edited documentation as necessary, both as docstrings to be rendered in the API documentation and narrative documentation, following the Style guide.

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the pull request, @EyalHefer! A maintainer will review it shortly.

@github-actions

Copy link
Copy Markdown
Contributor

Insufficient data

Not enough activity yet to make a reliable assessment.

View full analysis →

This is an automated analysis by AgentScan

@github-actions github-actions Bot added the ai-suspicion This contribution is possibly created with lots of AI help without enough human understanding. label Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request did not pass quality checks and AI use is suspected. Please review Contribute and make any necessary amendments.

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 PyCalendar#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 <noreply@anthropic.com>
@EyalHefer EyalHefer changed the title Add icalendar.parse to detect and parse iCal, jCal and xCal Add icalendar.parse for iCal, jCal and xCal Oct 20, 2026
@EyalHefer EyalHefer closed this Oct 20, 2026
@EyalHefer EyalHefer reopened this Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request did not pass quality checks and AI use is suspected. Please review Contribute and make any necessary amendments.

@EyalHefer

Copy link
Copy Markdown
Author

Thanks for the automated feedback! I shortened the description and the commit message, and ran the full test suite on Linux (21,329 passed) so I could check every item in the checklist. I also added a test for the "no calendars found" case.

The one remaining failure, code-references, comes from the PR template itself: its checklist contains four links ending in .html, which anti-slop counts as file paths, so every PR that keeps the template gets 4 and exceeds max-code-references: 3. Raising the limit to 4 or more would fix this. I'm happy to open a separate PR for that if you'd like.

@niccokunzmann niccokunzmann left a comment

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.

Thanks, this looks really nice! I added some suggestions.

Comment thread docs/how-to/parse.rst Outdated
=======================

By default, :func:`~icalendar.cal.calendar.parse` returns one calendar.
If the data contains more than one calendar, it raises a :class:`ValueError`.

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 think as an interface, it would be nice to raise a subclass of ValueError: CalendarInformationLost with a nice error message.

Also, this should explain that iCalendar and xCal may both contain several calendars. jCal can only contain one. But mostly there is only one calendar anyway.

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.

Done. parse now raises CalendarInformationLost (a ValueError subclass), and the docs explain that iCalendar and xCal data may contain several calendars and jCal only one.

Comment thread docs/how-to/parse.rst Outdated

- iCalendar, as specified in :rfc:`5545`
- jCal, a JSON representation, as specified in :rfc:`7265`
- xCal, an XML representation, as specified in :rfc:`6321`

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.

Would it work to list the from_... functions next to the RFC?

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.

Done, each RFC now lists its parser.

Comment thread docs/how-to/parse.rst Outdated
The function :func:`~icalendar.cal.calendar.parse` detects the format for you and calls the matching parser.


Parse data

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
Parse data
Parse :class:`bytes`

Does that work?

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.

Yes, it works. Changed.

Comment thread docs/how-to/parse.rst Outdated
from pathlib import Path
from icalendar import parse

calendar = parse(Path("calendar.ics"))

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 think, you can convert this into a >>> pycon block if you use this.

Suggested change
calendar = parse(Path("calendar.ics"))
calendar = parse(Path("example.ics"))

We have the example.ics file at the root of the repo to make that possible.

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.

Changed to pycon blocks. The root example.ics is in .gitignore (a tutorial doctest creates it), so the doctest failed on a fresh clone. I used src/icalendar/tests/calendars/example.ics instead, like usage.rst does.

Comment thread src/icalendar/cal/calendar.py Outdated
A calendar, or a list of calendars if ``multiple`` is ``True``.

Raises:
ValueError: If the format cannot be detected, or if ``multiple`` is

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 would value a subclass for the format problem here - then it is clear from the error type what the problem is.

Can also be a subclass: NoCalendarsFound.

Let's make the front pretty!

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 UnknownCalendarFormat and NoCalendarsFound. Both inherit from InvalidCalendar, because the data itself is the problem. CalendarInformationLost inherits from ValueError directly, because there the data is valid.

Comment thread src/icalendar/cal/calendar.py Outdated
data = source.read_bytes()
else:
data = source.read()
data = data.lstrip()

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.

It is relatively costly to perform that operation. It might be faster to use a regex.

\w*([bB\[<])

I am not sure if begin would also be accepted...

That is a speed add-on.

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.

I measured this before changing it. lstrip() returns the same object when there is nothing to strip, so in the usual case nothing is copied. With leading whitespace, a 50 MB file takes about 0.02 s, which is small compared to parsing. A regex would only find the first character, and the whitespace would still need to be cut before from_xcal, which is also a copy. So I kept lstrip().

About begin: from_ical accepts lowercase, so parse now does too. I also skip a UTF-8 BOM, which from_ical already accepts. Both have tests.


def test_empty_data_raises_error():
"""Data with no detectable format raises a ValueError."""
with pytest.raises(ValueError):

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.

That should check which error and the message should be descriptive.

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.

Each error test now checks the error class and part of the message.

assert calendars[0]["PRODID"] == "test"


def test_parse_open_file(tmp_path):

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 could be parametrized with a fixture for all valid kinds of data.

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.

Done, the path and file tests use the data_path and data_file fixtures, so they run for all three formats.

)


ALL_FORMATS = pytest.mark.parametrize(

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.

Please transform this into a fixture and use it throughout the tests.

Since there is an if/else statement, this ensures propert handling in the future when others edit this.

@pytest.fixture(params=..., ids=...)
def data(request):
    return request.param

@pytest.fixture()
def data_path(data, tmp_path):
    path = tmp_path / "calendar.ics"
    path.write_bytes(ICAL)
    return path

@pytest.fixture()
def data_file(data_path):
    return data_path.open("rb")

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.

Done. One small change from your example: data_path writes data instead of ICAL.

Comment thread src/icalendar/cal/calendar.py Outdated
data = source.read_bytes()
else:
data = source.read()
data = data.lstrip()

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.

Before this line, you need to check that data is actually bytes. If not, that justifies a TypeError.

Tested e.g. by passing data_path.open("r")

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. A str, or a file opened with "r", now raises a TypeError that says to open files with "rb". Both cases have tests.

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 <noreply@anthropic.com>
@EyalHefer

Copy link
Copy Markdown
Author

Thanks for the review! I addressed all comments and the full test suite passes. Ready for another look.

@niccokunzmann niccokunzmann left a comment

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.

Thanks! I left another comment!

Comment thread docs/how-to/parse.rst Outdated
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.

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

We can link the now !

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.

Done. I merged main so that the jCal and xCal glossary terms resolve.

Comment thread docs/how-to/parse.rst
>>> 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.

Comment thread docs/how-to/parse.rst

To get all calendars as a list, pass ``multiple=True``.

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

Comment thread src/icalendar/cal/calendar.py Outdated
f"Expected bytes, but got {type(data).__name__}. "
'Open files in binary mode with "rb".'
)
data = data.removeprefix(b"\xef\xbb\xbf").lstrip()

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.

If you remove the byte order mark - can you check that UTF-8 with a different order still works?

It might be better to do both:

  • use a regex to find the first important character
  • pass the bytes to the parsing function and not altering them here

You could add another example calendar with the BOM but in the other direction.

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.

Good catch. parse now finds the first important character with a regex and passes the data to the parser unchanged.

I tested the byte order marks: a UTF-8 BOM works for all three formats (jCal is now decoded with utf-8-sig), and UTF-16 xCal works in both byte orders. UTF-16 iCal and jCal still raise an error, because from_ical and from_jcal don't support them.

One behavior change: since the data is not altered, leading whitespace now behaves as in the parsers. jCal accepts it, iCal and xCal raise an error. The tests cover this.

EyalHefer and others added 3 commits October 29, 2026 15:20
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 <noreply@anthropic.com>
@EyalHefer

Copy link
Copy Markdown
Author

Thanks! I addressed the new comments and merged main. The full test suite passes. Ready for another look.

@stevepiercy stevepiercy left a comment

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 is nice work. I'd like to see a docstring added for the regular expression, and made a few nitpicky suggestions. Would you please take care?

Also @niccokunzmann needs to do another review and work through his unresolved suggestions.

Comment thread news/1917.feature Resolved
Comment thread src/icalendar/error.py Resolved


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

Comment thread src/icalendar/cal/calendar.py Resolved
Comment thread src/icalendar/cal/calendar.py Resolved
Comment thread docs/how-to/parse.rst Resolved
Comment thread docs/how-to/parse.rst Resolved
Comment thread docs/how-to/parse.rst Resolved
Comment thread docs/how-to/parse.rst Resolved
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 <noreply@anthropic.com>
@EyalHefer

Copy link
Copy Markdown
Author

Thanks @stevepiercy! I applied all your suggestions and added a docstring to FORMAT_START that explains what it skips and links to the review comment it's based on. @niccokunzmann, ready for another look when you have time.

@stevepiercy

Copy link
Copy Markdown
Member

@EyalHefer please update your branch against main. Do you have the "Update branch" button available?

@stevepiercy stevepiercy left a comment

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.

Changes look good, but I can't verify since the branch is not current with main and RTD can't build the docs. Would you please take care?

Also need a review from @niccokunzmann to go over his unresolved comments.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-suspicion This contribution is possibly created with lots of AI help without enough human understanding.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

icalendar.parse convenience method

3 participants