From fea0dcd36995015d6b4dd303f001803cbfe51bcb Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Mon, 1 Dec 2025 17:29:12 -0800 Subject: [PATCH 1/5] Correctly infer timezone from event begin time Timezones can come from the YAML file (`timezone: ...`), or from the event start time: `2025-07-15 17:00:00 +00:00` However it is specified, this timezone should be propagated to the RRULE. This PR is also a bit more strict, failing when the timezone specifier is unrecognized. --- README.md | 2 ++ yaml2ics.py | 25 +++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) 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/yaml2ics.py b/yaml2ics.py index 59b8ed1..da6e328 100644 --- a/yaml2ics.py +++ b/yaml2ics.py @@ -13,7 +13,7 @@ import dateutil.rrule import ics import yaml -from dateutil.tz import gettz +from dateutil.tz import gettz as _gettz interval_type = { "seconds": dateutil.rrule.SECONDLY, @@ -41,6 +41,13 @@ def utcnow(): return datetime.datetime.utcnow().replace(tzinfo=dateutil.tz.UTC) +def gettz(tzname): + tz = _gettz(tzname) + if tz is None: + raise ValueError(f"Invalid timezone encountered: `{tzname}`") + return tz + + # 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: From 1ae265023701f64f99c735a3429efc8983b239f3 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Mon, 1 Dec 2025 23:21:38 -0800 Subject: [PATCH 2/5] More strict IANA name verification --- tests/test_cli.py | 23 ++++++++++++++++++----- yaml2ics.py | 10 +++++----- 2 files changed, 23 insertions(+), 10 deletions(-) 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/yaml2ics.py b/yaml2ics.py index da6e328..d4d2eae 100644 --- a/yaml2ics.py +++ b/yaml2ics.py @@ -13,6 +13,7 @@ import dateutil.rrule import ics import yaml +import zoneinfo from dateutil.tz import gettz as _gettz interval_type = { @@ -41,11 +42,10 @@ def utcnow(): return datetime.datetime.utcnow().replace(tzinfo=dateutil.tz.UTC) -def gettz(tzname): - tz = _gettz(tzname) - if tz is None: - raise ValueError(f"Invalid timezone encountered: `{tzname}`") - return tz +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 From 226c16c1c11ece0c65ca04849f319aa328567e14 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Mon, 1 Dec 2025 23:29:06 -0800 Subject: [PATCH 3/5] On Windows, install timezone database --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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] From d1aa3c06a0eb155b5d29c6e0dd50e663befe90f8 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Tue, 2 Dec 2025 00:03:32 -0800 Subject: [PATCH 4/5] Add additional timezone tests --- tests/test_events.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/test_events.py b/tests/test_events.py index 248d4da..2a127ff 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() == "UTC" + assert events[1].begin.tzname() == "UTC" + assert events[2].begin.tzname() == "PDT" + assert events[3].begin.tzname() == "PST" From f866600973e229cfee7defe0b366f55155813df3 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Tue, 2 Dec 2025 00:06:32 -0800 Subject: [PATCH 5/5] Fix tests on Windows --- tests/test_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_events.py b/tests/test_events.py index 2a127ff..d535c4c 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -169,7 +169,7 @@ def test_events_with_multiple_timezones(): duration: { minutes: 60 } """) events, _ = files_to_events([f]) - assert events[0].begin.tzname() == "UTC" - assert events[1].begin.tzname() == "UTC" + 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"