From 804ab5297092428c2ec0c440f28d50db5ac686ba Mon Sep 17 00:00:00 2001 From: Abe Hanoka Date: Mon, 9 Dec 2024 15:01:15 -0500 Subject: [PATCH 1/3] Add from_file and to_file method --- CHANGES.rst | 3 +- src/icalendar/cal.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d50a55c7..9aeb1467 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,8 +13,7 @@ Breaking changes: - The ``relative`` attribute of ``vWeekday`` components has the correct sign now. See `Issue 749 `_. New features: - -- ... +- Add ``from_file()`` and ``to_file()`` methods to ``Component`` class for easier file handling of iCalendar data. See `Issue 756 `_. Bug fixes: diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index bae86bd8..209e5bc0 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -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 @@ -587,6 +588,74 @@ 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 + >>> # 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) + """ + # 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 + >>> from pathlib import Path + >>> # Read a calendar file + >>> cal = Calendar.from_file("src/icalendar/tests/calendars/example.ics") + >>> # or pass a Path object + >>> path = Path("src/icalendar/tests/calendars/example.ics") + >>> cal = Calendar.from_file(path) + >>> # Read multiple calendars + >>> cals = Calendar.from_file("src/icalendar/tests/calendars/multiple_calendar_components.ics", multiple=True) + + """ + + # Handle string path + if isinstance(file, str): + file = Path(file) + + file.write_bytes(self.to_ical(sorted=sorted)) + + ####################################### # components defined in RFC 5545 From 87d90b49827f61c4ec666c1c53820c75d336be5e Mon Sep 17 00:00:00 2001 From: Abe Hanoka Date: Tue, 10 Dec 2024 09:58:45 -0500 Subject: [PATCH 2/3] fix inline examples --- src/icalendar/cal.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 209e5bc0..9334e461 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -613,10 +613,14 @@ def from_file(cls, file: Union[str, Path], multiple: bool = False): 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) """ # Handle string path by converting to Path if isinstance(file, str): @@ -637,15 +641,13 @@ def to_file(self, file: Union[str, Path], sorted: bool = True) -> None: sorted: Whether parameters and properties should be lexicographically sorted. Example: - >>> from icalendar import Calendar - >>> from pathlib import Path - >>> # Read a calendar file - >>> cal = Calendar.from_file("src/icalendar/tests/calendars/example.ics") - >>> # or pass a Path object - >>> path = Path("src/icalendar/tests/calendars/example.ics") - >>> cal = Calendar.from_file(path) - >>> # Read multiple calendars - >>> cals = Calendar.from_file("src/icalendar/tests/calendars/multiple_calendar_components.ics", multiple=True) + >>> 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") """ From 7002bb6afdbf05e7d9b4b321337a9181ca9cb2cd Mon Sep 17 00:00:00 2001 From: Abe Hanoka Date: Mon, 23 Dec 2024 09:57:08 -0500 Subject: [PATCH 3/3] add file io tests --- src/icalendar/tests/test_file_io.py | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/icalendar/tests/test_file_io.py diff --git a/src/icalendar/tests/test_file_io.py b/src/icalendar/tests/test_file_io.py new file mode 100644 index 00000000..d8473e2c --- /dev/null +++ b/src/icalendar/tests/test_file_io.py @@ -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