Skip to content
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

Add from_file and to_file method #757

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ Breaking changes:
- The ``relative`` attribute of ``vWeekday`` components has the correct sign now. See `Issue 749 <https://github.com/collective/icalendar/issues/749>`_.

New features:

- ...
- Add ``from_file()`` and ``to_file()`` methods to ``Component`` class for easier file handling of iCalendar data. See `Issue 756 <https://github.com/collective/icalendar/issues/756>`_.

Bug fixes:

Expand Down
71 changes: 71 additions & 0 deletions src/icalendar/cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from datetime import date, datetime, timedelta, tzinfo
from typing import List, Optional, Tuple
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, List, NamedTuple, Optional, Tuple, Union

import dateutil.rrule
Expand Down Expand Up @@ -587,6 +588,76 @@ def is_thunderbird(self) -> bool:
return any(attr.startswith("X-MOZ-") for attr in self.keys())


@classmethod
def from_file(cls, file: Union[str, Path], multiple: bool = False):
"""Create a Component from a file.

This class method can be used by any Component subclass (Calendar, Event, etc.)
to read their data from a file.

Args:
file: The file to read from. Can be:
- A string path to a file
- A Path object
multiple: If True, allows parsing multiple components from the file.

Returns:
If multiple=False (default):
A single Component instance of the appropriate type
If multiple=True:
A list of Component instances

Raises:
FileNotFoundError: If the file path doesn't exist
ValueError: If the file contents are not valid iCalendar format

Example:
>>> from icalendar import Calendar
>>> from pathlib import Path
>>> # Read a calendar file
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
>>> # Read a calendar file
# Read a calendar file

>>> cal = Calendar.from_file("src/icalendar/tests/calendars/example.ics")
>>> # Read multiple calendars
Copy link
Member

Choose a reason for hiding this comment

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

You can remove the >>> here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Are you suggestion to remove the >>> on the lines where there is a comment? or perhaps just remove the comments altogether?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

if I remove the >>> from the comments then the test fails
This doesn't work:

        Example:
            >>> from icalendar import Calendar
            >>> from pathlib import Path
            # Read a calendar file
            >>> cal = Calendar.from_file("src/icalendar/tests/calendars/example.ics")
            # Read multiple calendars
            >>> cals = Calendar.from_file("src/icalendar/tests/calendars/multiple_calendar_components.ics", multiple=True)
            # or pass a Path object
            >>> path = Path("src/icalendar/tests/calendars/example.ics")
            >>> cal = Calendar.from_file(path)

This does work:

        Example:
            >>> from icalendar import Calendar
            >>> from pathlib import Path
            >>> # Read a calendar file
            >>> cal = Calendar.from_file("src/icalendar/tests/calendars/example.ics")
            >>> # Read multiple calendars
            >>> cals = Calendar.from_file("src/icalendar/tests/calendars/multiple_calendar_components.ics", multiple=True)
            >>> # or pass a Path object
            >>> path = Path("src/icalendar/tests/calendars/example.ics")
            >>> cal = Calendar.from_file(path)

Copy link
Member

Choose a reason for hiding this comment

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

It needs a new line:

>>> from pathlib import Path

# Read a calendar file

Otherwise the comment is considered output.

>>> cals = Calendar.from_file("src/icalendar/tests/calendars/multiple_calendar_components.ics", multiple=True)
>>> # or pass a Path object
>>> path = Path("src/icalendar/tests/calendars/example.ics")
>>> cal = Calendar.from_file(path)
"""
# Handle string path by converting to Path
if isinstance(file, str):
file = Path(file)

return cls.from_ical(file.read_bytes(), multiple=multiple)

def to_file(self, file: Union[str, Path], sorted: bool = True) -> None:
"""Write the component to a file.

This method can be used by any Component subclass (Calendar, Event, etc.)
to write their data to a file.

Args:
file: Where to write the component. Can be:
- A string path to a file
- A Path object
sorted: Whether parameters and properties should be lexicographically sorted.

Example:
>>> from icalendar import Calendar, Event
>>> # Write a calendar
>>> cal = Calendar()
>>> cal.to_file("calendar.ics")
>>> # Write an event
>>> event = Event()
>>> event.to_file("event.ics")

"""

# Handle string path
if isinstance(file, str):
file = Path(file)

file.write_bytes(self.to_ical(sorted=sorted))



#######################################
# components defined in RFC 5545
Expand Down
100 changes: 100 additions & 0 deletions src/icalendar/tests/test_file_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import pytest
from datetime import datetime
from pathlib import Path
from icalendar import Calendar, Event, Todo, Journal


@pytest.fixture
def temp_path(tmp_path):
"""Create a temp directory and change to it for tests"""
return tmp_path / "test.ics"


@pytest.fixture
def multiple_calendars_path():
"""Path to test file containing multiple calendar components"""
return Path("src/icalendar/tests/calendars/multiple_calendar_components.ics")


def test_from_file_str_path(calendars):
"""Test reading from file using string path"""
path = "src/icalendar/tests/calendars/example.ics"
cal = Calendar.from_file(path)
assert cal == calendars.example


def test_from_file_path_object(calendars):
"""Test reading from file using Path object"""
path = Path("src/icalendar/tests/calendars/example.ics")
cal = Calendar.from_file(path)
assert cal == calendars.example


def test_from_file_multiple(multiple_calendars_path):
"""Test reading multiple components from a file"""
cals = Calendar.from_file(multiple_calendars_path, multiple=True)
assert isinstance(cals, list)
assert len(cals) > 1
assert all(isinstance(cal, Calendar) for cal in cals)


def test_from_file_non_existent():
"""Test attempting to read from non-existent file"""
with pytest.raises(FileNotFoundError):
Calendar.from_file("non_existent.ics")


def test_to_file_str_path(temp_path, calendars):
"""Test writing to file using string path"""
cal = calendars.example
cal.to_file(str(temp_path))
assert temp_path.exists()
# Verify contents by reading back
cal2 = Calendar.from_file(temp_path)
assert cal == cal2


def test_to_file_path_object(temp_path, calendars):
"""Test writing to file using Path object"""
cal = calendars.example
cal.to_file(temp_path)
assert temp_path.exists()
# Verify contents by reading back
cal2 = Calendar.from_file(temp_path)
assert cal == cal2


def test_other_components(temp_path):
"""Test file I/O with other component types"""
components = [Event(), Todo(), Journal()]

for comp in components:
comp.add("summary", "Test")
comp.to_file(temp_path)
assert temp_path.exists()
# Read back and verify it's the correct type
comp2 = type(comp).from_file(temp_path)
assert isinstance(comp2, type(comp))
assert comp == comp2


def test_component_roundtrip(temp_path):
"""Test that a component survives a write/read cycle preserving all data"""
# Create a complex calendar with nested components
cal = Calendar()
event = Event()
event.add("summary", "Test Event")

dt = datetime(2024, 1, 1, 12, 0, 0)
event.add("dtstart", dt)
cal.add_component(event)

# Write and read back
cal.to_file(temp_path)
cal2 = Calendar.from_file(temp_path)

# Verify equality
assert cal == cal2
assert len(cal2.subcomponents) == len(cal.subcomponents)
assert cal2.subcomponents[0]["summary"] == "Test Event"
assert cal2.subcomponents[0]["dtstart"].dt == dt
Loading