Last active
April 23, 2025 16:11
-
-
Save dceoy/7e29ea15a3cbc494bbb40cc4f256dd7e to your computer and use it in GitHub Desktop.
[Python] ICS string checker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Utilities for working with iCalendar (ICS) data.""" | |
import logging | |
import os | |
from datetime import datetime, timedelta | |
from zoneinfo import ZoneInfo | |
from icalendar import Calendar | |
logging.basicConfig(level=logging.DEBUG) | |
logger = logging.getLogger(__name__) | |
def check_if_now_in_event(ics: str, timezone: str | None = None) -> bool: | |
"""Check if the current time is within at least one VEVENT in an iCalendar string. | |
Args: | |
ics (str): iCalendar string. | |
timezone (str | None): IANA time-zone. | |
Returns: | |
bool: Whether the current time is within any VEVENT in the iCalendar string. | |
""" | |
tz = timezone or os.getenv("TZ", "UTC") | |
now = datetime.now(ZoneInfo(tz)) | |
logger.info("now: %s", now) | |
for v in Calendar.from_ical(ics).walk("VEVENT"): | |
input_start = v.decoded("DTSTART") | |
logger.info("input_start: %s", input_start) | |
if "DTEND" in v: | |
input_end = v.decoded("DTEND") | |
elif "DURATION" in v: | |
input_end = input_start + v.decoded("DURATION") | |
elif not isinstance(input_start, datetime): | |
input_end = input_start + timedelta(days=1) | |
else: | |
continue | |
logger.info("input_end: %s", input_end) | |
dt_start_end = [ | |
( | |
datetime.combine(d, datetime.min.time()) | |
if not isinstance(d, datetime) | |
else d | |
) | |
for d in [input_start, input_end] | |
] | |
logger.info("dt_start_end: %s", dt_start_end) | |
tz_dt_start_end = [ | |
(d.replace(tzinfo=ZoneInfo(tz)) if d.tzinfo is None else d) | |
for d in dt_start_end | |
] | |
logger.info("tz_dt_start_end: %s", tz_dt_start_end) | |
if tz_dt_start_end[0] <= now < tz_dt_start_end[1]: | |
summary = v.decoded("SUMMARY") if "SUMMARY" in v else None | |
logger.info("Now in event: %s %s", summary, tuple(tz_dt_start_end)) | |
return True | |
logger.info("Not in any event") | |
return False |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Unit tests for ics.check_if_now_in_event.""" | |
import pytest | |
from freezegun import freeze_time | |
from ics import check_if_now_in_event | |
MEETING_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;TZID=Asia/Tokyo:20250419T090000 | |
DTEND;TZID=Asia/Tokyo:20250419T100000 | |
SUMMARY:Morning meeting | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
ALL_DAY_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;VALUE=DATE:20250419 | |
DTEND;VALUE=DATE:20250420 | |
SUMMARY:Holiday | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
EMPTY_ICS = """\ | |
BEGIN:VCALENDAR | |
END:VCALENDAR | |
""" | |
DURATION_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;TZID=Asia/Tokyo:20250419T130000 | |
DURATION:PT1H | |
SUMMARY:Lunch meeting | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
NO_END_OR_DURATION_DATE_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;TZID=DATE:20250419 | |
SUMMARY:Quick check-in | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
NO_END_OR_DURATION_DATETIME_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;TZID=Asia/Tokyo:20250419T150000 | |
SUMMARY:Quick check-in | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
NO_SUMMARY_ICS = """\ | |
BEGIN:VCALENDAR | |
BEGIN:VEVENT | |
UID:[email protected] | |
DTSTART;TZID=Asia/Tokyo:20250419T170000 | |
DTEND;TZID=Asia/Tokyo:20250419T180000 | |
END:VEVENT | |
END:VCALENDAR | |
""" | |
@pytest.fixture | |
def mock_env(monkeypatch: pytest.MonkeyPatch) -> None: | |
monkeypatch.setenv("TZ", "Asia/Tokyo") | |
@pytest.mark.parametrize( | |
("ics_str", "now", "expected"), | |
[ | |
(MEETING_ICS, "2025-04-19 09:30:00+09:00", True), | |
(MEETING_ICS, "2025-04-19 08:59:00+09:00", False), | |
(MEETING_ICS, "2025-04-19 10:01:00+09:00", False), | |
(ALL_DAY_ICS, "2025-04-19 12:00:00+09:00", True), | |
(ALL_DAY_ICS, "2025-04-21 00:00:00+09:00", False), | |
(EMPTY_ICS, "2025-04-19 12:00:00+09:00", False), | |
(DURATION_ICS, "2025-04-19 13:30:00+09:00", True), | |
(DURATION_ICS, "2025-04-19 14:01:00+09:00", False), | |
(NO_END_OR_DURATION_DATE_ICS, "2025-04-19 15:00:00+09:00", True), | |
(NO_END_OR_DURATION_DATE_ICS, "2025-04-20 15:00:00+09:00", False), | |
(NO_END_OR_DURATION_DATETIME_ICS, "2025-04-19 15:00:00+09:00", False), | |
(NO_END_OR_DURATION_DATETIME_ICS, "2025-04-19 15:01:00+09:00", False), | |
(NO_SUMMARY_ICS, "2025-04-19 17:30:00+09:00", True), | |
], | |
) | |
def test_check_if_now_in_event( | |
ics_str: str, now: str, expected: bool, mock_env: None | |
) -> None: | |
with freeze_time(now): | |
assert check_if_now_in_event(ics_str) is expected | |
@pytest.mark.parametrize( | |
("ics_str", "now", "timezone", "expected"), | |
[ | |
(MEETING_ICS, "2025-04-19 00:30:00+00:00", "UTC", True), | |
(MEETING_ICS, "2025-04-19 01:30:00+01:00", "Europe/London", True), | |
], | |
) | |
def test_check_if_now_in_event_with_timezone( | |
ics_str: str, now: str, timezone: str, expected: str, mock_env: None | |
) -> None: | |
with freeze_time(now): | |
assert check_if_now_in_event(ics_str, timezone=timezone) is expected |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment