Skip to content

Commit b21e9de

Browse files
authored
Correctly infer timezone from event begin time (#125)
* 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. * More strict IANA name verification * On Windows, install timezone database * Add additional timezone tests * Fix tests on Windows
1 parent 2cfae63 commit b21e9de

5 files changed

Lines changed: 70 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ events:
6060
In this meeting we will ...
6161
```
6262
63+
Valid timezones are listed at https://datetime.app/iana-timezones
64+
6365
## Contributing
6466
6567
Contributions are welcomed! This project is still in active development

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ dependencies = [
2727
"ics == 0.8.0.dev0",
2828
"python-dateutil >= 2.8",
2929
"pyyaml >= 6",
30-
"importlib-resources >= 5.2.1"
30+
"importlib-resources >= 5.2.1",
31+
"tzdata; platform_system == 'Windows'"
3132
]
3233

3334
[project.optional-dependencies]

tests/test_cli.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import datetime
2+
import io
13
import os
24

35
import pytest
6+
import zoneinfo
47

5-
from yaml2ics import event_from_yaml, main
8+
from yaml2ics import event_from_yaml, files_to_events, main
69

710
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__)))
811
example_calendar = os.path.join(basedir, "../example/test_calendar.yaml")
@@ -21,18 +24,28 @@ def test_cli():
2124

2225

2326
def test_errors():
27+
begin = datetime.date(2025, 12, 1)
2428
with pytest.raises(RuntimeError) as e:
25-
event_from_yaml({"repeat": {"interval": {}}})
29+
event_from_yaml({"begin": begin, "repeat": {"interval": {}}})
2630
assert "interval must specify" in str(e)
2731

2832
with pytest.raises(RuntimeError) as e:
29-
event_from_yaml({"ics": "123"})
33+
event_from_yaml({"begin": begin, "ics": "123"})
3034
assert "Invalid custom ICS" in str(e)
3135

3236
with pytest.raises(RuntimeError) as e:
33-
event_from_yaml({"repeat": {"interval": {"weeks": 1}}})
37+
event_from_yaml({"begin": begin, "repeat": {"interval": {"weeks": 1}}})
3438
assert "must specify end date for repeating events" in str(e)
3539

3640
with pytest.raises(RuntimeError) as e:
37-
event_from_yaml({"repeat": {"interval": {"epochs": 4}}})
41+
event_from_yaml({"begin": begin, "repeat": {"interval": {"epochs": 4}}})
3842
assert "expected interval to be specified in seconds, minutes" in str(e)
43+
44+
45+
def test_invalid_timezone():
46+
f = io.BytesIO(b"""
47+
name: Invalid tz cal
48+
timezone: US/Pacificana
49+
""")
50+
with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
51+
files_to_events([f])

tests/test_events.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from yaml2ics import event_from_yaml
1+
import io
2+
3+
from yaml2ics import event_from_yaml, files_to_events
24

35
from .util import parse_yaml
46

@@ -145,3 +147,29 @@ def test_event_with_custom_ics():
145147
)
146148
event_str = event.serialize()
147149
assert "RRULE:FREQ=YEARLY;UNTIL=20280422T000000" in event_str
150+
151+
152+
def test_events_with_multiple_timezones():
153+
f = io.BytesIO(b"""
154+
name: Multiple Timezone Cal
155+
timezone: America/Los_Angeles
156+
events:
157+
- summary: Meeting A
158+
begin: 2025-07-15 17:00:00 +00:00
159+
duration: { minutes: 60 }
160+
- summary: Meeting B
161+
timezone: UTC
162+
begin: 2025-12-01 09:00:00
163+
duration: { minutes: 60 }
164+
- summary: Meeting C
165+
begin: 2025-09-02 17:00:00
166+
duration: { minutes: 60 }
167+
- summary: Meeting D
168+
begin: 2025-12-01 09:00:00
169+
duration: { minutes: 60 }
170+
""")
171+
events, _ = files_to_events([f])
172+
assert events[0].begin.tzname() in ("UTC", "Coordinated Universal Time")
173+
assert events[1].begin.tzname() in ("UTC", "Coordinated Universal Time")
174+
assert events[2].begin.tzname() == "PDT"
175+
assert events[3].begin.tzname() == "PST"

yaml2ics.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import dateutil.rrule
1414
import ics
1515
import yaml
16-
from dateutil.tz import gettz
16+
import zoneinfo
17+
from dateutil.tz import gettz as _gettz
1718

1819
interval_type = {
1920
"seconds": dateutil.rrule.SECONDLY,
@@ -41,6 +42,12 @@ def utcnow():
4142
return datetime.datetime.utcnow().replace(tzinfo=dateutil.tz.UTC)
4243

4344

45+
def gettz(tzname: str) -> datetime.tzinfo:
46+
# Run this to ensure the timezone is valid IANA name
47+
zoneinfo.ZoneInfo(tzname)
48+
return _gettz(tzname)
49+
50+
4451
# See RFC2445, 4.8.5 REcurrence Component Properties
4552
# This function can be used to add a list of e.g. exception dates (EXDATE) or
4653
# recurrence dates (RDATE) to a reoccurring event
@@ -62,7 +69,8 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event:
6269
ics_custom = d.pop("ics", None)
6370

6471
if "timezone" in d:
65-
tz = gettz(d.pop("timezone"))
72+
tzname = d.pop("timezone")
73+
tz = gettz(tzname)
6674

6775
# Strip all string values, since they often end on `\n`
6876
for key in d:
@@ -76,6 +84,15 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event:
7684
# organizer, geo, classification
7785
event = ics.Event(**d)
7886

87+
event.dtstamp = utcnow()
88+
if tz and event.floating and not event.all_day:
89+
event.replace_timezone(tz)
90+
91+
# At this point, we are sure that our event has a timezone
92+
# Either it was set in the YAML file under `timezone: ...`,
93+
# or it was inferred from event start time.
94+
tz = event.timespan.begin_time.tzinfo
95+
7996
# Handle all-day events
8097
if not ("duration" in d or "end" in d):
8198
event.make_all_day()
@@ -129,10 +146,6 @@ def event_from_yaml(event_yaml: dict, tz: datetime.tzinfo = None) -> ics.Event:
129146
rdates = [datetime_to_str(rdate) for rdate in repeat["also_on"]]
130147
add_recurrence_property(event, "RDATE", rdates, tz)
131148

132-
event.dtstamp = utcnow()
133-
if tz and event.floating and not event.all_day:
134-
event.replace_timezone(tz)
135-
136149
if ics_custom:
137150
for line in ics_custom.split("\n"):
138151
if not line:

0 commit comments

Comments
 (0)