diff --git a/README.md b/README.md index d9043ae..0222978 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ events: In this meeting we will ... ``` +Valid timezones are listed at https://datetime.app/iana-timezones + ## Contributing Contributions are welcomed! This project is still in active development diff --git a/pyproject.toml b/pyproject.toml index 1609fd9..c21bd62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ dependencies = [ "ics == 0.8.0.dev0", "python-dateutil >= 2.8", "pyyaml >= 6", - "importlib-resources >= 5.2.1" + "importlib-resources >= 5.2.1", + "tzdata; platform_system == 'Windows'" ] [project.optional-dependencies] diff --git a/tests/test_cli.py b/tests/test_cli.py index 529b0a4..eb45f2b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,12 @@ +import datetime +import io import os import sys import pytest +import zoneinfo -from yaml2ics import event_from_yaml, main +from yaml2ics import event_from_yaml, files_to_events, main basedir = os.path.abspath(os.path.join(os.path.dirname(__file__))) example_calendar = os.path.join(basedir, "../example/test_calendar.yaml") @@ -30,18 +33,28 @@ def test_cli(monkeypatch): def test_errors(): + begin = datetime.date(2025, 12, 1) with pytest.raises(RuntimeError) as e: - event_from_yaml({"repeat": {"interval": {}}}) + event_from_yaml({"begin": begin, "repeat": {"interval": {}}}) assert "interval must specify" in str(e) with pytest.raises(RuntimeError) as e: - event_from_yaml({"ics": "123"}) + event_from_yaml({"begin": begin, "ics": "123"}) assert "Invalid custom ICS" in str(e) with pytest.raises(RuntimeError) as e: - event_from_yaml({"repeat": {"interval": {"weeks": 1}}}) + event_from_yaml({"begin": begin, "repeat": {"interval": {"weeks": 1}}}) assert "must specify end date for repeating events" in str(e) with pytest.raises(RuntimeError) as e: - event_from_yaml({"repeat": {"interval": {"epochs": 4}}}) + event_from_yaml({"begin": begin, "repeat": {"interval": {"epochs": 4}}}) assert "expected interval to be specified in seconds, minutes" in str(e) + + +def test_invalid_timezone(): + f = io.BytesIO(b""" + name: Invalid tz cal + timezone: US/Pacificana + """) + with pytest.raises(zoneinfo.ZoneInfoNotFoundError): + files_to_events([f]) diff --git a/tests/test_events.py b/tests/test_events.py index 248d4da..d535c4c 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,4 +1,6 @@ -from yaml2ics import event_from_yaml +import io + +from yaml2ics import event_from_yaml, files_to_events from .util import parse_yaml @@ -145,3 +147,29 @@ def test_event_with_custom_ics(): ) event_str = event.serialize() assert "RRULE:FREQ=YEARLY;UNTIL=20280422T000000" in event_str + + +def test_events_with_multiple_timezones(): + f = io.BytesIO(b""" + name: Multiple Timezone Cal + timezone: America/Los_Angeles + events: + - summary: Meeting A + begin: 2025-07-15 17:00:00 +00:00 + duration: { minutes: 60 } + - summary: Meeting B + timezone: UTC + begin: 2025-12-01 09:00:00 + duration: { minutes: 60 } + - summary: Meeting C + begin: 2025-09-02 17:00:00 + duration: { minutes: 60 } + - summary: Meeting D + begin: 2025-12-01 09:00:00 + duration: { minutes: 60 } + """) + events, _ = files_to_events([f]) + assert events[0].begin.tzname() in ("UTC", "Coordinated Universal Time") + assert events[1].begin.tzname() in ("UTC", "Coordinated Universal Time") + assert events[2].begin.tzname() == "PDT" + assert events[3].begin.tzname() == "PST" diff --git a/yaml2ics.py b/yaml2ics.py index 59b8ed1..d4d2eae 100644 --- a/yaml2ics.py +++ b/yaml2ics.py @@ -13,7 +13,8 @@ import dateutil.rrule import ics import yaml -from dateutil.tz import gettz +import zoneinfo +from dateutil.tz import gettz as _gettz interval_type = { "seconds": dateutil.rrule.SECONDLY, @@ -41,6 +42,12 @@ def utcnow(): return datetime.datetime.utcnow().replace(tzinfo=dateutil.tz.UTC) +def gettz(tzname: str) -> datetime.tzinfo: + # Run this to ensure the timezone is valid IANA name + zoneinfo.ZoneInfo(tzname) + return _gettz(tzname) + + # See RFC2445, 4.8.5 REcurrence Component Properties # This function can be used to add a list of e.g. exception dates (EXDATE) or # recurrence dates (RDATE) to a reoccurring event @@ -62,7 +69,8 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event: ics_custom = d.pop("ics", None) if "timezone" in d: - tz = gettz(d.pop("timezone")) + tzname = d.pop("timezone") + tz = gettz(tzname) # Strip all string values, since they often end on `\n` for key in d: @@ -76,6 +84,15 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event: # organizer, geo, classification event = ics.Event(**d) + event.dtstamp = utcnow() + if tz and event.floating and not event.all_day: + event.replace_timezone(tz) + + # At this point, we are sure that our event has a timezone + # Either it was set in the YAML file under `timezone: ...`, + # or it was inferred from event start time. + tz = event.timespan.begin_time.tzinfo + # Handle all-day events if not ("duration" in d or "end" in d): event.make_all_day() @@ -129,10 +146,6 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event: rdates = [datetime2utc(rdate) for rdate in repeat["also_on"]] add_recurrence_property(event, "RDATE", rdates, tz) - event.dtstamp = utcnow() - if tz and event.floating and not event.all_day: - event.replace_timezone(tz) - if ics_custom: for line in ics_custom.split("\n"): if not line: