Repository: jazzband/icalevents

Issue:
Repeating event raises an error with tzinfo
Using python 3.9.7 and icalevents 0.1.27

I have an Atlassian Wiki page with a calendar object.  I can export the calendar to iCal format.  When I try to parse the file with icalevents, I get an error with this specific block:

> BEGIN:VEVENT
> DTSTAMP:20231221T180428Z
> DTSTART;VALUE=DATE:20231127
> DTEND;VALUE=DATE:20231128
> SUMMARY:DSOC
> CATEGORIES:other
> SUBCALENDAR-ID:3dfdd9f8-dc5a-49b6-9b54-644138545076
> PARENT-CALENDAR-ID:f681d504-b5ed-4258-a68b-7deb867cf1bf
> PARENT-CALENDAR-NAME:
> SUBSCRIPTION-ID:
> SUBCALENDAR-TZ-ID:America/Los_Angeles
> SUBCALENDAR-NAME:My calendar
> EVENT-ID:163463
> EVENT-ALLDAY:true
> UID:20231012T192858Z--1042663660@my_org.com
> DESCRIPTION:
> ORGANIZER;X-CONFLUENCE-USER-KEY=09ceee004595a94a014595d847942220;CN=My Name;CUTYPE=INDIVIDUAL:mailto:my_email@email.com
> RRULE:FREQ=WEEKLY;UNTIL=20240115;INTERVAL=1;BYDAY=MO
> CREATED:20231012T192858Z
> LAST-MODIFIED:20231130T174704Z
> SEQUENCE:3
> X-CONFLUENCE-SUBCALENDAR-TYPE:other
> TRANSP:TRANSPARENT
> STATUS:CONFIRMED
> EXDATE;VALUE=DATE:20231225
> EXDATE;VALUE=DATE:20231218
> EXDATE;VALUE=DATE:20231225
> EXDATE;VALUE=DATE:20231218
> END:VEVENT

Here is the error:

> Traceback (most recent call last):
>   File "<stdin>", line 1, in <module>
>   File "icalevents.py", line 57, in events
>     found_events += parse_events(
>   File "icalparser.py", line 370, in parse_events
>     rule = parse_rrule(component)
>   File "icalparser.py", line 528, in parse_rrule
>     rule.exdate(exd.replace(tzinfo=None))
> TypeError: 'tzinfo' is an invalid keyword argument for replace()

Removing the VEVENT block results in parsing just fine (with 106 other VEVENTS in the file).  Possibly related to #47.

Hints from issue comments:
@Andrevg09: Hi i have the same error, is there any resolution for this?

Solution patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8d09d89..80e551e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -22,7 +22,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install poetry==1.1.11
+          pip install poetry==1.8.3
           poetry install
       - name: Test with pytest
         run: |
diff --git a/icalevents/icalparser.py b/icalevents/icalparser.py
index ffae683..586e3fb 100644
--- a/icalevents/icalparser.py
+++ b/icalevents/icalparser.py
@@ -1,6 +1,7 @@
 """
 Parse iCal data to Events.
 """
+
 # for UID generation
 from faulthandler import is_enabled
 from random import randint
@@ -166,7 +167,7 @@ def encode(value: Optional[vText]) -> Optional[str]:
         return str(value.encode("utf-8"))
 
 
-def create_event(component, utc_default):
+def create_event(component, utc_default, strict):
     """
     Create an event from its iCal representation.
 
@@ -179,7 +180,16 @@ def create_event(component, utc_default):
     event.start = component.get("dtstart").dt
     # The RFC specifies that the TZID parameter must be specified for datetime or time
     # Otherwise we set a default timezone (if only one is set with VTIMEZONE) or utc
-    event.floating = type(component.get("dtstart").dt) == date and utc_default
+    if not strict:
+        event.floating = (
+            type(component.get("dtstart").dt) == date
+            or component.get("dtstart").dt.tzinfo is None
+        )
+    else:
+        event.floating = (
+            type(component.get("dtstart").dt) == datetime
+            and component.get("dtstart").dt.tzinfo is None
+        )
 
     if component.get("dtend"):
         event.end = component.get("dtend").dt
@@ -323,15 +333,14 @@ def parse_events(
 
     found = []
 
-    def add_if_not_exception(event):
+    def is_not_exception(date):
         exdate = "%04d%02d%02d" % (
-            event.start.year,
-            event.start.month,
-            event.start.day,
+            date.year,
+            date.month,
+            date.day,
         )
 
-        if exdate not in exceptions:
-            found.append(event)
+        return exdate not in exceptions
 
     for component in calendar.walk():
         exceptions = {}
@@ -349,14 +358,12 @@ def parse_events(
                 exceptions[exdate[0:8]] = exdate
 
         if component.name == "VEVENT":
-            e = create_event(component, utc_default)
+            e = create_event(component, utc_default, strict)
 
             # make rule.between happy and provide from, to points in time that have the same format as dtstart
             s = component["dtstart"].dt
-            if type(s) is date and not e.recurring:
-                f, t = date(start.year, start.month, start.day), date(
-                    end.year, end.month, end.day
-                )
+            if type(s) is date and e.recurring == False:
+                f, t = start, end
             elif type(s) is datetime and s.tzinfo:
                 f, t = datetime(
                     start.year, start.month, start.day, tzinfo=s.tzinfo
@@ -372,25 +379,29 @@ def parse_events(
                     # Recompute the start time in the current timezone *on* the
                     # date of *this* occurrence. This handles the case where the
                     # recurrence has crossed over the daylight savings time boundary.
-                    if type(dt) is datetime and dt.tzinfo:
-                        dtstart = dt.replace(tzinfo=get_timezone(str(dt.tzinfo)))
-                        ecopy = e.copy_to(
-                            dtstart.date() if type(s) is date else dtstart, e.uid
-                        )
-                    else:
-                        ecopy = e.copy_to(dt.date() if type(s) is date else dt, e.uid)
-                    add_if_not_exception(ecopy)
-
-            elif e.end >= f and e.start <= t:
-                add_if_not_exception(e)
+                    if is_not_exception(dt):
+                        if type(dt) is datetime and dt.tzinfo:
+                            ecopy = e.copy_to(
+                                dt.replace(tzinfo=get_timezone(str(dt.tzinfo))),
+                                e.uid,
+                            )
+                        else:
+                            ecopy = e.copy_to(
+                                dt.date() if type(s) is date else dt, e.uid
+                            )
+                        found.append(ecopy)
+
+            elif e.end >= f and e.start <= t and is_not_exception(e.start):
+                found.append(e)
 
     result = found.copy()
 
     # Remove events that are replaced in ical
     for event in found:
-        if not event.recurrence_id and (event.uid, event.start) in [
-            (f.uid, f.recurrence_id) for f in found
-        ]:
+        if not event.recurrence_id and (
+            event.uid,
+            event.start,
+        ) in [(f.uid, f.recurrence_id) for f in found]:
             result.remove(event)
 
     # > Will be deprecated ========================
@@ -502,9 +513,8 @@ def parse_rrule(component):
                     year=until.year, month=until.month, day=until.day, tzinfo=UTC
                 )
             ) + (
-                (dtstart.tzinfo.utcoffset(dtstart) if dtstart.tzinfo else None)
-                or timedelta()
-            )
+                dtstart.tzinfo.utcoffset(dtstart) if dtstart.tzinfo else None
+            ) or timedelta()
 
         return until.date() + timedelta(days=1) if type(until) is datetime else until
 
@@ -525,7 +535,12 @@ def parse_rrule(component):
         # Add exdates to the rruleset
         for exd in extract_exdates(component):
             if type(dtstart) is date:
-                rule.exdate(exd.replace(tzinfo=None))
+                if type(exd) is date:
+                    # Always convert exdates to datetimes because rrule.between does not like dates
+                    # https://github.com/dateutil/dateutil/issues/938
+                    rule.exdate(datetime.combine(exd, datetime.min.time()))
+                else:
+                    rule.exdate(exd.replace(tzinfo=None))
             else:
                 rule.exdate(exd)
 
diff --git a/pyproject.toml b/pyproject.toml
index cc61fc8..9b91309 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,16 +9,14 @@ readme = "README.md"
 
 [tool.poetry.dependencies]
 python = "^3.9"
-icalendar = "4.0.9"
-python-dateutil = "^2.8.2"
-pytz = "^2021.3"
-DateTime = "^4.3"
-httplib2 = "^0.20.4"
+icalendar = "5.0.13"
+python-dateutil = "^2.9.0"
+pytz = "^2024.1"
+httplib2 = "^0.22.0"
 
 [tool.poetry.dev-dependencies]
 coverage = "^6.0.1"
-codecov = "^2.1.12"
-pytest = "^6.2.5"
+pytest = "^8.3.2"
 black = "^22.3.0"
 flake8 = "^5.0.4"
 


Test patch:
diff --git a/test/test_icalevents.py b/test/test_icalevents.py
index 8a899f9..0f88de2 100644
--- a/test/test_icalevents.py
+++ b/test/test_icalevents.py
@@ -5,6 +5,7 @@ from time import sleep
 from dateutil.relativedelta import relativedelta
 from dateutil.tz import UTC, gettz
 from re import search
+import pytz
 
 
 class ICalEventsTests(unittest.TestCase):
@@ -421,6 +422,7 @@ class ICalEventsTests(unittest.TestCase):
         events = icalevents.events(
             file=ical, start=start, end=end, tzinfo=tz, sort=True, strict=True
         )
+        # self.assertEqual(events[2].start, 2)
 
         times = [
             ((2021, 4, 1, 14, 0), (2021, 4, 1, 14, 30)),
@@ -432,7 +434,6 @@ class ICalEventsTests(unittest.TestCase):
             ((2021, 4, 8, 14, 30), (2021, 4, 8, 15, 0)),
             ((2021, 4, 8, 15, 0), (2021, 4, 8, 15, 30)),
             ((2021, 4, 9), (2021, 4, 10)),
-            # ((2021, 4, 9), (2021, 4, 10)), # Google said this should exist, microsoft disagrees
             ((2021, 4, 12, 11, 0), (2021, 4, 12, 11, 30)),
             ((2021, 4, 12, 16, 0), (2021, 4, 12, 17, 0)),
             ((2021, 4, 14), (2021, 4, 15)),
@@ -642,6 +643,26 @@ class ICalEventsTests(unittest.TestCase):
         self.assertEqual(e2.floating, True, "respect floating time")
         self.assertEqual(e2.start.tzinfo, UTC, "check tz as default utc")
 
+    def test_floating_strict(self):
+        ical = "test/test_data/floating.ics"
+        start = date(2021, 1, 1)
+        end = date(2021, 12, 31)
+
+        [e1, e2] = icalevents.events(file=ical, start=start, end=end, strict=True)
+
+        self.assertEqual(e1.transparent, False, "respect transparency")
+        self.assertEqual(
+            e1.start.astimezone(pytz.utc).hour, 6, "check start of the day"
+        )
+        self.assertEqual(e1.end.astimezone(pytz.utc).hour, 14, "check end of the day")
+        self.assertEqual(e1.floating, False, "respect floating time")
+        self.assertEqual(e1.start.tzname(), "CEST", "check tz as specified in calendar")
+
+        self.assertEqual(e2.transparent, True, "respect transparency")
+        self.assertEqual(e2.start, date(2021, 10, 13), "check start of the day")
+        self.assertEqual(e2.end, date(2021, 10, 14), "check end of the day")
+        self.assertEqual(e2.floating, False, "dates are not floating floating time")
+
     def test_non_floating(self):
         ical = "test/test_data/non_floating.ics"
         start = date(2021, 1, 1)
@@ -660,11 +681,30 @@ class ICalEventsTests(unittest.TestCase):
         self.assertEqual(e2.transparent, True, "respect transparency")
         self.assertEqual(e2.start.hour, 0, "check start of the day")
         self.assertEqual(e2.end.hour, 0, "check end of the day")
-        self.assertEqual(e2.floating, False, "respect floating time")
+        self.assertEqual(e2.floating, True, "respect floating time")
         self.assertEqual(
             e2.start.tzinfo, gettz("Europe/Zurich"), "check tz as specified in calendar"
         )
 
+    def test_non_floating_strict(self):
+        ical = "test/test_data/non_floating.ics"
+        start = date(2021, 1, 1)
+        end = date(2021, 12, 31)
+
+        [e1, e2] = icalevents.events(file=ical, start=start, end=end, strict=True)
+
+        self.assertEqual(e1.transparent, False, "respect transparency")
+        self.assertEqual(e1.start.hour, 8, "check start of the day")
+        self.assertEqual(e1.end.hour, 16, "check end of the day")
+        self.assertEqual(e1.floating, False, "respect floating time")
+        self.assertEqual(e1.start.tzname(), "CEST", "check tz as specified in calendar")
+
+        self.assertEqual(e2.transparent, True, "respect transparency")
+        self.assertEqual(e2.floating, False, "respect floating time")
+        self.assertEqual(e2.all_day, True, "it is an all day event")
+        self.assertEqual(e2.start, date(2021, 10, 13), "it is an all day event")
+        self.assertEqual(e2.end, date(2021, 10, 14), "it is an all day event")
+
     def test_recurring_override(self):
         ical = "test/test_data/recurring_override.ics"
         start = date(2021, 11, 23)
@@ -795,3 +835,47 @@ class ICalEventsTests(unittest.TestCase):
         self.assertEqual(evs[3].start, datetime(2022, 4, 22, 11, 0, 0, tzinfo=tz))
         self.assertEqual(evs[4].start, datetime(2022, 4, 29, 11, 0, 0, tzinfo=tz))
         # parsing stops at 2022-05-01
+
+    def test_google_2024(self):
+        ical = "test/test_data/google_2024.ics"
+        start = date(2024, 1, 1)
+        end = date(2024, 12, 31)
+
+        [e1, *events] = icalevents.events(file=ical, start=start, end=end, strict=True)
+
+        self.assertEqual(e1.start.astimezone(pytz.utc).hour, 6, "starts at 6 utc")
+        self.assertEqual(e1.end.astimezone(pytz.utc).hour, 7, "ends at 7 utc")
+        self.assertEqual(e1.floating, False, "respect floating time")
+        self.assertEqual(e1.start.tzname(), "CET", "check tz as specified in calendar")
+
+        self.assertEqual(
+            events[4].start.astimezone(pytz.utc).hour, 6, "starts at 6 utc"
+        )
+        self.assertEqual(
+            events[5].start.astimezone(pytz.utc).hour,
+            5,
+            "starts at 5 utc summer time (+2:00)",
+        )
+        self.assertEqual(
+            events[6].start.astimezone(pytz.utc).hour,
+            5,
+            "starts at 5 utc summer time (+2:00)",
+        )
+
+    def test_regression_repeating_events_raise_an_error(self):
+        ical = "test/test_data/recurrence_tzinfo.ics"
+        start = date(2023, 1, 1)
+        end = date(2024, 12, 31)
+
+        events = icalevents.events(file=ical, start=start, end=end, strict=True)
+
+        self.assertEqual(len(events), 6, "6 events")
+        self.assertEqual(events[0].start, date(2023, 11, 27), "first on 27. nov")
+        self.assertEqual(events[1].start, date(2023, 12, 4), "second event on 4. dec")
+        self.assertEqual(events[2].start, date(2023, 12, 11), "third event on 11. dec")
+        self.assertEqual(
+            events[3].start,
+            date(2024, 1, 1),
+            "fourth event on 1. jan - 18. and 25. dec are excluded",
+        )
+        self.assertEqual(events[4].start, date(2024, 1, 8), "fifth event on 8. jan")