Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
23 changes: 18 additions & 5 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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])
30 changes: 29 additions & 1 deletion tests/test_events.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"
25 changes: 19 additions & 6 deletions yaml2ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
Loading