`_
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 | 
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 | [](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 | 
15 |
16 | {rfc}`2445` is deprecated and has been replaced by {rfc}`5545` and {rfc}`7529`.
17 |
18 | ### RFC 5545 - iCalendar
19 |
20 | 
21 |
22 | {rfc}`5545` is fully supported.
23 |
24 | ### RFC 7529 - Non-Gregorian Recurrence Rules
25 |
26 | 
27 |
28 | {rfc}`7529` is not implemented.
29 |
30 | ### RFC 7953 - Calendar Availability
31 |
32 | 
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 |
--------------------------------------------------------------------------------