From d6de71b828f93b7984cd5608ab57186ad3df6022 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Wed, 17 Nov 2021 14:36:13 +0100 Subject: [PATCH] Bugfix for datetime objects with tzinfo from zoneinfo library See https://github.com/collective/icalendar/issues/333 for details Includes test code and changelog entry --- CHANGES.rst | 5 +- docs/credits.rst | 1 + src/icalendar/parser.py | 11 +-- src/icalendar/tests/test_timezoned.py | 129 +++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 036523a..a6d1fe4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,7 +15,10 @@ New features: Bug fixes: -- *add item here* +- proper handling of datetime objects with `tzinfo` generated through zoneinfo.ZoneInfo. + Ref: #334 + Fixes: #333 + [tobixen] 4.0.9 (2021-10-16) diff --git a/docs/credits.rst b/docs/credits.rst index 420af8d..c9d7732 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -56,6 +56,7 @@ icalendar contributors - Clive Stevens - Dalton Durst - Kamil Mańkowski +- Tobias Brox Find out who contributed:: diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index 5344de8..5d0a576 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -53,15 +53,14 @@ def tzid_from_dt(dt): tzid = None if hasattr(dt.tzinfo, 'zone'): tzid = dt.tzinfo.zone # pytz implementation + elif hasattr(dt.tzinfo, 'key'): + tzid = dt.tzinfo.key # ZoneInfo implementation elif hasattr(dt.tzinfo, 'tzname'): - try: - tzid = dt.tzinfo.tzname(dt) # dateutil implementation - except AttributeError: - # No tzid available - pass + # dateutil implementation, but this is broken + # See https://github.com/collective/icalendar/issues/333 for details + tzid = dt.tzinfo.tzname(dt) return tzid - def foldline(line, limit=75, fold_sep='\r\n '): """Make a string folded as defined in RFC5545 Lines of text SHOULD NOT be longer than 75 octets, excluding the line diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index c80abd4..d81ab0e 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -8,11 +8,50 @@ import dateutil.parser import icalendar import os import pytz - +try: + import zoneinfo +except: + try: + from backports import zoneinfo + except: + zoneinfo = None class TestTimezoned(unittest.TestCase): - def test_create_from_ical(self): + def test_create_from_ical_zoneinfo(self): + directory = os.path.dirname(__file__) + with open(os.path.join(directory, 'timezoned.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + + self.assertEqual( + cal['prodid'].to_ical(), + b"-//Plone.org//NONSGML plone.app.event//EN" + ) + + timezones = cal.walk('VTIMEZONE') + self.assertEqual(len(timezones), 1) + + tz = timezones[0] + self.assertEqual(tz['tzid'].to_ical(), b"Europe/Vienna") + + std = tz.walk('STANDARD')[0] + self.assertEqual( + std.decoded('TZOFFSETFROM'), + datetime.timedelta(0, 7200) + ) + + ev1 = cal.walk('VEVENT')[0] + self.assertEqual( + ev1.decoded('DTSTART'), + datetime.datetime(2012, 2, 13, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo('Europe/Vienna')) + ) + self.assertEqual( + ev1.decoded('DTSTAMP'), + datetime.datetime(2010, 10, 10, 9, 10, 10, tzinfo=zoneinfo.ZoneInfo('UTC')) + ) + + def test_create_from_ical_pytz(self): directory = os.path.dirname(__file__) with open(os.path.join(directory, 'timezoned.ics'), 'rb') as fp: data = fp.read() @@ -49,7 +88,7 @@ class TestTimezoned(unittest.TestCase): ) ) - def test_create_to_ical(self): + def test_create_to_ical_pytz(self): cal = icalendar.Calendar() cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") @@ -132,6 +171,90 @@ class TestTimezoned(unittest.TestCase): self.assertTrue("DTSTAMP;VALUE=DATE-TIME:20101010T081010Z" in test_out) self.assertTrue("CREATED;VALUE=DATE-TIME:20101010T081010Z" in test_out) + def test_create_to_ical_zoneinfo(self): + cal = icalendar.Calendar() + + cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") + cal.add('version', "2.0") + cal.add('x-wr-calname', "test create calendar") + cal.add('x-wr-caldesc', "icalendar tests") + cal.add('x-wr-relcalid', "12345") + cal.add('x-wr-timezone', "Europe/Vienna") + + tzc = icalendar.Timezone() + tzc.add('tzid', 'Europe/Vienna') + tzc.add('x-lic-location', 'Europe/Vienna') + + tzs = icalendar.TimezoneStandard() + tzs.add('tzname', 'CET') + tzs.add('dtstart', datetime.datetime(1970, 10, 25, 3, 0, 0)) + tzs.add('rrule', {'freq': 'yearly', 'bymonth': 10, 'byday': '-1su'}) + tzs.add('TZOFFSETFROM', datetime.timedelta(hours=2)) + tzs.add('TZOFFSETTO', datetime.timedelta(hours=1)) + + tzd = icalendar.TimezoneDaylight() + tzd.add('tzname', 'CEST') + tzd.add('dtstart', datetime.datetime(1970, 3, 29, 2, 0, 0)) + tzs.add('rrule', {'freq': 'yearly', 'bymonth': 3, 'byday': '-1su'}) + tzd.add('TZOFFSETFROM', datetime.timedelta(hours=1)) + tzd.add('TZOFFSETTO', datetime.timedelta(hours=2)) + + tzc.add_component(tzs) + tzc.add_component(tzd) + cal.add_component(tzc) + + event = icalendar.Event() + tz = zoneinfo.ZoneInfo("Europe/Vienna") + event.add( + 'dtstart', + datetime.datetime(2012, 2, 13, 10, 00, 00, tzinfo=tz)) + event.add( + 'dtend', + datetime.datetime(2012, 2, 17, 18, 00, 00, tzinfo=tz)) + event.add( + 'dtstamp', + datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) + event.add( + 'created', + datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) + event.add('uid', '123456') + event.add( + 'last-modified', + datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) + event.add('summary', 'artsprint 2012') + # event.add('rrule', 'FREQ=YEARLY;INTERVAL=1;COUNT=10') + event.add('description', 'sprinting at the artsprint') + event.add('location', 'aka bild, wien') + event.add('categories', 'first subject') + event.add('categories', 'second subject') + event.add('attendee', 'häns') + event.add('attendee', 'franz') + event.add('attendee', 'sepp') + event.add('contact', 'Max Mustermann, 1010 Wien') + event.add('url', 'http://plone.org') + cal.add_component(event) + + test_out = b'|'.join(cal.to_ical().splitlines()) + test_out = test_out.decode('utf-8') + + vtimezone_lines = "BEGIN:VTIMEZONE|TZID:Europe/Vienna|X-LIC-LOCATION:" + "Europe/Vienna|BEGIN:STANDARD|DTSTART;VALUE=DATE-TIME:19701025T03" + "0000|RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10|RRULE:FREQ=YEARLY;B" + "YDAY=-1SU;BYMONTH=3|TZNAME:CET|TZOFFSETFROM:+0200|TZOFFSETTO:+01" + "00|END:STANDARD|BEGIN:DAYLIGHT|DTSTART;VALUE=DATE-TIME:19700329T" + "020000|TZNAME:CEST|TZOFFSETFROM:+0100|TZOFFSETTO:+0200|END:DAYLI" + "GHT|END:VTIMEZONE" + self.assertTrue(vtimezone_lines in test_out) + + test_str = "DTSTART;TZID=Europe/Vienna;VALUE=DATE-TIME:20120213T100000" + self.assertTrue(test_str in test_out) + self.assertTrue("ATTENDEE:sepp" in test_out) + + # ical standard expects DTSTAMP and CREATED in UTC + self.assertTrue("DTSTAMP;VALUE=DATE-TIME:20101010T081010Z" in test_out) + self.assertTrue("CREATED;VALUE=DATE-TIME:20101010T081010Z" in test_out) + + def test_tzinfo_dateutil(self): # Test for issues #77, #63 # references: #73,7430b66862346fe3a6a100ab25e35a8711446717