Repository: pimutils/khal
Pull request #972 (2020-11-04 19:02:43)

[icalendar] Support rrules that ended in the past
Currently we were throwing an UnsupportedRecurrenceError when there
was an rrule that happened to have been finished (RRULE:UNTIL) in the
past.

This just returns an empty list of events when expanding instead.

This also adds freezegun (through pytest-freezegun) to the
khalendar_utils_test in order to be able to test this case, as most
tests were using old dates.


Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index a2a893e..4a4975c 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -43,3 +43,4 @@ Axel Danguin
 Yorick Barbanneau - git [at] epha [dot] se - https://xieme-art.org
 Florian Lassenay - pizzacoca [at] aquilenet [dot] fr
 Simon Crespeau - simon [at] art-e-toile [dot] com - https://www.art-e-toile.com
+David Caro - david [at] dcaro [dot] es
diff --git a/khal/icalendar.py b/khal/icalendar.py
index 1f39971..de7a6d5 100644
--- a/khal/icalendar.py
+++ b/khal/icalendar.py
@@ -261,6 +261,9 @@ def expand(vevent, href=''):
             rrule._until = pytz.UTC.localize(
                 rrule._until).astimezone(events_tz).replace(tzinfo=None)
 
+        if rrule._until <= dt.datetime.now():
+            return []
+
         rrule = map(sanitize_datetime, rrule)
 
         logger.debug('calculating recurrence dates for {}, this might take some time.'.format(href))
diff --git a/tox.ini b/tox.ini
index cf93b7b..7770243 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,6 +17,7 @@ passenv =
 deps =
     pytest
     freezegun
+    pytest-freezegun
     vdirsyncer
     python-dateutil
     pytz201702: pytz==2017.2


Test patch:
diff --git a/tests/backend_test.py b/tests/backend_test.py
index 5ef0ff2..f0b6771 100644
--- a/tests/backend_test.py
+++ b/tests/backend_test.py
@@ -13,6 +13,7 @@ from .utils import BERLIN, LOCALE_BERLIN, _get_text
 calname = 'home'
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_new_db_version():
     dbi = backend.SQLiteDb(calname, ':memory:', locale=LOCALE_BERLIN)
     backend.DB_VERSION += 1
@@ -20,6 +21,7 @@ def test_new_db_version():
         dbi._check_table_version()
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_recurrence_id():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert dbi.list(calname) == list()
@@ -53,6 +55,7 @@ def test_event_rrule_recurrence_id():
     assert len(calendars) == 6
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_recurrence_id_invalid_tzid():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(_get_text('event_rrule_recuid_invalid_tzid'), href='12345.ics', etag='abcd',
@@ -91,6 +94,7 @@ END:VCALENDAR
 """
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_recurrence_id_reverse():
     """as icalendar elements can be saved in arbitrary order, we also have to
     deal with `reverse` ordered icalendar files
@@ -117,6 +121,7 @@ def test_event_rrule_recurrence_id_reverse():
     assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_recurrence_id_update_with_exclude():
     """
     test if updates work as they should. The updated event has the extra
@@ -137,6 +142,7 @@ def test_event_rrule_recurrence_id_update_with_exclude():
     assert events[4][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0))
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_recuid_no_master():
     """
     test for events which have a RECUID component, but the master event is
@@ -155,6 +161,7 @@ def test_event_recuid_no_master():
     assert 'SUMMARY:Infrastructure Planning' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_recuid_rrule_no_master():
     """
     test for events which have a RECUID and a RRULE component, but the master event is
@@ -178,6 +185,7 @@ def test_event_recuid_rrule_no_master():
     assert len(list(events)) == 2
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_no_valid_timezone():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(_get_text('event_dt_local_missing_tz'),
@@ -191,6 +199,7 @@ def test_no_valid_timezone():
     assert event[3] == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30))
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_delete():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert dbi.list(calname) == list()
@@ -228,6 +237,7 @@ END:VCALENDAR
 """
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_this_and_prior():
     """we do not support THISANDPRIOR, therefore this should fail"""
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
@@ -258,6 +268,7 @@ event_rrule_this_and_future = \
     event_rrule_this_and_future_temp.format('20140707T090000', '20140707T180000')
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_this_and_future():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(event_rrule_this_and_future, href='12345.ics', etag='abcd', calendar=calname)
@@ -290,6 +301,7 @@ event_rrule_this_and_future_multi_day_shift = \
     event_rrule_this_and_future_temp.format('20140708T090000', '20140709T150000')
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_this_and_future_multi_day_shift():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(event_rrule_this_and_future_multi_day_shift,
@@ -342,6 +354,7 @@ event_rrule_this_and_future_allday = \
     event_rrule_this_and_future_allday_temp.format(20140708, 20140709)
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_this_and_future_allday():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(event_rrule_this_and_future_allday,
@@ -369,6 +382,7 @@ def test_event_rrule_this_and_future_allday():
         assert 'SUMMARY:Arbeit (lang)\n' in event[0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_this_and_future_allday_prior():
     event_rrule_this_and_future_allday_prior = \
         event_rrule_this_and_future_allday_temp.format(20140705, 20140706)
@@ -424,6 +438,7 @@ END:VEVENT
 END:VCALENDAR"""
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_event_rrule_multi_this_and_future_allday():
     dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     dbi.update(event_rrule_multi_this_and_future_allday,
@@ -480,6 +495,7 @@ DURATION:PT4H30M
 END:VEVENT""")
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_calc_shift_deltas():
     assert (dt.timedelta(hours=2), dt.timedelta(hours=5)) == \
         backend.calc_shift_deltas(recuid_this_future)
@@ -504,6 +520,7 @@ DTEND;TZID=Europe/Berlin:20140630T120000
 END:VEVENT"""
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_two_calendars_same_uid():
     home = 'home'
     work = 'work'
@@ -550,6 +567,7 @@ def test_two_calendars_same_uid():
     assert dbi.list(work) == [('12345.ics', 'abcd')]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_update_one_should_not_affect_others():
     """test if an THISANDFUTURE param effects other events as well"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
@@ -561,6 +579,7 @@ def test_update_one_should_not_affect_others():
     assert len(events) == 1
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_no_dtend():
     """test support for events with no dtend"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
@@ -588,6 +607,7 @@ supported_events = [
 ]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_check_support():
     for cal_str in supported_events:
         ical = icalendar.Calendar.from_ical(cal_str)
@@ -605,6 +625,7 @@ def test_check_support():
             [backend.check_support(event, '', '') for event in ical.walk()]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_check_support_rdate_no_values():
     """check if `check_support` doesn't choke on events with an RDATE property
     without a VALUE parameter"""
@@ -689,6 +710,7 @@ start = dt.datetime.combine(day, dt.time.min)
 end = dt.datetime.combine(day, dt.time.max)
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_birthdays():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -704,6 +726,7 @@ def test_birthdays():
     assert 'SUMMARY:Unix\'s birthday' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_birthdays_update():
     """test if we can update a birthday"""
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
@@ -711,6 +734,7 @@ def test_birthdays_update():
     db.update_vcf_dates(card, 'unix.vcf', calendar=calname)
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_birthdays_no_fn():
     db = backend.SQLiteDb(['home'], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(dt.datetime(1941, 9, 9, 0, 0),
@@ -722,6 +746,7 @@ def test_birthdays_no_fn():
     assert 'SUMMARY:Dennis MacAlistair Ritchie\'s birthday' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_birthday_does_not_parse():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -730,6 +755,7 @@ def test_birthday_does_not_parse():
     assert len(events) == 0
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_vcard_two_birthdays():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -738,6 +764,7 @@ def test_vcard_two_birthdays():
     assert len(events) == 0
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_anniversary():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -753,6 +780,7 @@ def test_anniversary():
     assert 'SUMMARY:Unix\'s anniversary' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_abdate():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -768,6 +796,7 @@ def test_abdate():
     assert 'SUMMARY:Unix\'s spouse\'s birthday' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_abdate_nolabel():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
@@ -783,6 +812,7 @@ def test_abdate_nolabel():
     assert 'SUMMARY:Unix\'s custom event from vcard' in events[0][0]
 
 
+@pytest.mark.freeze_time("2000-01-01")
 def test_birthday_v3():
     db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN)
     assert list(db.get_floating(start, end)) == list()
diff --git a/tests/controller_test.py b/tests/controller_test.py
index df8b37e..f65e606 100644
--- a/tests/controller_test.py
+++ b/tests/controller_test.py
@@ -93,27 +93,28 @@ class TestGetAgenda:
 
 class TestImport:
     def test_import(self, coll_vdirs):
-        coll, vdirs = coll_vdirs
-        view = {'event_format': '{title}'}
-        conf = {'locale': utils.LOCALE_BERLIN, 'view': view}
-        import_ics(coll, conf, _get_text('event_rrule_recuid'), batch=True)
-        start_date = utils.BERLIN.localize(dt.datetime(2014, 4, 30))
-        end_date = utils.BERLIN.localize(dt.datetime(2014, 9, 26))
-        events = list(coll.get_localized(start_date, end_date))
-        assert len(events) == 6
-        events = sorted(events)
-        assert events[1].start_local == utils.BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
-        assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) in \
-            [ev.start for ev in events]
-
-        import_ics(coll, conf, _get_text('event_rrule_recuid_update'), batch=True)
-        events = list(coll.get_localized(start_date, end_date))
-        for ev in events:
-            print(ev.start)
-            assert ev.calendar == 'foobar'
-        assert len(events) == 5
-        assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) not in \
-            [ev.start_local for ev in events]
+        with freeze_time('2000-01-01 12:33'):
+            coll, vdirs = coll_vdirs
+            view = {'event_format': '{title}'}
+            conf = {'locale': utils.LOCALE_BERLIN, 'view': view}
+            import_ics(coll, conf, _get_text('event_rrule_recuid'), batch=True)
+            start_date = utils.BERLIN.localize(dt.datetime(2014, 4, 30))
+            end_date = utils.BERLIN.localize(dt.datetime(2014, 9, 26))
+            events = list(coll.get_localized(start_date, end_date))
+            assert len(events) == 6
+            events = sorted(events)
+            assert events[1].start_local == utils.BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0))
+            assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) in \
+                [ev.start for ev in events]
+
+            import_ics(coll, conf, _get_text('event_rrule_recuid_update'), batch=True)
+            events = list(coll.get_localized(start_date, end_date))
+            for ev in events:
+                print(ev.start)
+                assert ev.calendar == 'foobar'
+            assert len(events) == 5
+            assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) not in \
+                [ev.start_local for ev in events]
 
     def test_mix_datetime_types(self, coll_vdirs):
         """
diff --git a/tests/ics/event_dt_rrule_expired_until.ics b/tests/ics/event_dt_rrule_expired_until.ics
new file mode 100644
index 0000000..fb6be3a
--- /dev/null
+++ b/tests/ics/event_dt_rrule_expired_until.ics
@@ -0,0 +1,8 @@
+BEGIN:VEVENT
+SUMMARY:An Event
+DTSTART;VALUE=DATE-TIME:20140409T093000
+DTEND;VALUE=DATE-TIME:20140409T103000
+RRULE:FREQ=DAILY;COUNT=10;UNTIL=19000110T065959Z
+DTSTAMP;VALUE=DATE-TIME:20140401T234817Z
+UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU
+END:VEVENT
diff --git a/tests/khalendar_utils_test.py b/tests/khalendar_utils_test.py
index 6d46638..44b0716 100644
--- a/tests/khalendar_utils_test.py
+++ b/tests/khalendar_utils_test.py
@@ -1,4 +1,5 @@
 import datetime as dt
+import pytest
 
 import icalendar
 from khal import icalendar as icalendar_helpers, utils
@@ -230,7 +231,14 @@ def _get_vevent(event):
             return component
 
 
-class TestExpand(object):
+class FrozenTest:
+    @pytest.fixture(autouse=True)
+    def freeze_time(self, freezer):
+        freezer.move_to('2000-01-01')
+        yield
+
+
+class TestExpand(FrozenTest):
     dtstartend_berlin = [
         (berlin.localize(dt.datetime(2013, 3, 1, 14, 0, )),
          berlin.localize(dt.datetime(2013, 3, 1, 16, 0, ))),
@@ -370,7 +378,7 @@ class TestExpand(object):
         ]
 
 
-class TestExpandNoRR(object):
+class TestExpandNoRR(FrozenTest):
     dtstartend_berlin = [
         (berlin.localize(dt.datetime(2013, 3, 1, 14, 0)),
          berlin.localize(dt.datetime(2013, 3, 1, 16, 0))),
@@ -575,7 +583,7 @@ END:VEVENT
 """
 
 
-class TestSpecial(object):
+class TestSpecial(FrozenTest):
     """collection of strange test cases that don't fit anywhere else really"""
 
     def test_count(self):
@@ -683,6 +691,13 @@ class TestSpecial(object):
         assert dtstart[-1] == (berlin.localize(dt.datetime(2014, 12, 3, 9, 30)),
                                berlin.localize(dt.datetime(2014, 12, 3, 10, 30)))
 
+    def test_event_dt_rrule_expired_until(self):
+        """RRULE:UNTIL might be in the past, if that's so, no events are expanden
+        """
+        vevent = _get_vevent(_get_text('event_dt_rrule_expired_until'))
+        dtstart = icalendar_helpers.expand(vevent, berlin)
+        assert len(dtstart) == 0
+
 
 simple_rdate = """BEGIN:VEVENT
 SUMMARY:Simple Rdate
@@ -704,7 +719,7 @@ UID:datetime123
 END:VEVENT"""
 
 
-class TestRDate(object):
+class TestRDate(FrozenTest):
     """Testing expanding of recurrence rules"""
     def test_simple_rdate(self):
         vevent = _get_vevent(simple_rdate)
@@ -766,7 +781,7 @@ END:VCALENDAR
 """
 
 
-class TestSanitize(object):
+class TestSanitize(FrozenTest):
 
     def test_noend_date(self):
         vevent = _get_vevent(noend_date)