├── benchmark ├── __init__.py ├── README.md └── issue42.py ├── recurring_ical_events ├── test │ ├── __init__.py │ ├── calendars │ │ ├── no_events.ics │ │ ├── issue_113_period_rdate_duration.ics │ │ ├── issue_243_recurrence_id_is_not_identical_to_dtstart.ics │ │ ├── rdate2.ics │ │ ├── issue_179_example.ics │ │ ├── issue_201_mixed_datetime_and_date.ics │ │ ├── issue_97_todo_nodtstart.ics │ │ ├── issue_97_simple_todo.ics │ │ ├── issue_86_x_wr_timezone_without_time_zone_in_dt.ics │ │ ├── issue_4_rrule_until.ics │ │ ├── bad_rrule_missing_until_event.ics │ │ ├── issue_148_exdate_and_rdate_unedited.ics │ │ ├── issue_117_until_before_dtstart.ics │ │ ├── issue_128_only_first_event.ics │ │ ├── issue_15_duplicated_events.ics │ │ ├── issue_97_simple_journal.ics │ │ ├── duplicated_rrule.ics │ │ ├── multiple_rrule.ics │ │ ├── date_exclude.txt │ │ ├── issue_4_weidenrinde.ics │ │ ├── issue_44_double_event.ics │ │ ├── duration.ics │ │ ├── issue_107_omitting_last_event.ics │ │ ├── issue_148_ignored_exdate.ics │ │ ├── rdate.ics │ │ ├── issue_148_exdate_and_rdate_updated.ics │ │ ├── zero_size_event.ics │ │ ├── one_day_event.ics │ │ ├── issue_113_period_in_rdate.ics │ │ ├── one_event.ics │ │ ├── recurrence_sequence_number.ics │ │ ├── end_before_start_event.ics │ │ ├── event_10_times.ics │ │ ├── one_day_event_repeat_every_day.ics │ │ ├── rdate_hackerpublicradio.ics │ │ ├── issue_132_swapped_start_and_end.ics │ │ ├── three_events.ics │ │ ├── one_event_repeat_every_3_days.ics │ │ ├── each_week_but_one_deleted.ics │ │ ├── each_week_but_two_deleted.ics │ │ ├── issue_36_recurrence_ID_format.ics │ │ ├── issue_164_duplicated_event.ics │ │ ├── x_wr_timezone_simple_events_issue_59.ics │ │ ├── issue_148_edge_case_2.ics │ │ ├── issue_163_deleted_modification.ics │ │ ├── issue_75_range_parameter.ics │ │ ├── issue_148_edge_case_1.ics │ │ ├── issue_223_one_event_with_sequence.ics │ │ ├── issue_27_t1.ics │ │ ├── issue_27_t2.ics │ │ ├── issue_4.ics │ │ ├── issue_48_daylight_aware_repeats.ics │ │ ├── subcomponents.ics │ │ ├── issue_18_cancel_status.ics │ │ ├── duration_edited.ics │ │ ├── three_events_one_edited.ics │ │ ├── issue_62_moved_event.ics │ │ ├── issue_151_macos_linux_difference.ics │ │ ├── issue_151_macos_linux_difference2.ics │ │ ├── issue_62_moved_event_2.ics │ │ ├── recurring_events_moved.ics │ │ ├── issue_20_exdate_ignored.ics │ │ ├── rdate_falls_on_rrule_until.ics │ │ ├── recurring_events_changed_duration.ics │ │ ├── several_events_at_the_same_time.ics │ │ └── issue_201_test_matrix.ics │ ├── test_repeated_properties.py │ ├── test_deleted_entries.py │ ├── test_bad_rrule_format.py │ ├── py.py │ ├── test_recurrence_sequence_number.py │ ├── test_issue_117_until_before_dtstart.py │ ├── test_issue_139_no_duration.py │ ├── test_issue_6_copy_subcomponents.py │ ├── test_multiple_rrule.py │ ├── test_issue_163_deleted_modification.py │ ├── test_count.py │ ├── test_issue_18_cancel_status.py │ ├── test_issue_164_duplicated_event.py │ ├── test_issue_179_span_in_event.py │ ├── test_issue_86_x_wr_timezone_but_no_tzid_in_dt.py │ ├── test_convert_inputs.py │ ├── test_skip_bad_events.py │ ├── test_issue_28_timezone_with_z.py │ ├── test_at_function.py │ ├── test_example_function.py │ ├── test_issue_61.py │ ├── test_readme.py │ ├── test_issue_173_only_modification_included.py │ ├── test_util_functions.py │ ├── test_issue_36_recurrence_id_format.py │ ├── test_daylight_saving_time.py │ ├── test_issue_15.py │ ├── test_single_events.py │ ├── test_end_before_start_event.py │ ├── test_time_arguments.py │ ├── test_timedelta_for_between.py │ ├── test_issue_128_only_first_event.py │ ├── test_issue_7_datetime_and_date_start_stop.py │ ├── test_issue_186_icalendar_alarm_interface.py │ ├── test_properties.py │ ├── test_issue_107_omitting_last_event.py │ ├── test_keep_recurrence_attributes.py │ ├── test_issue_243_recurrence_id_is_not_identical_to_dtstart.py │ ├── test_issue_151_macos_linux_difference.py │ ├── test_issue_48_daylight.py │ ├── test_zero_size_events.py │ ├── test_issue_44_day_event_reported_twice.py │ ├── test_issue_4.py │ ├── test_simple_recurrent_events.py │ ├── test_issue_48_dst.py │ ├── test_issue_27.py │ ├── test_repetitions_do_not_change.py │ ├── test_issue_132_swapped_start_and_end.py │ ├── test_time_zones_differ.py │ ├── test_issue_113_period_in_rdate.py │ ├── test_duration.py │ ├── test_issue_62_moved_event.py │ ├── test_issue_97_simple_recurrent_todos_and_journals.py │ ├── test_with_doctest.py │ ├── test_event_values_and_edits.py │ ├── test_after.py │ ├── test_issue_223_sequence_number.py │ ├── test_issue_101_select_components.py │ ├── test_issue_20_exdate_ignored.py │ ├── test_x_wr_timezone.py │ ├── test_zoneinfo_issue_57.py │ ├── test_examples.py │ └── test_rdate.py ├── .gitignore ├── selection │ ├── __init__.py │ ├── base.py │ ├── all.py │ └── alarm.py ├── series │ └── __init__.py ├── adapters │ ├── __init__.py │ ├── alarm.py │ ├── journal.py │ ├── event.py │ └── todo.py ├── version.py ├── constants.py ├── types.py ├── examples.py └── errors.py ├── docs ├── .gitignore ├── docutils.conf ├── reference │ ├── license.md │ ├── dependencies.rst │ ├── architecture.rst │ ├── documentation.md │ ├── research.rst │ ├── related-projects.rst │ ├── compatibility.md │ └── api.md ├── img │ └── architecture.png ├── security_policy.md ├── requirements.txt ├── community │ ├── maintenance.rst │ └── media.md ├── user-guide │ └── index.md └── index.md ├── .gitignore ├── .pre-commit-config.yaml ├── SECURITY.md ├── .github ├── dependabot.yml ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── tox.ini ├── .readthedocs.yml ├── .cleanup-branches.sh └── README.rst /benchmark/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /recurring_ical_events/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /recurring_ical_events/.gitignore: -------------------------------------------------------------------------------- 1 | _version.py 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | __pycache__ 3 | *.pyc 4 | _* 5 | -------------------------------------------------------------------------------- /docs/docutils.conf: -------------------------------------------------------------------------------- 1 | [restructuredtext parser] 2 | tab_width: 4 3 | -------------------------------------------------------------------------------- /docs/reference/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{include} ../../LICENSE 4 | ``` 5 | -------------------------------------------------------------------------------- /docs/img/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niccokunzmann/python-recurring-ical-events/HEAD/docs/img/architecture.png -------------------------------------------------------------------------------- /docs/security_policy.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | The security policy of the recurring-ical-events library for Python. 6 | --- 7 | 8 | ```{include} ../SECURITY.md 9 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /ENV 2 | __pycache__ 3 | *.pyc 4 | *egg-info 5 | /build 6 | /dist 7 | .pytest_cache/ 8 | .vscode/ 9 | ENV2 10 | *.swp 11 | env-2 12 | /htmlcov 13 | .coverage 14 | .venv 15 | venv 16 | /requirements.txt 17 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/no_events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | END:VCALENDAR 8 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_repeated_properties.py: -------------------------------------------------------------------------------- 1 | def test_duplicated_rrule(calendars): 2 | # Test that a repetition of the same `RRULE` property should be ignored 3 | assert len(calendars.duplicated_rrule.at(2023)) == 20 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=7 2 | pydata-sphinx-theme 3 | sphinx-autobuild 4 | sphinx-copybutton 5 | .. 6 | sphinx-sitemap 7 | sphinx-autoapi>=3.0.0 8 | # For examples section 9 | myst-parser 10 | sphinx-autodoc-typehints 11 | sphinx-toolbox 12 | sphinx-tabs 13 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_113_period_rdate_duration.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART:20240912T120000Z 4 | DTEND:20240912T130000Z 5 | RDATE;VALUE=PERIOD:20240913T120000Z/PT2H 6 | SUMMARY:The RDATE is a period with a duration. 7 | END:VEVENT 8 | END:VCALENDAR 9 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_243_recurrence_id_is_not_identical_to_dtstart.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | SUMMARY:daily testing 4 | DTSTART:20150901T080000 5 | DTEND:20150901T100000 6 | DTSTAMP:20250529T181439Z 7 | UID:test1 8 | RRULE:FREQ=DAILY 9 | END:VEVENT 10 | END:VCALENDAR 11 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_deleted_entries.py: -------------------------------------------------------------------------------- 1 | def test_one_deleted_event(calendars): 2 | events = list(calendars.each_week_but_one_deleted.all()) 3 | assert len(events) == 7 4 | 5 | 6 | def test_one_deleted_event_2(calendars): 7 | events = list(calendars.each_week_but_two_deleted.all()) 8 | assert len(events) == 6 9 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_bad_rrule_format.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from recurring_ical_events.errors import BadRuleStringFormat 4 | 5 | 6 | def test_bad_rrule_until_format(calendars): 7 | with pytest.raises(BadRuleStringFormat, match=r"UNTIL parameter is missing"): 8 | calendars.bad_rrule_missing_until_event.at(2019) 9 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # speed tests 2 | 3 | This folder contains speed tests. Run them from within the root folder (next to the module). 4 | 5 | Get the time of a benchmark: 6 | ``` 7 | time python3 benchmark/issue42.py 8 | ``` 9 | 10 | Profile what takes time during a run: 11 | ``` 12 | python3 -m profile benchmark/issue42.py | tee benchmark/issue42.txt 13 | ``` 14 | 15 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/rdate2.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART:20140803T190000Z 4 | DTEND:20140803T210000Z 5 | RDATE;VALUE=DATE-TIME:20150705T190000Z 6 | EXDATE;VALUE=DATE-TIME:20150705T190000Z 7 | RRULE:FREQ=DAILY;UNTIL=20160320T030000Z 8 | SUMMARY:rdate and rrule overlap but exdate removes the date again 9 | END:VEVENT 10 | END:VCALENDAR 11 | -------------------------------------------------------------------------------- /recurring_ical_events/selection/__init__.py: -------------------------------------------------------------------------------- 1 | """Select components for calculation.""" 2 | 3 | from .alarm import Alarms 4 | from .all import AllKnownComponents 5 | from .base import SelectComponents 6 | from .name import ComponentsWithName 7 | 8 | __all__ = [ 9 | "Alarms", 10 | "AllKnownComponents", 11 | "ComponentsWithName", 12 | "SelectComponents", 13 | ] 14 | -------------------------------------------------------------------------------- /recurring_ical_events/test/py.py: -------------------------------------------------------------------------------- 1 | # shim for pylib going away 2 | # if pylib is installed this file will get skipped 3 | # (`py/__init__.py` has higher precedence) 4 | from __future__ import annotations 5 | 6 | import sys 7 | 8 | from _pytest._py import error, path 9 | 10 | sys.modules["py.error"] = error 11 | sys.modules["py.path"] = path 12 | 13 | __all__ = ["error", "path"] 14 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_179_example.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | UID:19970901T130000Z-123403@example.com 4 | DTSTAMP:19970901T130000Z 5 | DTSTART;VALUE=DATE:19971102 6 | SUMMARY:Our Blissful Anniversary 7 | TRANSP:TRANSPARENT 8 | CLASS:CONFIDENTIAL 9 | CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION 10 | RRULE:FREQ=YEARLY 11 | END:VEVENT 12 | END:VCALENDAR 13 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_recurrence_sequence_number.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for modified recurrences with lower sequence number than their base event 3 | """ 4 | 5 | 6 | def test_modified_recurrence_lower_sequence_number(calendars): 7 | events = calendars.recurrence_sequence_number.at("20200922") 8 | assert len(events) == 1 9 | assert events[0].get("SUMMARY") == "Modified event" 10 | -------------------------------------------------------------------------------- /recurring_ical_events/series/__init__.py: -------------------------------------------------------------------------------- 1 | """Calculation of occurrences in a series.""" 2 | 3 | from .alarm import ( 4 | AbsoluteAlarmSeries, 5 | AlarmSeriesRelativeToEnd, 6 | AlarmSeriesRelativeToStart, 7 | ) 8 | from .rrule import Series 9 | 10 | __all__ = [ 11 | "AbsoluteAlarmSeries", 12 | "AlarmSeriesRelativeToEnd", 13 | "AlarmSeriesRelativeToStart", 14 | "Series", 15 | ] 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: debug-statements 6 | 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.11.9 9 | hooks: 10 | - id: ruff 11 | args: [--config, "pyproject.toml", --fix] 12 | - id: ruff-format 13 | args: [--config, "pyproject.toml"] 14 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_201_mixed_datetime_and_date.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART;VALUE=DATE:20230724 4 | CREATED:20230606T153716Z 5 | STATUS:CONFIRMED 6 | SUMMARY:Congés 7 | TRANSP:TRANSPARENT 8 | DTSTAMP:20230704T085547Z 9 | DTEND:20230817T000000Z 10 | SEQUENCE:1 11 | LAST-MODIFIED:20230731T161724Z 12 | UID:19970901T130000Z-123403@example.com 13 | END:VEVENT 14 | END:VCALENDAR 15 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_97_todo_nodtstart.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//Example Corp.//CalDAV Client//EN 4 | BEGIN:VTODO 5 | UID:19920901T130000Z-123408@host.com 6 | DTSTAMP:19920901T130000Z 7 | DUE:19920516T045959Z 8 | SUMMARY:Yearly Income Tax Preparation 9 | RRULE:FREQ=YEARLY 10 | CLASS:CONFIDENTIAL 11 | CATEGORIES:FAMILY,FINANCE 12 | PRIORITY:1 13 | END:VTODO 14 | END:VCALENDAR 15 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_117_until_before_dtstart.py: -------------------------------------------------------------------------------- 1 | """The atlassian confluence calendar sets the until value lower than the DTSTART when then event is deleted. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/117 4 | """ 5 | 6 | 7 | def test_event_is_deleted(calendars): 8 | """No event takes place.""" 9 | assert not list(calendars.issue_117_until_before_dtstart.all()) 10 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_139_no_duration.py: -------------------------------------------------------------------------------- 1 | """We want to check that the DURATION is removed. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/139 4 | """ 5 | 6 | 7 | def test_no_duration_in_event(calendars): 8 | """Check that there is no DURATION in the event.""" 9 | for event in calendars.duration.all(): 10 | assert "DURATION" not in event 11 | assert "DTEND" in event 12 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_6_copy_subcomponents.py: -------------------------------------------------------------------------------- 1 | """ 2 | This tests that subcomponents are carried over to different events. 3 | """ 4 | 5 | 6 | def test_subcomponents_are_compied(calendars): 7 | event = next(calendars.subcomponents.all()) 8 | assert event.subcomponents 9 | 10 | 11 | def test_there_are_no_subcomponents(calendars): 12 | event = next(calendars.Germany.all()) 13 | assert not event.subcomponents 14 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_97_simple_todo.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//Example Corp.//CalDAV Client//EN 4 | BEGIN:VTODO 5 | UID:19920901T130000Z-123408@host.com 6 | DTSTAMP:19920901T130000Z 7 | DTSTART:19920415T133000Z 8 | DUE:19920516T045959Z 9 | SUMMARY:Yearly Income Tax Preparation 10 | RRULE:FREQ=YEARLY 11 | CLASS:CONFIDENTIAL 12 | CATEGORIES:FAMILY,FINANCE 13 | PRIORITY:1 14 | END:VTODO 15 | END:VCALENDAR 16 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_86_x_wr_timezone_without_time_zone_in_dt.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:manually_generated 4 | X-WR-TIMEZONE:Europe/Brussels 5 | X-WR-CALNAME:MyCalendar 6 | X-WR-CALDESC:Description of calendar 7 | BEGIN:VEVENT 8 | UID:match_1025179 9 | DTSTAMP:20210911T014015Z 10 | DESCRIPTION:Summary 11 | DTSTART:20210916T210000 12 | DTEND:20210916T224500 13 | SUMMARY:Summary 14 | END:VEVENT 15 | END:VCALENDAR 16 | -------------------------------------------------------------------------------- /recurring_ical_events/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | """All adapters for different kinds of components.""" 2 | 3 | from .alarm import AbsoluteAlarmAdapter 4 | from .component import ComponentAdapter 5 | from .event import EventAdapter 6 | from .journal import JournalAdapter 7 | from .todo import TodoAdapter 8 | 9 | __all__ = [ 10 | "AbsoluteAlarmAdapter", 11 | "ComponentAdapter", 12 | "EventAdapter", 13 | "JournalAdapter", 14 | "TodoAdapter", 15 | ] 16 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_multiple_rrule.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | 4 | def test_multiple_rrule(calendars): 5 | events = calendars.multiple_rrule.at(2023) 6 | assert len(events) == 20 + 2 7 | event_dstarts = [event["DTSTART"].dt.date() for event in events] 8 | assert date(2023, 2, 9) in event_dstarts 9 | assert date(2023, 2, 13) in event_dstarts 10 | assert date(2023, 2, 16) in event_dstarts 11 | assert date(2023, 3, 13) in event_dstarts 12 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_163_deleted_modification.py: -------------------------------------------------------------------------------- 1 | """EXDATE does not exclude a modified instance for an event with higher SEQUENCE and the same UID. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/163 4 | """ 5 | 6 | 7 | def test_exdate_excludes_modification(calendars): 8 | """The exdate should exclude the modification mentioned.""" 9 | events = calendars.issue_163_deleted_modification.at("20240819") 10 | assert events == [] 11 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_4_rrule_until.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | SUMMARY:blabla 4 | DTSTART;TZID=Europe/London:20190801T140000 5 | DTEND;TZID=Europe/London:20190801T150000 6 | DTSTAMP:20190801T083416Z 7 | UID:blabla 8 | SEQUENCE:0 9 | RRULE:FREQ=WEEKLY;UNTIL=20191023;BYDAY=TH;WKST=SU 10 | CREATED:20190729T105342Z 11 | DESCRIPTION:blabla 12 | LAST-MODIFIED:20190801T064315Z 13 | LOCATION: 14 | STATUS:CONFIRMED 15 | TRANSP:OPAQUE 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/bad_rrule_missing_until_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | SUMMARY:blabla 4 | DTSTART;TZID=Europe/London:20190801T140000 5 | DTEND;TZID=Europe/London:20190801T150000 6 | DTSTAMP:20190801T083416Z 7 | UID:blabla 8 | SEQUENCE:0 9 | RRULE:FREQ=WEEKLY;UNTL=20191023;BYDAY=TH;WKST=SU 10 | CREATED:20190729T105342Z 11 | DESCRIPTION:blabla 12 | LAST-MODIFIED:20190801T064315Z 13 | LOCATION: 14 | STATUS:CONFIRMED 15 | TRANSP:OPAQUE 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_148_exdate_and_rdate_unedited.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240707T214014Z 4 | DTSTART;VALUE=DATE:20240701 5 | DTEND;VALUE=DATE:20240702 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | EXDATE;VALUE=DATE:20240715 15 | RDATE;VALUE=DATE:20240717 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Security Policy 2 | =============== 3 | 4 | Supported Versions 5 | ------------------ 6 | 7 | Security fixes will be released as a new version. Since the API is relatively stable and this is a small project, we release security fixes in new releases. 8 | 9 | Reporting a Vulnerability 10 | ------------------------- 11 | 12 | To report a security vulnerability, please use the 13 | [Tidelift security contact](https://tidelift.com/security). 14 | Tidelift will coordinate the fix and disclosure. 15 | -------------------------------------------------------------------------------- /docs/reference/dependencies.rst: -------------------------------------------------------------------------------- 1 | 2 | Libraries Used 3 | ============== 4 | 5 | - `python-dateutil `_ - to compute the recurrences of events using ``rrule`` 6 | - `icalendar`_ - the library used to parse ICS files 7 | - `pytz `_ - for timezones 8 | - `x-wr-timezone `_ for handling the non-standard ``X-WR-TIMEZONE`` property. 9 | 10 | 11 | .. _icalendar: https://pypi.org/project/icalendar/ -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_117_until_before_dtstart.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20231102T221721Z 4 | DTSTART;VALUE=DATE:20231002 5 | DTEND;VALUE=DATE:20231009 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | SUBCALENDAR-NAME:test 9 | EVENT-ID:538924 10 | EVENT-ALLDAY:true 11 | RRULE:FREQ=WEEKLY;UNTIL=20231001;INTERVAL=2;BYDAY=MO 12 | CREATED:20231102T221633Z 13 | LAST-MODIFIED:20231102T221716Z 14 | TRANSP:TRANSPARENT 15 | STATUS:CONFIRMED 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_128_only_first_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20231102T221721Z 4 | DTSTART;VALUE=DATE:20231002 5 | DTEND;VALUE=DATE:20231009 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | SUBCALENDAR-NAME:test 9 | EVENT-ID:538924 10 | EVENT-ALLDAY:true 11 | RRULE:FREQ=WEEKLY;UNTIL=20240331;COUNT=-1;INTERVAL=4;BYDAY=MO 12 | CREATED:20231102T221633Z 13 | LAST-MODIFIED:20231102T221716Z 14 | TRANSP:TRANSPARENT 15 | STATUS:CONFIRMED 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_15_duplicated_events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART:20130803T190000Z 4 | DTEND:20130803T210000Z 5 | SUMMARY:This is an event 6 | END:VEVENT 7 | BEGIN:VEVENT 8 | DTSTART:20130803T190000Z 9 | DTEND:20130803T210000Z 10 | SUMMARY:This event is almost the same event 11 | END:VEVENT 12 | BEGIN:VEVENT 13 | DTSTART:20130803T190000Z 14 | DTEND:20130803T220000Z 15 | SUMMARY:This event is a little longer but starts at the same time 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /recurring_ical_events/version.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Nicco Kunzmann and Open Web Calendar Contributors 2 | # 3 | # SPDX-License-Identifier: GPL-2.0-only 4 | 5 | try: 6 | from ._version import __version__, __version_tuple__, version, version_tuple 7 | except ModuleNotFoundError: 8 | __version__ = version = "0.0dev0" 9 | __version_tuple__ = version_tuple = (0, 0, "dev0") 10 | 11 | __all__ = [ 12 | "__version__", 13 | "__version_tuple__", 14 | "version", 15 | "version_tuple", 16 | ] 17 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_97_simple_journal.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//Example Corp.//CalDAV Client//EN 4 | BEGIN:VJOURNAL 5 | UID:19920901T130000Z-123409@host.com 6 | DTSTAMP:19920901T130000Z 7 | DTSTART:19920420 8 | SUMMARY:Yearly Income Tax Report 9 | DESCRIPTION:We made it this year too. Probably. What's the point of a recurring journal entry? Journals are supposed to describe past events, aren't they? 10 | RRULE:FREQ=YEARLY 11 | CLASS:CONFIDENTIAL 12 | CATEGORIES:FAMILY,FINANCE 13 | PRIORITY:1 14 | END:VJOURNAL 15 | END:VCALENDAR 16 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_count.py: -------------------------------------------------------------------------------- 1 | """Create an test the count() method. 2 | 3 | We want to be able to count the amount of events really fast. 4 | """ 5 | 6 | import pytest 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("calendar", "count"), 11 | [ 12 | ("issue_20_exdate_ignored", 7), 13 | ("issue_148_ignored_exdate", 2), 14 | ("issue_117_until_before_dtstart", 0), 15 | ], 16 | ) 17 | def test_check_count_of_calendars(calendars, calendar, count): 18 | """We count the events.""" 19 | assert calendars[calendar].count() == count 20 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/duplicated_rrule.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//CyrusIMAP.org/Cyrus 4 | 3.9.0-alpha0-85-gd6d859e0cf-fm-20230116.001-gd6d859e0//EN 5 | BEGIN:VEVENT 6 | CREATED:20230109T084023Z 7 | LAST-MODIFIED:20230119T110732Z 8 | DTSTAMP:20230119T110732Z 9 | UID:56cdc4dc-11b7-407c-86c6-9faedfc28afb 10 | SUMMARY:My repeating event 11 | RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=20 12 | RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=20 13 | DTSTART;TZID=Europe/London:20230112T100000 14 | DTEND;TZID=Europe/London:20230112T120000 15 | END:VEVENT 16 | END:VCALENDAR 17 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/multiple_rrule.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//CyrusIMAP.org/Cyrus 4 | 3.9.0-alpha0-85-gd6d859e0cf-fm-20230116.001-gd6d859e0//EN 5 | BEGIN:VEVENT 6 | CREATED:20230109T084023Z 7 | LAST-MODIFIED:20230119T110732Z 8 | DTSTAMP:20230119T110732Z 9 | UID:56cdc4dc-11b7-407c-86c6-9faedfc28afb 10 | SUMMARY:My repeating event 11 | RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=20 12 | RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=2 13 | DTSTART;TZID=Europe/London:20230112T100000 14 | DTEND;TZID=Europe/London:20230112T120000 15 | END:VEVENT 16 | END:VCALENDAR 17 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/date_exclude.txt: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//PYVOBJECT//NONSGML Version 1//EN 4 | BEGIN:VEVENT 5 | UID:d9b8ba07-1d4a-40ec-bdb5-c4f538d261af 6 | DTSTART:20231215T060000Z 7 | DTEND:20231215T070000Z 8 | CATEGORIES:Kategorie 9 | CLASS:PUBLIC 10 | CREATED:20231215T070936Z 11 | DESCRIPTION:La concha de su madre 12 | DTSTAMP:20231215T154916Z 13 | EXDATE;VALUE=DATE:20231216 14 | LAST-MODIFIED:20231215T154916Z 15 | RRULE:FREQ=DAILY;UNTIL=20231218T023000Z;FREQ=DAILY 16 | SEQUENCE:35 17 | SUMMARY:Tag ausgeschlossen 18 | TRANSP:OPAQUE 19 | END:VEVENT 20 | END:VCALENDAR 21 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_4_weidenrinde.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | UID:2334a952-f89f-4308-9639-fc22dc70519f 4 | RRULE:FREQ=WEEKLY;UNTIL=20190830;INTERVAL=1;BYDAY=FR 5 | SUMMARY:TEXT 6 | DTSTART;VALUE=DATE:20190823 7 | DTEND;VALUE=DATE:20190824 8 | STATUS:CONFIRMED 9 | CLASS:PUBLIC 10 | X-MICROSOFT-CDO-ALLDAYEVENT:TRUE 11 | X-MICROSOFT-CDO-INTENDEDSTATUS:OOF 12 | TRANSP:OPAQUE 13 | LAST-MODIFIED:20190814T093121Z 14 | DTSTAMP:20190814T093121Z 15 | SEQUENCE:0 16 | BEGIN:VALARM 17 | ACTION:DISPLAY 18 | TRIGGER;RELATED=START:-PT5M 19 | DESCRIPTION:Reminder 20 | END:VALARM 21 | END:VEVENT 22 | END:VCALENDAR 23 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_44_double_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:agy35@gmail.com 7 | X-WR-TIMEZONE:Europe/London 8 | BEGIN:VEVENT 9 | DTSTART;VALUE=DATE:20200814 10 | DTEND;VALUE=DATE:20200815 11 | DTSTAMP:20200819T200956Z 12 | UID:42sftcvqfh1jj1uk9lfrsufsi@google.com 13 | CREATED:20200819T200939Z 14 | DESCRIPTION: 15 | LAST-MODIFIED:20200819T200939Z 16 | LOCATION: 17 | SEQUENCE:0 18 | STATUS:CONFIRMED 19 | SUMMARY:test2 20 | TRANSP:TRANSPARENT 21 | END:VEVENT 22 | END:VCALENDAR 23 | -------------------------------------------------------------------------------- /recurring_ical_events/adapters/alarm.py: -------------------------------------------------------------------------------- 1 | """Adapter for VALARM components.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from recurring_ical_events.adapters.component import ComponentAdapter 8 | 9 | if TYPE_CHECKING: 10 | from icalendar import Alarm 11 | 12 | 13 | class AbsoluteAlarmAdapter(ComponentAdapter): # TODO: remove 14 | """Adapter for absolute alarms.""" 15 | 16 | def __init__(self, alarm: Alarm, parent: ComponentAdapter): 17 | """Create a new adapter.""" 18 | super().__init__(alarm) 19 | self.parent = parent 20 | 21 | 22 | __all__ = ["AbsoluteAlarmAdapter"] 23 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_18_cancel_status.py: -------------------------------------------------------------------------------- 1 | """Test that cancelled events are actually repeated as cancelled.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("date", "attr", "value"), 8 | [ 9 | ("20200128", "STATUS", None), 10 | ("20200129", "STATUS", "CANCELLED"), 11 | ("20200130", "STATUS", None), 12 | ("20200128", "TRANSP", "OPAQUE"), 13 | ("20200129", "TRANSP", "OPAQUE"), 14 | ("20200130", "TRANSP", "OPAQUE"), 15 | ], 16 | ) 17 | def test_events_are_cancelles(calendars, date, attr, value): 18 | event = calendars.issue_18_cancel_status.at(date)[0] 19 | assert event.get(attr) == value 20 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_164_duplicated_event.py: -------------------------------------------------------------------------------- 1 | """Duplicated instances of the same event are returned in v3.1.0. The bug is not present in v2.1.3. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/164 4 | """ 5 | 6 | 7 | def test_event_is_only_returned_once(calendars): 8 | """We should not see the same event twice!""" 9 | events = calendars.issue_164_duplicated_event.at([2024, 8]) 10 | for event in events: 11 | start = event["DTSTART"].dt 12 | duration = event["DTEND"].dt - event["DTSTART"].dt 13 | print(f"start {start} duration {duration}") 14 | print(event.to_ical().decode()) 15 | assert len(events) == 2 16 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_179_span_in_event.py: -------------------------------------------------------------------------------- 1 | """We want to check that even if the span falls into a day long event, the event is found. 2 | 3 | RFC 5545: 4 | 5 | For cases where a "VEVENT" calendar component 6 | specifies a "DTSTART" property with a DATE value type but no 7 | "DTEND" nor "DURATION" property, the event's duration is taken to 8 | be one day. 9 | """ 10 | 11 | import pytest 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "dt", 16 | [ 17 | (1997, 11, 2, 0), 18 | (1997, 11, 2, 1), 19 | ], 20 | ) 21 | def test_event_occurs(calendars, dt): 22 | """The event should occur.""" 23 | events = calendars.issue_179_example.at(dt) 24 | assert len(events) == 1 25 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_86_x_wr_timezone_but_no_tzid_in_dt.py: -------------------------------------------------------------------------------- 1 | """ 2 | This tests the fix present in x-wr-timezone v0.0.4. 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/86 4 | 5 | """ 6 | 7 | 8 | def test_event_can_be_retrieved(calendars): 9 | event = next(calendars.issue_86_x_wr_timezone_without_time_zone_in_dt.all()) 10 | assert event["DTSTART"].dt.tzinfo is not None, "should be replaced" 11 | assert event["DTSTART"].dt.tzname() == "CEST" 12 | assert event["DTSTART"].dt.year == 2021 13 | assert event["DTSTART"].dt.month == 9 14 | assert event["DTSTART"].dt.day == 16 15 | assert event["DTSTART"].dt.hour == 21 16 | assert event["DTSTART"].dt.minute == 0 17 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/duration.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | X-SOURCE:https://github.com/irgangla/icalevents/blob/master/test/test_data/duration.ics 3 | X-LICENSE:https://github.com/irgangla/icalevents/blob/master/LICENSE 4 | BEGIN:VEVENT 5 | DTSTART:20180110 6 | DURATION:P3D 7 | DESCRIPTION:Event with duration (3 days), instead of explicit end. 8 | SUMMARY:Duration Event 1 9 | END:VEVENT 10 | BEGIN:VEVENT 11 | DTSTART:20180115T100000 12 | DURATION:PT3H 13 | DESCRIPTION:Event with duration (3 hours), instead of explicit end. 14 | SUMMARY:Duration Event 2 15 | END:VEVENT 16 | BEGIN:VEVENT 17 | DTSTART:20180120T120000 18 | DESCRIPTION:Event without explicit dtend, nor duration property. 19 | SUMMARY:Short event 20 | END:VEVENT 21 | END:VCALENDAR 22 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_convert_inputs.py: -------------------------------------------------------------------------------- 1 | """Test that different inputs are understood 2 | 3 | Also see test_time_arguments.py 4 | """ 5 | 6 | import pytest 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("start", "stop", "event_count"), 11 | [ 12 | (2020, 2021, 366), 13 | ((2020,), (2021,), 366), 14 | ((2019, 2), (2020, 2), 334), 15 | ((2019, 2, 4), (2019, 5, 21), 78), 16 | ("20190204", "20190521", 78), 17 | ("20190204T000000Z", "20190521T235959Z", 79), # 78 = that of the day 18 | ], 19 | ) 20 | def test_calendar_between_allows_tuple(calendars, start, stop, event_count): 21 | events = calendars.one_day_event_repeat_every_day.between(start, stop) 22 | assert len(events) == event_count 23 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_skip_bad_events.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from recurring_ical_events import of 6 | from recurring_ical_events.errors import InvalidCalendar 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("calendar_name", "start", "end"), 11 | [ 12 | ("bad_rrule_missing_until_event", date(2019, 3, 1), date(2019, 12, 31)), 13 | ], 14 | ) 15 | def test_skip_bad_events(calendars, calendar_name, start, end): 16 | calendar = calendars.raw[calendar_name] 17 | with pytest.raises(InvalidCalendar): 18 | rcalendar = of(calendar, skip_bad_series=False) 19 | rcalendar.between(start, end) 20 | 21 | rcalendar = of(calendar, skip_bad_series=True) 22 | rcalendar.between(start, end) 23 | -------------------------------------------------------------------------------- /docs/community/maintenance.rst: -------------------------------------------------------------------------------- 1 | 2 | Maintenance 3 | =========== 4 | 5 | This sets you up for maintenance. 6 | 7 | New Releases 8 | ------------ 9 | 10 | You can build the new release by running this command: 11 | 12 | .. code-block:: shell 13 | 14 | tox -e build 15 | 16 | To release new versions, 17 | 18 | 1. Edit the Changelog Section. 19 | 2. Create a commit and push it. 20 | 3. Wait for `GitHub Actions `_ to finish the build. 21 | 4. Run 22 | 23 | .. code-block:: shell 24 | 25 | git tag v3.5.1 26 | git push origin v3.5.1 27 | 28 | 5. Wait for the tag to build. @niccokunzmann or another maintainer needs to approve the push to PyPI. 29 | 6. Notify the issues about their release. 30 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_107_omitting_last_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Pacific Standard Time: 6 | BEGIN:STANDARD 7 | DTSTART:16010101T020000 8 | TZOFFSETFROM:-0700 9 | TZOFFSETTO:-0800 10 | RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11 11 | END:STANDARD 12 | BEGIN:DAYLIGHT 13 | DTSTART:16010101T020000 14 | TZOFFSETFROM:-0800 15 | TZOFFSETTO:-0700 16 | RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3 17 | END:DAYLIGHT 18 | END:VTIMEZONE 19 | BEGIN:VEVENT 20 | RRULE:FREQ=WEEKLY;UNTIL=20230608T170000Z;INTERVAL=1;BYDAY=TH;WKST=SU 21 | DTSTART;TZID=Pacific Standard Time:20230105T100000 22 | DTEND;TZID=Pacific Standard Time:20230105T110000 23 | END:VEVENT 24 | END:VCALENDAR -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_28_timezone_with_z.py: -------------------------------------------------------------------------------- 1 | """These tests are for Issue 28 2 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/28 3 | 4 | """ 5 | 6 | 7 | def test_expected_amount_of_events(calendars): 8 | events = calendars.issue_28_rrule_with_UTC_endinginZ.between( 9 | (2020, 5, 25), 10 | (2020, 9, 5), 11 | ) 12 | assert len(events) == 15, ( 13 | "Microsoft Outlook online imports this calendar and shows that there are 15 events." 14 | ) 15 | 16 | 17 | def test_modification_of_event(calendars): 18 | events = calendars.issue_28_rrule_with_UTC_endinginZ.between( 19 | (2020, 9, 3), 20 | (2020, 9, 5), 21 | ) 22 | assert len(events) == 1, "Microsoft Outlook online shows one event moved." 23 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_at_function.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("a_date", "count"), 8 | [ 9 | # Year 10 | (2018, 3), 11 | ((2018,), 3), 12 | # Month 13 | ((2018, 1), 3), 14 | # Day 15 | ((2018, 1, 11), 1), 16 | (date(2018, 1, 11), 1), 17 | ("20180111", 1), 18 | ((2018, 1, 9), 0), 19 | (date(2018, 1, 9), 0), 20 | ("20180109", 0), 21 | # Datetime 22 | (datetime(2018, 1, 11, 10, 0, 0), 1), 23 | (datetime(2018, 1, 9, 10, 0, 0), 0), 24 | ], 25 | ) 26 | def test_at_input_arguments(a_date, count, calendars): 27 | events = calendars.duration.at(a_date) 28 | assert len(events) == count 29 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_example_function.py: -------------------------------------------------------------------------------- 1 | """Test the example function.""" 2 | 3 | import pytest 4 | 5 | from recurring_ical_events.examples import example_calendar 6 | 7 | 8 | def test_valid_example_is_returned(): 9 | """We can get a valid example.""" 10 | c = example_calendar("fablab_cottbus") 11 | assert c["X-FROM-URL"] == "http://blog.fablab-cottbus.de" 12 | 13 | 14 | def test_we_can_remove_the_ics(): 15 | """We can get a valid example.""" 16 | assert example_calendar("duration") == example_calendar("duration.ics") 17 | 18 | 19 | def test_we_know_which_files_are_ok(): 20 | """The error message shows us which examples to use.""" 21 | with pytest.raises(ValueError) as e: 22 | example_calendar("missing") 23 | assert "issue_4" in str(e.value) 24 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_148_ignored_exdate.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240707T214014Z 4 | DTSTART;VALUE=DATE:20240701 5 | DTEND;VALUE=DATE:20240708 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | END:VEVENT 15 | BEGIN:VEVENT 16 | DTSTAMP:20240707T214014Z 17 | DTSTART;VALUE=DATE:20240701 18 | DTEND;VALUE=DATE:20240708 19 | SUMMARY:test123 - edited 20 | CATEGORIES:other 21 | UID:111 22 | ORGANIZER:aaa 23 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 24 | EXDATE;VALUE=DATE:20240715 25 | CREATED:20240311T051101Z 26 | LAST-MODIFIED:20240701T063743Z 27 | SEQUENCE:2 28 | END:VEVENT 29 | END:VCALENDAR 30 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/rdate.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART:20130803T190000Z 4 | DTEND:20130803T210000Z 5 | RDATE;VALUE=DATE-TIME:20140705T190000Z 6 | RRULE:FREQ=DAILY;UNTIL=20150320T030000Z 7 | SUMMARY:rdate and rrule overlap 8 | END:VEVENT 9 | BEGIN:VEVENT 10 | DTSTART:20140803T190000Z 11 | DTEND:20140803T210000Z 12 | RDATE;VALUE=DATE-TIME:20150705T190000Z 13 | EXDATE;VALUE=DATE-TIME:20150705T190000Z 14 | RRULE:FREQ=DAILY;UNTIL=20160320T030000Z 15 | SUMMARY:rdate and rrule overlap but exdate removes the date again 16 | END:VEVENT 17 | BEGIN:VEVENT 18 | DTSTART:20240803T190000Z 19 | DTEND:20240803T210000Z 20 | RDATE;VALUE=DATE-TIME:20250705T190000Z 21 | EXDATE;VALUE=DATE-TIME:20250705T190000Z 22 | SUMMARY:rdate but exdate removes the date again 23 | END:VEVENT 24 | END:VCALENDAR 25 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_61.py: -------------------------------------------------------------------------------- 1 | """This file tests the issue 61. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/61 4 | 5 | We expect DATE as the value. 6 | 7 | DTSTART;VALUE=DATE:20211215 8 | DTEND;VALUE=DATE:20211216 9 | 10 | """ 11 | 12 | import datetime 13 | 14 | from pytz import timezone 15 | 16 | 17 | def test_sequence_is_not_present(calendars): 18 | tz = str(calendars.raw.issue_61_time_zone_error.get("X-WR-TIMEZONE")) 19 | now = timezone(tz).localize( 20 | datetime.datetime(2021, 12, 15, 17, 41, 1, 446354) 21 | ) # datetime.datetime.now(timezone(tz)) # use fixed time 22 | events = calendars.issue_61_time_zone_error.at(now) 23 | assert len(events) == 1 24 | assert isinstance(events[0]["DTSTART"].dt, datetime.date) 25 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_readme.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the README file. 3 | 4 | This is necessary because a deployment does not work if the README file 5 | has errors. 6 | 7 | Credits: https://stackoverflow.com/a/47494076/1320237 8 | """ 9 | 10 | from pathlib import Path 11 | 12 | import restructuredtext_lint 13 | 14 | HERE = Path(__file__).parent 15 | readme_path = Path(HERE).parent.parent / "README.rst" 16 | 17 | 18 | def test_readme_file(): 19 | """CHeck README file for errors.""" 20 | messages = restructuredtext_lint.lint_file(str(readme_path)) 21 | error_message = "expected to have no messages about the README file!" 22 | for message in messages: 23 | print(message.astext()) 24 | error_message += "\n" + message.astext() 25 | assert len(messages) == 0, error_message 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: github-actions 13 | directory: / 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" # Group all Actions updates into a single larger pull request 18 | schedule: 19 | interval: weekly 20 | -------------------------------------------------------------------------------- /recurring_ical_events/constants.py: -------------------------------------------------------------------------------- 1 | """Constants for recurring_ical_events.""" 2 | 3 | import datetime 4 | import re 5 | from pathlib import Path 6 | 7 | # The minimum value accepted as date (pytz + zoneinfo) 8 | DATE_MIN = (1970, 1, 1) 9 | DATE_MIN_DT = datetime.date(*DATE_MIN) 10 | # The maximum value accepted as date (pytz + zoneinfo) 11 | DATE_MAX = (2038, 1, 1) 12 | DATE_MAX_DT = datetime.date(*DATE_MAX) 13 | 14 | # the location of this file 15 | HERE = Path(__file__).parent 16 | 17 | # the directory with all example calendars 18 | CALENDARS = HERE / "test" / "calendars" 19 | 20 | NEGATIVE_RRULE_COUNT_REGEX = re.compile(r"COUNT=-\d+;?") 21 | 22 | __all__ = [ 23 | "CALENDARS", 24 | "DATE_MAX", 25 | "DATE_MAX_DT", 26 | "DATE_MIN", 27 | "DATE_MIN_DT", 28 | "NEGATIVE_RRULE_COUNT_REGEX", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/reference/architecture.rst: -------------------------------------------------------------------------------- 1 | Architecture 2 | ============ 3 | 4 | .. image:: ../img/architecture.png 5 | :alt: Architecture Diagram showing the components interacting 6 | 7 | Each icalendar **Calendar** can contain Events, Journal entries, 8 | TODOs and others, called **Components**. 9 | Those entries are grouped by their ``UID``. 10 | Such a ``UID`` defines a **Series** of **Occurrences** that take place at 11 | a given time. 12 | Since each **Component** is different, the **ComponentAdapter** offers a unified 13 | interface to interact with them. 14 | The **Calendar** gets filtered and for each ``UID``, 15 | a **Series** can use one or more **ComponentAdapters** to create 16 | **Occurrences** of what happens in a time span. 17 | These **Occurrences** are used internally and convert to **Components** for further use. 18 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_173_only_modification_included.py: -------------------------------------------------------------------------------- 1 | """Calendars do not only contain events with an RRULE but they can contain recurrences without 2 | any RRULE in them. 3 | 4 | I assume this can happen for example when one accepts a calendar invitation of 5 | one instance of an recurring event. 6 | 7 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/173 8 | """ 9 | 10 | 11 | def test_event_can_be_found_in_the_right_time(calendars): 12 | """The event reported should be present in the calculations.""" 13 | events = calendars.issue_173_only_modifications_error.at("20240108") 14 | assert len(events) >= 1 15 | uid = "_6krj2dhl74q34b9j60sj4b9k8h238b9p6gok2ba68gojgchl6cpj0h1o88_R20231009T130000@google.com" 16 | assert any(event["UID"] == uid for event in events) 17 | -------------------------------------------------------------------------------- /recurring_ical_events/types.py: -------------------------------------------------------------------------------- 1 | """Type annotations.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | from typing import Tuple, Union 7 | try: 8 | from typing import TypeAlias 9 | except ImportError: 10 | from typing_extensions import TypeAlias 11 | 12 | # Any types documented here should also be mentioned in the docs/conf.py. 13 | 14 | Time : TypeAlias = Union[datetime.date, datetime.datetime] 15 | DateArgument : TypeAlias = Union[Tuple[int], datetime.date, str, int] 16 | UID : TypeAlias = str 17 | Timestamp : TypeAlias = float 18 | RecurrenceID : TypeAlias = datetime.datetime 19 | RecurrenceIDs : TypeAlias = Tuple[RecurrenceID] 20 | 21 | 22 | __all__ = [ 23 | "UID", 24 | "DateArgument", 25 | "RecurrenceID", 26 | "RecurrenceIDs", 27 | "Time", 28 | "Timestamp", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/community/media.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | New, talks, tutorials and other media about this library. 6 | --- 7 | 8 | # Media 9 | 10 | Additional to this documentation, there are other sources of information. 11 | 12 | ## News 13 | 14 | You can view current news about this library on [Mastodon](https://toot.wales/tags/RecurringIcalEvents). 15 | 16 | ## Videos 17 | 18 | There are a few videos that cover using this library on [Youtube](https://www.youtube.com/watch?v=nwpS2dCk_Rk&list=PLxMGFFiBKgdb3L550U5EAiCvft2IK08xK). 19 | 20 | 21 | ## Conferences 22 | 23 | Nicco Kunzmann talked about this library at the 24 | FOSSASIA 2022 Summit: 25 | 26 | [![FOSSASIA 2022 talk by Nicco Kunzmann](https://niccokunzmann.github.io/ical-talk-fossasia-2022/youtube.png)](https://youtu.be/8l3opDdg92I?t=10369) 27 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_util_functions.py: -------------------------------------------------------------------------------- 1 | """Check some utility functions.""" 2 | 3 | from typing import NamedTuple 4 | 5 | import pytest 6 | 7 | from recurring_ical_events.util import with_highest_sequence 8 | 9 | 10 | class Component(NamedTuple): 11 | sequence: int 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("a1", "a2", "result"), 16 | [ 17 | (None, None, None), 18 | (Component(1), Component(2), Component(2)), 19 | (Component(5), Component(2), Component(5)), 20 | (Component(1), None, Component(1)), 21 | (None, Component(4), Component(4)), 22 | (None, Component(-3), Component(-3)), 23 | (Component(-1), None, Component(-1)), 24 | ], 25 | ) 26 | def test_highest_sequence(a1, a2, result): 27 | """Check the result""" 28 | assert with_highest_sequence(a1, a2) == result 29 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_36_recurrence_id_format.py: -------------------------------------------------------------------------------- 1 | """ 2 | These tests are for issue 36 3 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/36 4 | """ 5 | 6 | 7 | def test_datetime_replaced_by_datetime(calendars): 8 | events = calendars.issue_36_recurrence_ID_format.at("20200917") 9 | assert len(events) == 1 10 | assert events[0].get("SUMMARY") == "Modified event" 11 | 12 | 13 | def test_date_replaced_by_date(calendars): 14 | events = calendars.issue_36_recurrence_ID_format.at("20200914") 15 | assert len(events) == 1 16 | assert events[0].get("SUMMARY") == "Modified event 1" 17 | 18 | 19 | def test_date_replaced_by_datetime(calendars): 20 | events = calendars.issue_36_recurrence_ID_format.at("20200921") 21 | assert len(events) == 1 22 | assert events[0].get("SUMMARY") == "Modified event 2" 23 | -------------------------------------------------------------------------------- /recurring_ical_events/examples.py: -------------------------------------------------------------------------------- 1 | """Functionality for smaller examples.""" 2 | 3 | import icalendar 4 | 5 | from recurring_ical_events.constants import CALENDARS 6 | 7 | 8 | def example_calendar(name: str = "") -> icalendar.Calendar: 9 | """Return an example calendar. 10 | 11 | Args: 12 | name (str): The name of the example file. 13 | 14 | Returns: 15 | icalendar.cal.Calendar: The parsed calendar example. 16 | """ 17 | if not name.endswith(".ics"): 18 | name += ".ics" 19 | path = CALENDARS / name 20 | try: 21 | return icalendar.Calendar.from_ical(path.read_bytes()) 22 | except FileNotFoundError: 23 | raise ValueError( # noqa: B904 24 | f"File {name!r} not found. " 25 | f"Use one of {', '.join(p.name for p in CALENDARS.glob('*.ics'))!r}." 26 | ) 27 | 28 | 29 | __all__ = ["example_calendar"] 30 | -------------------------------------------------------------------------------- /benchmark/issue42.py: -------------------------------------------------------------------------------- 1 | # py3 2 | # 3 | # This is the benchmark for rrule speed 4 | # see https://github.com/niccokunzmann/python-recurring-ical-events/issues/42 5 | # 6 | 7 | import sys 8 | from pathlib import Path 9 | 10 | import icalendar 11 | 12 | import recurring_ical_events 13 | 14 | HERE = Path(__file__).parent or Path() 15 | sys.path.append(HERE.parent) 16 | 17 | 18 | # read utf 19 | text = [] 20 | with Path(HERE / "issue42.ics").open("rb") as fobj: 21 | text += fobj.readlines() 22 | text_utf = [i.decode("utf-8") for i in text] 23 | 24 | ical_string = "".join(text_utf) 25 | 26 | 27 | calendar = icalendar.Calendar.from_ical(ical_string) 28 | 29 | rec_calendar = recurring_ical_events.of(calendar) 30 | 31 | for day in range(1, 29): 32 | print("day", day) # noqa: T201 33 | start_date = (2011, 11, day) 34 | events = rec_calendar.at(start_date) 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_148_exdate_and_rdate_updated.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240707T214014Z 4 | DTSTART;VALUE=DATE:20240701 5 | DTEND;VALUE=DATE:20240702 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | EXDATE;VALUE=DATE:20240715 15 | RDATE;VALUE=DATE:20240717 16 | END:VEVENT 17 | BEGIN:VEVENT 18 | DTSTAMP:20240707T214014Z 19 | DTSTART;VALUE=DATE:20240701 20 | DTEND;VALUE=DATE:20240702 21 | SUMMARY:test123 - edited 22 | CATEGORIES:other 23 | UID:111 24 | ORGANIZER:aaa 25 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 26 | EXDATE;VALUE=DATE:20240729 27 | RDATE;VALUE=DATE:20240730 28 | CREATED:20240311T051101Z 29 | LAST-MODIFIED:20240701T063743Z 30 | SEQUENCE:2 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/zero_size_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:zero size event 31 | DTSTART;TZID=Europe/Berlin:20190304T080000 32 | END:VEVENT 33 | END:VCALENDAR 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_daylight_saving_time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from pytz import timezone 5 | 6 | berlin = timezone("Europe/Berlin") 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("date", "time"), 11 | [ 12 | ( 13 | (2019, 3, 20), 14 | berlin.localize(datetime.datetime(2019, 3, 20, 19)), 15 | ), # winter time, UTC+1 16 | ( 17 | (2019, 4, 24), 18 | berlin.localize(datetime.datetime(2019, 4, 24, 19)), 19 | ), # summer time UTC+2 20 | ], 21 | ) 22 | def test_daylight_saving_events(calendars, date, time): 23 | """Test the event 7uartkcnhf0elbvs8md0itrf6c@google.com.""" 24 | event = calendars.daylight_saving_time.at(date)[0] 25 | expected_time = calendars.consistent_tz(time) 26 | print(event["UID"]) 27 | print(event["DTEND"].dt) 28 | assert event["DTSTART"].dt == expected_time 29 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/one_day_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test2 31 | DTSTART;VALUE=DATE:20190304 32 | DTEND;VALUE=DATE:20190305 33 | END:VEVENT 34 | END:VCALENDAR 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_15.py: -------------------------------------------------------------------------------- 1 | """This file tests the issue 15. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/15 4 | 5 | calendars = 6 | 7 | def test_rdate_does_not_double_rrule_entry(calendars): 8 | > events = calendars.rdate.at("20140705") 9 | 10 | test/test_rdate.py:56: 11 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 12 | recurring_ical_events.py:277: in at 13 | return self.between(dt, dt + self._DELTAS[len(date) - 3]) 14 | recurring_ical_events.py:306: in between 15 | add_event(repetition.as_vevent()) 16 | recurring_ical_events.py:292: in add_event 17 | if event["SEQUENCE"] < other["SEQUENCE"]: 18 | 19 | """ 20 | 21 | 22 | def test_sequence_is_not_present(calendars): 23 | events = calendars.issue_15_duplicated_events.at("20130803") 24 | assert len(events) == 3 25 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_113_period_in_rdate.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | X-WR-CALNAME;VALUE=TEXT:Test RDATE 4 | BEGIN:VTIMEZONE 5 | TZID:America/Vancouver 6 | BEGIN:STANDARD 7 | DTSTART:20221106T020000 8 | TZOFFSETFROM:-0700 9 | TZOFFSETTO:-0800 10 | RDATE:20231105T020000 11 | TZNAME:PST 12 | END:STANDARD 13 | BEGIN:DAYLIGHT 14 | DTSTART:20230312T020000 15 | TZOFFSETFROM:-0800 16 | TZOFFSETTO:-0700 17 | RDATE:20240310T020000 18 | TZNAME:PDT 19 | END:DAYLIGHT 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | UID:1 23 | DESCRIPTION:Test RDATE 24 | DTSTART;TZID=America/Vancouver:20230920T120000 25 | DTEND;TZID=America/Vancouver:20230920T140000 26 | EXDATE;TZID=America/Vancouver:20231220T120000 27 | RDATE;VALUE=PERIOD;TZID=America/Vancouver:20231213T120000/20231213T150000 28 | RRULE:FREQ=MONTHLY;COUNT=9;INTERVAL=1;BYDAY=+3WE;BYMONTH=1,2,3,4,5,9,10,11, 29 | 12;WKST=MO 30 | SUMMARY:Test RDATE 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_single_events.py: -------------------------------------------------------------------------------- 1 | from recurring_ical_events.constants import DATE_MAX 2 | 3 | 4 | def test_a_calendar_with_no_events_has_no_events(calendars): 5 | events = calendars.no_events.between((2000, 1, 1), DATE_MAX) 6 | assert not events 7 | 8 | 9 | def test_a_calendar_with_one_event_has_one_event(calendars): 10 | events = calendars.one_event.between((2000, 1, 1), DATE_MAX) 11 | assert len(events) == 1 12 | 13 | 14 | def test_event_is_not_included_if_it_is_later(calendars): 15 | events = calendars.one_event.between((2000, 1, 1), (2001, 1, 1)) 16 | assert not events 17 | 18 | 19 | def test_event_is_not_included_if_it_is_earlier(calendars): 20 | events = calendars.one_event.between((2030, 1, 1), DATE_MAX) 21 | assert not events 22 | 23 | 24 | def test_all_events(calendars): 25 | assert len(list(calendars.one_event.all())) == 1 26 | assert len(list(calendars.no_events.all())) == 0 27 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/one_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test1 31 | DTSTART;TZID=Europe/Berlin:20190304T080000 32 | DTEND;TZID=Europe/Berlin:20190304T083000 33 | END:VEVENT 34 | END:VCALENDAR 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/recurrence_sequence_number.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | CALSCALE:GREGORIAN 3 | PRODID:-//Ximian//NONSGML Evolution Calendar//EN 4 | VERSION:2.0 5 | BEGIN:VEVENT 6 | UID:212e857f74c7eb0b4874e024f6537b77bd77dbd5 7 | DTSTAMP:20200922T092948Z 8 | DTSTART;VALUE=DATE:20200908 9 | DTEND;VALUE=DATE:20200909 10 | SEQUENCE:4 11 | SUMMARY:Base event 12 | TRANSP:OPAQUE 13 | CLASS:PUBLIC 14 | CREATED:20200922T155408Z 15 | LAST-MODIFIED:20200922T155513Z 16 | LOCATION:Changed again 17 | RRULE:FREQ=WEEKLY;BYDAY=TU 18 | END:VEVENT 19 | BEGIN:VEVENT 20 | UID:212e857f74c7eb0b4874e024f6537b77bd77dbd5 21 | DTSTAMP:20200922T092948Z 22 | DTSTART;VALUE=DATE:20200922 23 | DTEND;VALUE=DATE:20200923 24 | SEQUENCE:3 25 | SUMMARY:Modified event 26 | TRANSP:OPAQUE 27 | CLASS:PUBLIC 28 | CREATED:20200922T155408Z 29 | LAST-MODIFIED:20200922T155424Z 30 | RECURRENCE-ID;VALUE=DATE:20200922 31 | END:VEVENT 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/end_before_start_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test1 31 | DTSTART;TZID=Europe/Berlin:20190304T083000 32 | DTEND;TZID=Europe/Berlin:20190304T080000 33 | END:VEVENT 34 | END:VCALENDAR 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/event_10_times.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20200115T225152Z 23 | LAST-MODIFIED:20200115T225240Z 24 | DTSTAMP:20200115T225240Z 25 | UID:64374d28-089b-4958-8c95-cdd00e6d8ad3 26 | SUMMARY:event 10 times 27 | RRULE:FREQ=DAILY;COUNT=10 28 | DTSTART;TZID=Europe/Berlin:20200113T074500 29 | DTEND;TZID=Europe/Berlin:20200113T100000 30 | TRANSP:OPAQUE 31 | SEQUENCE:1 32 | X-MOZ-GENERATION:1 33 | END:VEVENT 34 | END:VCALENDAR 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | - niccokunzmann 5 | polar: niccokunzmann/python-recurring-ical-events 6 | patreon: # Replace with a single Patreon username 7 | open_collective: open-web-calendar # Replace with a single Open Collective username 8 | ko_fi: # Replace with a single Ko-fi username 9 | tidelift: pypi/recurring-ical-events # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 10 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 11 | liberapay: # Replace with a single Liberapay username 12 | issuehunt: # Replace with a single IssueHunt username 13 | otechie: # Replace with a single Otechie username 14 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_end_before_start_event.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from recurring_ical_events.errors import PeriodEndBeforeStart 6 | from recurring_ical_events.util import time_span_contains_event 7 | 8 | 9 | def test_span_in_wrong_order(): 10 | """The timespan only works if the order is correct.""" 11 | with pytest.raises(PeriodEndBeforeStart): 12 | time_span_contains_event( 13 | datetime(2019, 10, 13), 14 | datetime(2019, 10, 12), 15 | datetime(2019, 10, 13), 16 | datetime(2019, 10, 14), 17 | ) 18 | 19 | 20 | def test_event_in_wrong_order(): 21 | """The timespan only works if the order is correct.""" 22 | with pytest.raises(PeriodEndBeforeStart): 23 | time_span_contains_event( 24 | datetime(2019, 10, 11), 25 | datetime(2019, 10, 12), 26 | datetime(2019, 10, 15), 27 | datetime(2019, 10, 14), 28 | ) 29 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/one_day_event_repeat_every_day.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test3 31 | CLASS:PUBLIC 32 | STATUS:CONFIRMED 33 | DTSTART;VALUE=DATE:20190304 34 | DTEND;VALUE=DATE:20190305 35 | RRULE:FREQ=DAILY 36 | END:VEVENT 37 | END:VCALENDAR 38 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/rdate_hackerpublicradio.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:Data::ICal 0.20 4 | X-WR-CALNAME:Hacker Public Radio 5 | X-WR-TIMEZONE:Europe/London 6 | BEGIN:VEVENT 7 | DESCRIPTION:This is from http://www.hackerpublicradio.org/eps/hpr1286/iCalendar_ 8 | Hacking_shownotes.html 9 | DTEND:20130803T210000Z 10 | DTSTART:20130803T190000Z 11 | LOCATION:mumble.openspeak.cc port: 64747 12 | RDATE;VALUE=DATE-TIME:20130803T190000Z 13 | RDATE;VALUE=DATE-TIME:20130831T190000Z 14 | RDATE;VALUE=DATE-TIME:20131005T190000Z 15 | RDATE;VALUE=DATE-TIME:20131102T190000Z 16 | RDATE;VALUE=DATE-TIME:20131130T190000Z 17 | RDATE;VALUE=DATE-TIME:20140104T190000Z 18 | RDATE;VALUE=DATE-TIME:20140201T190000Z 19 | RDATE;VALUE=DATE-TIME:20140301T190000Z 20 | RDATE;VALUE=DATE-TIME:20140405T190000Z 21 | RDATE;VALUE=DATE-TIME:20140503T190000Z 22 | RDATE;VALUE=DATE-TIME:20140531T190000Z 23 | RDATE;VALUE=DATE-TIME:20140705T190000Z 24 | SUMMARY:HPR Community News 25 | END:VEVENT 26 | END:VCALENDAR 27 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_132_swapped_start_and_end.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | SUMMARY:XXX 4 | DTSTART;TZID=Europe/Paris:20231218T234500 5 | DTEND;TZID=Europe/Paris:20231218T233000 6 | DTSTAMP:20231213T104027Z 7 | UID:84A72-65798A00-5-20465200 8 | SEQUENCE:1 9 | ATTENDEE;CN="XXX";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPA 10 | NT;RSVP=TRUE:mailto:xxx@yyy.zzz 11 | CLASS:PUBLIC 12 | CREATED:20231213T104027Z 13 | LAST-MODIFIED:20231213T104027Z 14 | ORGANIZER;CN="XXX":mailto:aaa@yyy.zzz 15 | TRANSP:OPAQUE 16 | END:VEVENT 17 | BEGIN:VTODO 18 | SUMMARY:XXX 19 | DTSTART;TZID=Europe/Paris:20231218T234500 20 | DUE;TZID=Europe/Paris:20231218T233000 21 | DTSTAMP:20231213T104027Z 22 | UID:84A72-65798A00-5-20465200 23 | SEQUENCE:1 24 | ATTENDEE;CN="XXX";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPA 25 | NT;RSVP=TRUE:mailto:xxx@yyy.zzz 26 | CLASS:PUBLIC 27 | CREATED:20231213T104027Z 28 | LAST-MODIFIED:20231213T104027Z 29 | ORGANIZER;CN="XXX":mailto:aaa@yyy.zzz 30 | TRANSP:OPAQUE 31 | END:VTODO 32 | END:VCALENDAR 33 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/three_events.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test4 31 | CLASS:PUBLIC 32 | STATUS:CONFIRMED 33 | RRULE:FREQ=DAILY;COUNT=3;INTERVAL=3 34 | DTSTART;TZID=Europe/Berlin:20190304T000000 35 | DTEND;TZID=Europe/Berlin:20190304T010000 36 | END:VEVENT 37 | END:VCALENDAR 38 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_time_arguments.py: -------------------------------------------------------------------------------- 1 | """This file tests whether the time input is correctly converted. 2 | 3 | Also see test_convert_inputs.py 4 | """ 5 | 6 | from datetime import datetime 7 | 8 | import pytest 9 | from pytz import utc 10 | 11 | from recurring_ical_events.query import CalendarQuery 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("input_date", "output_datetime"), 16 | [ 17 | ((2019, 1, 1), datetime(2019, 1, 1)), 18 | ((2000, 12, 2), datetime(2000, 12, 2)), 19 | ((2000, 12, 2, 4), datetime(2000, 12, 2, 4)), 20 | ((2000, 12, 2, 4, 44), datetime(2000, 12, 2, 4, 44)), 21 | ((2000, 12, 2, 4, 44, 55), datetime(2000, 12, 2, 4, 44, 55)), 22 | (datetime(2001, 3, 12, tzinfo=utc), datetime(2001, 3, 12, tzinfo=utc)), 23 | ("20140511T000000Z", datetime(2014, 5, 11)), 24 | ("20150521", datetime(2015, 5, 21)), 25 | ], 26 | ) 27 | def test_conversion(input_date, output_datetime): 28 | assert CalendarQuery.to_datetime(input_date) == output_datetime 29 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/one_event_repeat_every_3_days.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test4 31 | CLASS:PUBLIC 32 | STATUS:CONFIRMED 33 | RRULE:FREQ=DAILY;INTERVAL=3 34 | DTSTART;TZID=Europe/Berlin:20190304T000000 35 | DTEND;TZID=Europe/Berlin:20190304T010000 36 | END:VEVENT 37 | END:VCALENDAR 38 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_timedelta_for_between.py: -------------------------------------------------------------------------------- 1 | """This tests that a timedelta can be used as the second argument to between. 2 | 3 | This is useful when you do not want to calculate this yourself. 4 | """ 5 | 6 | from datetime import timedelta 7 | 8 | import pytest 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("start", "delta", "count"), 13 | [ 14 | ("20190301", timedelta(days=3), 0), 15 | ("20190301", timedelta(days=5), 1), 16 | ("20190301", timedelta(days=3, hours=8), 0), 17 | ("20190301", timedelta(days=3, hours=9), 1), 18 | ("20190301", timedelta(days=3, hours=8, seconds=1), 1), 19 | ("20190304", timedelta(days=1), 1), 20 | ("20190304", timedelta(hours=8), 0), 21 | ("20190304", timedelta(hours=9), 1), 22 | ], 23 | ) 24 | def test_event_with_between_and_timedelta(calendars, start, delta, count): 25 | """The event starts at 20190304T080000 and ends at 20190304T080000""" 26 | events = calendars.one_event.between(start, delta) 27 | assert len(events) == count 28 | -------------------------------------------------------------------------------- /docs/reference/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | Reference for writing documentation 6 | --- 7 | # Documentation 8 | 9 | This section contains links and explanations for writing documentation. 10 | 11 | ## Markdown/MyST files 12 | 13 | The documentation files with `.md` are structured using [Markdown] and [MyST]. 14 | 15 | ## reStructuredText files 16 | 17 | [reStructuredText] is used for `.rst` files to include the source code using [Sphinx]. 18 | 19 | ## Source code 20 | 21 | The source code is written in Python and tested with [doctest]. 22 | The format of the docstrings follows [Google's style guide]. 23 | 24 | [Markdown]: https://www.markdownguide.org/cheat-sheet/ 25 | [reStructuredText]: https://github.com/ralsina/rst-cheatsheet/blob/master/rst-cheatsheet.rst 26 | [MyST]: https://mystmd.org/ 27 | [Sphinx]: https://www.sphinx-doc.org/ 28 | [doctest]: https://docs.python.org/3/library/doctest.html 29 | [Google's style guide]: https://google.github.io/styleguide/pyguide.html#383-functions-and-methods 30 | -------------------------------------------------------------------------------- /docs/reference/research.rst: -------------------------------------------------------------------------------- 1 | 2 | Research 3 | ======== 4 | 5 | - `RFC 5545 `_ 6 | - `RFC 7986 `_ -- an update to RFC 5545. It does not change any properties useful for scheduling events. 7 | - `Stackoverflow question this is created for `_ 8 | - ``_ 9 | 10 | - ``_ 11 | 12 | - ``_ 13 | - ``_ 14 | - ``_ 15 | - RDATE ``_ 16 | 17 | - ``_ 18 | 19 | 20 | .. _`ics-query`: https://github.com/niccokunzmann/ics-query#readme 21 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/each_week_but_one_deleted.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | BEGIN:DAYLIGHT 10 | TZOFFSETFROM:+0100 11 | TZOFFSETTO:+0200 12 | TZNAME:CEST 13 | DTSTART:19700329T020000 14 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 15 | END:DAYLIGHT 16 | BEGIN:STANDARD 17 | TZOFFSETFROM:+0200 18 | TZOFFSETTO:+0100 19 | TZNAME:CET 20 | DTSTART:19701025T030000 21 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 22 | END:STANDARD 23 | END:VTIMEZONE 24 | BEGIN:VEVENT 25 | CREATED:20190303T153829 26 | LAST-MODIFIED:20190303T144155Z 27 | DTSTAMP:20190303T144155Z 28 | UID:SX2CURHKFTKKFFU3VUD7K 29 | SUMMARY:test6 30 | STATUS:CONFIRMED 31 | RRULE:FREQ=WEEKLY;COUNT=8 32 | EXDATE:20190310T233000Z 33 | DTSTART;TZID=Europe/Berlin:20190304T003000 34 | DTEND;TZID=Europe/Berlin:20190304T010000 35 | CLASS:PUBLIC 36 | SEQUENCE:1 37 | X-MOZ-GENERATION:1 38 | END:VEVENT 39 | END:VCALENDAR 40 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/each_week_but_two_deleted.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | BEGIN:DAYLIGHT 10 | TZOFFSETFROM:+0100 11 | TZOFFSETTO:+0200 12 | TZNAME:CEST 13 | DTSTART:19700329T020000 14 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 15 | END:DAYLIGHT 16 | BEGIN:STANDARD 17 | TZOFFSETFROM:+0200 18 | TZOFFSETTO:+0100 19 | TZNAME:CET 20 | DTSTART:19701025T030000 21 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 22 | END:STANDARD 23 | END:VTIMEZONE 24 | BEGIN:VEVENT 25 | CREATED:20190303T153829 26 | LAST-MODIFIED:20190303T151329Z 27 | DTSTAMP:20190303T151329Z 28 | UID:SX2CURHKFTKKFFU3VUD7K 29 | SUMMARY:test6 30 | STATUS:CONFIRMED 31 | RRULE:FREQ=WEEKLY;COUNT=8 32 | EXDATE:20190310T233000Z 33 | EXDATE:20190324T233000Z 34 | DTSTART;TZID=Europe/Berlin:20190304T003000 35 | DTEND;TZID=Europe/Berlin:20190304T010000 36 | CLASS:PUBLIC 37 | SEQUENCE:2 38 | X-MOZ-GENERATION:2 39 | END:VEVENT 40 | END:VCALENDAR 41 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_36_recurrence_ID_format.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | 3 | BEGIN:VEVENT 4 | DTSTART;TZID=Europe/Berlin:20200910T140000 5 | DTEND;TZID=Europe/Berlin:20200910T150000 6 | RRULE:FREQ=WEEKLY 7 | SEQUENCE:0 8 | SUMMARY:Base event 9 | UID:series 1 10 | END:VEVENT 11 | 12 | BEGIN:VEVENT 13 | DTSTART;TZID=Europe/Berlin:20200917T140000 14 | DTEND;TZID=Europe/Berlin:20200917T150000 15 | RECURRENCE-ID:20200917T120000Z 16 | SEQUENCE:1 17 | SUMMARY:Modified event 18 | UID:series 1 19 | END:VEVENT 20 | 21 | 22 | BEGIN:VEVENT 23 | DTSTART;VALUE=DATE:20200907 24 | DTEND;VALUE=DATE:20200908 25 | RRULE:FREQ=WEEKLY 26 | SEQUENCE:0 27 | SUMMARY:Base event 28 | UID:series 2 29 | END:VEVENT 30 | 31 | BEGIN:VEVENT 32 | DTSTART;VALUE=DATE:20200914 33 | DTEND;VALUE=DATE:20200915 34 | RECURRENCE-ID:20200914 35 | SEQUENCE:1 36 | SUMMARY:Modified event 1 37 | UID:series 2 38 | END:VEVENT 39 | 40 | BEGIN:VEVENT 41 | DTSTART;VALUE=DATE:20200921 42 | DTEND;VALUE=DATE:20200922 43 | RECURRENCE-ID:20200921T000000Z 44 | SEQUENCE:2 45 | SUMMARY:Modified event 2 46 | UID:series 2 47 | END:VEVENT 48 | 49 | END:VCALENDAR 50 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_128_only_first_event.py: -------------------------------------------------------------------------------- 1 | """The atlassian confluence calendar sets the count value to -1 when future events are deleted. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/128 4 | """ 5 | 6 | import pytest 7 | 8 | import recurring_ical_events.constants 9 | 10 | 11 | def test_all_events_are_present(calendars): 12 | """All events are shown and not just the first one.""" 13 | assert len(list(calendars.issue_128_only_first_event.all())) == 7 14 | 15 | 16 | @pytest.mark.parametrize( 17 | ("string", "matches"), 18 | [ 19 | ("COUNT=1", False), 20 | ("COUNT=1;", False), 21 | ("COUNT=-1", True), 22 | ("COUNT=-1;", True), 23 | ("COUNT=-100", True), 24 | ("COUNT=-100;", True), 25 | ], 26 | ) 27 | def test_matching_negative_count(string, matches): 28 | """Make sure the general replacement pattern works.""" 29 | actually_matches = ( 30 | recurring_ical_events.constants.NEGATIVE_RRULE_COUNT_REGEX.match(string) 31 | is not None 32 | ) 33 | assert actually_matches == matches 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_164_duplicated_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240821T032819Z 4 | DTSTART;VALUE=DATE:20240401 5 | DTEND;VALUE=DATE:20240408 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | END:VEVENT 15 | BEGIN:VEVENT 16 | DTSTAMP:20240821T032820Z 17 | DTSTART;VALUE=DATE:20240826 18 | DTEND;VALUE=DATE:20240902 19 | SUMMARY:test123 20 | CATEGORIES:other 21 | UID:111 22 | ORGANIZER:aaa 23 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 24 | RECURRENCE-ID;VALUE=DATE:20240826 25 | CREATED:20240729T125457Z 26 | LAST-MODIFIED:20240729T125457Z 27 | SEQUENCE:1 28 | END:VEVENT 29 | BEGIN:VEVENT 30 | DTSTAMP:20240821T032820Z 31 | DTSTART;VALUE=DATE:20240826 32 | DTEND;VALUE=DATE:20240902 33 | SUMMARY:test123 34 | CATEGORIES:other 35 | UID:111 36 | ORGANIZER:aaa 37 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 38 | RECURRENCE-ID;VALUE=DATE:20240826 39 | CREATED:20240729T125551Z 40 | LAST-MODIFIED:20240729T125551Z 41 | SEQUENCE:1 42 | END:VEVENT 43 | END:VCALENDAR 44 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/x_wr_timezone_simple_events_issue_59.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:This calendar features two events of which DTSTART and DTEND must be changed. 7 | X-WR-TIMEZONE:America/New_York 8 | BEGIN:VEVENT 9 | DTSTART:20211222T170000Z 10 | DTEND:20211222T180000Z 11 | DTSTAMP:20211228T180046Z 12 | UID:3bc4jff97631or97ntnk75n4se@google.com 13 | CREATED:20211222T190737Z 14 | DESCRIPTION: 15 | LAST-MODIFIED:20211222T190947Z 16 | LOCATION: 17 | SEQUENCE:2 18 | STATUS:CONFIRMED 19 | SUMMARY:Google Calendar says this is noon to 1PM on 12/22/2021 20 | TRANSP:OPAQUE 21 | END:VEVENT 22 | BEGIN:VEVENT 23 | DTSTART:20211223T020000Z 24 | DTEND:20211223T030000Z 25 | DTSTAMP:20211228T180046Z 26 | UID:14n7h56i35m32ukcq76s46d45p@google.com 27 | CREATED:20211222T190622Z 28 | DESCRIPTION: 29 | LAST-MODIFIED:20211222T190622Z 30 | LOCATION: 31 | SEQUENCE:0 32 | STATUS:CONFIRMED 33 | SUMMARY:Google says this is 9PM to 10PM on 12/22/2021 34 | TRANSP:OPAQUE 35 | END:VEVENT 36 | END:VCALENDAR 37 | -------------------------------------------------------------------------------- /recurring_ical_events/selection/base.py: -------------------------------------------------------------------------------- 1 | """Base interface for selection of components.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import TYPE_CHECKING, Sequence 7 | 8 | if TYPE_CHECKING: 9 | from icalendar.cal import Component 10 | 11 | from recurring_ical_events.series import Series 12 | 13 | 14 | class SelectComponents(ABC): 15 | """Abstract class to select components from a calendar.""" 16 | 17 | @staticmethod 18 | def component_name(): 19 | """The name of the component if there is only one.""" 20 | raise NotImplementedError("This should be implemented in subclasses.") 21 | 22 | @abstractmethod 23 | def collect_series_from( 24 | self, source: Component, suppress_errors: tuple[Exception] 25 | ) -> Sequence[Series]: 26 | """Collect all components from the source grouped together into a series. 27 | 28 | suppress_errors - a list of errors that should be suppressed. 29 | A Series of events with such an error is removed from all results. 30 | """ 31 | 32 | 33 | __all__ = ["SelectComponents"] 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_148_edge_case_2.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240707T214014Z 4 | DTSTART;VALUE=DATE:20240701 5 | DTEND;VALUE=DATE:20240708 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | END:VEVENT 15 | BEGIN:VEVENT 16 | RECURRENCE-ID;VALUE=DATE:20240715 17 | DTSTAMP:20240707T214014Z 18 | DTSTART;VALUE=DATE:20240702 19 | DTEND;VALUE=DATE:20240709 20 | SUMMARY:test123 - edited event!!!! 21 | CATEGORIES:other 22 | UID:111 23 | ORGANIZER:aaa 24 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 25 | CREATED:20240311T051101Z 26 | LAST-MODIFIED:20240311T051101Z 27 | SEQUENCE:2 28 | END:VEVENT 29 | BEGIN:VEVENT 30 | DTSTAMP:20240707T214014Z 31 | DTSTART;VALUE=DATE:20240701 32 | DTEND;VALUE=DATE:20240708 33 | SUMMARY:test123 34 | CATEGORIES:other 35 | UID:111 36 | ORGANIZER:aaa 37 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 38 | CREATED:20240311T051101Z 39 | LAST-MODIFIED:20240701T063743Z 40 | SEQUENCE:3 41 | END:VEVENT 42 | END:VCALENDAR 43 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_163_deleted_modification.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240820T210909Z 4 | DTSTART;VALUE=DATE:20240729 5 | DTEND;VALUE=DATE:20240805 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | END:VEVENT 15 | BEGIN:VEVENT 16 | DTSTAMP:20240820T210909Z 17 | DTSTART;VALUE=DATE:20240819 18 | DTEND;VALUE=DATE:20240822 19 | SUMMARY:test123 20 | CATEGORIES:other 21 | UID:111 22 | ORGANIZER:aaa 23 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 24 | RECURRENCE-ID;VALUE=DATE:20240819 25 | CREATED:20240729T132247Z 26 | LAST-MODIFIED:20240729T132247Z 27 | SEQUENCE:1 28 | END:VEVENT 29 | BEGIN:VEVENT 30 | DTSTAMP:20240820T210908Z 31 | DTSTART;VALUE=DATE:20240729 32 | DTEND;VALUE=DATE:20240805 33 | SUMMARY:test123 34 | CATEGORIES:other 35 | UID:111 36 | ORGANIZER:aaa 37 | RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO 38 | EXDATE;VALUE=DATE:20240819 39 | CREATED:20240311T051101Z 40 | LAST-MODIFIED:20240729T133342Z 41 | EXDATE;VALUE=DATE:20240819 42 | SEQUENCE:2 43 | END:VEVENT 44 | END:VCALENDAR -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_75_range_parameter.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:RESERVAS 1.0//EN 4 | BEGIN:VEVENT 5 | UID:210 6 | DTSTART:20240901T120000Z 7 | DTEND:20240901T140000Z 8 | RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20250920 9 | RDATE:20240914T090000Z 10 | SEQUENCE:0 11 | SUMMARY:ORIGINAL EVENT 12 | DESCRIPTION: 2 hours long 13 | END:VEVENT 14 | BEGIN:VEVENT 15 | UID:210 16 | RECURRENCE-ID;RANGE=THISANDFUTURE:20240913T120000Z 17 | DTSTART:20240913T090000Z 18 | DTEND:20240913T160000Z 19 | SEQUENCE:1 20 | SUMMARY:MODIFIED EVENT 21 | DESCRIPTION: move -3h, make 7 hours long 22 | END:VEVENT 23 | BEGIN:VEVENT 24 | UID:210 25 | RECURRENCE-ID:20240915T120000Z 26 | DTSTART:20240915T170000Z 27 | DTEND:20240915T190000Z 28 | SEQUENCE:1 29 | SUMMARY:MODIFIED EVENT 30 | DESCRIPTION: move +5h, 2 hours long 31 | END:VEVENT 32 | BEGIN:VEVENT 33 | UID:210 34 | RECURRENCE-ID;RANGE=THISANDFUTURE:20240921T120000Z 35 | DTSTART:20240922T142200Z 36 | DTEND:20240922T161300Z 37 | SEQUENCE:1 38 | SUMMARY:EDITED EVENT 39 | DESCRIPTION: moved +1 day +2h +22min, 1 hour 51min long 40 | END:VEVENT 41 | END:VCALENDAR 42 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_148_edge_case_1.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20240707T214014Z 4 | DTSTART;VALUE=DATE:20240701 5 | DTEND;VALUE=DATE:20240708 6 | SUMMARY:test123 7 | CATEGORIES:other 8 | UID:111 9 | ORGANIZER:aaa 10 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 11 | CREATED:20240311T051101Z 12 | LAST-MODIFIED:20240311T051101Z 13 | SEQUENCE:1 14 | END:VEVENT 15 | BEGIN:VEVENT 16 | RECURRENCE-ID;VALUE=DATE:20240715 17 | DTSTAMP:20240707T214014Z 18 | DTSTART;VALUE=DATE:20240702 19 | DTEND;VALUE=DATE:20240709 20 | SUMMARY:test123 - edited event!!!! 21 | CATEGORIES:other 22 | UID:111 23 | ORGANIZER:aaa 24 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 25 | CREATED:20240311T051101Z 26 | LAST-MODIFIED:20240311T051101Z 27 | SEQUENCE:2 28 | END:VEVENT 29 | BEGIN:VEVENT 30 | DTSTAMP:20240707T214014Z 31 | DTSTART;VALUE=DATE:20240701 32 | DTEND;VALUE=DATE:20240708 33 | SUMMARY:test123 34 | CATEGORIES:other 35 | UID:111 36 | ORGANIZER:aaa 37 | RRULE:FREQ=WEEKLY;UNTIL=20240801;INTERVAL=2;BYDAY=MO 38 | EXDATE;VALUE=DATE:20240715 39 | CREATED:20240311T051101Z 40 | LAST-MODIFIED:20240701T063743Z 41 | SEQUENCE:3 42 | END:VEVENT 43 | END:VCALENDAR 44 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | skipsdist = True 8 | envlist = py38, py39, py310, py311, py312, ruff, build 9 | 10 | [testenv] 11 | setenv = TMPDIR={envtmpdir} 12 | passenv = TZ 13 | deps = -e .[test] 14 | commands = 15 | pytest --basetemp="{envtmpdir}" {posargs} 16 | 17 | [testenv:ruff] 18 | deps = ruff 19 | skip_install = True 20 | commands = 21 | ruff format 22 | ruff check --fix 23 | 24 | [testenv:build] 25 | deps = 26 | build 27 | twine 28 | pip-tools 29 | commands = 30 | pip-compile 31 | python -c "from shutil import rmtree; rmtree('dist', ignore_errors=True)" 32 | python -m build . 33 | twine check dist/* 34 | 35 | [testenv:docs] 36 | skip_install = True 37 | changedir = docs 38 | deps = 39 | allowlist_externals = 40 | make 41 | git 42 | commands = 43 | make cleanhtml 44 | # see https://stackoverflow.com/a/59897351/1320237 45 | make html SPHINXOPTS="-W --keep-going -n" 46 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_7_datetime_and_date_start_stop.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the test cases which test that the 3 | event uses the right class: date or datetime. 4 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/7 5 | """ 6 | 7 | import datetime 8 | 9 | import pytest 10 | from icalendar import Event 11 | 12 | 13 | def test_can_serialize(calendars): 14 | """Test that the event can be serialized.""" 15 | event = next(calendars.one_day_event.all()) 16 | string = event.to_ical() 17 | assert isinstance(string, bytes) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | ("attribute", "dt_type", "event_name"), 22 | [ 23 | ("dtstart", datetime.date, "one_day_event"), 24 | ("dtend", datetime.date, "one_day_event"), 25 | ("dtstart", datetime.datetime, "one_event"), 26 | ("dtend", datetime.datetime, "one_event"), 27 | ], 28 | ) 29 | def test_is_date(calendars, attribute, dt_type, event_name): 30 | """Check the type of the attributes""" 31 | event = next(calendars[event_name].all()) 32 | event = Event.from_ical(event.to_ical()) 33 | dt = event[attribute] 34 | assert isinstance(dt.dt, dt_type), "content of ical should match" 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_186_icalendar_alarm_interface.py: -------------------------------------------------------------------------------- 1 | """Check icalendar's alarms interface. 2 | 3 | We need to check if icalendar is doinng the right thing. 4 | Also, recurring ical events now needs to consider alarms 5 | in a different way. 6 | """ 7 | 8 | from datetime import date, timedelta 9 | 10 | import pytest 11 | 12 | 13 | def test_an_event_has_subcomponents_even_if_it_has_an_alarm(calendars): 14 | """We want the events to have no alarms by default.""" 15 | events = calendars.alarm_at_start_of_event.at("20241004") 16 | assert len(events) == 1 17 | event = events[0] 18 | assert event.subcomponents != [] 19 | assert len(event.alarms.times) != 0 20 | 21 | 22 | @pytest.mark.parametrize("dt", [date(2024, 11, 26), date(2024, 11, 29)]) 23 | def test_alarm_time_for_event_is_correctly_computed_for_recurring_instance( 24 | calendars, dt 25 | ): 26 | """When an event is a repeated instance, we want the alarm times to be right.""" 27 | events = calendars.alarm_recurring_and_acknowledged_at_2024_11_27_16_27.at(dt) 28 | assert len(events) == 1 29 | event = events[0] 30 | assert len(event.alarms.times) == 1 31 | assert event.alarms.times[0].trigger == event.start - timedelta(hours=1) 32 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_properties.py: -------------------------------------------------------------------------------- 1 | """Test the properties of events.""" 2 | 3 | import pytest 4 | 5 | 6 | def test_event_has_summary(calendars): 7 | event = next(calendars.one_event.all()) 8 | assert event["SUMMARY"] == "test1" 9 | 10 | 11 | @pytest.mark.parametrize("attribute", ["DTSTART", "DTEND"]) 12 | def test_recurrent_events_change_start_and_end(calendars, attribute): 13 | events = calendars.three_events_one_edited.all() 14 | values = set(event[attribute] for event in events) 15 | assert len(values) == 3 16 | 17 | 18 | @pytest.mark.parametrize("index", [1, 2]) 19 | def test_duration_stays_the_same(calendars, index): 20 | events = list(calendars.three_events_one_edited.all()) 21 | duration1 = events[0]["DTEND"].dt - events[0]["DTSTART"].dt 22 | duration2 = events[index]["DTEND"].dt - events[index]["DTSTART"].dt 23 | assert duration1 == duration2 24 | 25 | 26 | def test_attributes_are_created(calendars): 27 | """Some properties should be part of every event 28 | 29 | This is, even if they are not given in the event at the beginning.""" 30 | events = calendars.discourse_no_dtend.at((2019, 1, 17)) 31 | assert len(events) == 1 32 | event = events[0] 33 | assert "DTEND" in event 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_223_one_event_with_sequence.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:UYDQSG9TH4DE0WM3QFL2J 30 | SUMMARY:test1 31 | SEQUENCE:0 32 | DTSTART;TZID=Europe/Berlin:20190304T080000 33 | DTEND;TZID=Europe/Berlin:20190304T083000 34 | END:VEVENT 35 | BEGIN:VEVENT 36 | CREATED:20190303T111937 37 | DTSTAMP:20190303T111937 38 | LAST-MODIFIED:20190303T111937 39 | UID:UYDQSG9TH4DE0WM3QFL2J2 40 | SUMMARY:test2 41 | SEQUENCE:1 42 | DTSTART;TZID=Europe/Berlin:20190305T080000 43 | DTEND;TZID=Europe/Berlin:20190305T083000 44 | END:VEVENT 45 | END:VCALENDAR 46 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_107_omitting_last_event.py: -------------------------------------------------------------------------------- 1 | """bug: recurring event series that start in daylight savings time and end in standard time omit last event 2 | 3 | Using a calendar application, I created a weekly event series in Pacific Standard Time that begins on January 5th and ends on June 8th. I filtered out events using between from today's date (~January 2023) and 1 year in the future (~January 2024). However, it incorrectly omitted the last event in the series on June 8th. 4 | 5 | Upon further investigation, it seems to just be an issue for a recurring event series that begin in standard time but end in daylight savings time. 6 | 7 | see https://github.com/niccokunzmann/python-recurring-ical-events/issues/107 8 | see also test_issue_20_exdate_ignored.py - same problem with pytz 9 | """ 10 | 11 | import datetime 12 | 13 | 14 | def test_last_event_is_present(calendars): 15 | today = datetime.date(2023, 1, 30) 16 | future = today + datetime.timedelta(days=365) 17 | events = calendars.issue_107_omitting_last_event.between(today, future) 18 | dates = [event["DTSTART"].dt.date() for event in events] 19 | assert datetime.date(2023, 6, 1) in dates, "event before last is present" 20 | assert datetime.date(2023, 6, 8) in dates, "last event is present" 21 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_keep_recurrence_attributes.py: -------------------------------------------------------------------------------- 1 | """Test that some attributes of the calendar objects 2 | are kept if required. 3 | 4 | See Issue 23 https://github.com/niccokunzmann/python-recurring-ical-events/issues/23. 5 | """ 6 | 7 | import pytest 8 | 9 | from recurring_ical_events import of 10 | 11 | RRULE = b"FREQ=DAILY;UNTIL=20160320T030000Z" 12 | RDATE = b"20150705T190000Z" 13 | EXDATE = b"20150705T190000Z" 14 | 15 | 16 | class Default: 17 | @staticmethod 18 | def to_ical(): 19 | return None 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ("keywords", "rrule", "rdate", "exdate"), 24 | [ 25 | ({}, None, None, None), 26 | ({"keep_recurrence_attributes": False}, None, None, None), 27 | ({"keep_recurrence_attributes": True}, RRULE, RDATE, EXDATE), 28 | ], 29 | ) 30 | def test_keep_recurrence_attributes_default(calendars, keywords, rrule, rdate, exdate): 31 | calendar = calendars.raw.rdate2 32 | rcalendar = of(calendar, **keywords) 33 | events = rcalendar.at(2014) 34 | assert events 35 | for event in events: 36 | assert event.get("RRULE", Default).to_ical() == rrule 37 | assert event.get("RDATE", Default).to_ical() == rdate 38 | assert event.get("EXDATE", Default).to_ical() == exdate 39 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_27_t1.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | PRODID:Microsoft Exchange Server 2010 4 | VERSION:2.0 5 | X-WR-CALNAME:Events 6 | X-EVOLUTION-DATA-REVISION:2020-04-23T17:43:39.112248Z(2) 7 | BEGIN:VTIMEZONE 8 | TZID:W. Europe Standard Time 9 | BEGIN:STANDARD 10 | DTSTART:16010101T030000 11 | TZOFFSETFROM:+0200 12 | TZOFFSETTO:+0100 13 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 14 | END:STANDARD 15 | BEGIN:DAYLIGHT 16 | DTSTART:16010101T020000 17 | TZOFFSETFROM:+0100 18 | TZOFFSETTO:+0200 19 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 20 | END:DAYLIGHT 21 | END:VTIMEZONE 22 | 23 | BEGIN:VEVENT 24 | UID:3bbe38c205956551730fc9233525fe268296ec02 25 | DTSTAMP:20200423T174240Z 26 | DTSTART;TZID=Europe/Berlin: 27 | 20200426T140000 28 | DTEND;TZID=Europe/Berlin: 29 | 20200426T143000 30 | SUMMARY:Reoccur 31 | SEQUENCE:7 32 | X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for DESCRIPTION 33 | property. Removing entire property: 34 | CREATED:20200423T174316Z 35 | LAST-MODIFIED:20200423T174339Z 36 | X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for DESCRIPTION 37 | property. Removing entire property: 38 | RRULE:FREQ=DAILY;UNTIL=20200429T000000 39 | EXDATE:20200427T120000Z 40 | 41 | END:VEVENT 42 | END:VCALENDAR 43 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_27_t2.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | PRODID:Microsoft Exchange Server 2010 4 | VERSION:2.0 5 | X-WR-CALNAME:Events 6 | X-EVOLUTION-DATA-REVISION:2020-04-23T17:43:39.112248Z(2) 7 | BEGIN:VTIMEZONE 8 | TZID:W. Europe Standard Time 9 | BEGIN:STANDARD 10 | DTSTART:16010101T030000 11 | TZOFFSETFROM:+0200 12 | TZOFFSETTO:+0100 13 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 14 | END:STANDARD 15 | BEGIN:DAYLIGHT 16 | DTSTART:16010101T020000 17 | TZOFFSETFROM:+0100 18 | TZOFFSETTO:+0200 19 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 20 | END:DAYLIGHT 21 | END:VTIMEZONE 22 | 23 | BEGIN:VEVENT 24 | UID:3bbe38c205956551730fc9233525fe268296ec02 25 | DTSTAMP:20200423T174240Z 26 | DTSTART;TZID=Europe/Berlin: 27 | 20200426T140000 28 | DTEND;TZID=Europe/Berlin: 29 | 20200426T143000 30 | SUMMARY:Reoccur 31 | SEQUENCE:7 32 | X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for DESCRIPTION 33 | property. Removing entire property: 34 | CREATED:20200423T174316Z 35 | LAST-MODIFIED:20200423T174339Z 36 | X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for DESCRIPTION 37 | property. Removing entire property: 38 | RRULE:FREQ=DAILY;UNTIL=20200429T000000Z 39 | EXDATE:20200427T120000Z 40 | 41 | END:VEVENT 42 | END:VCALENDAR 43 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_4.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTAMP:20190613T171521Z 4 | DTSTART;VALUE=DATE:20190124 5 | DTEND;VALUE=DATE:20190125 6 | SUMMARY:WfH 7 | X-CONFLUENCE-CUSTOM-TYPE-ID:6972027e-37ba-4a3e-82cb-5690b51c4ab4 8 | CATEGORIES:WFH 9 | SUBCALENDAR-ID:7bdacef4-2d77-432b-bd52-10dffc67d4fb 10 | PARENT-CALENDAR-ID:5552044f-86e9-4d75-b038-9779bccf7a96 11 | PARENT-CALENDAR-NAME: 12 | SUBSCRIPTION-ID: 13 | SUBCALENDAR-TZ-ID:GB 14 | SUBCALENDAR-NAME:Calendar name here 15 | EVENT-ID:75697 16 | EVENT-ALLDAY:true 17 | CUSTOM-EVENTTYPE-ID:aaaaaaaa-37ba-4a3e-82cb-5690b51c4ab4 18 | UID:20190119T053217Z--1927336845@domain.com 19 | DESCRIPTION: 20 | ORGANIZER;X-CONFLUENCE-USER-KEY=aaaaaaaaaaaaa4eb01585ecded610030;CN=Fred Blo 21 | ggs;CUTYPE=INDIVIDUAL:mailto:person@domain.com 22 | RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TH 23 | CREATED:20190119T053217Z 24 | LAST-MODIFIED:20190216T121411Z 25 | ATTENDEE;X-CONFLUENCE-USER-KEY=ff808181582474eb01585ecded610030;CN=Fred Blo 26 | ggs;CUTYPE=INDIVIDUAL:mailto:person@domain.com 27 | EXDATE;VALUE=DATE:20190718 28 | EXDATE;VALUE=DATE:20190314 29 | EXDATE;VALUE=DATE:20190307 30 | EXDATE;VALUE=DATE:20190228 31 | EXDATE;VALUE=DATE:20190221 32 | SEQUENCE:6 33 | X-CONFLUENCE-SUBCALENDAR-TYPE:custom 34 | STATUS:CONFIRMED 35 | END:VEVENT 36 | END:VCALENDAR 37 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_243_recurrence_id_is_not_identical_to_dtstart.py: -------------------------------------------------------------------------------- 1 | """The RECURRENCE-ID should not be identical to DTSTART. 2 | 3 | That is confusing. 4 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/243 5 | """ 6 | 7 | from datetime import datetime 8 | 9 | 10 | def test_recurrence_id_is_not_identical_to_dtstart(calendars): 11 | """We need to make sure they are distinct to set the values independently.""" 12 | start = datetime(2015,9,1) 13 | end = datetime(2015,9,4) 14 | recurrings = calendars.issue_243_recurrence_id_is_not_identical_to_dtstart.between(start, end) 15 | r = recurrings[0] 16 | 17 | ## This break, it should pass 18 | assert id(r["dtstart"]) != id(r["recurrence-id"]) 19 | assert r["dtstart"] is not r["recurrence-id"] 20 | 21 | ## This is true 22 | assert r["recurrence-id"].dt == datetime(2015,9,1,8) 23 | 24 | ## The test recurrence at this particlar day should start at 9, not at 8 25 | r["dtstart"].dt = datetime(2015,9,1,9) 26 | 27 | ## This should not break, but breaks 28 | assert r["recurrence-id"].dt == datetime(2015,9,1,8) 29 | 30 | r["dtstart"] = datetime(2015,9,1,9) 31 | 32 | ## This should not break, but breaks 33 | assert r["recurrence-id"].dt == datetime(2015,9,1,8) 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_151_macos_linux_difference.py: -------------------------------------------------------------------------------- 1 | """This tests if there is a difference between macOS and Linux 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/151 4 | """ 5 | 6 | from datetime import datetime, timezone 7 | 8 | 9 | def test_count_events_from_issue(calendars): 10 | """Avents were omitted through version upgrade from 2.2.2 to 2.2.3.""" 11 | 12 | start_time = datetime.fromtimestamp(1722564000, timezone.utc) 13 | end_time = datetime.fromtimestamp(1722567600, timezone.utc) 14 | print(f"from {start_time.timestamp()} to {end_time.timestamp()}") 15 | events = calendars.issue_151_macos_linux_difference.between(start_time, end_time) 16 | for event in events: 17 | print(event["UID"], event["DTSTART"], event["SUMMARY"]) 18 | assert len(events) == 1 19 | 20 | 21 | def test_check_event_count_for_that_day(calendars): 22 | """Avents were omitted through version upgrade from 2.2.2 to 2.2.3.""" 23 | 24 | events = calendars.issue_151_macos_linux_difference.at("20240801") 25 | for event in events: 26 | print( 27 | event["UID"], 28 | event["DTSTART"], 29 | event["SUMMARY"], 30 | event["DTSTART"].dt.timestamp(), 31 | ) 32 | assert len(events) == 1 33 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_48_daylight.py: -------------------------------------------------------------------------------- 1 | """These tests are for Issue 48 2 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/48 3 | 4 | These were the events occurring when the issue was raised: 5 | EVENT2: 6 | start: 2020-11-02 11:30:00+00:00 7 | stop: 2020-11-02 13:00:00+00:00 8 | 9 | March to October: UTC+1 10 | October to March: UTC+0 11 | 12 | """ 13 | 14 | from datetime import datetime 15 | 16 | import pytest 17 | from pytz import timezone 18 | 19 | TZ = timezone("Europe/Lisbon") 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ("date", "event_name"), 24 | [ 25 | (datetime(2020, 11, 2, 11, 15, 0, 0), 0), 26 | (datetime(2020, 11, 2, 11, 31, 0, 0), "EVENT2"), 27 | (datetime(2020, 11, 2, 12, 0, 0, 0), "EVENT2"), 28 | (datetime(2020, 11, 2, 12, 1, 0, 0), "EVENT2"), 29 | (datetime(2020, 11, 2, 12, 59, 0, 0), "EVENT2"), 30 | (datetime(2020, 11, 2, 13, 1, 0, 0), 0), 31 | ], 32 | ) 33 | def test_event_timing(calendars, date, event_name): 34 | date = TZ.localize(date) 35 | events = calendars.issue_48_daylight_aware_repeats.at(date) 36 | if event_name: 37 | assert len(events) == 1 38 | assert events[0]["UID"] == event_name 39 | else: 40 | assert not events, "no events expected" 41 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_zero_size_events.py: -------------------------------------------------------------------------------- 1 | """This tests events of zero size. 2 | 3 | In the specification, the DTSTART is the only mandatory attribute. 4 | DTEND and DURATION are both optimal. 5 | """ 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("date", "event_count"), 12 | [ 13 | # DTSTART:20190304T080000 14 | ("20190303", 0), 15 | ("20190304", 1), 16 | ("20190305", 0), 17 | ((2019, 3, 4, 7), 0), 18 | ((2019, 3, 4, 8), 1), 19 | ((2019, 3, 4, 9), 0), 20 | ], 21 | ) 22 | def test_zero_sized_events_at(calendars, date, event_count): 23 | events = calendars.zero_size_event.at(date) 24 | assert len(events) == event_count 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ("start", "stop", "event_count", "message"), 29 | [ 30 | # DTSTART:20190304T080000 31 | ((2019, 3, 4, 7), (2019, 3, 4, 8), 0, "event is at end of span"), 32 | ((2019, 3, 4, 8), (2019, 3, 4, 9), 1, "event is at start of span"), 33 | ((2019, 3, 4, 8), (2019, 3, 4, 8), 1, "event is at the exact span"), 34 | ], 35 | ) 36 | def test_zero_sized_events_at_2(calendars, start, stop, event_count, message): 37 | events = calendars.zero_size_event.between(start, stop) 38 | assert len(events) == event_count, message 39 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF and ePub 13 | formats: all 14 | 15 | build: 16 | os: ubuntu-24.04 17 | tools: 18 | python: "3.13" 19 | commands: 20 | - git fetch --tags 21 | - pip install build 22 | - python -m build 23 | # Cancel building pull requests when there aren't changes in the docs directory or YAML file. 24 | # You can add any other files or directories that you'd like here as well, 25 | # like your docs requirements file, or other files that will change your docs build. 26 | # 27 | # If there are no changes (git diff exits with 0) we force the command to return with 183. 28 | # This is a special exit code on Read the Docs that will cancel the build immediately. 29 | - | 30 | if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- . docs/ recurring_ical_events/ *.rst .readthedocs.yaml docs/requirements.txt; 31 | then 32 | exit 183; 33 | fi 34 | - cd docs && make rtd-pr-preview 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_48_daylight_aware_repeats.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Horario sem-5 7 | X-WR-TIMEZONE:Europe/Lisbon 8 | BEGIN:VTIMEZONE 9 | TZID:Europe/Lisbon 10 | X-LIC-LOCATION:Europe/Lisbon 11 | BEGIN:STANDARD 12 | TZOFFSETFROM:+0100 13 | TZOFFSETTO:+0000 14 | TZNAME:WET 15 | DTSTART:19701025T020000 16 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 17 | END:STANDARD 18 | BEGIN:DAYLIGHT 19 | TZOFFSETFROM:+0000 20 | TZOFFSETTO:+0100 21 | TZNAME:WEST 22 | DTSTART:19700329T010000 23 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 24 | END:DAYLIGHT 25 | END:VTIMEZONE 26 | BEGIN:VEVENT 27 | DTSTART;TZID=Europe/Lisbon:20200921T113000 28 | DTEND;TZID=Europe/Lisbon:20200921T130000 29 | RRULE:FREQ=WEEKLY;BYDAY=MO 30 | DTSTAMP:20201026T103342Z 31 | UID:EVENT2 32 | CREATED:20200920T235116Z 33 | DESCRIPTION:

https://videoconf-colibr 35 | i.zoom.us/j/93800310242?pwd=K3FBUVJUV2hrTm1OR2RWb0ZabTJkZz09

36 | LAST-MODIFIED:20200920T235214Z 37 | LOCATION: 38 | SEQUENCE:0 39 | STATUS:CONFIRMED 40 | SUMMARY:MDS-t 41 | TRANSP:OPAQUE 42 | END:VEVENT 43 | END:VCALENDAR 44 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_44_day_event_reported_twice.py: -------------------------------------------------------------------------------- 1 | """ 2 | It seems that the event is reported on its day and the next. 3 | 4 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/44 5 | """ 6 | 7 | import pytest 8 | 9 | 10 | def test_event_is_present_where_it_should_be(calendars): 11 | events = calendars.issue_44_double_event.at((2020, 8, 14)) 12 | assert len(events) == 1 13 | event = events[0] 14 | assert event["SUMMARY"] == "test2" 15 | 16 | 17 | def test_event_is_absent_on_the_next_day(calendars): 18 | events = calendars.issue_44_double_event.at((2020, 8, 15)) 19 | assert events == [], "the issue is that an event could turn up here" 20 | 21 | 22 | def test_event_is_absent_on_the_previous_day(calendars): 23 | events = calendars.issue_44_double_event.at((2020, 8, 13)) 24 | assert events == [], "the issue is that an event could turn up here" 25 | 26 | 27 | @pytest.mark.parametrize("offset", list(range(4))) 28 | def test_event_of_recurrence_should_behave_the_same(calendars, offset): 29 | """we should check that a repeated event does not have the same problem.""" 30 | events = calendars.one_day_event_repeat_every_day.at((2019, 3, 4 + offset)) 31 | assert len(events) == 1, ( 32 | "Events of the the day before and after should not be mentioned." 33 | ) 34 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_4.py: -------------------------------------------------------------------------------- 1 | """ 2 | These are tests concerning issue 4 3 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/4 4 | 5 | It seems the rrule until parameter includes the last date 6 | https://dateutil.readthedocs.io/en/stable/rrule.html 7 | """ 8 | 9 | import datetime 10 | 11 | start_date = (2019, 6, 13, 12, 00, 00, 00) 12 | end_date = (2019, 6, 14) 13 | a_date = (2019, 6, 13) 14 | 15 | 16 | def test_print_events(calendars): 17 | events = calendars.issue_4.between((2019, 6, 1), (2019, 7, 1)) 18 | for event in events: 19 | print(event["DTSTART"].dt) 20 | 21 | 22 | def test_between(calendars): 23 | events = calendars.issue_4.between(start_date, end_date) 24 | print(events) 25 | assert len(events) == 1 26 | assert events[0]["DTSTART"].dt == datetime.date(2019, 6, 13) 27 | 28 | 29 | def test_at(calendars): 30 | events = calendars.issue_4.at(a_date) 31 | print(events) 32 | assert len(events) == 1 33 | assert events[0]["DTSTART"].dt == datetime.date(2019, 6, 13) 34 | 35 | 36 | def test_can_use_different_rrule_until(calendars): 37 | events = list(calendars.issue_4_rrule_until.all()) 38 | assert len(events) == 12 39 | 40 | 41 | def test_weidenrinde(calendars): 42 | events = list(calendars.issue_4_weidenrinde.all()) 43 | assert len(events) == 2 44 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/subcomponents.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | SUMMARY:redacted 4 | DTSTART;TZID=Europe/Berlin:20190527T140000 5 | DTEND;TZID=Europe/Berlin:20190527T163000 6 | DTSTAMP:20190510T070457Z 7 | UID:00000000-0000-0000-0000-000000000000 8 | SEQUENCE:4 9 | ATTENDEE;CN=redacted;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:redacted@example.com 10 | ATTENDEE;CN="redacted";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:redacted@example.com 11 | ATTENDEE;CN="redacted";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:redacted@example.com 12 | ATTENDEE;CN="redacted";PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:redacted@example.com 13 | ATTENDEE;CUTYPE=RESOURCE;PARTSTAT=ACCEPTED;ROLE=NON-PARTICIPANT;RSVP=TRUE:mailto:redacted@example.com 14 | ATTENDEE;CUTYPE=RESOURCE;PARTSTAT=ACCEPTED;ROLE=NON-PARTICIPANT;RSVP=TRUE:mailto:redacted@example.com 15 | CLASS:PUBLIC 16 | DESCRIPTION:redacted 17 | LAST-MODIFIED:20190510T070457Z 18 | LOCATION:redacted@example.com 19 | ORGANIZER;CN=redacted;SENT-BY="mailto:redacted@example.com":mailto:redacted@example.com 20 | STATUS:CONFIRMED 21 | TRANSP:OPAQUE 22 | X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY 23 | X-MS-OLK-SENDER:mailto:redacted@example.com 24 | BEGIN:VALARM 25 | ACTION:DISPLAY 26 | DESCRIPTION:Reminder 27 | TRIGGER;RELATED=START:-PT10M 28 | END:VALARM 29 | END:VEVENT 30 | END:VCALENDAR 31 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_simple_recurrent_events.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from recurring_ical_events.constants import DATE_MAX 4 | 5 | 6 | def test_event_is_not_included_if_it_is_later(calendars): 7 | events = calendars.three_events.between((2000, 1, 1), (2001, 1, 1)) 8 | assert not events 9 | 10 | 11 | def test_event_is_not_included_if_it_is_earlier(calendars): 12 | events = calendars.three_events.between((2030, 1, 1), DATE_MAX) 13 | assert not events 14 | 15 | 16 | def test_all_events_in_time_span(calendars): 17 | events = calendars.three_events.between((2000, 1, 1), DATE_MAX) 18 | assert len(events) == 3 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("count", "end"), 23 | [ 24 | (0, (2019, 3, 3)), 25 | (1, (2019, 3, 5)), 26 | (2, (2019, 3, 8)), 27 | ], 28 | ) 29 | def test_events_occur_after_and_before_span_end(calendars, count, end): 30 | events = calendars.three_events.between((2000, 1, 1), end) 31 | assert len(events) == count 32 | 33 | 34 | @pytest.mark.parametrize( 35 | ("count", "start"), 36 | [ 37 | (3, (2019, 3, 3)), 38 | (2, (2019, 3, 5)), 39 | (1, (2019, 3, 8)), 40 | ], 41 | ) 42 | def test_events_occur_after_and_before_span_start(calendars, count, start): 43 | events = calendars.three_events.between(start, DATE_MAX) 44 | assert len(events) == count 45 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_18_cancel_status.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20200206T210738Z 23 | LAST-MODIFIED:20200206T210826Z 24 | DTSTAMP:20200206T210826Z 25 | UID:b65c2b5b-b785-4edc-9560-e0379036d1f2 26 | SUMMARY:one is cancelled 27 | RRULE:FREQ=DAILY;COUNT=3 28 | DTSTART;TZID=Europe/Berlin:20200128T220000 29 | DTEND;TZID=Europe/Berlin:20200128T230000 30 | TRANSP:OPAQUE 31 | X-MOZ-GENERATION:3 32 | SEQUENCE:1 33 | END:VEVENT 34 | BEGIN:VEVENT 35 | CREATED:20200206T210806Z 36 | LAST-MODIFIED:20200206T210826Z 37 | DTSTAMP:20200206T210826Z 38 | UID:b65c2b5b-b785-4edc-9560-e0379036d1f2 39 | SUMMARY:one is cancelled 40 | STATUS:CANCELLED 41 | RECURRENCE-ID;TZID=Europe/Berlin:20200129T220000 42 | DTSTART;TZID=Europe/Berlin:20200129T220000 43 | DTEND;TZID=Europe/Berlin:20200129T230000 44 | LOCATION: 45 | DESCRIPTION: 46 | TRANSP:OPAQUE 47 | CLASS: 48 | SEQUENCE:2 49 | END:VEVENT 50 | END:VCALENDAR 51 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_48_dst.py: -------------------------------------------------------------------------------- 1 | """ 2 | These are tests concerning issue 4 3 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/4 4 | 5 | It seems the rrule until parameter includes the last date 6 | https://dateutil.readthedocs.io/en/stable/rrule.html 7 | """ 8 | 9 | import datetime 10 | 11 | import pytest 12 | import pytz 13 | 14 | chicago = pytz.timezone("America/Chicago") 15 | 16 | 17 | @pytest.mark.parametrize( 18 | ("start_time", "end_time", "expected_count"), 19 | [ 20 | # ( 21 | # chicago.localize(datetime.datetime(2020, 12, 11, 8)), 22 | # chicago.localize(datetime.datetime(2020, 12, 11, 15)), 23 | # 4, 24 | # ), 25 | # ( 26 | # chicago.localize(datetime.datetime(2020, 12, 11, 9)), 27 | # chicago.localize(datetime.datetime(2020, 12, 11, 15)), 28 | # 3, 29 | # ), 30 | ( 31 | chicago.localize(datetime.datetime(2020, 12, 11, 10)), 32 | chicago.localize(datetime.datetime(2020, 12, 11, 15)), 33 | 3, 34 | ), 35 | ], 36 | ) 37 | def test_between(calendars, start_time, end_time, expected_count): 38 | events = calendars.issue_48_dst.between(start_time, end_time) 39 | assert len(events) == expected_count, ( 40 | f"{expected_count} events expected between {start_time} and {end_time}" 41 | ) 42 | -------------------------------------------------------------------------------- /.cleanup-branches.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Srcipt to clean up branches that are no longer in use. 4 | # 5 | # Due to gitlab mirroring back and forth, the branches are not 6 | # being deleted but put back in place again. 7 | # 8 | 9 | set -e 10 | 11 | cd "`dirname \"$0\"`" 12 | 13 | remotes="" 14 | branches="" 15 | 16 | for remote in `git remote`; do 17 | echo -n "Choose branches from remote $remote? (Y/n) " 18 | read 19 | if [ -z "$REPLY" ] || [ "$REPLY" == "y" ] || [ "$REPLY" == "Y" ]; then 20 | echo "Using remote $remote." 21 | remotes="$remotes $remote" 22 | fi 23 | done 24 | 25 | for branch in `git branch -r | grep -oE '[^/]+$' | sort | uniq | grep -vE 'master'`; do 26 | echo -n "Delete branch $branch? (y/N) " 27 | read 28 | if [ "$REPLY" == "y" ] || [ "$REPLY" == "Y" ]; then 29 | echo "Remembering $branch for deletion." 30 | branches="$branches $branch" 31 | else 32 | echo "Keeping $branch." 33 | fi 34 | done 35 | 36 | echo "Selected remotes to delete branches from: $remotes." 37 | echo "Selected branches to delete: $branches" 38 | echo -n "Start deleting? (Control+C to stop) " 39 | read 40 | 41 | for branch in $branches; do 42 | echo " ---------- deleting $branch ---------- " 43 | git branch -d "$branch" || true 44 | for remote in $remotes; do 45 | git push --delete "$remote" "$branch" || true 46 | done 47 | done 48 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_27.py: -------------------------------------------------------------------------------- 1 | """These tests are for Issue 27 2 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/27 3 | https://github.com/niccokunzmann/python-recurring-ical-events/pull/32 4 | 5 | Diff of the two files: 6 | 7 | diff test/calendars/issue-27-t1.ics test/calendars/issue-27-t2.ics 8 | 9 | < RRULE:FREQ=DAILY;UNTIL=20200429T000000 10 | < EXDATE:20200427T120000Z 11 | --- 12 | > RRULE:FREQ=DAILY;UNTIL=20200429T000000Z 13 | > EXDATE:20200427T140000Z 14 | 15 | """ 16 | 17 | start_date = (2020, 4, 25) 18 | end_date = (2020, 4, 30) 19 | 20 | 21 | def print_events(events): 22 | for event in events: 23 | start = event["DTSTART"].dt 24 | duration = event["DTEND"].dt - event["DTSTART"].dt 25 | print(f"start {start} duration {duration}") 26 | 27 | 28 | def test_until_value_with_UNKNOWN_timezone_works_with_exdate(calendars): 29 | """The until value has no time zone attached.""" 30 | events = calendars.issue_27_t1.between(start_date, end_date) 31 | print_events(events) 32 | assert len(events) == 2, "two events, exdate matches one" 33 | 34 | 35 | def test_until_value_with_DEFAULT_timezone_works_with_exdate(calendars): 36 | """The until value uses the default time zone.""" 37 | events = calendars.issue_27_t2.between(start_date, end_date) 38 | print_events(events) 39 | assert len(events) == 2, "two events, exdate matches one" 40 | -------------------------------------------------------------------------------- /recurring_ical_events/adapters/journal.py: -------------------------------------------------------------------------------- 1 | """Adapter for VJOURNAL.""" 2 | 3 | from recurring_ical_events.adapters.component import ComponentAdapter 4 | from recurring_ical_events.constants import DATE_MIN_DT 5 | from recurring_ical_events.types import Time 6 | from recurring_ical_events.util import cached_property 7 | 8 | 9 | class JournalAdapter(ComponentAdapter): 10 | """Apdater for journal entries.""" 11 | 12 | @staticmethod 13 | def component_name() -> str: 14 | """The icalendar component name.""" 15 | return "VJOURNAL" 16 | 17 | @property 18 | def end_property(self) -> None: 19 | """There is no end property""" 20 | 21 | @property 22 | def raw_start(self) -> Time: 23 | """Return DTSTART if it set, do not panic if it's not set.""" 24 | ## according to the specification, DTSTART in a VJOURNAL is optional 25 | dtstart = self._component.get("DTSTART") 26 | if dtstart is not None: 27 | return dtstart.dt 28 | return DATE_MIN_DT 29 | 30 | @cached_property 31 | def raw_end(self) -> Time: 32 | """The end time is the same as the start.""" 33 | ## VJOURNAL cannot have a DTEND. We should consider a VJOURNAL to 34 | ## describe one day if DTSTART is a date, and we can probably 35 | ## consider it to have zero duration if a timestamp is given. 36 | return self.raw_start 37 | 38 | 39 | __all__ = ["JournalAdapter"] 40 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/duration_edited.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | BEGIN:DAYLIGHT 10 | TZOFFSETFROM:+0100 11 | TZOFFSETTO:+0200 12 | TZNAME:CEST 13 | DTSTART:19700329T020000 14 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 15 | END:DAYLIGHT 16 | BEGIN:STANDARD 17 | TZOFFSETFROM:+0200 18 | TZOFFSETTO:+0100 19 | TZNAME:CET 20 | DTSTART:19701025T030000 21 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 22 | END:STANDARD 23 | END:VTIMEZONE 24 | BEGIN:VEVENT 25 | CREATED:20190303T154052Z 26 | LAST-MODIFIED:20190303T154145Z 27 | DTSTAMP:20190303T154145Z 28 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 29 | SUMMARY:original event 30 | RRULE:FREQ=DAILY;UNTIL=20190320T030000Z 31 | DTSTART;TZID=Europe/Berlin:20190318T040000 32 | DTEND;TZID=Europe/Berlin:20190318T050000 33 | TRANSP:OPAQUE 34 | X-MOZ-GENERATION:3 35 | SEQUENCE:1 36 | END:VEVENT 37 | BEGIN:VEVENT 38 | CREATED:20190303T154131Z 39 | LAST-MODIFIED:20190303T154145Z 40 | DTSTAMP:20190303T154145Z 41 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 42 | SUMMARY:edited duration 43 | RECURRENCE-ID;TZID=Europe/Berlin:20190319T040000 44 | DTSTART;TZID=Europe/Berlin:20190319T040000 45 | DURATION:PT3H 46 | TRANSP:OPAQUE 47 | X-MOZ-GENERATION:3 48 | SEQUENCE:2 49 | LOCATION:location 50 | DESCRIPTION: 51 | CLASS: 52 | END:VEVENT 53 | END:VCALENDAR 54 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/three_events_one_edited.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | BEGIN:DAYLIGHT 10 | TZOFFSETFROM:+0100 11 | TZOFFSETTO:+0200 12 | TZNAME:CEST 13 | DTSTART:19700329T020000 14 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 15 | END:DAYLIGHT 16 | BEGIN:STANDARD 17 | TZOFFSETFROM:+0200 18 | TZOFFSETTO:+0100 19 | TZNAME:CET 20 | DTSTART:19701025T030000 21 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 22 | END:STANDARD 23 | END:VTIMEZONE 24 | BEGIN:VEVENT 25 | CREATED:20190303T154052Z 26 | LAST-MODIFIED:20190303T154145Z 27 | DTSTAMP:20190303T154145Z 28 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 29 | SUMMARY:test7 30 | RRULE:FREQ=DAILY;UNTIL=20190320T030000Z 31 | DTSTART;TZID=Europe/Berlin:20190318T040000 32 | DTEND;TZID=Europe/Berlin:20190318T050000 33 | TRANSP:OPAQUE 34 | X-MOZ-GENERATION:3 35 | SEQUENCE:1 36 | END:VEVENT 37 | BEGIN:VEVENT 38 | CREATED:20190303T154131Z 39 | LAST-MODIFIED:20190303T154145Z 40 | DTSTAMP:20190303T154145Z 41 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 42 | SUMMARY:test7 - edited 43 | RECURRENCE-ID;TZID=Europe/Berlin:20190319T040000 44 | DTSTART;TZID=Europe/Berlin:20190319T040000 45 | DTEND;TZID=Europe/Berlin:20190319T050000 46 | TRANSP:OPAQUE 47 | X-MOZ-GENERATION:3 48 | SEQUENCE:2 49 | LOCATION:location 50 | DESCRIPTION: 51 | CLASS: 52 | END:VEVENT 53 | END:VCALENDAR 54 | -------------------------------------------------------------------------------- /docs/reference/related-projects.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | Related Projects 4 | ================ 5 | 6 | - `icalevents `_ - another library for roughly the same use-case 7 | - `Open Web Calendar `_ - a web calendar to embed into websites which uses this library 8 | - `icspy `_ - to create your own calendar events 9 | - `pyICSParser `_ - parse icalendar files and return event times (`GitHub `__) 10 | - `ics-query`_ - a **command line** impementation of ``recurring-ical-events`` 11 | - `icalendar-events-cli`_ - another **command line** impementation of ``recurring-ical-events`` 12 | - `caldav`_ - the python caldav client library 13 | - `plann`_ - a **command line** caldav client 14 | - `ics_calendar`_ - Provides a component for ICS (icalendar) calendars for `Home Assistant`_ 15 | 16 | .. _`ics-query`: https://github.com/niccokunzmann/ics-query#readme 17 | .. _`icalendar-events-cli`: https://github.com/waldbaer/icalendar-events-cli#readme 18 | .. _`caldav`: https://github.com/python-caldav/caldav 19 | .. _`plann`: https://github.com/tobixen/plann 20 | .. _`ics_calendar`: https://github.com/franc6/ics_calendar/ 21 | .. _`Home Assistant`: https://www.home-assistant.io/ 22 | 23 | Command line interface 24 | ---------------------- 25 | 26 | If you would like to use this functionality on the command line or in the shell, you can use 27 | `ics-query`_. 28 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_repetitions_do_not_change.py: -------------------------------------------------------------------------------- 1 | """The objective of this test is to ensure that repeated events can be copied 2 | into an ICAL calendar again without multiplying themselves to wrong dates. 3 | """ 4 | 5 | import recurring_ical_events 6 | 7 | 8 | def assert_event_does_not_duplicate(event): 9 | for i, _ in enumerate(recurring_ical_events.of(event).all()): 10 | assert i <= 1, event 11 | 12 | 13 | def assert_events_do_not_duplicate(events): 14 | assert events 15 | for event in events: 16 | assert_event_does_not_duplicate(event) 17 | 18 | 19 | def test_simple_event(calendars): 20 | """An event with no repetitions.""" 21 | assert_events_do_not_duplicate(calendars.duration.at(2018)) 22 | 23 | 24 | def test_rdate_event(calendars): 25 | """An event with rdate.""" 26 | assert_events_do_not_duplicate(calendars.rdate_hackerpublicradio.all()) 27 | 28 | 29 | def test_rrule(calendars): 30 | """An event with rrule and a number of events.""" 31 | assert_events_do_not_duplicate(calendars.event_10_times.at(2020)) 32 | 33 | 34 | def test_rrule_with_exdate(calendars): 35 | """An event with rrule and exrule.""" 36 | assert_events_do_not_duplicate(calendars.each_week_but_two_deleted.at(2019)) 37 | 38 | 39 | def test_exdate_is_removed_because_it_is_not_needed(calendars): 40 | """A repeated event removed RDATE and RRULE and as such should 41 | also remove the EXDATE values.""" 42 | for event in calendars.rdate.all(): 43 | assert "EXDATE" not in event 44 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_132_swapped_start_and_end.py: -------------------------------------------------------------------------------- 1 | """Check that we can still compute if start and end are swapped.""" 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | 7 | 8 | def test_event_case(calendars): 9 | """Test an event with swapped start and end.""" 10 | event = calendars.issue_132_swapped_start_and_end.first 11 | assert event.start.replace(tzinfo=None) == datetime(2023, 12, 18, 23, 30) 12 | assert event.end.replace(tzinfo=None) == datetime(2023, 12, 18, 23, 45) 13 | 14 | 15 | def test_todo_case(calendars): 16 | """Test an event with swapped start and end.""" 17 | calendars.components = ["VTODO"] 18 | todo = calendars.issue_132_swapped_start_and_end.first 19 | print(todo) 20 | assert todo.start.replace(tzinfo=None) == datetime(2023, 12, 18, 23, 30) 21 | assert todo.end.replace(tzinfo=None) == datetime(2023, 12, 18, 23, 45) 22 | 23 | 24 | @pytest.mark.parametrize("skip_invalid", [True, False]) 25 | def test_old_example_works_now(calendars, ZoneInfo, skip_invalid): 26 | """The old tests works now.""" 27 | calendars.skip_bad_series = skip_invalid 28 | events = calendars.end_before_start_event.at(2019) 29 | print(list(calendars.end_before_start_event.all())) 30 | assert len(events) == 1 31 | event = events[0] 32 | assert event.start == datetime( 33 | 2019, 3, 4, 8, tzinfo=ZoneInfo("Europe/Berlin") 34 | ) # 20190304T080000 35 | assert event.end == datetime( 36 | 2019, 3, 4, 8, 30, tzinfo=ZoneInfo("Europe/Berlin") 37 | ) # 20190304T080300 38 | -------------------------------------------------------------------------------- /recurring_ical_events/errors.py: -------------------------------------------------------------------------------- 1 | """All the errors.""" 2 | 3 | from recurring_ical_events.types import Time 4 | 5 | 6 | class InvalidCalendar(ValueError): 7 | """Exception thrown for bad icalendar content.""" 8 | 9 | def __init__(self, message: str): 10 | """Create a new error with a message.""" 11 | self._message = message 12 | super().__init__(self.message) 13 | 14 | @property 15 | def message(self) -> str: 16 | """The error message.""" 17 | return self._message 18 | 19 | 20 | class PeriodEndBeforeStart(InvalidCalendar): 21 | """An event or component starts before it ends.""" 22 | 23 | def __init__(self, message: str, start: Time, end: Time): 24 | """Create a new PeriodEndBeforeStart error.""" 25 | super().__init__(message) 26 | self._start = start 27 | self._end = end 28 | 29 | @property 30 | def start(self) -> Time: 31 | """The start of the component's period.""" 32 | return self._start 33 | 34 | @property 35 | def end(self) -> Time: 36 | """The end of the component's period.""" 37 | return self._end 38 | 39 | 40 | class BadRuleStringFormat(InvalidCalendar): 41 | """An iCal rule string is badly formatted.""" 42 | 43 | def __init__(self, message: str, rule: str): 44 | """Create an error with a bad rule string.""" 45 | super().__init__(message + ": " + rule) 46 | self._rule = rule 47 | 48 | @property 49 | def rule(self) -> str: 50 | """The malformed rule string""" 51 | return self._rule 52 | 53 | 54 | __all__ = ["BadRuleStringFormat", "InvalidCalendar", "PeriodEndBeforeStart"] 55 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_time_zones_differ.py: -------------------------------------------------------------------------------- 1 | """Imputs of different time zones should make a difference in the output""" 2 | 3 | import datetime 4 | 5 | import pytest 6 | import pytz 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("date", "hours", "timezone", "number_of_events", "calendar_name"), 11 | [ 12 | # DTSTART;TZID=Europe/Berlin:20190304T000000 13 | # time zone offset 6:07:00 between Europe/Berlin and Asia/Ho_Chi_Minh 14 | ((2019, 3, 4), 24, "Europe/Berlin", 1, "three_events"), 15 | ((2019, 3, 4), 24, "America/Panama", 0, "three_events"), 16 | ((2019, 3, 4), 24, "Asia/Ho_Chi_Minh", 1, "three_events"), 17 | ((2019, 3, 4), 1, "Asia/Ho_Chi_Minh", 0, "three_events"), 18 | ((2019, 3, 4), 6, "Asia/Ho_Chi_Minh", 0, "three_events"), 19 | ((2019, 3, 4), 7, "Asia/Ho_Chi_Minh", 1, "three_events"), 20 | # events that have no time zone, New Year 21 | ((2019, 1, 1), 1, "Europe/Berlin", 1, "Germany"), 22 | ((2019, 1, 1), 1, "Asia/Ho_Chi_Minh", 1, "Germany"), 23 | ((2019, 1, 1), 1, "America/Panama", 1, "Germany"), 24 | ], 25 | ) 26 | def test_include_events_if_the_time_zone_differs( 27 | calendars, date, hours, timezone, number_of_events, calendar_name 28 | ): 29 | """When the time zone is different, events can be included or 30 | excluded because they are in another time zone. 31 | """ 32 | tzinfo = pytz.timezone(timezone) 33 | start = tzinfo.localize(datetime.datetime(*date)) 34 | stop = start + datetime.timedelta(hours=hours) 35 | events = calendars[calendar_name].between(start, stop) 36 | assert len(events) == number_of_events, ( 37 | f"in calendar {calendar_name} and {date} in {timezone}" 38 | ) 39 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_113_period_in_rdate.py: -------------------------------------------------------------------------------- 1 | """This tests that RDATE can be a PERIOD. 2 | 3 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/113 4 | 5 | Value Type: The default value type for this property is DATE-TIME. 6 | The value type can be set to DATE or PERIOD. 7 | 8 | If the "RDATE" property is 9 | specified as a PERIOD value the duration of the recurrence 10 | instance will be the one specified by the "RDATE" property, and 11 | not the duration of the recurrence instance defined by the 12 | "DTSTART" property. 13 | 14 | """ 15 | 16 | from datetime import datetime, timedelta 17 | 18 | import pytz 19 | 20 | 21 | def test_start_of_rdate(calendars): 22 | """The event starts on that time.""" 23 | event = calendars.issue_113_period_in_rdate.at("20231213")[0] 24 | expected_start = pytz.timezone("America/Vancouver").localize( 25 | datetime(2023, 12, 13, 12, 0) 26 | ) 27 | start = event["DTSTART"].dt 28 | assert start == expected_start 29 | 30 | 31 | def test_end_of_rdate(calendars): 32 | """The event starts on that time.""" 33 | event = calendars.issue_113_period_in_rdate.at("20231213")[0] 34 | assert event["DTEND"].dt == pytz.timezone("America/Vancouver").localize( 35 | datetime(2023, 12, 13, 15, 0) 36 | ) 37 | 38 | 39 | def test_rdate_with_a_period_with_duration(calendars): 40 | """Check that we can process RDATE with a duration as second value.""" 41 | events = calendars.issue_113_period_rdate_duration.at("20240913") 42 | assert len(events) == 1, "We found the event with the rdate." 43 | event = events[0] 44 | duration = event["DTEND"].dt - event["DTSTART"].dt 45 | assert duration == timedelta(hours=2) 46 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Recurring ICal events for Python 2 | ================================ 3 | 4 | .. image:: https://github.com/niccokunzmann/python-recurring-ical-events/actions/workflows/tests.yml/badge.svg 5 | :target: https://github.com/niccokunzmann/python-recurring-ical-events/actions/workflows/tests.yml 6 | :alt: GitHub CI build and test status 7 | .. image:: https://badge.fury.io/py/recurring-ical-events.svg 8 | :target: https://pypi.python.org/pypi/recurring-ical-events 9 | :alt: Python Package Version on Pypi 10 | .. image:: https://img.shields.io/pypi/dm/recurring-ical-events.svg 11 | :target: https://pypi.org/project/recurring-ical-events/#files 12 | :alt: Downloads from Pypi 13 | .. image:: https://img.shields.io/opencollective/all/open-web-calendar?label=support%20on%20open%20collective 14 | :target: https://opencollective.com/open-web-calendar/ 15 | :alt: Support on Open Collective 16 | .. image:: https://img.shields.io/github/issues/niccokunzmann/python-recurring-ical-events?logo=github&label=issues%20seek%20funding&color=%230062ff 17 | :target: https://polar.sh/niccokunzmann/python-recurring-ical-events 18 | :alt: issues seek funding 19 | 20 | ICal has some complexity to it: 21 | Events, TODOs, Journal entries and Alarms can be repeated, removed from the feed and edited later on. 22 | This tool takes care of these complexities. 23 | 24 | Please have a look here: 25 | 26 | - `Documentation`_ 27 | - `Changelog`_ 28 | - `PyPI package`_ 29 | - `GitHub repository`_ 30 | 31 | .. _Documentation: https://recurring-ical-events.readthedocs.io/ 32 | .. _Changelog: https://recurring-ical-events.readthedocs.io/en/latest/changelog.html 33 | .. _PyPI package: https://pypi.org/project/recurring-ical-events/ 34 | .. _GitHub repository: https://github.com/niccokunzmann/python-recurring-ical-events 35 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_duration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the DURATION property. 3 | 4 | Not all events have an end. 5 | Some events define no explicit end and some a DURATION. 6 | RFC: https://www.kanzaki.com/docs/ical/duration.html 7 | 8 | """ 9 | 10 | import pytest 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("date", "count"), 15 | [ 16 | # event 3 days 17 | ("20180110", 1), 18 | ("20180111", 1), 19 | ("20180112", 1), 20 | ("20180109", 0), 21 | ("20180114", 0), 22 | # event 3 hours 23 | ((2018, 1, 15, 10), 1), 24 | ((2018, 1, 15, 11), 1), 25 | ((2018, 1, 15, 12), 1), 26 | ((2018, 1, 15, 9), 0), 27 | ((2018, 1, 15, 14), 0), 28 | # event with no duration nor end 29 | ((2018, 1, 20), 1), 30 | ((2018, 1, 19), 0), 31 | ((2018, 1, 21), 0), 32 | ], 33 | ) 34 | def test_events_expected(date, count, calendars): 35 | events = calendars.duration.at(date) 36 | assert len(events) == count 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ("date", "summary", "expected_hours"), 41 | [ 42 | ("20190318", "original event", 1), 43 | ("20190319", "edited duration", 3), 44 | ("20190320", "original event", 1), 45 | ], 46 | ) 47 | def test_duration_is_edited(calendars, date, summary, expected_hours): 48 | """Test that the duration of an event can be edited.""" 49 | events = calendars.duration_edited.at(date) 50 | assert len(events) == 1 51 | event = events[0] 52 | event_hours = (event["DTEND"].dt - event["DTSTART"].dt).total_seconds() / 3600 53 | assert summary == event["SUMMARY"], "we should have the correct event" 54 | assert event_hours == expected_hours, ( 55 | "the duration is only edited in the edited event" 56 | ) 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report and help this package improve 4 | title: 'bug: ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **ICS file** 17 | 18 | 19 | ```ics 20 | ``` 21 | 22 | **Expected behavior** 23 | 24 | 25 | **Console output** 26 | 27 | 28 | **Version:** 29 | 30 | ![](https://raster.shields.io/badge/version-3.3.0-brightgreen.png) 31 | 32 | 37 | ```shell 38 | pip list 39 | ``` 40 | 41 | **Additional context** 42 | 43 | 44 | **Suggested implementation** 45 | 46 | 47 | - [ ] add an ICS file, example: 48 | https://github.com/niccokunzmann/python-recurring-ical-events/blob/f4a90b211f30bf0522f03514ba50eb69826f0fdb/test/calendars/issue-15-duplicated-events.ics#L1 49 | - [ ] add a test, example: 50 | https://github.com/niccokunzmann/python-recurring-ical-events/blob/f4a90b211f30bf0522f03514ba50eb69826f0fdb/test/test_issue_15.py#L21 51 | - [ ] fix the bug and ensure the test code passes 52 | -------------------------------------------------------------------------------- /docs/user-guide/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | Documentation for users who wish query calendars for occurrences of events and other components. 6 | --- 7 | 8 | # Get started 9 | 10 | This section gets you started using this library. 11 | 12 | ## Installation 13 | 14 | ```{eval-rst} 15 | .. tabs:: 16 | 17 | .. tab:: Pip 18 | 19 | .. code-block:: bash 20 | 21 | pip install 'recurring-ical-events==3.*' 22 | 23 | .. tab:: Debian/Ubuntu 24 | 25 | .. code-block:: bash 26 | 27 | sudo apt-get install python-recurring-ical-events 28 | 29 | .. tab:: Alpine Linux 30 | 31 | .. code-block:: bash 32 | 33 | apk add py3-recurring-ical-events 34 | 35 | .. tab:: Fedora 36 | 37 | .. code-block:: bash 38 | 39 | sudo dnf install ??? 40 | 41 | .. tab:: Arch Linux 42 | 43 | .. code-block:: bash 44 | 45 | sudo pacman -S python-recurring-ical-events 46 | 47 | 48 | ``` 49 | 50 | If not listed, this library is available as a package on the following platforms: 51 | 52 | [![Packaging status](https://repology.org/badge/vertical-allrepos/python%3Arecurring-ical-events.svg?columns=3)](https://repology.org/project/python%3Arecurring-ical-events/versions) 53 | 54 | ## Usage 55 | 56 | The [icalendar] module is responsible for parsing files with a calendar specification in it. 57 | This library takes such a {py:class}`icalendar.cal.Calendar` and computes the occurrences. 58 | 59 | To import this module, write 60 | 61 | ```python 62 | >>> import recurring_ical_events 63 | 64 | ``` 65 | 66 | If you like to go deeper, have a look at the [API documentation](../reference/api) at this point. 67 | We have a comprehensive list of **[examples]** to get you started. 68 | 69 | [icalendar]: https://icalendar.readthedocs.io 70 | [examples]: examples.rst 71 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_62_moved_event.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Partyborn Zeitgeist 7 | X-WR-TIMEZONE:Europe/Berlin 8 | X-WR-CALDESC:Alle Events des Zeitgeist Paderborn für den Partyborn Partyala 9 | rm\nhttps://partyborn.de/partyalarm 10 | BEGIN:VTIMEZONE 11 | TZID:Europe/Berlin 12 | X-LIC-LOCATION:Europe/Berlin 13 | BEGIN:DAYLIGHT 14 | TZOFFSETFROM:+0100 15 | TZOFFSETTO:+0200 16 | TZNAME:CEST 17 | DTSTART:19700329T020000 18 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 19 | END:DAYLIGHT 20 | BEGIN:STANDARD 21 | TZOFFSETFROM:+0200 22 | TZOFFSETTO:+0100 23 | TZNAME:CET 24 | DTSTART:19701025T030000 25 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 26 | END:STANDARD 27 | END:VTIMEZONE 28 | BEGIN:VEVENT 29 | DTSTART;TZID=Europe/Berlin:20211217T213000 30 | DTSTAMP:20211218T004508Z 31 | UID:38m812jicsrer5gorh3mlp7qhc@google.com 32 | RECURRENCE-ID;TZID=Europe/Berlin:20211231T213000 33 | CREATED:20211218T004036Z 34 | DESCRIPTION:Jeden letzten Freitag im Monat https://www.instagram.com/p/CWwxmqbKXsC/ 36 | LAST-MODIFIED:20211218T004234Z 37 | LOCATION: 38 | SEQUENCE:3 39 | STATUS:CONFIRMED 40 | SUMMARY:Karaoke 41 | TRANSP:TRANSPARENT 42 | END:VEVENT 43 | BEGIN:VEVENT 44 | DTSTART;TZID=Europe/Berlin:20211126T213000 45 | DTEND;TZID=Europe/Berlin:20211126T213000 46 | RRULE:FREQ=MONTHLY;BYDAY=-1FR 47 | DTSTAMP:20211218T004508Z 48 | UID:38m812jicsrer5gorh3mlp7qhc@google.com 49 | CREATED:20211218T004036Z 50 | DESCRIPTION:Jeden letzten Freitag im Monat https://www.instagram.com/p/CWwxmqbKXsC/ 52 | LAST-MODIFIED:20211218T004214Z 53 | LOCATION: 54 | SEQUENCE:2 55 | STATUS:CONFIRMED 56 | SUMMARY:Karaoke 57 | TRANSP:TRANSPARENT 58 | END:VEVENT 59 | END:VCALENDAR -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_62_moved_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | This tests the move of a december event. 3 | 4 | Issue: https://github.com/niccokunzmann/python-recurring-ical-events/issues/62 5 | """ 6 | 7 | import pytest 8 | 9 | 10 | def test_event_is_absent(calendars): 11 | """RRULE:FREQ=MONTHLY;BYDAY=-1FR""" 12 | events = calendars.issue_62_moved_event.at("20211231") 13 | assert events == [] 14 | 15 | 16 | def test_event_has_moved(calendars): 17 | """DTSTART;TZID=Europe/Berlin:20211217T213000""" 18 | events = calendars.issue_62_moved_event.at("20211217") 19 | assert len(events) == 1 20 | 21 | 22 | def test_there_is_only_one_event_in_december(calendars): 23 | """Maybe, if we get the whole December, there might be one event.""" 24 | events = calendars.issue_62_moved_event.at((2021, 12)) 25 | assert len(events) == 1 26 | 27 | 28 | @pytest.mark.parametrize( 29 | ("date", "summary"), 30 | [ 31 | ("20230810", "All Day"), 32 | ("20230816", "All Day"), 33 | ("20230824", "All Day"), 34 | ("20230808", "Datetime"), 35 | ("20230814", "Datetime"), 36 | ("20230822", "Datetime"), 37 | ], 38 | ) 39 | def test_event_is_present(calendars, date, summary): 40 | """Test that the middle event has moved""" 41 | events = calendars.issue_62_moved_event_2.at(date) 42 | assert len(events) == 1 43 | event = events[0] 44 | assert event["SUMMARY"] == summary 45 | 46 | 47 | @pytest.mark.parametrize("date", ["20230815", "20230817"]) 48 | def test_event_is_absent_2(calendars, date): 49 | """We make sure that the moved event is not there.""" 50 | events = calendars.issue_62_moved_event_2.at(date) 51 | assert len(events) == 0 52 | 53 | 54 | def test_total_amount_of_events(calendars): 55 | """There are only 6 events!""" 56 | events = calendars.issue_62_moved_event_2.at((2023, 8)) 57 | assert len(events) == 6 58 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_97_simple_recurrent_todos_and_journals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | calendars_parametrized = pytest.mark.parametrize( 4 | "ical_file", 5 | [ 6 | "issue_97_simple_todo", 7 | "issue_97_simple_journal", 8 | "issue_97_todo_nodtstart", 9 | ], 10 | ) 11 | 12 | 13 | @calendars_parametrized 14 | def test_recurring_task_is_not_included1(calendars, ical_file): 15 | """The three files given starts in late 1991, no recurrences 16 | should be found before 1991. Refers to 17 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/97. 18 | Test passes prior to fixing #97, should still pass after #97 is 19 | fixed. 20 | """ 21 | calendars.components = ["VJOURNAL", "VTODO", "VEVENT"] 22 | tasks = calendars[ical_file].between((1989, 1, 1), (1991, 1, 1)) 23 | assert not tasks 24 | 25 | 26 | @calendars_parametrized 27 | def test_recurring_task_is_not_included2(calendars, ical_file): 28 | """Every recurrence of the three ical files is in October, hence 29 | no recurrences should be found. Refers to 30 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/97. 31 | Test passes prior to fixing #97, should still pass after #97 is 32 | fixed. 33 | """ 34 | calendars.components = ["VJOURNAL", "VTODO", "VEVENT"] 35 | tasks = calendars[ical_file].between((1998, 1, 1), (1998, 4, 14)) 36 | assert not tasks 37 | 38 | 39 | @calendars_parametrized 40 | def test_recurring_task_is_repeated(calendars, ical_file): 41 | """Expansion of a yearly task over seven years. 42 | The issue 43 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/97 44 | needs to be fixed before this test can pass 45 | """ 46 | calendars.components = ["VJOURNAL", "VTODO", "VEVENT"] 47 | events = calendars[ical_file].between((1995, 1, 1), (2002, 1, 1)) 48 | assert len(events) == 7 49 | -------------------------------------------------------------------------------- /recurring_ical_events/adapters/event.py: -------------------------------------------------------------------------------- 1 | """Adapter for VEVENT.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | from typing import TYPE_CHECKING 7 | 8 | from recurring_ical_events.adapters.component import ComponentAdapter 9 | from recurring_ical_events.util import ( 10 | convert_to_datetime, 11 | is_date, 12 | normalize_pytz, 13 | ) 14 | 15 | if TYPE_CHECKING: 16 | from recurring_ical_events.types import Time 17 | 18 | 19 | class EventAdapter(ComponentAdapter): 20 | """An icalendar event adapter.""" 21 | 22 | @staticmethod 23 | def component_name() -> str: 24 | """The icalendar component name.""" 25 | return "VEVENT" 26 | 27 | @property 28 | def end_property(self) -> str: 29 | """DTEND""" 30 | return "DTEND" 31 | 32 | @property 33 | def raw_start(self) -> Time: 34 | """Return DTSTART""" 35 | # Arguably, it may be considered a feature that this breaks 36 | # if no DTSTART is set 37 | return self._component["DTSTART"].dt 38 | 39 | @property 40 | def raw_end(self) -> Time: 41 | """Yield DTEND or calculate the end of the event based on 42 | DTSTART and DURATION. 43 | """ 44 | ## an even may have DTEND or DURATION, but not both 45 | end = self._component.get("DTEND") 46 | if end is not None: 47 | return end.dt 48 | duration = self._component.get("DURATION") 49 | if duration is not None: 50 | start = self._component["DTSTART"].dt 51 | if duration.dt.seconds != 0 and is_date(start): 52 | start = convert_to_datetime(start, None) 53 | return normalize_pytz(start + duration.dt) 54 | start = self._component["DTSTART"].dt 55 | if is_date(start): 56 | return start + datetime.timedelta(days=1) 57 | return start 58 | 59 | 60 | __all__ = ["EventAdapter"] 61 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_with_doctest.py: -------------------------------------------------------------------------------- 1 | """This file tests the source code provided by the documentation. 2 | 3 | See 4 | - doctest documentation: https://docs.python.org/3/library/doctest.html 5 | - Issue 443: https://github.com/collective/icalendar/issues/443 6 | 7 | This file should be tests, too: 8 | 9 | >>> print("Hello World!") 10 | Hello World! 11 | 12 | """ 13 | 14 | import doctest 15 | import importlib 16 | import pathlib 17 | import sys 18 | 19 | import pytest 20 | 21 | HERE = pathlib.Path(__file__).parent 22 | PROJECT_PATH = HERE.parent.parent 23 | 24 | PYTHON_FILES = list(PROJECT_PATH.rglob("*.py")) 25 | 26 | MODULE_NAMES = [ 27 | "recurring_ical_events", 28 | ] 29 | 30 | 31 | @pytest.mark.parametrize("module_name", MODULE_NAMES) 32 | def test_docstring_of_python_file(module_name): 33 | """This test runs doctest on the Python module.""" 34 | module = importlib.import_module(module_name) 35 | test_result = doctest.testmod(module, name=module_name) 36 | assert test_result.failed == 0, f"{test_result.failed} errors in {module_name}" 37 | 38 | 39 | # This collection needs to exclude .tox and other subdirectories 40 | DOCS = PROJECT_PATH / "docs" 41 | DOCUMENT_PATHS = [PROJECT_PATH / "README.rst", *list(DOCS.glob("*/*.rst")), *list(DOCS.glob("*.rst")), *list(DOCS.glob("*/*.md")), *list(DOCS.glob("*/*.md"))] 42 | 43 | 44 | @pytest.mark.parametrize("document", DOCUMENT_PATHS) 45 | def test_documentation_file(document, env_for_doctest): 46 | """This test runs doctest on a documentation file. 47 | 48 | functions are also replaced to work. 49 | """ 50 | test_result = doctest.testfile( 51 | str(document), 52 | module_relative=False, 53 | globs=env_for_doctest, 54 | raise_on_error=False, 55 | ) 56 | assert test_result.failed == 0, f"{test_result.failed} errors in {document.name}" 57 | 58 | 59 | def test_can_import_zoneinfo(env_for_doctest): # noqa: ARG001 60 | """Allow importing zoneinfo for tests.""" 61 | assert "zoneinfo" in sys.modules 62 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_151_macos_linux_difference.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Saint Marina Calendar 7 | X-WR-TIMEZONE:America/Los_Angeles 8 | BEGIN:VTIMEZONE 9 | TZID:America/Los_Angeles 10 | X-LIC-LOCATION:America/Los_Angeles 11 | BEGIN:DAYLIGHT 12 | TZOFFSETFROM:-0800 13 | TZOFFSETTO:-0700 14 | TZNAME:PDT 15 | DTSTART:19700308T020000 16 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU 17 | END:DAYLIGHT 18 | BEGIN:STANDARD 19 | TZOFFSETFROM:-0700 20 | TZOFFSETTO:-0800 21 | TZNAME:PST 22 | DTSTART:19701101T020000 23 | RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU 24 | END:STANDARD 25 | END:VTIMEZONE 26 | BEGIN:VEVENT 27 | DTSTART;TZID=America/Los_Angeles:20140801T190000 28 | DTEND;TZID=America/Los_Angeles:20140801T200000 29 | RRULE:FREQ=YEARLY 30 | EXDATE;TZID=America/Los_Angeles:20160801T190000 31 | DTSTAMP:20240807T000326Z 32 | UID:aogpprh4bolu8ckmop49ca6404@google.com 33 | CREATED:20140331T192650Z 34 | LAST-MODIFIED:20150731T022326Z 35 | SEQUENCE:1 36 | STATUS:CONFIRMED 37 | SUMMARY:Vespers for the Feast of St. Joseph 38 | TRANSP:OPAQUE 39 | BEGIN:VALARM 40 | ACTION:NONE 41 | TRIGGER;VALUE=DATE-TIME:19760401T005545Z 42 | X-WR-ALARMUID:0D3A9816-AC61-499A-A594-930AA281666B 43 | UID:0D3A9816-AC61-499A-A594-930AA281666B 44 | END:VALARM 45 | END:VEVENT 46 | BEGIN:VEVENT 47 | DTSTART;TZID=America/Los_Angeles:20140801T183000 48 | DTEND;TZID=America/Los_Angeles:20140801T193000 49 | DTSTAMP:20240807T000326Z 50 | UID:aogpprh4bolu8ckmop49ca6404@google.com 51 | RECURRENCE-ID;TZID=America/Los_Angeles:20140801T190000 52 | CREATED:20140331T192650Z 53 | LAST-MODIFIED:20150731T022326Z 54 | SEQUENCE:3 55 | STATUS:CONFIRMED 56 | SUMMARY:Vespers for the Feast of St. Joseph 57 | TRANSP:OPAQUE 58 | BEGIN:VALARM 59 | ACTION:NONE 60 | TRIGGER;VALUE=DATE-TIME:19760401T005545Z 61 | X-WR-ALARMUID:8744D632-C9F8-483C-B095-590E0A3D2E39 62 | UID:8744D632-C9F8-483C-B095-590E0A3D2E39 63 | END:VALARM 64 | END:VEVENT 65 | END:VCALENDAR 66 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_151_macos_linux_difference2.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Saint Marina Calendar 7 | X-WR-TIMEZONE:America/Los_Angeles 8 | BEGIN:VTIMEZONE 9 | TZID:America/Los_Angeles 10 | X-LIC-LOCATION:America/Los_Angeles 11 | BEGIN:DAYLIGHT 12 | TZOFFSETFROM:-0800 13 | TZOFFSETTO:-0700 14 | TZNAME:PDT 15 | DTSTART:19700308T020000 16 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU 17 | END:DAYLIGHT 18 | BEGIN:STANDARD 19 | TZOFFSETFROM:-0700 20 | TZOFFSETTO:-0800 21 | TZNAME:PST 22 | DTSTART:19701101T020000 23 | RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU 24 | END:STANDARD 25 | END:VTIMEZONE 26 | BEGIN:VEVENT 27 | DTSTART;TZID=America/Los_Angeles:20140801T190000 28 | DTEND;TZID=America/Los_Angeles:20140801T200000 29 | RRULE:FREQ=YEARLY 30 | EXDATE;TZID=America/Los_Angeles:20160801T190000 31 | DTSTAMP:20240807T000326Z 32 | UID:aogpprh4bolu8ckmop49ca6404@google.com 33 | CREATED:20140331T192650Z 34 | LAST-MODIFIED:20150731T022326Z 35 | SEQUENCE:1 36 | STATUS:CONFIRMED 37 | SUMMARY:Vespers for the Feast of St. Joseph 38 | TRANSP:OPAQUE 39 | BEGIN:VALARM 40 | ACTION:NONE 41 | TRIGGER;VALUE=DATE-TIME:19760401T005545Z 42 | X-WR-ALARMUID:0D3A9816-AC61-499A-A594-930AA281666B 43 | UID:0D3A9816-AC61-499A-A594-930AA281666B 44 | END:VALARM 45 | END:VEVENT 46 | BEGIN:VEVENT 47 | DTSTART;TZID=America/Los_Angeles:20140801T183000 48 | DTEND;TZID=America/Los_Angeles:20140801T193000 49 | DTSTAMP:20240807T000326Z 50 | UID:aogpprh4bolu8ckmop49ca6404@google.com 51 | RECURRENCE-ID;TZID=America/Los_Angeles:20140801T190000 52 | CREATED:20140331T192650Z 53 | LAST-MODIFIED:20150731T022326Z 54 | SEQUENCE:3 55 | STATUS:CONFIRMED 56 | SUMMARY:Vespers for the Feast of St. Joseph 57 | TRANSP:OPAQUE 58 | BEGIN:VALARM 59 | ACTION:NONE 60 | TRIGGER;VALUE=DATE-TIME:19760401T005545Z 61 | X-WR-ALARMUID:8744D632-C9F8-483C-B095-590E0A3D2E39 62 | UID:8744D632-C9F8-483C-B095-590E0A3D2E39 63 | END:VALARM 64 | END:VEVENT 65 | END:VCALENDAR 66 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_62_moved_event_2.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Test Calendar 7 | X-WR-TIMEZONE:Australia/Sydney 8 | BEGIN:VTIMEZONE 9 | TZID:Australia/Sydney 10 | X-LIC-LOCATION:Australia/Sydney 11 | BEGIN:STANDARD 12 | TZOFFSETFROM:+1100 13 | TZOFFSETTO:+1000 14 | TZNAME:AEST 15 | DTSTART:19700405T030000 16 | RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU 17 | END:STANDARD 18 | BEGIN:DAYLIGHT 19 | TZOFFSETFROM:+1000 20 | TZOFFSETTO:+1100 21 | TZNAME:AEDT 22 | DTSTART:19701004T020000 23 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU 24 | END:DAYLIGHT 25 | END:VTIMEZONE 26 | BEGIN:VEVENT 27 | DTSTART;TZID=Australia/Sydney:20230808T140000 28 | DTEND;TZID=Australia/Sydney:20230808T150000 29 | RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230828T135959Z;BYDAY=TU 30 | DTSTAMP:20230629T040023Z 31 | UID:7v3ju5ft4je5iq18nfdk2s3spk@google.com 32 | CREATED:20230629T035454Z 33 | LAST-MODIFIED:20230629T035851Z 34 | SEQUENCE:0 35 | STATUS:CONFIRMED 36 | SUMMARY:Datetime 37 | TRANSP:OPAQUE 38 | END:VEVENT 39 | BEGIN:VEVENT 40 | DTSTART;TZID=Australia/Sydney:20230814T140000 41 | DTEND;TZID=Australia/Sydney:20230814T150000 42 | DTSTAMP:20230629T040023Z 43 | UID:7v3ju5ft4je5iq18nfdk2s3spk@google.com 44 | RECURRENCE-ID;TZID=Australia/Sydney:20230815T140000 45 | CREATED:20230629T035454Z 46 | LAST-MODIFIED:20230629T035851Z 47 | SEQUENCE:1 48 | STATUS:CONFIRMED 49 | SUMMARY:Datetime 50 | TRANSP:OPAQUE 51 | END:VEVENT 52 | BEGIN:VEVENT 53 | DTSTART;VALUE=DATE:20230810 54 | DTEND;VALUE=DATE:20230811 55 | RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20230830;BYDAY=TH 56 | DTSTAMP:20230629T040023Z 57 | UID:6ep37v20d728v14rcgn17v9is6@google.com 58 | CREATED:20230629T035522Z 59 | LAST-MODIFIED:20230629T035854Z 60 | SEQUENCE:0 61 | STATUS:CONFIRMED 62 | SUMMARY:All Day 63 | TRANSP:TRANSPARENT 64 | END:VEVENT 65 | BEGIN:VEVENT 66 | DTSTART;VALUE=DATE:20230816 67 | DTEND;VALUE=DATE:20230817 68 | DTSTAMP:20230629T040023Z 69 | UID:6ep37v20d728v14rcgn17v9is6@google.com 70 | RECURRENCE-ID;VALUE=DATE:20230817 71 | CREATED:20230629T035522Z 72 | LAST-MODIFIED:20230629T035854Z 73 | SEQUENCE:1 74 | STATUS:CONFIRMED 75 | SUMMARY:All Day 76 | TRANSP:TRANSPARENT 77 | END:VEVENT 78 | END:VCALENDAR 79 | 80 | -------------------------------------------------------------------------------- /docs/reference/compatibility.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | Specifications and compatibility 6 | --- 7 | 8 | # Compatibility 9 | 10 | ## RFC Specifications 11 | 12 | ### RFC 2445 - iCalendar 13 | 14 | ![RFC 2445 is deprecated](https://img.shields.io/badge/RFC_2445-deprecated-red) 15 | 16 | {rfc}`2445` is deprecated and has been replaced by {rfc}`5545` and {rfc}`7529`. 17 | 18 | ### RFC 5545 - iCalendar 19 | 20 | ![RFC 5545 is supported](https://img.shields.io/badge/RFC_5545-supported-green) 21 | 22 | {rfc}`5545` is fully supported. 23 | 24 | ### RFC 7529 - Non-Gregorian Recurrence Rules 25 | 26 | ![RFC 7529 is not implemented](https://img.shields.io/badge/RFC_7529-todo-red) 27 | 28 | {rfc}`7529` is not implemented. 29 | 30 | ### RFC 7953 - Calendar Availability 31 | 32 | ![RFC 7953 is not implemented](https://img.shields.io/badge/RFC_7953-todo-red) 33 | 34 | {rfc}`7953` is not implemented. 35 | 36 | ## Other Specifications 37 | 38 | ### X-WR-TIMEZONE 39 | 40 | `X-WR-TIMEZONE` is supported through the [X-WR-TIMEZONE] library. 41 | 42 | ## Feature list 43 | 44 | * ✅ day light saving time (DONE) 45 | * ✅ recurring events (DONE) 46 | * ✅ recurring events with edits (DONE) 47 | * ✅ recurring events where events are omitted (DONE) 48 | * ✅ recurring events events where the edit took place later (DONE) 49 | * ✅ normal events (DONE) 50 | * ✅ recurrence of dates but not hours, minutes, and smaller (DONE) 51 | * ✅ endless recurrence (DONE) 52 | * ✅ ending recurrence (DONE) 53 | * ✅ events with start date and no end date (DONE) 54 | * ✅ events with start as date and start as datetime (DONE) 55 | * ✅ [RRULE](https://www.kanzaki.com/docs/ical/rrule.html) (DONE) 56 | * ✅ events with multiple RRULE (DONE) 57 | * ✅ [RDATE](https://www.kanzaki.com/docs/ical/rdate.html) (DONE) 58 | * ✅ [DURATION](https://www.kanzaki.com/docs/ical/duration.html) (DONE) 59 | * ✅ [EXDATE](https://www.kanzaki.com/docs/ical/exdate.html) (DONE) 60 | * ✅ [X-WR-TIMEZONE] compatibilty (DONE) 61 | * ✅ RECURRENCE-ID with THISANDFUTURE - modify all future events (DONE) 62 | 63 | ## Missing features 64 | 65 | * ❌ non-gregorian event repetitions (TODO) 66 | * ❌ EXRULE (deprecated), see [8.3.2. Properties Registry](https://tools.ietf.org/html/rfc5545#section-8.3.2) 67 | 68 | 69 | [X-WR-TIMEZONE]: https://pypi.org/project/x-wr-timezone 70 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_event_values_and_edits.py: -------------------------------------------------------------------------------- 1 | """This tests the values of the event even when edited.""" 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | 8 | def test_two_events_have_the_same_values(calendars): 9 | events = calendars.three_events_one_edited.all() 10 | unedited_events = [event for event in events if event["SUMMARY"] == "test7"] 11 | assert len(unedited_events) == 2 12 | 13 | 14 | def test_one_event_is_edited(calendars): 15 | events = calendars.three_events_one_edited.all() 16 | edited_events = [event for event in events if event["SUMMARY"] == "test7 - edited"] 17 | assert len(edited_events) == 1 18 | edited_event = edited_events[0] 19 | assert edited_event["LOCATION"] == "location" 20 | 21 | 22 | def test_three_events_total(calendars): 23 | events = list(calendars.three_events_one_edited.all()) 24 | assert len(events) == 3 25 | 26 | 27 | # def test_edited_event_as_part_of_exdate(todo): 28 | # """What happens when an edited event is part of the exdate?""" 29 | # There is nothing written in the RFC 5545 about this case 30 | # I would assume that a software creating an event and exluding it is faulty. 31 | 32 | 33 | def test_edited_event_as_part_of_exrule(): 34 | """What happens when an edited event is part of the exrule? 35 | 36 | Well nothing, EXRULE is not supported by this module.""" 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ("date", "hour"), 41 | [ 42 | ((2019, 3, 7), 2), 43 | ((2019, 3, 8), 1), 44 | ((2019, 3, 9), 3), 45 | ((2019, 3, 10), 2), 46 | ], 47 | ) 48 | def test_event_moved_in_time(calendars, date, hour): 49 | events = calendars.recurring_events_moved.at(date) 50 | assert len(events) == 1 51 | event = events[0] 52 | assert event["DTSTART"].dt.hour == hour 53 | 54 | 55 | @pytest.mark.parametrize( 56 | ("date", "duration"), 57 | [ 58 | ((2019, 3, 7), datetime.timedelta(hours=1)), 59 | ((2019, 3, 8), datetime.timedelta(hours=2)), 60 | ((2019, 3, 9), datetime.timedelta(minutes=30)), 61 | ((2019, 3, 10), datetime.timedelta(days=1)), 62 | ], 63 | ) 64 | def test_event_moved_in_time_2(calendars, date, duration): 65 | events = calendars.recurring_events_changed_duration.at(date) 66 | assert len(events) == 1 67 | event = events[0] 68 | assert event["DTEND"].dt - event["DTSTART"].dt == duration 69 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_after.py: -------------------------------------------------------------------------------- 1 | """Test getting events in a specific order.""" 2 | 3 | import datetime 4 | 5 | import pytest 6 | import pytz 7 | 8 | 9 | def test_a_calendar_with_no_event_has_no_events(calendars): 10 | """No event""" 11 | for _ in calendars.no_events.after(datetime.datetime(2024, 3, 30, 12, 0, 0)): 12 | assert False, "No event expected." 13 | 14 | 15 | def test_a_calendar_with_events_before_has_no_events_later(calendars): 16 | """No event is found.""" 17 | for _ in calendars.event_10_times.after( 18 | datetime.datetime(2024, 3, 30, 12, 0, 0), 19 | ): 20 | assert False, "No event expected." 21 | 22 | 23 | def test_different_time_zones(): 24 | """If events with different time zones are compared.""" 25 | pytest.skip("TODO") 26 | 27 | 28 | def test_no_event_is_returned_twice(calendars): 29 | """Long events should not be returned several times.""" 30 | i = 1 31 | for event in calendars.after_many_events_in_order.after("20240324"): 32 | assert event["SUMMARY"] == f"event {i}" 33 | i += 1 34 | assert i == 8 35 | 36 | 37 | def test_todo_with_no_dtstart(): 38 | pytest.skip("TODO") 39 | 40 | 41 | @pytest.mark.parametrize( 42 | ("date", "count"), 43 | [ 44 | ("20200113", 10), 45 | ("20200114", 9), 46 | ("20200115", 8), 47 | ("20200116", 7), 48 | ("20200117", 6), 49 | ("20200118", 5), 50 | ("20200119", 4), 51 | ("20200120", 3), 52 | ("20200121", 2), 53 | ("20200122", 1), 54 | ("20200123", 0), 55 | (datetime.datetime(2020, 1, 19, 0, 0, 0, tzinfo=pytz.UTC), 4), 56 | ], 57 | ) 58 | def test_get_events_in_series(calendars, date, count): 59 | """Get a few events in a series.""" 60 | events = list(calendars.event_10_times.after(date)) 61 | assert len(events) == count, f"{count} events expected" 62 | 63 | 64 | def test_zero_size_event_is_included(calendars): 65 | """If a zero size event happens exactly at the earliest_end, then it is included.""" 66 | event = list(calendars.zero_size_event.after("20190304T080000Z"))[0] 67 | assert event["DTSTART"].to_ical() == b"20190304T080000" 68 | 69 | 70 | def test_zero_size_event_is_excluded_one_second_later(calendars): 71 | """If a zero size event happens exactly at the earliest_end, then it is included.""" 72 | assert not list(calendars.zero_size_event.after("20190304T080001Z")) 73 | -------------------------------------------------------------------------------- /recurring_ical_events/selection/all.py: -------------------------------------------------------------------------------- 1 | """Selection of all components with the correct adapters.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Sequence 6 | 7 | from recurring_ical_events.occurrence import Occurrence 8 | from recurring_ical_events.selection.base import SelectComponents 9 | from recurring_ical_events.selection.name import ComponentsWithName 10 | from recurring_ical_events.series import Series 11 | 12 | if TYPE_CHECKING: 13 | from icalendar.cal import Component 14 | 15 | from recurring_ical_events.adapters.component import ComponentAdapter 16 | 17 | 18 | class AllKnownComponents(SelectComponents): 19 | """Group all known components into series.""" 20 | 21 | @property 22 | def _component_adapters(self) -> Sequence[ComponentAdapter]: 23 | """Return all known component adapters.""" 24 | return ComponentsWithName.component_adapters 25 | 26 | @property 27 | def names(self) -> list[str]: 28 | """Return the names of the components to collect.""" 29 | result = [adapter.component_name() for adapter in self._component_adapters] 30 | result.sort() 31 | return result 32 | 33 | def __init__( 34 | self, 35 | series: type[Series] = Series, 36 | occurrence: type[Occurrence] = Occurrence, 37 | collector: type[ComponentsWithName] = ComponentsWithName, 38 | ) -> None: 39 | """Collect all known components and overide the series and occurrence. 40 | 41 | series - the Series class to override that is queried for Occurrences 42 | occurrence - the occurrence class that creates the resulting components 43 | collector - if you want to override the SelectComponentsByName class 44 | """ 45 | self._series = series 46 | self._occurrence = occurrence 47 | self._collector = collector 48 | 49 | def collect_series_from( 50 | self, source: Component, suppress_errors: tuple[Exception] 51 | ) -> Sequence[Series]: 52 | """Collect the components from the source groups into a series.""" 53 | result = [] 54 | for name in self.names: 55 | collector = self._collector( 56 | name, series=self._series, occurrence=self._occurrence 57 | ) 58 | result.extend(collector.collect_series_from(source, suppress_errors)) 59 | return result 60 | 61 | 62 | __all__ = ["AllKnownComponents"] 63 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_223_sequence_number.py: -------------------------------------------------------------------------------- 1 | """This tests generating the right sequence number for event editing. 2 | 3 | A computed, recurring event of a UID taking an event with a higher sequence in account, 4 | will be of that sequence. 5 | 6 | See https://github.com/niccokunzmann/python-recurring-ical-events/issues/223 7 | """ 8 | 9 | from typing import TYPE_CHECKING 10 | 11 | import pytest 12 | 13 | if TYPE_CHECKING: 14 | from icalendar import Event 15 | 16 | 17 | def test_sequence_number_is_not_set_for_single_events(calendars): 18 | """If we have singe events, they should not need a sequence number.""" 19 | assert "SEQUENCE" not in calendars.one_event.first 20 | 21 | 22 | @pytest.mark.parametrize(("date", "sequence"), [("20190305", 1), ("20190304", 0)]) 23 | def test_sequence_is_not_deleted(calendars, date, sequence): 24 | """We do not delete the sequence if it is 0.""" 25 | events: list[Event] = calendars.issue_223_one_event_with_sequence.at(date) 26 | assert len(events) == 1 27 | event = events[0] 28 | assert event["SEQUENCE"] == sequence, "sequence remains in here" 29 | 30 | 31 | def test_sequence_number_is_not_set_for_recurrence(calendars): 32 | """If we do not use sequences at all, we should not set them.""" 33 | events = calendars.one_day_event_repeat_every_day.at("20190308") 34 | assert len(events) == 1 35 | event = events[0] 36 | assert "SEQUENCE" not in event, "is not set if not needed" 37 | 38 | 39 | def test_sequence_number_is_highest_for_edited_event(calendars): 40 | """If an event was edited, it uses this sequence number.""" 41 | events: list[Event] = calendars.issue_223_thunderbird.at("20250424") 42 | assert len(events) == 1 43 | event = events[0] 44 | assert event["SEQUENCE"] == 3, "2 -> 3" 45 | 46 | 47 | def test_sequence_number_is_highest_for_base_event(calendars): 48 | """The base event with no modification has the highest sequence number.""" 49 | events: list[Event] = calendars.issue_223_thunderbird.at("20250423") 50 | assert len(events) == 1 51 | event = events[0] 52 | assert event["SEQUENCE"] == 3, "1 -> 3" 53 | 54 | 55 | def test_sequence_number_is_highest_for_last_event(calendars): 56 | """The last edited event keeps its sequence number.""" 57 | events: list[Event] = calendars.issue_223_thunderbird.at("20250425") 58 | assert len(events) == 1 59 | event = events[0] 60 | assert event["SEQUENCE"] == 3, "3 -> 3" 61 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_101_select_components.py: -------------------------------------------------------------------------------- 1 | """These tests make sure that you can select which components should be returned. 2 | 3 | By default, it should be events. 4 | If a component is not supported, an error is raised. 5 | """ 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("components", "count", "calendar", "message"), 12 | [ 13 | (None, 0, "issue_97_simple_todo", "by default, only events are returned"), 14 | (None, 0, "issue_97_simple_journal", "by default, only events are returned"), 15 | ([], 0, "rdate", "no components, no result"), 16 | ([], 0, "issue_97_simple_todo", "no components, no result"), 17 | ([], 0, "issue_97_simple_journal", "no components, no result"), 18 | (["VEVENT"], 0, "issue_97_simple_todo", "no events in the calendar"), 19 | (["VEVENT"], 0, "issue_97_simple_journal", "no events in the calendar"), 20 | (["VJOURNAL"], 0, "issue_97_simple_todo", "no journal, just a todo"), 21 | (["VTODO"], 1, "issue_97_simple_todo", "one todo is found"), 22 | (["VTODO"], 0, "issue_97_simple_journal", "no todo, just a journal"), 23 | (["VJOURNAL"], 1, "issue_97_simple_journal", "one journal is found"), 24 | (["VTODO", "VEVENT"], 0, "issue_97_simple_journal", "no todo, just a journal"), 25 | (["VJOURNAL", "VEVENT"], 1, "issue_97_simple_journal", "one journal is found"), 26 | ( 27 | ["VJOURNAL", "VEVENT", "VTODO"], 28 | 1, 29 | "issue_97_simple_journal", 30 | "one journal is found", 31 | ), 32 | ], 33 | ) 34 | def test_components_and_their_count(calendars, components, count, calendar, message): 35 | calendars.components = components 36 | repeated_components = calendars[calendar].at(2022) 37 | print(repeated_components) 38 | assert len(repeated_components) == count, f"{message}: {components}, {calendar}" 39 | 40 | 41 | @pytest.mark.parametrize( 42 | "component", 43 | [ 44 | "VTIMEZONE", # existing but not supported 45 | "vevent", # misspelled 46 | "ALDHKSJHK", # does not exist 47 | ], 48 | ) 49 | def test_unsupported_component_raises_error(component, calendars): 50 | """If a component is not recognized, we want to inform the user.""" 51 | with pytest.raises(ValueError) as error: 52 | calendars.components = [component] 53 | calendars.rdate # noqa: B018 54 | assert f'"{component}"' in str(error) 55 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | Documentation of the recurring-ical-events library for Python. 6 | --- 7 | 8 | # Python recurring ICal events 9 | 10 | ```{eval-rst} 11 | .. image:: https://github.com/niccokunzmann/python-recurring-ical-events/actions/workflows/tests.yml/badge.svg 12 | :target: https://github.com/niccokunzmann/python-recurring-ical-events/actions/workflows/tests.yml 13 | :alt: GitHub CI build and test status 14 | .. image:: https://badge.fury.io/py/recurring-ical-events.svg 15 | :target: https://pypi.python.org/pypi/recurring-ical-events 16 | :alt: Python Package Version on Pypi 17 | .. image:: https://img.shields.io/pypi/dm/recurring-ical-events.svg 18 | :target: https://pypi.org/project/recurring-ical-events/#files 19 | :alt: Downloads from Pypi 20 | .. image:: https://img.shields.io/opencollective/all/open-web-calendar?label=support%20on%20open%20collective 21 | :target: https://opencollective.com/open-web-calendar/ 22 | :alt: Support on Open Collective 23 | .. image:: https://img.shields.io/github/issues/niccokunzmann/python-recurring-ical-events?logo=github&label=issues%20seek%20funding&color=%230062ff 24 | :target: https://polar.sh/niccokunzmann/python-recurring-ical-events 25 | :alt: issues seek funding 26 | ``` 27 | 28 | Query [ICS calendars](https://icalendar.readthedocs.io) for occurrences of events, todos, journal entries and alarms. 29 | 30 | ICal has some complexity to it: 31 | Events, TODOs, Journal entries and Alarms can be repeated, removed from the feed and edited later on. 32 | This tool takes care of these complexities. 33 | 34 | Let's put our expertise together and build the solution solves ICalendar scheduling for Python! 35 | 36 | ## User Guide 37 | 38 | Information about usage. 39 | 40 | ```{toctree} 41 | :maxdepth: 2 42 | 43 | user-guide/index 44 | user-guide/examples 45 | ``` 46 | 47 | 48 | ## Community 49 | 50 | Information about contributing. 51 | 52 | ```{toctree} 53 | :maxdepth: 2 54 | 55 | community/index 56 | community/media 57 | community/maintenance 58 | ``` 59 | 60 | ```{toctree} 61 | :maxdepth: 1 62 | 63 | security_policy 64 | ``` 65 | 66 | ## Reference 67 | 68 | This is reference material for information. 69 | 70 | ```{toctree} 71 | :maxdepth: 2 72 | 73 | reference/api 74 | reference/architecture 75 | reference/compatibility 76 | reference/dependencies 77 | reference/documentation 78 | reference/related-projects 79 | reference/research 80 | 81 | ``` 82 | 83 | ```{toctree} 84 | :maxdepth: 1 85 | 86 | changelog 87 | reference/license 88 | ``` 89 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_issue_20_exdate_ignored.py: -------------------------------------------------------------------------------- 1 | """ 2 | This tests the issue 20. Exdates seem to be ignored. 3 | https://github.com/niccokunzmann/python-recurring-ical-events/issues/20 4 | 5 | Another issue of this calendar is that the UNTIL value ends one second 6 | before the next event. 7 | The event in February should be exluded therefore. 8 | """ 9 | 10 | import pytest 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "exdate", 15 | # exdates copied from the source 16 | [ 17 | "20191015T141500Z", 18 | "20191022T141500Z", 19 | "20191105T151500Z", 20 | "20191119T151500Z", 21 | "20191126T151500Z", 22 | "20191203T151500Z", 23 | "20191217T151500Z", 24 | "20191224T151500Z", 25 | "20191231T151500Z", 26 | ], 27 | ) 28 | def test_exdates_do_not_show_up(exdate, calendars): 29 | """Test that certain exdates do not occur.""" 30 | events = calendars.issue_20_exdate_ignored.at(exdate[:8]) 31 | assert not events, f"{events[0].to_ical().decode()} should not occur at {exdate}." 32 | 33 | 34 | expected_dates = [ 35 | # "20191015", # exdates are commented out 36 | # "20191022", 37 | "20191029", 38 | # "20191105", 39 | "20191112", 40 | # "20191119", 41 | # "20191126", 42 | # "20191203", 43 | "20191210", 44 | # "20191217", 45 | # "20191224", 46 | # "20191231", 47 | "20200107", 48 | "20200114", 49 | "20200121", 50 | "20200128", 51 | ] 52 | 53 | 54 | @pytest.mark.parametrize("date", expected_dates) 55 | def test_rrule_dates_show_up(date, calendars): 56 | """Test that the other events are present. 57 | 58 | The exdates are commented out. 59 | """ 60 | events = calendars.issue_20_exdate_ignored.at(date) 61 | assert len(events) == 1, "There should be an event at.".format() 62 | 63 | 64 | def test_there_are_n_events(calendars): 65 | """Test the total numer of events.""" 66 | events = list(calendars.issue_20_exdate_ignored.all()) 67 | for event, expected_date in zip(events, expected_dates): 68 | print("start: {} expected: {}".format(event["DTSTART"].dt, expected_date)) 69 | for date in expected_dates[len(events) :]: 70 | print(f"expected: {date}") 71 | for event in events[len(expected_dates) :]: 72 | print("not expected: {}".format(event["DTSTART"].dt)) 73 | assert len(events) == 7 74 | 75 | 76 | def test_rdate_after_until_also_in_rrule(calendars): 77 | """Special test for pytz, if the event is included.""" 78 | events = calendars.rdate_falls_on_rrule_until.at("20200204") 79 | for event in events: 80 | print(event) 81 | assert len(events) == 1 82 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/recurring_events_moved.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20190303T154052Z 23 | LAST-MODIFIED:20190303T154956Z 24 | DTSTAMP:20190303T154956Z 25 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 26 | SUMMARY:test7 27 | RRULE:FREQ=DAILY;UNTIL=20190320T030000Z 28 | DTSTART;TZID=Europe/Berlin:20190318T040000 29 | DTEND;TZID=Europe/Berlin:20190318T050000 30 | TRANSP:OPAQUE 31 | X-MOZ-GENERATION:4 32 | SEQUENCE:1 33 | DESCRIPTION:description should be the same 34 | END:VEVENT 35 | BEGIN:VEVENT 36 | CREATED:20190303T154131Z 37 | LAST-MODIFIED:20190303T154145Z 38 | DTSTAMP:20190303T154145Z 39 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 40 | SUMMARY:test7 - edited 41 | RECURRENCE-ID;TZID=Europe/Berlin:20190319T040000 42 | DTSTART;TZID=Europe/Berlin:20190319T040000 43 | DTEND;TZID=Europe/Berlin:20190319T050000 44 | TRANSP:OPAQUE 45 | X-MOZ-GENERATION:3 46 | SEQUENCE:2 47 | LOCATION:location 48 | X-LIC-ERROR:No value for CLASS property. Removing entire property: 49 | END:VEVENT 50 | BEGIN:VEVENT 51 | CREATED:20190307T194152Z 52 | LAST-MODIFIED:20190307T194216Z 53 | DTSTAMP:20190307T194216Z 54 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 55 | SUMMARY:New Event 56 | RRULE:FREQ=DAILY;UNTIL=20190310T010000Z 57 | DTSTART;TZID=Europe/Berlin:20190307T020000 58 | DTEND;TZID=Europe/Berlin:20190307T030000 59 | TRANSP:OPAQUE 60 | SEQUENCE:1 61 | X-MOZ-GENERATION:3 62 | END:VEVENT 63 | BEGIN:VEVENT 64 | CREATED:20190307T194207Z 65 | LAST-MODIFIED:20190307T194212Z 66 | DTSTAMP:20190307T194212Z 67 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 68 | SUMMARY:New Event 69 | RECURRENCE-ID;TZID=Europe/Berlin:20190308T020000 70 | DTSTART;TZID=Europe/Berlin:20190308T010000 71 | DTEND;TZID=Europe/Berlin:20190308T020000 72 | TRANSP:OPAQUE 73 | SEQUENCE:2 74 | X-MOZ-GENERATION:2 75 | DURATION:PT0S 76 | END:VEVENT 77 | BEGIN:VEVENT 78 | CREATED:20190307T194214Z 79 | LAST-MODIFIED:20190307T194216Z 80 | DTSTAMP:20190307T194216Z 81 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 82 | SUMMARY:New Event 83 | RECURRENCE-ID;TZID=Europe/Berlin:20190309T020000 84 | DTSTART;TZID=Europe/Berlin:20190309T030000 85 | DTEND;TZID=Europe/Berlin:20190309T040000 86 | TRANSP:OPAQUE 87 | SEQUENCE:2 88 | X-MOZ-GENERATION:3 89 | DURATION:PT0S 90 | END:VEVENT 91 | END:VCALENDAR 92 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_x_wr_timezone.py: -------------------------------------------------------------------------------- 1 | """These test the support of the non-standard X-WR-TIMEZONE attribute. 2 | 3 | See Issue 71: https://github.com/niccokunzmann/python-recurring-ical-events/issues/71 4 | """ 5 | 6 | import datetime 7 | 8 | import pytest 9 | from pytz import UTC, timezone 10 | 11 | from recurring_ical_events.util import timestamp 12 | 13 | tz_london = timezone("Europe/London") 14 | 15 | c1_t_utc = UTC.localize(datetime.datetime(2013, 8, 3, 19)) 16 | c1_t_london = tz_london.localize(datetime.datetime(2013, 8, 3, 20)) 17 | 18 | hour_1 = datetime.timedelta(hours=1) 19 | hour_2 = hour_1 + hour_1 20 | 21 | 22 | def test_dates_are_equal(): 23 | """These dates need to be equal so that the next test 'test_events_with_x_wr_timezone_returned()' works.""" 24 | t1 = timestamp(c1_t_utc) 25 | t2 = timestamp(c1_t_london) 26 | assert t1 == t2, f"time stamp should equal, delta {t1 - t2}" 27 | assert c1_t_utc == c1_t_london, "dates should equal" 28 | 29 | 30 | c1 = "rdate_hackerpublicradio" 31 | c2 = "x_wr_timezone_simple_events_issue_59" 32 | 33 | 34 | @pytest.mark.parametrize( 35 | ("calendar_name", "a_date", "event_count", "message"), 36 | [ 37 | # test c1 38 | # Europe/London changes into summer time after March to October. 39 | # We test with UTC as time zone 40 | (c1, c1_t_utc, 1, "(1) Exact start of the first event."), 41 | (c1, c1_t_utc + hour_1, 1, "(1) Middle of the first event."), 42 | (c1, c1_t_utc + hour_2, 0, "(1) Exact end of the first event."), 43 | # Other time zone as argument 44 | (c1, c1_t_london, 1, "(2) Exact start of the first event. (London)"), 45 | (c1, c1_t_london + hour_1, 1, "(2) Middle of the first event. (London)"), 46 | (c1, c1_t_london + hour_2, 0, "(2) Exact end of the first event. (London)"), 47 | # test c2 48 | # here, we have the dates and times of the events given 49 | # test the first event 50 | (c2, (2021, 12, 22, 12, 0), 1, "(3) Exact start of the event. New York"), 51 | (c2, (2021, 12, 22, 11, 59), 0, "(3) Before the event. New York"), 52 | (c2, (2021, 12, 22, 12, 59), 1, "(3) Event almost over. New York"), 53 | (c2, (2021, 12, 22, 13, 0), 0, "(3) After the event. New York"), 54 | # second event 55 | (c2, (2021, 12, 22, 21), 1, "(4) Exact start of the event. New York"), 56 | (c2, (2021, 12, 22, 20, 59), 0, "(4) Before the event. New York"), 57 | (c2, (2021, 12, 22, 21, 59), 1, "(4) Event almost over. New York"), 58 | (c2, (2021, 12, 22, 22), 0, "(4) After the event. New York"), 59 | ], 60 | ) 61 | def test_events_with_x_wr_timezone_returned( 62 | calendars, calendar_name, a_date, event_count, message 63 | ): 64 | """Test that X-WR-TIMEZONE influences the event results.""" 65 | calendar = calendars[calendar_name] 66 | for e in calendar.all(): 67 | print(e.to_ical().decode("UTF-8")) 68 | events = calendar.at(a_date) 69 | assert len(events) == event_count, message 70 | -------------------------------------------------------------------------------- /recurring_ical_events/adapters/todo.py: -------------------------------------------------------------------------------- 1 | """Adapter for VTODO.""" 2 | 3 | from recurring_ical_events.adapters.component import ComponentAdapter 4 | from recurring_ical_events.constants import DATE_MAX_DT, DATE_MIN_DT 5 | from recurring_ical_events.types import Time 6 | from recurring_ical_events.util import ( 7 | convert_to_datetime, 8 | is_date, 9 | normalize_pytz, 10 | ) 11 | 12 | 13 | class TodoAdapter(ComponentAdapter): 14 | """Unified access to TODOs.""" 15 | 16 | @staticmethod 17 | def component_name() -> str: 18 | """The icalendar component name.""" 19 | return "VTODO" 20 | 21 | @property 22 | def end_property(self) -> str: 23 | """DUE""" 24 | return "DUE" 25 | 26 | @property 27 | def raw_start(self) -> Time: 28 | """Return DTSTART if it set, do not panic if it's not set.""" 29 | ## easy case - DTSTART set 30 | start = self._component.get("DTSTART") 31 | if start is not None: 32 | return start.dt 33 | ## Tasks may have DUE set, but no DTSTART. 34 | ## Let's assume 0 duration and return the DUE 35 | due = self._component.get("DUE") 36 | if due is not None: 37 | return due.dt 38 | 39 | ## Assume infinite time span if neither is given 40 | ## (see the comments under _get_event_end) 41 | return DATE_MIN_DT 42 | 43 | @property 44 | def raw_end(self) -> Time: 45 | """Return DUE or DTSTART+DURATION or something""" 46 | ## Easy case - DUE is set 47 | end = self._component.get("DUE") 48 | if end is not None: 49 | return end.dt 50 | 51 | dtstart = self._component.get("DTSTART") 52 | 53 | ## DURATION can be specified instead of DUE. 54 | duration = self._component.get("DURATION") 55 | ## It is no requirement that DTSTART is set. 56 | ## Perhaps duration is a time estimate rather than an indirect 57 | ## way to set DUE. 58 | if duration is not None and dtstart is not None: 59 | start = dtstart.dt 60 | if duration.dt.seconds != 0 and is_date(start): 61 | start = convert_to_datetime(start, None) 62 | return normalize_pytz(start + duration.dt) 63 | 64 | ## According to the RFC, a VEVENT without an end/duration 65 | ## is to be considered to have zero duration. Assuming the 66 | ## same applies to VTODO. 67 | if dtstart: 68 | return dtstart.dt 69 | 70 | ## The RFC says this about VTODO: 71 | ## > A "VTODO" calendar component without the "DTSTART" and "DUE" (or 72 | ## > "DURATION") properties specifies a to-do that will be associated 73 | ## > with each successive calendar date, until it is completed. 74 | ## It can be interpreted in different ways, though probably it may 75 | ## be considered equivalent with a DTSTART in the infinite past and DUE 76 | ## in the infinite future? 77 | return DATE_MAX_DT 78 | 79 | 80 | __all__ = ["TodoAdapter"] 81 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_zoneinfo_issue_57.py: -------------------------------------------------------------------------------- 1 | """Test that zoneinfo timezones can be used. 2 | 3 | See also Issue https://github.com/niccokunzmann/python-recurring-ical-events/issues/57 4 | """ 5 | 6 | import sys 7 | from datetime import datetime, timedelta 8 | 9 | import pytest 10 | import pytz 11 | from icalendar import Calendar, Event, vDDDTypes 12 | 13 | import recurring_ical_events 14 | import recurring_ical_events.util 15 | 16 | 17 | def test_zoneinfo_example_yields_events(ZoneInfo): # noqa: N803 18 | """Test that there is no error. 19 | 20 | Source code is taken from Issue 57. 21 | """ 22 | tz = ZoneInfo("Europe/London") 23 | 24 | cal = Calendar() 25 | event = Event() 26 | cal.add_component(event) 27 | 28 | dt = datetime(2021, 6, 24, 21, 15).astimezone().astimezone(tz) 29 | # datetime.datetime(2021, 6, 24, 21, 15, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')) 30 | d = dt.date() 31 | 32 | event["dtstart"] = vDDDTypes(dt) 33 | 34 | events = recurring_ical_events.of(cal).between(d, d + timedelta(1)) 35 | 36 | assert len(events) == 1, "The event was found." 37 | 38 | 39 | def test_zoneinfo_must_be_installed_if_it_is_possible(): 40 | """Make sure that zoneinfo and tzdata are installed if possible.""" 41 | python_version = sys.version_info[:2] 42 | if python_version < (3, 7): 43 | return # no zoneinfo 44 | from importlib.util import find_spec as module_exists 45 | 46 | if python_version <= (3, 8): 47 | assert module_exists("backports.zoneinfo"), ( 48 | "zoneinfo should be installed with pip install backports.zoneinfo" 49 | ) 50 | else: 51 | assert module_exists("zoneinfo"), "We assume that zoneinfo exists." 52 | assert module_exists("tzdata"), ( 53 | "tzdata is necessary to test current time zone understanding." 54 | ) 55 | 56 | 57 | @pytest.mark.parametrize( 58 | "dt1", 59 | [ 60 | datetime(2019, 4, 24, 19), 61 | pytz.timezone("Europe/Berlin").localize(datetime(2019, 4, 24, 19)), 62 | pytz.timezone("America/New_York").localize(datetime(2019, 4, 24, 19)), 63 | ], 64 | ) 65 | def test_zoneinfo_consistent_conversion(calendars, dt1): 66 | """Make sure that the conversion function actually works.""" 67 | dt2 = calendars.consistent_tz(dt1) 68 | assert dt1.year == dt2.year 69 | assert dt1.month == dt2.month 70 | assert dt1.day == dt2.day 71 | assert dt1.hour == dt2.hour 72 | assert dt1.minute == dt2.minute 73 | assert dt1.second == dt2.second 74 | 75 | 76 | ATTRS = ["year", "month", "day", "hour", "minute", "second"] 77 | 78 | 79 | @pytest.mark.parametrize( 80 | ("dt", "tz", "times"), 81 | [ 82 | (datetime(2019, 2, 22, 4, 30), "Europe/Berlin", (2019, 2, 22, 4, 30)), 83 | (datetime(2019, 2, 22, 4, 30), "UTC", (2019, 2, 22, 4, 30)), 84 | ], 85 | ) 86 | def test_convert_to_date(dt, tz, times, ZoneInfo): # noqa: N803 87 | """Check that a datetime conversion takes place properly.""" 88 | new = recurring_ical_events.util.convert_to_datetime(dt, ZoneInfo(tz)) 89 | converted = () 90 | for attr, _ in zip(ATTRS, times): 91 | converted += (getattr(new, attr),) 92 | assert converted == times 93 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_20_exdate_ignored.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:+//IDN bitfire.at//DAVx5/2.6.1.1-gplay ical4j/2.2.6 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | TZURL:http://tzurl.org/zoneinfo/Europe/Berlin 7 | X-LIC-LOCATION:Europe/Berlin 8 | BEGIN:DAYLIGHT 9 | TZOFFSETFROM:+0100 10 | TZOFFSETTO:+0200 11 | TZNAME:CEST 12 | DTSTART:19810329T020000 13 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 14 | END:DAYLIGHT 15 | BEGIN:STANDARD 16 | TZOFFSETFROM:+0200 17 | TZOFFSETTO:+0100 18 | TZNAME:CET 19 | DTSTART:19961027T030000 20 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 21 | END:STANDARD 22 | BEGIN:STANDARD 23 | TZOFFSETFROM:+005328 24 | TZOFFSETTO:+0100 25 | TZNAME:CET 26 | DTSTART:18930401T000000 27 | RDATE:18930401T000000 28 | END:STANDARD 29 | BEGIN:DAYLIGHT 30 | TZOFFSETFROM:+0100 31 | TZOFFSETTO:+0200 32 | TZNAME:CEST 33 | DTSTART:19160430T230000 34 | RDATE:19160430T230000 35 | RDATE:19170416T020000 36 | RDATE:19180415T020000 37 | RDATE:19400401T020000 38 | RDATE:19430329T020000 39 | RDATE:19440403T020000 40 | RDATE:19450402T020000 41 | RDATE:19460414T020000 42 | RDATE:19470406T030000 43 | RDATE:19480418T020000 44 | RDATE:19490410T020000 45 | RDATE:19800406T020000 46 | END:DAYLIGHT 47 | BEGIN:STANDARD 48 | TZOFFSETFROM:+0200 49 | TZOFFSETTO:+0100 50 | TZNAME:CET 51 | DTSTART:19161001T010000 52 | RDATE:19161001T010000 53 | RDATE:19170917T030000 54 | RDATE:19180916T030000 55 | RDATE:19421102T030000 56 | RDATE:19431004T030000 57 | RDATE:19441002T030000 58 | RDATE:19451118T030000 59 | RDATE:19461007T030000 60 | RDATE:19471005T030000 61 | RDATE:19481003T030000 62 | RDATE:19491002T030000 63 | RDATE:19800928T030000 64 | RDATE:19810927T030000 65 | RDATE:19820926T030000 66 | RDATE:19830925T030000 67 | RDATE:19840930T030000 68 | RDATE:19850929T030000 69 | RDATE:19860928T030000 70 | RDATE:19870927T030000 71 | RDATE:19880925T030000 72 | RDATE:19890924T030000 73 | RDATE:19900930T030000 74 | RDATE:19910929T030000 75 | RDATE:19920927T030000 76 | RDATE:19930926T030000 77 | RDATE:19940925T030000 78 | RDATE:19950924T030000 79 | END:STANDARD 80 | BEGIN:DAYLIGHT 81 | TZOFFSETFROM:+0200 82 | TZOFFSETTO:+0300 83 | TZNAME:CEMT 84 | DTSTART:19450524T010000 85 | RDATE:19450524T010000 86 | RDATE:19470511T020000 87 | END:DAYLIGHT 88 | BEGIN:DAYLIGHT 89 | TZOFFSETFROM:+0300 90 | TZOFFSETTO:+0200 91 | TZNAME:CEST 92 | DTSTART:19450924T030000 93 | RDATE:19450924T030000 94 | RDATE:19470629T030000 95 | END:DAYLIGHT 96 | BEGIN:STANDARD 97 | TZOFFSETFROM:+0100 98 | TZOFFSETTO:+0100 99 | TZNAME:CET 100 | DTSTART:19460101T000000 101 | RDATE:19460101T000000 102 | RDATE:19800101T000000 103 | END:STANDARD 104 | END:VTIMEZONE 105 | BEGIN:VEVENT 106 | DTSTAMP:20191219T182547Z 107 | UID:f0f31ddb-6918-46af-a5a1-0a7254fbce71 108 | SEQUENCE:11 109 | SUMMARY:Test 110 | LOCATION:Example 111 | DTSTART;TZID=Europe/Berlin:20191015T161500 112 | DURATION:PT1H30M 113 | RRULE:FREQ=WEEKLY;UNTIL=20200204T151459Z;BYDAY=TU;WKST=SU 114 | EXDATE:20191015T141500Z,20191022T141500Z,20191105T151500Z,20191119T151500Z, 115 | 20191126T151500Z,20191203T151500Z,20191217T151500Z,20191224T151500Z,201912 116 | 31T151500Z 117 | CLASS:PUBLIC 118 | STATUS:CONFIRMED 119 | CREATED:20191013T184131Z 120 | X-MOZ-GENERATION:10 121 | END:VEVENT 122 | END:VCALENDAR 123 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/rdate_falls_on_rrule_until.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:+//IDN bitfire.at//DAVx5/2.6.1.1-gplay ical4j/2.2.6 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | TZURL:http://tzurl.org/zoneinfo/Europe/Berlin 7 | X-LIC-LOCATION:Europe/Berlin 8 | BEGIN:DAYLIGHT 9 | TZOFFSETFROM:+0100 10 | TZOFFSETTO:+0200 11 | TZNAME:CEST 12 | DTSTART:19810329T020000 13 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 14 | END:DAYLIGHT 15 | BEGIN:STANDARD 16 | TZOFFSETFROM:+0200 17 | TZOFFSETTO:+0100 18 | TZNAME:CET 19 | DTSTART:19961027T030000 20 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 21 | END:STANDARD 22 | BEGIN:STANDARD 23 | TZOFFSETFROM:+005328 24 | TZOFFSETTO:+0100 25 | TZNAME:CET 26 | DTSTART:18930401T000000 27 | RDATE:18930401T000000 28 | END:STANDARD 29 | BEGIN:DAYLIGHT 30 | TZOFFSETFROM:+0100 31 | TZOFFSETTO:+0200 32 | TZNAME:CEST 33 | DTSTART:19160430T230000 34 | RDATE:19160430T230000 35 | RDATE:19170416T020000 36 | RDATE:19180415T020000 37 | RDATE:19400401T020000 38 | RDATE:19430329T020000 39 | RDATE:19440403T020000 40 | RDATE:19450402T020000 41 | RDATE:19460414T020000 42 | RDATE:19470406T030000 43 | RDATE:19480418T020000 44 | RDATE:19490410T020000 45 | RDATE:19800406T020000 46 | END:DAYLIGHT 47 | BEGIN:STANDARD 48 | TZOFFSETFROM:+0200 49 | TZOFFSETTO:+0100 50 | TZNAME:CET 51 | DTSTART:19161001T010000 52 | RDATE:19161001T010000 53 | RDATE:19170917T030000 54 | RDATE:19180916T030000 55 | RDATE:19421102T030000 56 | RDATE:19431004T030000 57 | RDATE:19441002T030000 58 | RDATE:19451118T030000 59 | RDATE:19461007T030000 60 | RDATE:19471005T030000 61 | RDATE:19481003T030000 62 | RDATE:19491002T030000 63 | RDATE:19800928T030000 64 | RDATE:19810927T030000 65 | RDATE:19820926T030000 66 | RDATE:19830925T030000 67 | RDATE:19840930T030000 68 | RDATE:19850929T030000 69 | RDATE:19860928T030000 70 | RDATE:19870927T030000 71 | RDATE:19880925T030000 72 | RDATE:19890924T030000 73 | RDATE:19900930T030000 74 | RDATE:19910929T030000 75 | RDATE:19920927T030000 76 | RDATE:19930926T030000 77 | RDATE:19940925T030000 78 | RDATE:19950924T030000 79 | END:STANDARD 80 | BEGIN:DAYLIGHT 81 | TZOFFSETFROM:+0200 82 | TZOFFSETTO:+0300 83 | TZNAME:CEMT 84 | DTSTART:19450524T010000 85 | RDATE:19450524T010000 86 | RDATE:19470511T020000 87 | END:DAYLIGHT 88 | BEGIN:DAYLIGHT 89 | TZOFFSETFROM:+0300 90 | TZOFFSETTO:+0200 91 | TZNAME:CEST 92 | DTSTART:19450924T030000 93 | RDATE:19450924T030000 94 | RDATE:19470629T030000 95 | END:DAYLIGHT 96 | BEGIN:STANDARD 97 | TZOFFSETFROM:+0100 98 | TZOFFSETTO:+0100 99 | TZNAME:CET 100 | DTSTART:19460101T000000 101 | RDATE:19460101T000000 102 | RDATE:19800101T000000 103 | END:STANDARD 104 | END:VTIMEZONE 105 | BEGIN:VEVENT 106 | DTSTAMP:20191219T182547Z 107 | UID:f0f31ddb-6918-46af-a5a1-0a7254fbce71 108 | SEQUENCE:11 109 | SUMMARY:Test 110 | LOCATION:Example 111 | DTSTART;TZID=Europe/Berlin:20191015T161500 112 | DURATION:PT1H30M 113 | RDATE;TZID=Europe/Berlin:20200204T161500 114 | RRULE:FREQ=WEEKLY;UNTIL=20200204T151459Z;BYDAY=TU;WKST=SU 115 | EXDATE:20191015T141500Z,20191022T141500Z,20191105T151500Z,20191119T151500Z, 116 | 20191126T151500Z,20191203T151500Z,20191217T151500Z,20191224T151500Z,201912 117 | 31T151500Z 118 | CLASS:PUBLIC 119 | STATUS:CONFIRMED 120 | CREATED:20191013T184131Z 121 | X-MOZ-GENERATION:10 122 | END:VEVENT 123 | END:VCALENDAR 124 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/recurring_events_changed_duration.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | BEGIN:VTIMEZONE 5 | TZID:Europe/Berlin 6 | BEGIN:DAYLIGHT 7 | TZOFFSETFROM:+0100 8 | TZOFFSETTO:+0200 9 | TZNAME:CEST 10 | DTSTART:19700329T020000 11 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 12 | END:DAYLIGHT 13 | BEGIN:STANDARD 14 | TZOFFSETFROM:+0200 15 | TZOFFSETTO:+0100 16 | TZNAME:CET 17 | DTSTART:19701025T030000 18 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 19 | END:STANDARD 20 | END:VTIMEZONE 21 | BEGIN:VEVENT 22 | CREATED:20190303T154052Z 23 | LAST-MODIFIED:20190303T154956Z 24 | DTSTAMP:20190303T154956Z 25 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 26 | SUMMARY:test7 27 | RRULE:FREQ=DAILY;UNTIL=20190320T030000Z 28 | DTSTART;TZID=Europe/Berlin:20190318T040000 29 | DTEND;TZID=Europe/Berlin:20190318T050000 30 | TRANSP:OPAQUE 31 | X-MOZ-GENERATION:4 32 | SEQUENCE:1 33 | DESCRIPTION:description should be the same 34 | END:VEVENT 35 | BEGIN:VEVENT 36 | CREATED:20190303T154131Z 37 | LAST-MODIFIED:20190303T154145Z 38 | DTSTAMP:20190303T154145Z 39 | UID:5d4c6843-9300-4f91-8d88-6094d4b0b840 40 | SUMMARY:test7 - edited 41 | RECURRENCE-ID;TZID=Europe/Berlin:20190319T040000 42 | DTSTART;TZID=Europe/Berlin:20190319T040000 43 | DTEND;TZID=Europe/Berlin:20190319T050000 44 | TRANSP:OPAQUE 45 | X-MOZ-GENERATION:3 46 | SEQUENCE:2 47 | LOCATION:location 48 | X-LIC-ERROR:No value for CLASS property. Removing entire property: 49 | END:VEVENT 50 | BEGIN:VEVENT 51 | CREATED:20190307T194152Z 52 | LAST-MODIFIED:20190307T195005Z 53 | DTSTAMP:20190307T195005Z 54 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 55 | SUMMARY:New Event 56 | RRULE:FREQ=DAILY;UNTIL=20190310T010000Z 57 | DTSTART;TZID=Europe/Berlin:20190307T020000 58 | DTEND;TZID=Europe/Berlin:20190307T030000 59 | TRANSP:OPAQUE 60 | SEQUENCE:1 61 | X-MOZ-GENERATION:6 62 | END:VEVENT 63 | BEGIN:VEVENT 64 | CREATED:20190307T194207Z 65 | LAST-MODIFIED:20190307T194945Z 66 | DTSTAMP:20190307T194945Z 67 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 68 | SUMMARY:New Event 69 | RECURRENCE-ID;TZID=Europe/Berlin:20190308T020000 70 | DTSTART;TZID=Europe/Berlin:20190308T010000 71 | DTEND;TZID=Europe/Berlin:20190308T030000 72 | TRANSP:OPAQUE 73 | SEQUENCE:3 74 | X-MOZ-GENERATION:2 75 | END:VEVENT 76 | BEGIN:VEVENT 77 | CREATED:20190307T194214Z 78 | LAST-MODIFIED:20190307T194952Z 79 | DTSTAMP:20190307T194952Z 80 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 81 | SUMMARY:New Event 82 | RECURRENCE-ID;TZID=Europe/Berlin:20190309T020000 83 | DTSTART;TZID=Europe/Berlin:20190309T030000 84 | DTEND;TZID=Europe/Berlin:20190309T033000 85 | TRANSP:OPAQUE 86 | SEQUENCE:3 87 | X-MOZ-GENERATION:3 88 | END:VEVENT 89 | BEGIN:VEVENT 90 | CREATED:20190307T194955Z 91 | LAST-MODIFIED:20190307T195005Z 92 | DTSTAMP:20190307T195005Z 93 | UID:a0c78729-30b1-4ba3-a86e-6aedd995d788 94 | SUMMARY:New Event 95 | RECURRENCE-ID;TZID=Europe/Berlin:20190310T020000 96 | DTSTART;VALUE=DATE:20190310 97 | DTEND;VALUE=DATE:20190311 98 | TRANSP:TRANSPARENT 99 | SEQUENCE:2 100 | X-MOZ-GENERATION:6 101 | X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for CLASS property. 102 | Removing entire property: 103 | DURATION:PT0S 104 | END:VEVENT 105 | END:VCALENDAR 106 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/several_events_at_the_same_time.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//SabreDAV//SabreDAV//EN 4 | CALSCALE:GREGORIAN 5 | X-WR-CALNAME:test 6 | X-APPLE-CALENDAR-COLOR:#e78074 7 | BEGIN:VTIMEZONE 8 | TZID:Europe/Berlin 9 | X-LIC-LOCATION:Europe/Berlin 10 | BEGIN:DAYLIGHT 11 | TZOFFSETFROM:+0100 12 | TZOFFSETTO:+0200 13 | TZNAME:CEST 14 | DTSTART:19700329T020000 15 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU 16 | END:DAYLIGHT 17 | BEGIN:STANDARD 18 | TZOFFSETFROM:+0200 19 | TZOFFSETTO:+0100 20 | TZNAME:CET 21 | DTSTART:19701025T030000 22 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU 23 | END:STANDARD 24 | END:VTIMEZONE 25 | BEGIN:VEVENT 26 | CREATED:20190303T111937 27 | DTSTAMP:20190303T111937 28 | LAST-MODIFIED:20190303T111937 29 | UID:event-1 30 | SUMMARY:test1 31 | DTSTART;TZID=Europe/Berlin:20190304T080000 32 | DTEND;TZID=Europe/Berlin:20190304T083000 33 | END:VEVENT 34 | BEGIN:VEVENT 35 | CREATED:20190303T111937 36 | DTSTAMP:20190303T111937 37 | LAST-MODIFIED:20190303T111937 38 | UID:event-2 39 | SUMMARY:test1 40 | DTSTART;TZID=Europe/Berlin:20190304T080000 41 | DTEND;TZID=Europe/Berlin:20190304T083000 42 | END:VEVENT 43 | BEGIN:VEVENT 44 | CREATED:20190303T111937 45 | DTSTAMP:20190303T111937 46 | LAST-MODIFIED:20190303T111937 47 | UID:event-3 48 | SUMMARY:test1 49 | DTSTART;TZID=Europe/Berlin:20190304T080000 50 | DTEND;TZID=Europe/Berlin:20190304T083000 51 | END:VEVENT 52 | BEGIN:VEVENT 53 | CREATED:20190303T111937 54 | DTSTAMP:20190303T111937 55 | LAST-MODIFIED:20190303T111937 56 | UID:event-4 57 | SUMMARY:test1 58 | DTSTART;TZID=Europe/Berlin:20190304T080000 59 | DTEND;TZID=Europe/Berlin:20190304T083000 60 | END:VEVENT 61 | BEGIN:VEVENT 62 | CREATED:20190303T111937 63 | DTSTAMP:20190303T111937 64 | LAST-MODIFIED:20190303T111937 65 | UID:event-5 66 | SUMMARY:test1 67 | DTSTART;TZID=Europe/Berlin:20190304T080000 68 | DTEND;TZID=Europe/Berlin:20190304T083000 69 | END:VEVENT 70 | BEGIN:VEVENT 71 | CREATED:20190303T111937 72 | DTSTAMP:20190303T111937 73 | LAST-MODIFIED:20190303T111937 74 | UID:event-6 75 | SUMMARY:test1 76 | DTSTART;TZID=Europe/Berlin:20190304T080000 77 | DTEND;TZID=Europe/Berlin:20190304T083000 78 | END:VEVENT 79 | BEGIN:VEVENT 80 | CREATED:20190303T111937 81 | DTSTAMP:20190303T111937 82 | LAST-MODIFIED:20190303T111937 83 | UID:event-7 84 | SUMMARY:test1 85 | DTSTART;TZID=Europe/Berlin:20190304T080000 86 | DTEND;TZID=Europe/Berlin:20190304T083000 87 | END:VEVENT 88 | BEGIN:VEVENT 89 | CREATED:20190303T111937 90 | DTSTAMP:20190303T111937 91 | LAST-MODIFIED:20190303T111937 92 | UID:event-8 93 | SUMMARY:test1 94 | DTSTART;TZID=Europe/Berlin:20190304T080000 95 | DTEND;TZID=Europe/Berlin:20190304T083000 96 | END:VEVENT 97 | BEGIN:VEVENT 98 | CREATED:20190303T111937 99 | DTSTAMP:20190303T111937 100 | LAST-MODIFIED:20190303T111937 101 | UID:event-9 102 | SUMMARY:test1 103 | DTSTART;TZID=Europe/Berlin:20190304T080000 104 | DTEND;TZID=Europe/Berlin:20190304T083000 105 | END:VEVENT 106 | BEGIN:VEVENT 107 | CREATED:20190303T111937 108 | DTSTAMP:20190303T111937 109 | LAST-MODIFIED:20190303T111937 110 | UID:event-10 111 | SUMMARY:test1 112 | DTSTART;TZID=Europe/Berlin:20190304T080000 113 | DTEND;TZID=Europe/Berlin:20190304T083000 114 | END:VEVENT 115 | END:VCALENDAR 116 | -------------------------------------------------------------------------------- /recurring_ical_events/test/calendars/issue_201_test_matrix.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | BEGIN:VEVENT 3 | DTSTART;VALUE=DATE:20000101 4 | DTEND;VALUE=DATE:20000102 5 | UID:VEVENT-DATE-DATE 6 | END:VEVENT 7 | BEGIN:VTODO 8 | DTSTART;VALUE=DATE:20000101 9 | DUE;VALUE=DATE:20000102 10 | UID:VTODO-DATE-DATE 11 | END:VTOOD 12 | BEGIN:VEVENT 13 | DTSTART;VALUE=DATE:20000101 14 | DTEND:20000102T040000 15 | UID:VEVENT-DATE-DATETIME 16 | END:VEVENT 17 | BEGIN:VTODO 18 | DTSTART;VALUE=DATE:20000101 19 | DUE:20000102T040000 20 | UID:VTODO-DATE-DATETIME 21 | END:VTOOD 22 | BEGIN:VEVENT 23 | DTSTART;VALUE=DATE:20000101 24 | DTEND:20000103T020000Z 25 | UID:VEVENT-DATE-UTC 26 | END:VEVENT 27 | BEGIN:VTODO 28 | DTSTART;VALUE=DATE:20000101 29 | DUE:20000103T020000Z 30 | UID:VTODO-DATE-UTC 31 | END:VTOOD 32 | BEGIN:VEVENT 33 | DTSTART;VALUE=DATE:20000101 34 | DURATION:P3D 35 | UID:VEVENT-DATE-DAYS 36 | END:VEVENT 37 | BEGIN:VTODO 38 | DTSTART;VALUE=DATE:20000101 39 | DURATION:P3D 40 | UID:VTODO-DATE-DAYS 41 | END:VTOOD 42 | BEGIN:VEVENT 43 | DTSTART;VALUE=DATE:20000101 44 | DURATION:PT10H 45 | UID:VEVENT-DATE-HOURS 46 | END:VEVENT 47 | BEGIN:VTODO 48 | DTSTART;VALUE=DATE:20000101 49 | DURATION:PT10H 50 | UID:VTODO-DATE-HOURS 51 | END:VTOOD 52 | BEGIN:VEVENT 53 | DTSTART:20000101T000000 54 | DTEND;VALUE=DATE:20000102 55 | UID:VEVENT-DATETIME-DATE 56 | END:VEVENT 57 | BEGIN:VTODO 58 | DTSTART:20000101T000000 59 | DUE;VALUE=DATE:20000102 60 | UID:VTODO-DATETIME-DATE 61 | END:VTOOD 62 | BEGIN:VEVENT 63 | DTSTART:20000101T000000 64 | DTEND:20000102T040000 65 | UID:VEVENT-DATETIME-DATETIME 66 | END:VEVENT 67 | BEGIN:VTODO 68 | DTSTART:20000101T000000 69 | DUE:20000102T040000 70 | UID:VTODO-DATETIME-DATETIME 71 | END:VTOOD 72 | BEGIN:VEVENT 73 | DTSTART:20000101T000000 74 | DTEND:20000103T020000Z 75 | UID:VEVENT-DATETIME-UTC 76 | END:VEVENT 77 | BEGIN:VTODO 78 | DTSTART:20000101T000000 79 | DUE:20000103T020000Z 80 | UID:VTODO-DATETIME-UTC 81 | END:VTOOD 82 | BEGIN:VEVENT 83 | DTSTART:20000101T000000 84 | DURATION:P3D 85 | UID:VEVENT-DATETIME-DAYS 86 | END:VEVENT 87 | BEGIN:VTODO 88 | DTSTART:20000101T000000 89 | DURATION:P3D 90 | UID:VTODO-DATETIME-DAYS 91 | END:VTOOD 92 | BEGIN:VEVENT 93 | DTSTART:20000101T000000 94 | DURATION:PT10H 95 | UID:VEVENT-DATETIME-HOURS 96 | END:VEVENT 97 | BEGIN:VTODO 98 | DTSTART:20000101T000000 99 | DURATION:PT10H 100 | UID:VTODO-DATETIME-HOURS 101 | END:VTOOD 102 | BEGIN:VEVENT 103 | DTSTART:20000101T000000Z 104 | DTEND;VALUE=DATE:20000102 105 | UID:VEVENT-UTC-DATE 106 | END:VEVENT 107 | BEGIN:VTODO 108 | DTSTART:20000101T000000Z 109 | DUE;VALUE=DATE:20000102 110 | UID:VTODO-UTC-DATE 111 | END:VTOOD 112 | BEGIN:VEVENT 113 | DTSTART:20000101T000000Z 114 | DTEND:20000102T040000 115 | UID:VEVENT-UTC-DATETIME 116 | END:VEVENT 117 | BEGIN:VTODO 118 | DTSTART:20000101T000000Z 119 | DUE:20000102T040000 120 | UID:VTODO-UTC-DATETIME 121 | END:VTOOD 122 | BEGIN:VEVENT 123 | DTSTART:20000101T000000Z 124 | DTEND:20000103T020000Z 125 | UID:VEVENT-UTC-UTC 126 | END:VEVENT 127 | BEGIN:VTODO 128 | DTSTART:20000101T000000Z 129 | DUE:20000103T020000Z 130 | UID:VTODO-UTC-UTC 131 | END:VTOOD 132 | BEGIN:VEVENT 133 | DTSTART:20000101T000000Z 134 | DURATION:P3D 135 | UID:VEVENT-UTC-DAYS 136 | END:VEVENT 137 | BEGIN:VTODO 138 | DTSTART:20000101T000000Z 139 | DURATION:P3D 140 | UID:VTODO-UTC-DAYS 141 | END:VTOOD 142 | BEGIN:VEVENT 143 | DTSTART:20000101T000000Z 144 | DURATION:PT10H 145 | UID:VEVENT-UTC-HOURS 146 | END:VEVENT 147 | BEGIN:VTODO 148 | DTSTART:20000101T000000Z 149 | DURATION:PT10H 150 | UID:VTODO-UTC-HOURS 151 | END:VTOOD 152 | END:VCALENDAR 153 | -------------------------------------------------------------------------------- /recurring_ical_events/selection/alarm.py: -------------------------------------------------------------------------------- 1 | """Selection for alarms.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import datetime 7 | from typing import TYPE_CHECKING, Sequence 8 | 9 | from recurring_ical_events.adapters.event import EventAdapter 10 | from recurring_ical_events.adapters.todo import TodoAdapter 11 | from recurring_ical_events.selection.base import SelectComponents 12 | 13 | if TYPE_CHECKING: 14 | from icalendar.cal import Component 15 | 16 | from recurring_ical_events.adapters.component import ComponentAdapter 17 | from recurring_ical_events.series import Series 18 | 19 | 20 | class Alarms(SelectComponents): 21 | """Select alarms and find their times. 22 | 23 | By default, alarms from TODOs and events are collected. 24 | You can use this to change which alarms are collected: 25 | 26 | Alarms((EventAdapter,)) 27 | Alarms((TodoAdapter,)) 28 | """ 29 | 30 | def __init__( 31 | self, 32 | parents: tuple[type[ComponentAdapter] | SelectComponents] = ( 33 | EventAdapter, 34 | TodoAdapter, 35 | ), 36 | ): 37 | self.parents = parents 38 | 39 | @staticmethod 40 | def component_name(): 41 | """The name of the component we calculate.""" 42 | return "VALARM" 43 | 44 | def collect_parent_series_from( 45 | self, source: Component, suppress_errors: tuple[Exception] 46 | ) -> Sequence[Series]: 47 | """Collect the parent components of alarms.""" 48 | return [ 49 | s 50 | for parent in self.parents 51 | for s in parent.collect_series_from(source, suppress_errors) 52 | ] 53 | 54 | def collect_series_from( 55 | self, source: Component, suppress_errors: tuple[Exception] 56 | ) -> Sequence[Series]: 57 | """Collect all TODOs and Alarms from VEVENTs and VTODOs. 58 | 59 | suppress_errors - a list of errors that should be suppressed. 60 | A Series of events with such an error is removed from all results. 61 | """ 62 | from recurring_ical_events.series.alarm import ( 63 | AbsoluteAlarmSeries, 64 | AlarmSeriesRelativeToEnd, 65 | AlarmSeriesRelativeToStart, 66 | ) 67 | 68 | absolute_alarms = AbsoluteAlarmSeries() 69 | result = [] 70 | # alarms might be copied several times. We only compute them once. 71 | for series in self.collect_parent_series_from(source, suppress_errors): 72 | used_alarms = [] 73 | for component in series.components: 74 | for alarm in component.alarms: 75 | with contextlib.suppress(suppress_errors): 76 | trigger = alarm.TRIGGER 77 | if trigger is None or alarm in used_alarms: 78 | continue 79 | if isinstance(trigger, datetime.datetime): 80 | absolute_alarms.add(alarm, component) 81 | used_alarms.append(alarm) 82 | elif alarm.TRIGGER_RELATED == "START": 83 | result.append(AlarmSeriesRelativeToStart(alarm, series)) 84 | used_alarms.append(alarm) 85 | elif alarm.TRIGGER_RELATED == "END": 86 | result.append(AlarmSeriesRelativeToEnd(alarm, series)) 87 | used_alarms.append(alarm) 88 | if not absolute_alarms.is_empty(): 89 | result.append(absolute_alarms) 90 | return result 91 | 92 | 93 | __all__ = ["Alarms"] 94 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_examples.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | 6 | def test_fablab_cottbus(calendars): 7 | """This calendar threw an exception. 8 | 9 | TypeError: can't compare offset-naive and offset-aware datetimes 10 | """ 11 | today = datetime.datetime(2019, 3, 4, 16, 52, 10, 215209) 12 | one_year_ahead = today.replace(year=today.year + 1) 13 | one_year_before = today.replace(year=today.year - 1) 14 | calendars.fablab_cottbus.between(one_year_before, one_year_ahead) 15 | 16 | 17 | def test_example_from_README(calendars): 18 | """The examples from the README should be tested so we make no 19 | false promises. 20 | """ 21 | 22 | start_date = (2019, 3, 5) 23 | end_date = (2019, 4, 1) 24 | 25 | events = calendars.one_day_event_repeat_every_day.between(start_date, end_date) 26 | for event in events: 27 | start = event["DTSTART"].dt 28 | duration = event["DTEND"].dt - event["DTSTART"].dt 29 | print(f"start {start} duration {duration}") 30 | assert event 31 | 32 | 33 | def test_no_dtend(calendars): 34 | """This calendar has events which have no DTEND. 35 | 36 | KeyError: 'DTEND' 37 | """ 38 | list(calendars.discourse_no_dtend.all()) 39 | 40 | 41 | def test_date_events_are_in_the_date(calendars): 42 | events = calendars.Germany.at((2014, 5, 11)) 43 | assert len(events) == 1 44 | event = events[0] 45 | assert event["SUMMARY"] == "Germany: Mother's Day [Not a public holiday]" 46 | assert isinstance(event["DTSTART"].dt, datetime.date) 47 | 48 | 49 | mb_on_tour_dates = [ 50 | (2018, 9, 22), 51 | (2018, 10, 20), 52 | (2018, 11, 17), 53 | (2018, 12, 8), 54 | (2019, 1, 12), 55 | (2019, 1, 27), 56 | (2019, 2, 9), 57 | (2019, 2, 24), 58 | (2019, 3, 23), 59 | (2019, 4, 27), 60 | (2019, 5, 25), 61 | (2019, 6, 22), 62 | ] 63 | 64 | 65 | @pytest.mark.parametrize("date", mb_on_tour_dates) 66 | def test_events_are_scheduled(calendars, date): 67 | events = calendars.machbar_16_feb_2019.at(date) 68 | assert len(events) == 1 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "month", 73 | [ 74 | (2018, 9), 75 | (2018, 10), 76 | (2018, 11), 77 | (2018, 12), 78 | (2019, 1), 79 | (2019, 2), 80 | (2019, 3), 81 | (2019, 4), 82 | (2019, 5), 83 | (2019, 6), 84 | (2019, 7), 85 | ], 86 | ) 87 | def test_no_more_events_are_scheduled(calendars, month): 88 | dates = [date for date in mb_on_tour_dates if date[:2] == month] 89 | number_of_dates = len(dates) 90 | events = calendars.machbar_16_feb_2019.at(month) 91 | mb_events = [event for event in events if "mB-onTour" in event["SUMMARY"]] 92 | assert len(mb_events) == number_of_dates 93 | 94 | 95 | def test_german_holidays(calendars): 96 | """Test the calendar from 97 | https://www.calendarlabs.com/ical-calendar/ics/46/Germany_Holidays.ics 98 | """ 99 | holidays = calendars.Germany_Holidays.at(2020) 100 | assert len(holidays) == 17 101 | 102 | 103 | def test_exdate_date(calendars): 104 | """The EXDATE can be a date, too. 105 | 106 | See https://github.com/niccokunzmann/python-recurring-ical-events/pull/121 107 | """ 108 | assert calendars.date_exclude.at("20231216") == [] 109 | 110 | 111 | @pytest.mark.parametrize( 112 | ("date", "count"), 113 | [ 114 | ("20240923", 0), 115 | ("20240924", 3), 116 | ("20240925", 0), 117 | ("20240926", 3), 118 | ("20240927", 0), 119 | ], 120 | ) 121 | def test_same_events_at_same_time(calendars, date, count): 122 | """Make sure that events can be moved to the same time.""" 123 | assert len(calendars.same_event_recurring_at_same_time.at(date)) == count 124 | -------------------------------------------------------------------------------- /docs/reference/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | myst: 3 | html_meta: 4 | "description lang=en": | 5 | API Reference of functions and classes. 6 | --- 7 | 8 | 9 | # API Reference 10 | 11 | This is the public API for this library. 12 | 13 | ## Core functionality 14 | 15 | This is the core of the functionality of the library. 16 | 17 | The first step is to customize which components to query with {py:func}`recurring_ical_events.of`. 18 | 19 | ```{eval-rst} 20 | .. autofunction:: recurring_ical_events.of 21 | ``` 22 | 23 | ## Query 24 | 25 | `of()` returns a {py:class}`recurring_ical_events.CalendarQuery` object, which can be used to query the calendar. 26 | For the most common cases, you do not need to look any further. 27 | 28 | ```{eval-rst} 29 | .. autoclass:: recurring_ical_events.CalendarQuery 30 | :members: 31 | :exclude-members: ComponentsWithName 32 | ``` 33 | 34 | ### list from `at()` and `between()` 35 | 36 | The result of both {py:meth}`recurring_ical_events.CalendarQuery.between` and 37 | {py:meth}`recurring_ical_events.CalendarQuery.at` is a list of {py:class}`icalendar.cal.Component` 38 | objects like {py:class}`icalendar.cal.Event`. 39 | By default, all attributes of the event with repetitions are copied, like ``UID`` and ``SUMMARY``. 40 | However, these attributes may differ from the source event: 41 | 42 | * ``DTSTART`` which is the start of the event instance. (always present) 43 | * ``DTEND`` which is the end of the event instance. (always present) 44 | * ``RDATE``, ``EXDATE``, ``RRULE`` are the rules to create event repetitions. 45 | They are **not** included in repeated events, see [Issue 23]. 46 | To change this, use ``of(calendar, keep_recurrence_attributes=True)``. 47 | 48 | [Issue 23]: https://github.com/niccokunzmann/python-recurring-ical-events/issues/23 49 | 50 | ### Generator from `after()` and `all()` 51 | 52 | If the resulting components are ordered when {py:meth}`recurring_ical_events.CalendarQuery.after` or 53 | {py:meth}`recurring_ical_events.CalendarQuery.all` is used. 54 | The result is an iterator that returns the events in order. 55 | 56 | ```python 57 | for event in recurring_ical_events.of(an_icalendar_object).after(datetime.datetime.now()): 58 | print(event["DTSTART"]) # The start is ordered 59 | ``` 60 | 61 | ## Timezones and floating time 62 | 63 | This library makes a distinction between floating time and times with timezones. 64 | 65 | Examples: 66 | 67 | * Event 1 happes at 12:00 in Singapore and event 2 on the same day at 12:00 in New York. 68 | 69 | * If you query that day without timezone, you will get both events. 70 | * If you query 12:00 - 13:00 without timezone, you will get both events. 71 | * If you query 12:00 - 13:00 in `Asia/Singapore`, you will only get event 1. 72 | * If you query 12:00 - 13:00 in `America/New_York`, you will only get event 2. 73 | 74 | * If an event happens at night in floating time (without timezone) and 75 | you query that day it will appear regardless of the timezone of the query. 76 | Which is at different times in different timezones. 77 | 78 | * {py:class}`icalendar.cal.Alarm` has a `TRIGGER` which is in UTC. 79 | The timezone to compute that for alarms relative to floating events will be taken 80 | from the start and stop arguments. 81 | 82 | ## Pagination 83 | 84 | For ease of use, pagination has been introduced. 85 | These are the pages returned by the query. 86 | 87 | ```{eval-rst} 88 | .. automodule:: recurring_ical_events.pages 89 | :members: 90 | ``` 91 | 92 | ## Complete API 93 | 94 | ```{eval-rst} 95 | 96 | .. automodule:: recurring_ical_events 97 | :show-inheritance: 98 | :members: 99 | :exclude-members: CalendarQuery, of, OccurrenceID 100 | 101 | .. automodule:: recurring_ical_events.types 102 | :members: 103 | 104 | .. autoclass:: recurring_ical_events.OccurrenceID 105 | 106 | .. automethod:: to_string 107 | .. automethod:: from_string 108 | .. automethod:: from_occurrence 109 | 110 | ``` 111 | -------------------------------------------------------------------------------- /recurring_ical_events/test/test_rdate.py: -------------------------------------------------------------------------------- 1 | """From https://tools.ietf.org/html/rfc5545#section-3.8.5.2 2 | 3 | Property Name: RDATE 4 | 5 | Purpose: This property defines the list of DATE-TIME values for 6 | recurring events, to-dos, journal entries, or time zone 7 | definitions. 8 | 9 | Value Type: The default value type for this property is DATE-TIME. 10 | The value type can be set to DATE or PERIOD. 11 | 12 | Property Parameters: IANA, non-standard, value data type, and time 13 | zone identifier property parameters can be specified on this 14 | property. 15 | 16 | Conformance: This property can be specified in recurring "VEVENT", 17 | "VTODO", and "VJOURNAL" calendar components as well as in the 18 | "STANDARD" and "DAYLIGHT" sub-components of the "VTIMEZONE" 19 | calendar component. 20 | 21 | Description: This property can appear along with the "RRULE" 22 | property to define an aggregate set of repeating occurrences. 23 | When they both appear in a recurring component, the recurrence 24 | instances are defined by the union of occurrences defined by both 25 | the "RDATE" and "RRULE". 26 | 27 | The recurrence dates, if specified, are used in computing the 28 | recurrence set. The recurrence set is the complete set of 29 | recurrence instances for a calendar component. The recurrence set 30 | is generated by considering the initial "DTSTART" property along 31 | with the "RRULE", "RDATE", and "EXDATE" properties contained 32 | within the recurring component. The "DTSTART" property defines 33 | the first instance in the recurrence set. The "DTSTART" property 34 | value SHOULD match the pattern of the recurrence rule, if 35 | specified. The recurrence set generated with a "DTSTART" property 36 | value that doesn't match the pattern of the rule is undefined. 37 | The final recurrence set is generated by gathering all of the 38 | start DATE-TIME values generated by any of the specified "RRULE" 39 | and "RDATE" properties, and then excluding any start DATE-TIME 40 | values specified by "EXDATE" properties. This implies that start 41 | DATE-TIME values specified by "EXDATE" properties take precedence 42 | over those specified by inclusion properties (i.e., "RDATE" and 43 | "RRULE"). Where duplicate instances are generated by the "RRULE" 44 | and "RDATE" properties, only one recurrence is considered. 45 | Duplicate instances are ignored. 46 | """ 47 | 48 | import pytest 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "day", 53 | [ 54 | "20130803", 55 | "20130831", 56 | "20131005", 57 | "20131102", 58 | "20131130", 59 | "20140104", 60 | "20140201", 61 | "20140301", 62 | "20140405", 63 | "20140503", 64 | "20140531", 65 | "20140705", 66 | ], 67 | ) 68 | def test_rdate_is_included(calendars, day): 69 | events = calendars.rdate_hackerpublicradio.at(day) 70 | assert len(events) == 1 71 | 72 | 73 | def test_rdate_does_not_double_rrule_entry(calendars): 74 | """ 75 | When the combination of the "RRULE" and "RDATE" properties in a 76 | recurring component produces multiple instances having the same 77 | start DATE-TIME value, they should be collapsed to, and 78 | considered as, a single instance. 79 | """ 80 | events = calendars.rdate.at("20140705") 81 | assert len(events) == 1 82 | 83 | 84 | def test_rdate_can_be_excluded_by_exdate(calendars): 85 | events = calendars.rdate.at("20250705") 86 | assert len(events) == 0 87 | 88 | 89 | def test_rdate_and_rrule_can_be_excluded_by_exdate(calendars): 90 | events = calendars.rdate.at("20150705") 91 | assert len(events) == 0 92 | 93 | 94 | def test_rdate_occurs_multiple_times(calendars): 95 | """An event can not only have an RDATE once but also many of them.""" 96 | events = list(calendars.rdate_hackerpublicradio.all()) 97 | assert len(events) == 12 98 | --------------------------------------------------------------------------------