From ec3c8dcb105d264ad9c4f3c798d4fbcfb14c607d Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 12:53:17 +0100 Subject: [PATCH 01/70] Create timezone sub-package --- src/icalendar/cal.py | 2 +- src/icalendar/prop.py | 2 +- src/icalendar/timezone/__init__.py | 1 + src/icalendar/{timezone_cache.py => timezone/cache.py} | 0 4 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 src/icalendar/timezone/__init__.py rename src/icalendar/{timezone_cache.py => timezone/cache.py} (100%) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index f405a618..dbfdf942 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -13,7 +13,7 @@ from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.prop import TypesFactory from icalendar.prop import vText, vDDDLists -from icalendar.timezone_cache import _timezone_cache +from icalendar.timezone.cache import _timezone_cache import pytz import dateutil.rrule, dateutil.tz diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 5c4c10f4..e6936c45 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -54,7 +54,7 @@ from icalendar.parser_tools import SEQUENCE_TYPES from icalendar.parser_tools import to_unicode from icalendar.parser_tools import from_unicode -from icalendar.timezone_cache import _timezone_cache +from icalendar.timezone.cache import _timezone_cache from icalendar.windows_to_olson import WINDOWS_TO_OLSON import base64 diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py new file mode 100644 index 00000000..548c840a --- /dev/null +++ b/src/icalendar/timezone/__init__.py @@ -0,0 +1 @@ +"""This package contains all functionality for timezones.""" diff --git a/src/icalendar/timezone_cache.py b/src/icalendar/timezone/cache.py similarity index 100% rename from src/icalendar/timezone_cache.py rename to src/icalendar/timezone/cache.py From 78d21ca35322f7c79ae466d75c5865675a187fde Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:13:55 +0100 Subject: [PATCH 02/70] Start refactoring for zoneinfo: Move all pytz usage into timezone.pytz --- src/icalendar/cal.py | 7 ++----- src/icalendar/timezone/__init__.py | 6 ++++++ src/icalendar/timezone/provider.py | 9 ++++++++ src/icalendar/timezone/pytz.py | 13 ++++++++++++ src/icalendar/timezone/tzp.py | 33 ++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/icalendar/timezone/provider.py create mode 100644 src/icalendar/timezone/pytz.py create mode 100644 src/icalendar/timezone/tzp.py diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index dbfdf942..bf806120 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -14,6 +14,7 @@ from icalendar.prop import TypesFactory from icalendar.prop import vText, vDDDLists from icalendar.timezone.cache import _timezone_cache +from icalendar.timezone import tzp import pytz import dateutil.rrule, dateutil.tz @@ -178,11 +179,7 @@ def add(self, name, value, parameters=None, encode=1): if isinstance(value, datetime) and\ name.lower() in ('dtstamp', 'created', 'last-modified'): # RFC expects UTC for those... force value conversion. - if getattr(value, 'tzinfo', False) and value.tzinfo is not None: - value = value.astimezone(pytz.utc) - else: - # assume UTC for naive datetime instances - value = pytz.utc.localize(value) + value = tzp.make_utc(value) # encode value if encode and isinstance(value, list) \ diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index 548c840a..fef99fbf 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -1 +1,7 @@ """This package contains all functionality for timezones.""" +from .tzp import TZP + +tzp = TZP() +tzp.use_pytz() + +__all__ = ["tzp"] diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py new file mode 100644 index 00000000..156249cd --- /dev/null +++ b/src/icalendar/timezone/provider.py @@ -0,0 +1,9 @@ +"""This is an abstract class that provides timezomes.""" +from abc import ABC, abstractmethod + +class Provider(ABC): + """Provide a timezone implementation.""" + + @abstractmethod + def make_utc(self, datetime): + pass diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py new file mode 100644 index 00000000..a485d91b --- /dev/null +++ b/src/icalendar/timezone/pytz.py @@ -0,0 +1,13 @@ +"""Use pytz timezones.""" +import pytz +from .provider import Provider + +class PYTZ(Provider): + """Provide icalendar with timezones from pytz.""" + + def make_utc(self, value): + if getattr(value, 'tzinfo', False) and value.tzinfo is not None: + return value.astimezone(pytz.utc) + else: + # assume UTC for naive datetime instances + return pytz.utc.localize(value) diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py new file mode 100644 index 00000000..09822ca4 --- /dev/null +++ b/src/icalendar/timezone/tzp.py @@ -0,0 +1,33 @@ +from .provider import Provider +from datetime import datetime + +class TZP(Provider): + """This is the timezone provider proxy. + + If you would like to have another timezone implementation, + you can create a new one and pass it to this proxy. + All of icalendar will then use this timezone implementation. + """ + + def __init__(self): + """Create a new timezone implementation proxy.""" + self.use_pytz() + + def use_pytz(self): + """Use pytz as the timezone provider.""" + from .pytz import PYTZ + self.use(PYTZ()) + + def use(self, provider: Provider): + """Use another timezone implementation.""" + self.__provider = provider + + def make_utc(self, value: datetime): + """Convert a datetime object to use UTC. + + If there is no timezone, UTC is assumed. + """ + return self.__provider.make_utc(value) + + +__all__ = ["TZP"] From 678feb988ba3b1147f90b73ac03d5ea8fa3a1503 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:35:06 +0100 Subject: [PATCH 03/70] Refactor timezone caching --- src/icalendar/cal.py | 7 ++----- src/icalendar/timezone/provider.py | 9 --------- src/icalendar/timezone/pytz.py | 8 ++++++-- src/icalendar/timezone/tzp.py | 15 ++++++++++++--- 4 files changed, 20 insertions(+), 19 deletions(-) delete mode 100644 src/icalendar/timezone/provider.py diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index bf806120..cca841ee 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -364,11 +364,8 @@ def from_ical(cls, st, multiple=False): comps.append(component) else: stack[-1].add_component(component) - if vals == 'VTIMEZONE' and \ - 'TZID' in component and \ - component['TZID'] not in pytz.all_timezones and \ - component['TZID'] not in _timezone_cache: - _timezone_cache[component['TZID']] = component.to_tz() + if vals == 'VTIMEZONE' and 'TZID' in component: + tzp.cache_timezone_component(component) # we are adding properties to the current top of the stack else: factory = types_factory.for_property(name) diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py deleted file mode 100644 index 156249cd..00000000 --- a/src/icalendar/timezone/provider.py +++ /dev/null @@ -1,9 +0,0 @@ -"""This is an abstract class that provides timezomes.""" -from abc import ABC, abstractmethod - -class Provider(ABC): - """Provide a timezone implementation.""" - - @abstractmethod - def make_utc(self, datetime): - pass diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index a485d91b..463c107a 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,13 +1,17 @@ """Use pytz timezones.""" import pytz -from .provider import Provider -class PYTZ(Provider): +class PYTZ: """Provide icalendar with timezones from pytz.""" def make_utc(self, value): + """See icalendar.timezone.tzp.make_utc.""" if getattr(value, 'tzinfo', False) and value.tzinfo is not None: return value.astimezone(pytz.utc) else: # assume UTC for naive datetime instances return pytz.utc.localize(value) + + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + return id in pytz.all_timezones diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 09822ca4..4a638793 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -1,7 +1,10 @@ -from .provider import Provider +from __future__ import annotations from datetime import datetime +from .cache import _timezone_cache +from .. import cal -class TZP(Provider): + +class TZP: """This is the timezone provider proxy. If you would like to have another timezone implementation, @@ -18,7 +21,7 @@ def use_pytz(self): from .pytz import PYTZ self.use(PYTZ()) - def use(self, provider: Provider): + def use(self, provider): """Use another timezone implementation.""" self.__provider = provider @@ -29,5 +32,11 @@ def make_utc(self, value: datetime): """ return self.__provider.make_utc(value) + def cache_timezone_component(self, component: cal.VTIMEZONE): + """Cache a timezone component.""" + if not self.__provider.knows_timezone_id(component['TZID']) and \ + component['TZID'] not in _timezone_cache: + _timezone_cache[component['TZID']] = component.to_tz() + __all__ = ["TZP"] From 83e1ec613f765143d70661af992e1a201080bad6 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:41:38 +0100 Subject: [PATCH 04/70] Refactor out a pytz fix for rrule --- src/icalendar/cal.py | 5 +---- src/icalendar/timezone/pytz.py | 9 +++++++++ src/icalendar/timezone/tzp.py | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index cca841ee..637eece9 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -574,10 +574,7 @@ def _extract_offsets(component, tzname): rrulestr = component['RRULE'].to_ical().decode('utf-8') rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) - if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): - # pytz.timezones don't know any transition dates after 2038 - # either - rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + tzp.fix_pytz_rrule_until(rrule, component) # constructing the pytz-timezone requires UTC transition times. # here we construct local times without tzinfo, the offset to UTC diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 463c107a..4783897d 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,5 +1,7 @@ """Use pytz timezones.""" import pytz +from datetime import datetime + class PYTZ: """Provide icalendar with timezones from pytz.""" @@ -15,3 +17,10 @@ def make_utc(self, value): def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" return id in pytz.all_timezones + + def fix_pytz_rrule_until(self, rrule, component): + """Make sure the until value works.""" + if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): + # pytz.timezones don't know any transition dates after 2038 + # either + rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 4a638793..3f9ec048 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -38,5 +38,9 @@ def cache_timezone_component(self, component: cal.VTIMEZONE): component['TZID'] not in _timezone_cache: _timezone_cache[component['TZID']] = component.to_tz() + def fix_pytz_rrule_until(self, rrule, component): + """Make sure the until value works.""" + self.__provider.fix_pytz_rrule_until(rrule, component) + __all__ = ["TZP"] From 768741f4b80e8730cdeeb397f9c7c983d7d8a7bb Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:49:57 +0100 Subject: [PATCH 05/70] Refactor: Move timezone creation out of icalendar.cal --- src/icalendar/cal.py | 10 +--------- src/icalendar/timezone/pytz.py | 11 +++++++++++ src/icalendar/timezone/tzp.py | 7 +++++++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 637eece9..27bae5f1 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -16,9 +16,7 @@ from icalendar.timezone.cache import _timezone_cache from icalendar.timezone import tzp -import pytz import dateutil.rrule, dateutil.tz -from pytz.tzinfo import DstTzInfo @@ -676,13 +674,7 @@ def to_tz(self): assert dst_offset is not False transition_info.append((osto, dst_offset, name)) - cls = type(zone, (DstTzInfo,), { - 'zone': zone, - '_utc_transition_times': transition_times, - '_transition_info': transition_info - }) - - return cls() + return tzp.create_timezone(zone, transition_times, transition_info) class TimezoneStandard(Component): diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 4783897d..1c998f7e 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,6 +1,7 @@ """Use pytz timezones.""" import pytz from datetime import datetime +from pytz.tzinfo import DstTzInfo class PYTZ: @@ -24,3 +25,13 @@ def fix_pytz_rrule_until(self, rrule, component): # pytz.timezones don't know any transition dates after 2038 # either rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + + def create_timezone(self, name: str, transition_times, transition_info): + """Create a pytz timezone file given information.""" + cls = type(name, (DstTzInfo,), { + 'zone': name, + '_utc_transition_times': transition_times, + '_transition_info': transition_info + }) + + return cls() diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 3f9ec048..2328b444 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -42,5 +42,12 @@ def fix_pytz_rrule_until(self, rrule, component): """Make sure the until value works.""" self.__provider.fix_pytz_rrule_until(rrule, component) + def create_timezone(self, name: str, transition_times, transition_info): + """Create a timezone from given information.""" + return self.__provider.create_timezone(name, transition_times, transition_info) + + + + __all__ = ["TZP"] From 077e7af1c8dc47961a500ee847280bfe37cef3c9 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:52:01 +0100 Subject: [PATCH 06/70] remove pytz in comments --- src/icalendar/cal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 27bae5f1..b0c2c9a7 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -574,7 +574,7 @@ def _extract_offsets(component, tzname): rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) tzp.fix_pytz_rrule_until(rrule, component) - # constructing the pytz-timezone requires UTC transition times. + # constructing the timezone requires UTC transition times. # here we construct local times without tzinfo, the offset to UTC # gets subtracted in to_tz(). transtimes = [dt.replace (tzinfo=None) for dt in rrule] @@ -612,7 +612,7 @@ def _make_unique_tzname(tzname, tznames): return tzname def to_tz(self): - """convert this VTIMEZONE component to a pytz.timezone object + """convert this VTIMEZONE component to a timezone object """ try: zone = str(self['TZID']) From 8b4d87147b1e857b4d4efe47e7124bf93c3cf5a9 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 13:57:30 +0100 Subject: [PATCH 07/70] Move timezone id extraction --- src/icalendar/parser.py | 13 ------------- src/icalendar/prop.py | 2 +- src/icalendar/timezone/__init__.py | 18 +++++++++++++++++- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index a8a3091f..ddfcf2d3 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -46,19 +46,6 @@ def unescape_char(text): .replace(b'\\\\', b'\\') -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'): - # 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/prop.py b/src/icalendar/prop.py index e6936c45..b1ce7d7e 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -48,7 +48,7 @@ from icalendar.caselessdict import CaselessDict from icalendar.parser import Parameters from icalendar.parser import escape_char -from icalendar.parser import tzid_from_dt +from icalendar.timezone import tzid_from_dt from icalendar.parser import unescape_char from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.parser_tools import SEQUENCE_TYPES diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index fef99fbf..5c1570c1 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -1,7 +1,23 @@ """This package contains all functionality for timezones.""" from .tzp import TZP +from datetime import datetime +from typing import Optional tzp = TZP() tzp.use_pytz() -__all__ = ["tzp"] +def tzid_from_dt(dt: datetime) -> Optional[str]: + """Retrieve the timezone id from the datetime object.""" + 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'): + # dateutil implementation, but this is broken + # See https://github.com/collective/icalendar/issues/333 for details + tzid = dt.tzinfo.tzname(dt) + return tzid + + +__all__ = ["tzp", "tzid_from_dt"] From 8390c49db430f2c8b6fd7e5ce00e0d8f99a63b63 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 14:38:00 +0100 Subject: [PATCH 08/70] Refactor: remove pytz from prop.py --- src/icalendar/cal.py | 2 +- src/icalendar/prop.py | 22 ++++------- src/icalendar/tests/test_unit_prop.py | 4 +- src/icalendar/timezone/pytz.py | 27 +++++++++++--- src/icalendar/timezone/tzp.py | 37 +++++++++++++------ .../{ => timezone}/windows_to_olson.py | 0 6 files changed, 56 insertions(+), 36 deletions(-) rename src/icalendar/{ => timezone}/windows_to_olson.py (100%) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index b0c2c9a7..16a743ca 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -177,7 +177,7 @@ def add(self, name, value, parameters=None, encode=1): if isinstance(value, datetime) and\ name.lower() in ('dtstamp', 'created', 'last-modified'): # RFC expects UTC for those... force value conversion. - value = tzp.make_utc(value) + value = tzp.localize_utc(value) # encode value if encode and isinstance(value, list) \ diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index b1ce7d7e..9d4bc849 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -55,11 +55,10 @@ from icalendar.parser_tools import to_unicode from icalendar.parser_tools import from_unicode from icalendar.timezone.cache import _timezone_cache -from icalendar.windows_to_olson import WINDOWS_TO_OLSON import base64 import binascii -import pytz +from .timezone import tzp import re import time as _time @@ -400,9 +399,9 @@ def from_ical(ical): class vDatetime(TimeBase): """Render and generates icalendar datetime format. - vDatetime is timezone aware and uses the pytz library, an implementation of - the Olson database in Python. When a vDatetime object is created from an - ical string, you can pass a valid pytz timezone identifier. When a + vDatetime is timezone aware and uses a timezone library. + When a vDatetime object is created from an + ical string, you can pass a valid timezone identifier. When a vDatetime object is created from a python datetime object, it uses the tzinfo component, if present. Otherwise an timezone-naive object is created. Be aware that there are certain limitations with timezone naive @@ -428,14 +427,7 @@ def to_ical(self): def from_ical(ical, timezone=None): tzinfo = None if timezone: - try: - tzinfo = pytz.timezone(timezone.strip('/')) - except pytz.UnknownTimeZoneError: - if timezone in WINDOWS_TO_OLSON: - tzinfo = pytz.timezone( - WINDOWS_TO_OLSON.get(timezone.strip('/'))) - else: - tzinfo = _timezone_cache.get(timezone, None) + tzinfo = tzp.timezone(timezone) try: timetuple = ( @@ -447,11 +439,11 @@ def from_ical(ical, timezone=None): int(ical[13:15]), # second ) if tzinfo: - return tzinfo.localize(datetime(*timetuple)) + return tzp.localize(datetime(*timetuple), tzinfo) elif not ical[15:]: return datetime(*timetuple) elif ical[15:16] == 'Z': - return pytz.utc.localize(datetime(*timetuple)) + return tzp.localize_utc(datetime(*timetuple)) else: raise ValueError(ical) except Exception: diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index 7197392f..8fa6b064 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -5,7 +5,7 @@ from icalendar.parser import Parameters import unittest from icalendar.prop import vDatetime, vDDDTypes -from icalendar.windows_to_olson import WINDOWS_TO_OLSON +from icalendar.timezone.windows_to_olson import WINDOWS_TO_OLSON import pytest import pytz from copy import deepcopy @@ -323,7 +323,7 @@ def test_prop_vRecur(self): ) r = vRecur.from_ical('FREQ=WEEKLY;INTERVAL=1;BYWEEKDAY=TH') - + self.assertEqual( r, {'FREQ': ['WEEKLY'], 'INTERVAL': [1], 'BYWEEKDAY': ['TH']} diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 1c998f7e..4826b3f2 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,19 +1,24 @@ """Use pytz timezones.""" import pytz -from datetime import datetime +from datetime import datetime, tzinfo from pytz.tzinfo import DstTzInfo +from typing import Optional class PYTZ: """Provide icalendar with timezones from pytz.""" - def make_utc(self, value): - """See icalendar.timezone.tzp.make_utc.""" - if getattr(value, 'tzinfo', False) and value.tzinfo is not None: - return value.astimezone(pytz.utc) + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: + return dt.astimezone(pytz.utc) else: # assume UTC for naive datetime instances - return pytz.utc.localize(value) + return pytz.utc.localize(dt) + + def localize(self, dt: datetime, tz: tzinfo) -> datetime: + """Localize a datetime to a timezone.""" + return tz.localize(dt) def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" @@ -35,3 +40,13 @@ def create_timezone(self, name: str, transition_times, transition_info): }) return cls() + + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" + try: + return pytz.timezone(name) + except pytz.UnknownTimeZoneError: + pass + + +__all__ = ["PYTZ"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 2328b444..7087bd93 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -1,7 +1,9 @@ from __future__ import annotations -from datetime import datetime +import datetime from .cache import _timezone_cache from .. import cal +from typing import Optional +from .windows_to_olson import WINDOWS_TO_OLSON class TZP: @@ -16,38 +18,49 @@ def __init__(self): """Create a new timezone implementation proxy.""" self.use_pytz() - def use_pytz(self): + def use_pytz(self) -> None: """Use pytz as the timezone provider.""" from .pytz import PYTZ self.use(PYTZ()) - def use(self, provider): + def use(self, provider) -> None: """Use another timezone implementation.""" self.__provider = provider - def make_utc(self, value: datetime): - """Convert a datetime object to use UTC. + def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: + """Return the datetime in UTC. - If there is no timezone, UTC is assumed. + If the datetime has no timezone, UTC is set. """ - return self.__provider.make_utc(value) + return self.__provider.localize_utc(dt) - def cache_timezone_component(self, component: cal.VTIMEZONE): + def localize(self, dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime: + """Localize a datetime to a timezone.""" + return self.__provider.localize(dt, tz) + + def cache_timezone_component(self, component: cal.VTIMEZONE) -> None: """Cache a timezone component.""" if not self.__provider.knows_timezone_id(component['TZID']) and \ component['TZID'] not in _timezone_cache: _timezone_cache[component['TZID']] = component.to_tz() - def fix_pytz_rrule_until(self, rrule, component): + def fix_pytz_rrule_until(self, rrule, component) -> None: """Make sure the until value works.""" self.__provider.fix_pytz_rrule_until(rrule, component) - def create_timezone(self, name: str, transition_times, transition_info): + def create_timezone(self, name: str, transition_times, transition_info) -> datetime.tzinfo: """Create a timezone from given information.""" return self.__provider.create_timezone(name, transition_times, transition_info) - - + def timezone(self, id: str) -> datetime.tzinfo: + """Return a timezone with an id or None if we cannot find it.""" + clean_id = id.strip("/") + tz = self.__provider.timezone(clean_id) + if tz is not None: + return tz + if clean_id in WINDOWS_TO_OLSON: + tz = self.__provider.timezone(WINDOWS_TO_OLSON[clean_id]) + return tz or self.__provider.timezone(id) or _timezone_cache.get(id) __all__ = ["TZP"] diff --git a/src/icalendar/windows_to_olson.py b/src/icalendar/timezone/windows_to_olson.py similarity index 100% rename from src/icalendar/windows_to_olson.py rename to src/icalendar/timezone/windows_to_olson.py From f4461697765dd8f11477584145b249f52bbf32c7 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 16:55:34 +0100 Subject: [PATCH 09/70] remove pyts from test --- src/icalendar/tests/test_issue_116.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/icalendar/tests/test_issue_116.py b/src/icalendar/tests/test_issue_116.py index 2df8451a..c795f00e 100644 --- a/src/icalendar/tests/test_issue_116.py +++ b/src/icalendar/tests/test_issue_116.py @@ -3,7 +3,6 @@ import datetime import icalendar import os -import pytz import pytest from dateutil import tz From 71acafea05bd4292813ff50fac5729c1346a449f Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 17:02:52 +0100 Subject: [PATCH 10/70] refactor src/icalendar/tests/test_unit_cal.py --- src/icalendar/tests/conftest.py | 11 ++++++++++- src/icalendar/tests/test_unit_cal.py | 7 +++---- src/icalendar/timezone/tzp.py | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index ee864797..345af727 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -9,6 +9,7 @@ except ModuleNotFoundError: from backports import zoneinfo from icalendar.cal import Component, Calendar, Event, ComponentFactory +from icalendar.timezone.tzp import TZP class DataSource: @@ -116,7 +117,7 @@ def x_sometime(): yield icalendar.cal.types_factory.types_map.pop('X-SOMETIME') - + @pytest.fixture() def factory(): """Return a new component factory.""" @@ -169,3 +170,11 @@ def calendar_with_resources(): c = Calendar() c['resources'] = 'Chair, Table, "Room: 42"' return c + + +@pytest.fixture() +def tzp(): + """The time zone provider.""" + tzp = TZP() + tzp.use_pytz() + return tzp diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index 1a05244d..8b9acee1 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -6,7 +6,6 @@ import pytest import icalendar -import pytz import re from icalendar.cal import Component, Calendar, Event, ComponentFactory from icalendar import prop, cal @@ -199,15 +198,15 @@ def test_inline_free_busy_inline(c): assert isinstance(freebusy[0][1], timedelta) -def test_cal_Component_add(comp): +def test_cal_Component_add(comp, tzp): """Test the for timezone correctness: dtstart should preserve it's timezone, created, dtstamp and last-modified must be in UTC. """ - vienna = pytz.timezone("Europe/Vienna") + vienna = tzp.timezone("Europe/Vienna") comp.add('dtstart', vienna.localize(datetime(2010, 10, 10, 10, 0, 0))) comp.add('created', datetime(2010, 10, 10, 12, 0, 0)) comp.add('dtstamp', vienna.localize(datetime(2010, 10, 10, 14, 0, 0))) - comp.add('last-modified', pytz.utc.localize( + comp.add('last-modified', tzp.localize_utc( datetime(2010, 10, 10, 16, 0, 0))) lines = comp.to_ical().splitlines() diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 7087bd93..ab74044c 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -52,7 +52,7 @@ def create_timezone(self, name: str, transition_times, transition_info) -> datet """Create a timezone from given information.""" return self.__provider.create_timezone(name, transition_times, transition_info) - def timezone(self, id: str) -> datetime.tzinfo: + def timezone(self, id: str) -> Optional[datetime.tzinfo]: """Return a timezone with an id or None if we cannot find it.""" clean_id = id.strip("/") tz = self.__provider.timezone(clean_id) From caf2c7f397019200021041b4118f9d07b923053d Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 17:06:41 +0100 Subject: [PATCH 11/70] refactor src/icalendar/tests/test_period.py --- src/icalendar/tests/test_period.py | 7 +++---- src/icalendar/timezone/tzp.py | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/icalendar/tests/test_period.py b/src/icalendar/tests/test_period.py index 03df873f..ccb334ad 100644 --- a/src/icalendar/tests/test_period.py +++ b/src/icalendar/tests/test_period.py @@ -5,7 +5,6 @@ - https://github.com/pimutils/khal/issues/152#issuecomment-933635248 """ import pytest -import pytz from icalendar.prop import vDDDTypes import datetime @@ -55,9 +54,9 @@ def test_tzid_is_part_of_the_parameters(calendars): assert event["RDATE"].params["TZID"] == "America/Vancouver" -def test_tzid_is_part_of_the_period_values(calendars): +def test_tzid_is_part_of_the_period_values(calendars, tzp): """The TZID should be set in the datetime.""" event = list(calendars.period_with_timezone.walk("VEVENT"))[0] start, end = event["RDATE"].dts[0].dt - assert start == pytz.timezone("America/Vancouver").localize(datetime.datetime(2023, 12, 13, 12)) - assert end == pytz.timezone("America/Vancouver").localize(datetime.datetime(2023, 12, 13, 15)) + assert start == tzp.localize(datetime.datetime(2023, 12, 13, 12), "America/Vancouver") + assert end == tzp.localize(datetime.datetime(2023, 12, 13, 15), "America/Vancouver") diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index ab74044c..e18abf4e 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -2,7 +2,7 @@ import datetime from .cache import _timezone_cache from .. import cal -from typing import Optional +from typing import Optional, Union from .windows_to_olson import WINDOWS_TO_OLSON @@ -34,8 +34,10 @@ def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: """ return self.__provider.localize_utc(dt) - def localize(self, dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetime: + def localize(self, dt: datetime.datetime, tz: Union[datetime.tzinfo, str]) -> datetime.datetime: """Localize a datetime to a timezone.""" + if isinstance(tz, str): + tz = self.timezone(tz) return self.__provider.localize(dt, tz) def cache_timezone_component(self, component: cal.VTIMEZONE) -> None: From 5ed7fca53eeb74988cc786879fe4c6e35e3e185a Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 17:52:15 +0100 Subject: [PATCH 12/70] Run first test with timezone awareness --- src/icalendar/cal.py | 1 - src/icalendar/prop.py | 1 - src/icalendar/tests/conftest.py | 17 +- src/icalendar/tests/test_timezoned.py | 904 ++++++++++++-------------- src/icalendar/timezone/__init__.py | 3 +- src/icalendar/timezone/cache.py | 2 - src/icalendar/timezone/tzp.py | 21 +- src/icalendar/timezone/zoneinfo.py | 32 + tox.ini | 1 + 9 files changed, 485 insertions(+), 497 deletions(-) delete mode 100644 src/icalendar/timezone/cache.py create mode 100644 src/icalendar/timezone/zoneinfo.py diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 16a743ca..a976981d 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -13,7 +13,6 @@ from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.prop import TypesFactory from icalendar.prop import vText, vDDDLists -from icalendar.timezone.cache import _timezone_cache from icalendar.timezone import tzp import dateutil.rrule, dateutil.tz diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 9d4bc849..8e35d5f7 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -54,7 +54,6 @@ from icalendar.parser_tools import SEQUENCE_TYPES from icalendar.parser_tools import to_unicode from icalendar.parser_tools import from_unicode -from icalendar.timezone.cache import _timezone_cache import base64 import binascii diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 345af727..c9e41018 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -9,7 +9,8 @@ except ModuleNotFoundError: from backports import zoneinfo from icalendar.cal import Component, Calendar, Event, ComponentFactory -from icalendar.timezone.tzp import TZP +from icalendar.timezone import tzp as _tzp +from icalendar.timezone import TZP class DataSource: @@ -175,6 +176,16 @@ def calendar_with_resources(): @pytest.fixture() def tzp(): """The time zone provider.""" - tzp = TZP() - tzp.use_pytz() + _tzp.use_pytz() # todo: parametrize + return _tzp + + +@pytest.fixture(params=["pytz", "zoneinfo"]) +def other_tzp(request, tzp): + """This is annother timezone provider. + + The purpose here is to cross test: pytz <-> zoneinfo. + tzp as parameter makes sure we test the cross product. + """ + tzp = TZP(request.param) return tzp diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index d5b33ac6..18e913a7 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -4,484 +4,426 @@ import dateutil.parser import icalendar import os -import pytz -try: - import zoneinfo -except: - from backports import zoneinfo - -HERE = os.path.dirname(__file__) -CALENDARS_DIRECTORY = os.path.join(HERE, 'calendars') - -class TestTimezoned(unittest.TestCase): - - def test_create_from_ical_zoneinfo(self): - with open(os.path.join(CALENDARS_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): - with open(os.path.join(CALENDARS_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'), - pytz.timezone('Europe/Vienna').localize( - datetime.datetime(2012, 2, 13, 10, 0, 0) - ) - ) - self.assertEqual( - ev1.decoded('DTSTAMP'), - pytz.utc.localize( - datetime.datetime(2010, 10, 10, 9, 10, 10) - ) - ) - - def test_create_to_ical_pytz(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 = pytz.timezone("Europe/Vienna") - event.add( - 'dtstart', - tz.localize(datetime.datetime(2012, 2, 13, 10, 00, 00))) - event.add( - 'dtend', - tz.localize(datetime.datetime(2012, 2, 17, 18, 00, 00))) - event.add( - 'dtstamp', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - event.add( - 'created', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - event.add('uid', '123456') - event.add( - 'last-modified', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - 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', 'https://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: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: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: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:20101010T081010Z" in test_out) - self.assertTrue("CREATED: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: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: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: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:20101010T081010Z" in test_out) - self.assertTrue("CREATED:20101010T081010Z" in test_out) - - - def test_tzinfo_dateutil(self): - # Test for issues #77, #63 - # references: #73,7430b66862346fe3a6a100ab25e35a8711446717 - date = dateutil.parser.parse('2012-08-30T22:41:00Z') - date2 = dateutil.parser.parse('2012-08-30T22:41:00 +02:00') - self.assertTrue(date.tzinfo.__module__.startswith('dateutil.tz')) - self.assertTrue(date2.tzinfo.__module__.startswith('dateutil.tz')) - - # make sure, it's parsed properly and doesn't throw an error - self.assertTrue(icalendar.vDDDTypes(date).to_ical() - == b'20120830T224100Z') - self.assertTrue(icalendar.vDDDTypes(date2).to_ical() - == b'20120830T224100') - - -class TestTimezoneCreation(unittest.TestCase): - - def test_create_america_new_york(self): - """testing America/New_York, the most complex example from the - RFC""" - with open(os.path.join(CALENDARS_DIRECTORY, 'america_new_york.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_America/New_York') - pytz_new_york = pytz.timezone('America/New_York') - # for reasons (tm) the locally installed version of the time zone - # database isn't always complete, therefore we only compare some - # transition times - ny_transition_times = [] - ny_transition_info = [] - for num, date in enumerate(pytz_new_york._utc_transition_times): - if datetime.datetime(1967, 4, 30, 7, 0)\ - <= date <= datetime.datetime(2037, 11, 1, 6, 0): - ny_transition_times.append(date) - ny_transition_info.append(pytz_new_york._transition_info[num]) - self.assertEqual(tz._utc_transition_times[:142], ny_transition_times) - self.assertEqual(tz._transition_info[0:142], ny_transition_info) - self.assertIn( - ( - datetime.timedelta(-1, 72000), - datetime.timedelta(0, 3600), 'EDT' - ), - tz._tzinfos.keys() - ) - self.assertIn( - (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST'), - tz._tzinfos.keys() - ) - - def test_create_pacific_fiji(self): - """testing Pacific/Fiji, another pretty complex example with more than - one RDATE property per subcomponent""" - self.maxDiff = None - - with open(os.path.join(CALENDARS_DIRECTORY, 'pacific_fiji.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_Pacific/Fiji') - self.assertEqual(tz._utc_transition_times, - [datetime.datetime(1915, 10, 25, 12, 4), - datetime.datetime(1998, 10, 31, 14, 0), - datetime.datetime(1999, 2, 27, 14, 0), - datetime.datetime(1999, 11, 6, 14, 0), - datetime.datetime(2000, 2, 26, 14, 0), - datetime.datetime(2009, 11, 28, 14, 0), - datetime.datetime(2010, 3, 27, 14, 0), - datetime.datetime(2010, 10, 23, 14, 0), - datetime.datetime(2011, 3, 5, 14, 0), - datetime.datetime(2011, 10, 22, 14, 0), - datetime.datetime(2012, 1, 21, 14, 0), - datetime.datetime(2012, 10, 20, 14, 0), - datetime.datetime(2013, 1, 19, 14, 0), - datetime.datetime(2013, 10, 26, 14, 0), - datetime.datetime(2014, 1, 18, 13, 0), - datetime.datetime(2014, 10, 25, 14, 0), - datetime.datetime(2015, 1, 17, 13, 0), - datetime.datetime(2015, 10, 24, 14, 0), - datetime.datetime(2016, 1, 23, 13, 0), - datetime.datetime(2016, 10, 22, 14, 0), - datetime.datetime(2017, 1, 21, 13, 0), - datetime.datetime(2017, 10, 21, 14, 0), - datetime.datetime(2018, 1, 20, 13, 0), - datetime.datetime(2018, 10, 20, 14, 0), - datetime.datetime(2019, 1, 19, 13, 0), - datetime.datetime(2019, 10, 26, 14, 0), - datetime.datetime(2020, 1, 18, 13, 0), - datetime.datetime(2020, 10, 24, 14, 0), - datetime.datetime(2021, 1, 23, 13, 0), - datetime.datetime(2021, 10, 23, 14, 0), - datetime.datetime(2022, 1, 22, 13, 0), - datetime.datetime(2022, 10, 22, 14, 0), - datetime.datetime(2023, 1, 21, 13, 0), - datetime.datetime(2023, 10, 21, 14, 0), - datetime.datetime(2024, 1, 20, 13, 0), - datetime.datetime(2024, 10, 26, 14, 0), - datetime.datetime(2025, 1, 18, 13, 0), - datetime.datetime(2025, 10, 25, 14, 0), - datetime.datetime(2026, 1, 17, 13, 0), - datetime.datetime(2026, 10, 24, 14, 0), - datetime.datetime(2027, 1, 23, 13, 0), - datetime.datetime(2027, 10, 23, 14, 0), - datetime.datetime(2028, 1, 22, 13, 0), - datetime.datetime(2028, 10, 21, 14, 0), - datetime.datetime(2029, 1, 20, 13, 0), - datetime.datetime(2029, 10, 20, 14, 0), - datetime.datetime(2030, 1, 19, 13, 0), - datetime.datetime(2030, 10, 26, 14, 0), - datetime.datetime(2031, 1, 18, 13, 0), - datetime.datetime(2031, 10, 25, 14, 0), - datetime.datetime(2032, 1, 17, 13, 0), - datetime.datetime(2032, 10, 23, 14, 0), - datetime.datetime(2033, 1, 22, 13, 0), - datetime.datetime(2033, 10, 22, 14, 0), - datetime.datetime(2034, 1, 21, 13, 0), - datetime.datetime(2034, 10, 21, 14, 0), - datetime.datetime(2035, 1, 20, 13, 0), - datetime.datetime(2035, 10, 20, 14, 0), - datetime.datetime(2036, 1, 19, 13, 0), - datetime.datetime(2036, 10, 25, 14, 0), - datetime.datetime(2037, 1, 17, 13, 0), - datetime.datetime(2037, 10, 24, 14, 0), - datetime.datetime(2038, 1, 23, 13, 0), - datetime.datetime(2038, 10, 23, 14, 0)] - - ) - self.assertEqual( - tz._transition_info, - [( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' - )] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') - ] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - )] + - 25 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' - )] + - [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - )] - ) - - self.assertIn( - ( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), - tz._tzinfos.keys() - ) - self.assertIn( - ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - ), - tz._tzinfos.keys() - ) - - def test_same_start_date(self): - """testing if we can handle VTIMEZONEs whose different components - have the same start DTIMEs.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') - - def test_same_start_date_and_offset(self): - """testing if we can handle VTIMEZONEs whose different components - have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start_and_offset.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') - - def test_rdate(self): - """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE - """ - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_rdate.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - vevent = cal.walk('VEVENT')[0] - tz = vevent['DTSTART'].dt.tzinfo - self.assertEqual(str(tz), 'posix/Europe/Vaduz') - self.assertEqual( - tz._utc_transition_times[:6], - [ - datetime.datetime(1901, 12, 13, 20, 45, 38), - datetime.datetime(1941, 5, 5, 0, 0, 0), - datetime.datetime(1941, 10, 6, 0, 0, 0), - datetime.datetime(1942, 5, 4, 0, 0, 0), - datetime.datetime(1942, 10, 5, 0, 0, 0), - datetime.datetime(1981, 3, 29, 1, 0), - ]) - self.assertEqual( - tz._transition_info[:6], - [ - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - ] - ) + + +def test_create_from_ical(calendars, other_tzp): + """Create a calendar from a .ics file.""" + cal = calendars.timezoned + + assert cal['prodid'].to_ical() == b"-//Plone.org//NONSGML plone.app.event//EN" + + timezones = cal.walk('VTIMEZONE') + assert len(timezones) == 1 + + tz = timezones[0] + assert tz['tzid'].to_ical() == b"Europe/Vienna" + + std = tz.walk('STANDARD')[0] + assert std.decoded('TZOFFSETFROM') == datetime.timedelta(0, 7200) + + ev1 = cal.walk('VEVENT')[0] + assert ev1.decoded('DTSTART') == other_tzp.localize(datetime.datetime(2012, 2, 13, 10, 0, 0), 'Europe/Vienna') + assert ev1.decoded('DTSTAMP') == other_tzp.localize(datetime.datetime(2010, 10, 10, 9, 10, 10), 'UTC') + + +def test_create_to_ical_tz(self, tzp): + 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 = tzp.timezone("Europe/Vienna") + event.add( + 'dtstart', + tz.localize(datetime.datetime(2012, 2, 13, 10, 00, 00))) + event.add( + 'dtend', + tz.localize(datetime.datetime(2012, 2, 17, 18, 00, 00))) + event.add( + 'dtstamp', + tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + event.add( + 'created', + tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + event.add('uid', '123456') + event.add( + 'last-modified', + tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + 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', 'https://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: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: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: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:20101010T081010Z" in test_out) + self.assertTrue("CREATED: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: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: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: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:20101010T081010Z" in test_out) + self.assertTrue("CREATED:20101010T081010Z" in test_out) + + +def test_tzinfo_dateutil(self): + # Test for issues #77, #63 + # references: #73,7430b66862346fe3a6a100ab25e35a8711446717 + date = dateutil.parser.parse('2012-08-30T22:41:00Z') + date2 = dateutil.parser.parse('2012-08-30T22:41:00 +02:00') + self.assertTrue(date.tzinfo.__module__.startswith('dateutil.tz')) + self.assertTrue(date2.tzinfo.__module__.startswith('dateutil.tz')) + + # make sure, it's parsed properly and doesn't throw an error + self.assertTrue(icalendar.vDDDTypes(date).to_ical() + == b'20120830T224100Z') + self.assertTrue(icalendar.vDDDTypes(date2).to_ical() + == b'20120830T224100') + + +def test_create_america_new_york(self, tzp): + """testing America/New_York, the most complex example from the + RFC""" + with open(os.path.join(CALENDARS_DIRECTORY, 'america_new_york.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + + tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo + self.assertEqual(str(tz), 'custom_America/New_York') + tz_new_york = tzp.timezone('America/New_York') + # for reasons (tm) the locally installed version of the time zone + # database isn't always complete, therefore we only compare some + # transition times + ny_transition_times = [] + ny_transition_info = [] + for num, date in enumerate(tz_new_york._utc_transition_times): + if datetime.datetime(1967, 4, 30, 7, 0)\ + <= date <= datetime.datetime(2037, 11, 1, 6, 0): + ny_transition_times.append(date) + ny_transition_info.append(tz_new_york._transition_info[num]) + self.assertEqual(tz._utc_transition_times[:142], ny_transition_times) + self.assertEqual(tz._transition_info[0:142], ny_transition_info) + self.assertIn( + ( + datetime.timedelta(-1, 72000), + datetime.timedelta(0, 3600), 'EDT' + ), + tz._tzinfos.keys() + ) + self.assertIn( + (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST'), + tz._tzinfos.keys() + ) + + +def test_create_pacific_fiji(self): + """testing Pacific/Fiji, another pretty complex example with more than + one RDATE property per subcomponent""" + self.maxDiff = None + + with open(os.path.join(CALENDARS_DIRECTORY, 'pacific_fiji.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + + tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo + self.assertEqual(str(tz), 'custom_Pacific/Fiji') + self.assertEqual(tz._utc_transition_times, + [datetime.datetime(1915, 10, 25, 12, 4), + datetime.datetime(1998, 10, 31, 14, 0), + datetime.datetime(1999, 2, 27, 14, 0), + datetime.datetime(1999, 11, 6, 14, 0), + datetime.datetime(2000, 2, 26, 14, 0), + datetime.datetime(2009, 11, 28, 14, 0), + datetime.datetime(2010, 3, 27, 14, 0), + datetime.datetime(2010, 10, 23, 14, 0), + datetime.datetime(2011, 3, 5, 14, 0), + datetime.datetime(2011, 10, 22, 14, 0), + datetime.datetime(2012, 1, 21, 14, 0), + datetime.datetime(2012, 10, 20, 14, 0), + datetime.datetime(2013, 1, 19, 14, 0), + datetime.datetime(2013, 10, 26, 14, 0), + datetime.datetime(2014, 1, 18, 13, 0), + datetime.datetime(2014, 10, 25, 14, 0), + datetime.datetime(2015, 1, 17, 13, 0), + datetime.datetime(2015, 10, 24, 14, 0), + datetime.datetime(2016, 1, 23, 13, 0), + datetime.datetime(2016, 10, 22, 14, 0), + datetime.datetime(2017, 1, 21, 13, 0), + datetime.datetime(2017, 10, 21, 14, 0), + datetime.datetime(2018, 1, 20, 13, 0), + datetime.datetime(2018, 10, 20, 14, 0), + datetime.datetime(2019, 1, 19, 13, 0), + datetime.datetime(2019, 10, 26, 14, 0), + datetime.datetime(2020, 1, 18, 13, 0), + datetime.datetime(2020, 10, 24, 14, 0), + datetime.datetime(2021, 1, 23, 13, 0), + datetime.datetime(2021, 10, 23, 14, 0), + datetime.datetime(2022, 1, 22, 13, 0), + datetime.datetime(2022, 10, 22, 14, 0), + datetime.datetime(2023, 1, 21, 13, 0), + datetime.datetime(2023, 10, 21, 14, 0), + datetime.datetime(2024, 1, 20, 13, 0), + datetime.datetime(2024, 10, 26, 14, 0), + datetime.datetime(2025, 1, 18, 13, 0), + datetime.datetime(2025, 10, 25, 14, 0), + datetime.datetime(2026, 1, 17, 13, 0), + datetime.datetime(2026, 10, 24, 14, 0), + datetime.datetime(2027, 1, 23, 13, 0), + datetime.datetime(2027, 10, 23, 14, 0), + datetime.datetime(2028, 1, 22, 13, 0), + datetime.datetime(2028, 10, 21, 14, 0), + datetime.datetime(2029, 1, 20, 13, 0), + datetime.datetime(2029, 10, 20, 14, 0), + datetime.datetime(2030, 1, 19, 13, 0), + datetime.datetime(2030, 10, 26, 14, 0), + datetime.datetime(2031, 1, 18, 13, 0), + datetime.datetime(2031, 10, 25, 14, 0), + datetime.datetime(2032, 1, 17, 13, 0), + datetime.datetime(2032, 10, 23, 14, 0), + datetime.datetime(2033, 1, 22, 13, 0), + datetime.datetime(2033, 10, 22, 14, 0), + datetime.datetime(2034, 1, 21, 13, 0), + datetime.datetime(2034, 10, 21, 14, 0), + datetime.datetime(2035, 1, 20, 13, 0), + datetime.datetime(2035, 10, 20, 14, 0), + datetime.datetime(2036, 1, 19, 13, 0), + datetime.datetime(2036, 10, 25, 14, 0), + datetime.datetime(2037, 1, 17, 13, 0), + datetime.datetime(2037, 10, 24, 14, 0), + datetime.datetime(2038, 1, 23, 13, 0), + datetime.datetime(2038, 10, 23, 14, 0)] + + ) + self.assertEqual( + tz._transition_info, + [( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' + )] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') + ] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' + )] + + 25 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' + )] + + [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + )] + ) + + self.assertIn( + ( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' + ), + tz._tzinfos.keys() + ) + self.assertIn( + ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' + ), + tz._tzinfos.keys() + ) + +def test_same_start_date(self): + """testing if we can handle VTIMEZONEs whose different components + have the same start DTIMEs.""" + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + d = cal.subcomponents[1]['DTSTART'].dt + self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') + +def test_same_start_date_and_offset(self): + """testing if we can handle VTIMEZONEs whose different components + have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start_and_offset.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + d = cal.subcomponents[1]['DTSTART'].dt + self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') + +def test_rdate(self): + """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE + """ + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_rdate.ics'), 'rb') as fp: + data = fp.read() + cal = icalendar.Calendar.from_ical(data) + vevent = cal.walk('VEVENT')[0] + tz = vevent['DTSTART'].dt.tzinfo + self.assertEqual(str(tz), 'posix/Europe/Vaduz') + self.assertEqual( + tz._utc_transition_times[:6], + [ + datetime.datetime(1901, 12, 13, 20, 45, 38), + datetime.datetime(1941, 5, 5, 0, 0, 0), + datetime.datetime(1941, 10, 6, 0, 0, 0), + datetime.datetime(1942, 5, 4, 0, 0, 0), + datetime.datetime(1942, 10, 5, 0, 0, 0), + datetime.datetime(1981, 3, 29, 1, 0), + ]) + self.assertEqual( + tz._transition_info[:6], + [ + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + ] + ) diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index 5c1570c1..7b70933d 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -3,8 +3,7 @@ from datetime import datetime from typing import Optional -tzp = TZP() -tzp.use_pytz() +tzp = TZP("pytz") def tzid_from_dt(dt: datetime) -> Optional[str]: """Retrieve the timezone id from the datetime object.""" diff --git a/src/icalendar/timezone/cache.py b/src/icalendar/timezone/cache.py deleted file mode 100644 index 35e565bf..00000000 --- a/src/icalendar/timezone/cache.py +++ /dev/null @@ -1,2 +0,0 @@ -# we save all timezone with TZIDs unknown to the TZDB in here -_timezone_cache = {} diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index e18abf4e..4a392eaf 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -1,6 +1,5 @@ from __future__ import annotations import datetime -from .cache import _timezone_cache from .. import cal from typing import Optional, Union from .windows_to_olson import WINDOWS_TO_OLSON @@ -14,17 +13,26 @@ class TZP: All of icalendar will then use this timezone implementation. """ - def __init__(self): + def __init__(self, provider_name:str): """Create a new timezone implementation proxy.""" - self.use_pytz() + provider = getattr(self, f"use_{provider_name}", None) + if provider is None: + raise ValueError(f"Unknown provider {provider_name}. Use 'pytz' or 'zoneinfo'.") + provider() def use_pytz(self) -> None: """Use pytz as the timezone provider.""" from .pytz import PYTZ self.use(PYTZ()) + def use_zoneinfo(self) -> None: + """Use zoneinfo as timezone provider.""" + from .zoneinfo import ZONEINFO + self.use(ZONEINFO()) + def use(self, provider) -> None: """Use another timezone implementation.""" + self.__tz_cache = {} self.__provider = provider def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: @@ -42,9 +50,8 @@ def localize(self, dt: datetime.datetime, tz: Union[datetime.tzinfo, str]) -> da def cache_timezone_component(self, component: cal.VTIMEZONE) -> None: """Cache a timezone component.""" - if not self.__provider.knows_timezone_id(component['TZID']) and \ - component['TZID'] not in _timezone_cache: - _timezone_cache[component['TZID']] = component.to_tz() + if not self.__provider.knows_timezone_id(component['TZID']): + self.__tz_cache.setdefault(component['TZID'], component.to_tz()) def fix_pytz_rrule_until(self, rrule, component) -> None: """Make sure the until value works.""" @@ -62,7 +69,7 @@ def timezone(self, id: str) -> Optional[datetime.tzinfo]: return tz if clean_id in WINDOWS_TO_OLSON: tz = self.__provider.timezone(WINDOWS_TO_OLSON[clean_id]) - return tz or self.__provider.timezone(id) or _timezone_cache.get(id) + return tz or self.__provider.timezone(id) or self.__tz_cache.get(id) __all__ = ["TZP"] diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py new file mode 100644 index 00000000..b683e2b7 --- /dev/null +++ b/src/icalendar/timezone/zoneinfo.py @@ -0,0 +1,32 @@ +"""Use zoneinfo timezones""" +try: + import zoneinfo +except: + from backports import zoneinfo +from datetime import datetime, tzinfo +from typing import Optional + + +class ZONEINFO: + """Provide icalendar with timezones from zoneinfo.""" + + utc = zoneinfo.ZoneInfo("UTC") + + def localize(self, dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime: + """Localize a datetime to a timezone.""" + return dt.replace(tzinfo=tz) + + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + return self.localize(dt, self.utc) + + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" + try: + return zoneinfo.ZoneInfo(name) + except ValueError: + pass + + + +__all__ = ["ZONEINFO"] diff --git a/tox.ini b/tox.ini index 54e7050e..14bb24ac 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = pytest coverage hypothesis + backports.zoneinfo ; python_version<'3.9' commands = coverage run --source=src/icalendar --omit=*/tests/hypothesis/* --omit=*/tests/fuzzed/* --module pytest [] coverage report From c7629e1fa87829661448b9eef526c970b180326c Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 21:37:28 +0100 Subject: [PATCH 13/70] timezoned: +1 test --- src/icalendar/tests/test_timezoned.py | 137 +++++--------------------- src/icalendar/timezone/pytz.py | 1 + src/icalendar/timezone/tzp.py | 2 +- 3 files changed, 25 insertions(+), 115 deletions(-) diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 18e913a7..e38337cf 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -26,7 +26,7 @@ def test_create_from_ical(calendars, other_tzp): assert ev1.decoded('DTSTAMP') == other_tzp.localize(datetime.datetime(2010, 10, 10, 9, 10, 10), 'UTC') -def test_create_to_ical_tz(self, tzp): +def test_create_to_ical(tzp): cal = icalendar.Calendar() cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") @@ -99,124 +99,39 @@ def test_create_to_ical_tz(self, tzp): "00|END:STANDARD|BEGIN:DAYLIGHT|DTSTART:19700329T" "020000|TZNAME:CEST|TZOFFSETFROM:+0100|TZOFFSETTO:+0200|END:DAYLI" "GHT|END:VTIMEZONE" - self.assertTrue(vtimezone_lines in test_out) + assert vtimezone_lines in test_out test_str = "DTSTART;TZID=Europe/Vienna:20120213T100000" - self.assertTrue(test_str in test_out) - self.assertTrue("ATTENDEE:sepp" in test_out) + assert (test_str in test_out) + assert ("ATTENDEE:sepp" in test_out) # ical standard expects DTSTAMP and CREATED in UTC - self.assertTrue("DTSTAMP:20101010T081010Z" in test_out) - self.assertTrue("CREATED:20101010T081010Z" in test_out) + assert ("DTSTAMP:20101010T081010Z" in test_out) + assert ("CREATED: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: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: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: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:20101010T081010Z" in test_out) - self.assertTrue("CREATED:20101010T081010Z" in test_out) - - -def test_tzinfo_dateutil(self): - # Test for issues #77, #63 - # references: #73,7430b66862346fe3a6a100ab25e35a8711446717 +def test_tzinfo_dateutil(): + """Test for issues #77, #63 + references: #73,7430b66862346fe3a6a100ab25e35a8711446717 + """ date = dateutil.parser.parse('2012-08-30T22:41:00Z') date2 = dateutil.parser.parse('2012-08-30T22:41:00 +02:00') - self.assertTrue(date.tzinfo.__module__.startswith('dateutil.tz')) - self.assertTrue(date2.tzinfo.__module__.startswith('dateutil.tz')) + assert (date.tzinfo.__module__.startswith('dateutil.tz')) + assert (date2.tzinfo.__module__.startswith('dateutil.tz')) # make sure, it's parsed properly and doesn't throw an error - self.assertTrue(icalendar.vDDDTypes(date).to_ical() + assert (icalendar.vDDDTypes(date).to_ical() == b'20120830T224100Z') - self.assertTrue(icalendar.vDDDTypes(date2).to_ical() + assert (icalendar.vDDDTypes(date2).to_ical() == b'20120830T224100') -def test_create_america_new_york(self, tzp): - """testing America/New_York, the most complex example from the - RFC""" - with open(os.path.join(CALENDARS_DIRECTORY, 'america_new_york.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) +def test_create_america_new_york(calendars, tzp): + """testing America/New_York, the most complex example from the RFC""" + cal = calendars.america_new_york tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_America/New_York') + assert str(tz) == 'custom_America/New_York' tz_new_york = tzp.timezone('America/New_York') # for reasons (tm) the locally installed version of the time zone # database isn't always complete, therefore we only compare some @@ -228,19 +143,13 @@ def test_create_america_new_york(self, tzp): <= date <= datetime.datetime(2037, 11, 1, 6, 0): ny_transition_times.append(date) ny_transition_info.append(tz_new_york._transition_info[num]) - self.assertEqual(tz._utc_transition_times[:142], ny_transition_times) - self.assertEqual(tz._transition_info[0:142], ny_transition_info) - self.assertIn( - ( + assert tz._utc_transition_times[:142] == ny_transition_times + assert tz._transition_info[0:142] == ny_transition_info + assert ( datetime.timedelta(-1, 72000), datetime.timedelta(0, 3600), 'EDT' - ), - tz._tzinfos.keys() - ) - self.assertIn( - (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST'), - tz._tzinfos.keys() - ) + ) in tz._tzinfos.keys() + assert (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST') in tz._tzinfos.keys() def test_create_pacific_fiji(self): diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 4826b3f2..06eb1a16 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -38,6 +38,7 @@ def create_timezone(self, name: str, transition_times, transition_info): '_utc_transition_times': transition_times, '_transition_info': transition_info }) + print("create_timezone", cls) return cls() diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 4a392eaf..e91c7748 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -69,7 +69,7 @@ def timezone(self, id: str) -> Optional[datetime.tzinfo]: return tz if clean_id in WINDOWS_TO_OLSON: tz = self.__provider.timezone(WINDOWS_TO_OLSON[clean_id]) - return tz or self.__provider.timezone(id) or self.__tz_cache.get(id) + return tz or self.__provider.timezone(id) or self.__tz_cache.get(id) __all__ = ["TZP"] From 8415bb0bde4aacd22db6dbe60652a97048d6f4af Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 21:43:14 +0100 Subject: [PATCH 14/70] Refactor tests to use pytest style --- src/icalendar/tests/test_timezoned.py | 195 ++++++++++++-------------- src/icalendar/timezone/pytz.py | 2 - 2 files changed, 86 insertions(+), 111 deletions(-) diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index e38337cf..481e539d 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -152,86 +152,80 @@ def test_create_america_new_york(calendars, tzp): assert (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST') in tz._tzinfos.keys() -def test_create_pacific_fiji(self): +def test_create_pacific_fiji(calendars): """testing Pacific/Fiji, another pretty complex example with more than one RDATE property per subcomponent""" - self.maxDiff = None - - with open(os.path.join(CALENDARS_DIRECTORY, 'pacific_fiji.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) + cal = calendars.pacific_fiji tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_Pacific/Fiji') - self.assertEqual(tz._utc_transition_times, - [datetime.datetime(1915, 10, 25, 12, 4), - datetime.datetime(1998, 10, 31, 14, 0), - datetime.datetime(1999, 2, 27, 14, 0), - datetime.datetime(1999, 11, 6, 14, 0), - datetime.datetime(2000, 2, 26, 14, 0), - datetime.datetime(2009, 11, 28, 14, 0), - datetime.datetime(2010, 3, 27, 14, 0), - datetime.datetime(2010, 10, 23, 14, 0), - datetime.datetime(2011, 3, 5, 14, 0), - datetime.datetime(2011, 10, 22, 14, 0), - datetime.datetime(2012, 1, 21, 14, 0), - datetime.datetime(2012, 10, 20, 14, 0), - datetime.datetime(2013, 1, 19, 14, 0), - datetime.datetime(2013, 10, 26, 14, 0), - datetime.datetime(2014, 1, 18, 13, 0), - datetime.datetime(2014, 10, 25, 14, 0), - datetime.datetime(2015, 1, 17, 13, 0), - datetime.datetime(2015, 10, 24, 14, 0), - datetime.datetime(2016, 1, 23, 13, 0), - datetime.datetime(2016, 10, 22, 14, 0), - datetime.datetime(2017, 1, 21, 13, 0), - datetime.datetime(2017, 10, 21, 14, 0), - datetime.datetime(2018, 1, 20, 13, 0), - datetime.datetime(2018, 10, 20, 14, 0), - datetime.datetime(2019, 1, 19, 13, 0), - datetime.datetime(2019, 10, 26, 14, 0), - datetime.datetime(2020, 1, 18, 13, 0), - datetime.datetime(2020, 10, 24, 14, 0), - datetime.datetime(2021, 1, 23, 13, 0), - datetime.datetime(2021, 10, 23, 14, 0), - datetime.datetime(2022, 1, 22, 13, 0), - datetime.datetime(2022, 10, 22, 14, 0), - datetime.datetime(2023, 1, 21, 13, 0), - datetime.datetime(2023, 10, 21, 14, 0), - datetime.datetime(2024, 1, 20, 13, 0), - datetime.datetime(2024, 10, 26, 14, 0), - datetime.datetime(2025, 1, 18, 13, 0), - datetime.datetime(2025, 10, 25, 14, 0), - datetime.datetime(2026, 1, 17, 13, 0), - datetime.datetime(2026, 10, 24, 14, 0), - datetime.datetime(2027, 1, 23, 13, 0), - datetime.datetime(2027, 10, 23, 14, 0), - datetime.datetime(2028, 1, 22, 13, 0), - datetime.datetime(2028, 10, 21, 14, 0), - datetime.datetime(2029, 1, 20, 13, 0), - datetime.datetime(2029, 10, 20, 14, 0), - datetime.datetime(2030, 1, 19, 13, 0), - datetime.datetime(2030, 10, 26, 14, 0), - datetime.datetime(2031, 1, 18, 13, 0), - datetime.datetime(2031, 10, 25, 14, 0), - datetime.datetime(2032, 1, 17, 13, 0), - datetime.datetime(2032, 10, 23, 14, 0), - datetime.datetime(2033, 1, 22, 13, 0), - datetime.datetime(2033, 10, 22, 14, 0), - datetime.datetime(2034, 1, 21, 13, 0), - datetime.datetime(2034, 10, 21, 14, 0), - datetime.datetime(2035, 1, 20, 13, 0), - datetime.datetime(2035, 10, 20, 14, 0), - datetime.datetime(2036, 1, 19, 13, 0), - datetime.datetime(2036, 10, 25, 14, 0), - datetime.datetime(2037, 1, 17, 13, 0), - datetime.datetime(2037, 10, 24, 14, 0), - datetime.datetime(2038, 1, 23, 13, 0), - datetime.datetime(2038, 10, 23, 14, 0)] - - ) - self.assertEqual( - tz._transition_info, + assert str(tz) == 'custom_Pacific/Fiji' + assert tz._utc_transition_times == [ + datetime.datetime(1915, 10, 25, 12, 4), + datetime.datetime(1998, 10, 31, 14, 0), + datetime.datetime(1999, 2, 27, 14, 0), + datetime.datetime(1999, 11, 6, 14, 0), + datetime.datetime(2000, 2, 26, 14, 0), + datetime.datetime(2009, 11, 28, 14, 0), + datetime.datetime(2010, 3, 27, 14, 0), + datetime.datetime(2010, 10, 23, 14, 0), + datetime.datetime(2011, 3, 5, 14, 0), + datetime.datetime(2011, 10, 22, 14, 0), + datetime.datetime(2012, 1, 21, 14, 0), + datetime.datetime(2012, 10, 20, 14, 0), + datetime.datetime(2013, 1, 19, 14, 0), + datetime.datetime(2013, 10, 26, 14, 0), + datetime.datetime(2014, 1, 18, 13, 0), + datetime.datetime(2014, 10, 25, 14, 0), + datetime.datetime(2015, 1, 17, 13, 0), + datetime.datetime(2015, 10, 24, 14, 0), + datetime.datetime(2016, 1, 23, 13, 0), + datetime.datetime(2016, 10, 22, 14, 0), + datetime.datetime(2017, 1, 21, 13, 0), + datetime.datetime(2017, 10, 21, 14, 0), + datetime.datetime(2018, 1, 20, 13, 0), + datetime.datetime(2018, 10, 20, 14, 0), + datetime.datetime(2019, 1, 19, 13, 0), + datetime.datetime(2019, 10, 26, 14, 0), + datetime.datetime(2020, 1, 18, 13, 0), + datetime.datetime(2020, 10, 24, 14, 0), + datetime.datetime(2021, 1, 23, 13, 0), + datetime.datetime(2021, 10, 23, 14, 0), + datetime.datetime(2022, 1, 22, 13, 0), + datetime.datetime(2022, 10, 22, 14, 0), + datetime.datetime(2023, 1, 21, 13, 0), + datetime.datetime(2023, 10, 21, 14, 0), + datetime.datetime(2024, 1, 20, 13, 0), + datetime.datetime(2024, 10, 26, 14, 0), + datetime.datetime(2025, 1, 18, 13, 0), + datetime.datetime(2025, 10, 25, 14, 0), + datetime.datetime(2026, 1, 17, 13, 0), + datetime.datetime(2026, 10, 24, 14, 0), + datetime.datetime(2027, 1, 23, 13, 0), + datetime.datetime(2027, 10, 23, 14, 0), + datetime.datetime(2028, 1, 22, 13, 0), + datetime.datetime(2028, 10, 21, 14, 0), + datetime.datetime(2029, 1, 20, 13, 0), + datetime.datetime(2029, 10, 20, 14, 0), + datetime.datetime(2030, 1, 19, 13, 0), + datetime.datetime(2030, 10, 26, 14, 0), + datetime.datetime(2031, 1, 18, 13, 0), + datetime.datetime(2031, 10, 25, 14, 0), + datetime.datetime(2032, 1, 17, 13, 0), + datetime.datetime(2032, 10, 23, 14, 0), + datetime.datetime(2033, 1, 22, 13, 0), + datetime.datetime(2033, 10, 22, 14, 0), + datetime.datetime(2034, 1, 21, 13, 0), + datetime.datetime(2034, 10, 21, 14, 0), + datetime.datetime(2035, 1, 20, 13, 0), + datetime.datetime(2035, 10, 20, 14, 0), + datetime.datetime(2036, 1, 19, 13, 0), + datetime.datetime(2036, 10, 25, 14, 0), + datetime.datetime(2037, 1, 17, 13, 0), + datetime.datetime(2037, 10, 24, 14, 0), + datetime.datetime(2038, 1, 23, 13, 0), + datetime.datetime(2038, 10, 23, 14, 0) + ] + assert tz._transition_info == ( [( datetime.timedelta(0, 43200), datetime.timedelta(0), @@ -271,63 +265,47 @@ def test_create_pacific_fiji(self): )] ) - self.assertIn( - ( + assert ( datetime.timedelta(0, 46800), datetime.timedelta(0, 3600), 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), - tz._tzinfos.keys() - ) - self.assertIn( - ( + ) in tz._tzinfos.keys() + assert ( datetime.timedelta(0, 43200), datetime.timedelta(0), 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - ), - tz._tzinfos.keys() - ) + ) in tz._tzinfos.keys() -def test_same_start_date(self): +def test_same_start_date(calendars): """testing if we can handle VTIMEZONEs whose different components have the same start DTIMEs.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) + cal = calendars.timezone_same_start d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') + assert d.strftime('%c') == 'Fri Feb 24 12:00:00 2017' -def test_same_start_date_and_offset(self): +def test_same_start_date_and_offset(calendars): """testing if we can handle VTIMEZONEs whose different components have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start_and_offset.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) + cal = calendars.timezone_same_start_and_offset d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') + assert d.strftime('%c') == 'Fri Feb 24 12:00:00 2017' -def test_rdate(self): +def test_rdate(calendars): """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE """ - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_rdate.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) + cal = calendars.timezone_rdate vevent = cal.walk('VEVENT')[0] tz = vevent['DTSTART'].dt.tzinfo - self.assertEqual(str(tz), 'posix/Europe/Vaduz') - self.assertEqual( - tz._utc_transition_times[:6], - [ + assert str(tz) == 'posix/Europe/Vaduz' + assert tz._utc_transition_times[:6] == [ datetime.datetime(1901, 12, 13, 20, 45, 38), datetime.datetime(1941, 5, 5, 0, 0, 0), datetime.datetime(1941, 10, 6, 0, 0, 0), datetime.datetime(1942, 5, 4, 0, 0, 0), datetime.datetime(1942, 10, 5, 0, 0, 0), datetime.datetime(1981, 3, 29, 1, 0), - ]) - self.assertEqual( - tz._transition_info[:6], - [ + ] + assert tz._transition_info[:6] == [ (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), @@ -335,4 +313,3 @@ def test_rdate(self): (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), ] - ) diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 06eb1a16..96af85ba 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -38,8 +38,6 @@ def create_timezone(self, name: str, transition_times, transition_info): '_utc_transition_times': transition_times, '_transition_info': transition_info }) - print("create_timezone", cls) - return cls() def timezone(self, name: str) -> Optional[tzinfo]: From 34042414e08261537c238e911b62dcca02b8551e Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 21:54:34 +0100 Subject: [PATCH 15/70] refactor: move tests --- .../tests/prop/test_identity_and_equality.py | 0 .../tests/prop/test_property_values.py | 17 +++++++++ .../prop/test_windows_to_olson_mapping.py | 17 +++++++++ src/icalendar/tests/test_unit_prop.py | 37 ------------------- 4 files changed, 34 insertions(+), 37 deletions(-) create mode 100644 src/icalendar/tests/prop/test_identity_and_equality.py create mode 100644 src/icalendar/tests/prop/test_property_values.py create mode 100644 src/icalendar/tests/prop/test_windows_to_olson_mapping.py diff --git a/src/icalendar/tests/prop/test_identity_and_equality.py b/src/icalendar/tests/prop/test_identity_and_equality.py new file mode 100644 index 00000000..e69de29b diff --git a/src/icalendar/tests/prop/test_property_values.py b/src/icalendar/tests/prop/test_property_values.py new file mode 100644 index 00000000..193c7ba8 --- /dev/null +++ b/src/icalendar/tests/prop/test_property_values.py @@ -0,0 +1,17 @@ +"""Test that composed values are properly converted.""" +from icalendar import Event +from datetime import datetime + + +def test_vDDDLists_timezone(tzp): + """Test vDDDLists with timezone information.""" + vevent = Event() + dt1 = tzp.localize(datetime(2013, 1, 1), 'Europe/Vienna') + dt2 = tzp.localize(datetime(2013, 1, 2), 'Europe/Vienna') + dt3 = tzp.localize(datetime(2013, 1, 3), 'Europe/Vienna') + vevent.add('rdate', [dt1, dt2]) + vevent.add('exdate', dt3) + ical = vevent.to_ical() + + assert b'RDATE;TZID=Europe/Vienna:20130101T000000,20130102T000000' in ical + assert b'EXDATE;TZID=Europe/Vienna:20130103T000000' in ical diff --git a/src/icalendar/tests/prop/test_windows_to_olson_mapping.py b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py new file mode 100644 index 00000000..61efbd85 --- /dev/null +++ b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py @@ -0,0 +1,17 @@ +"""Test the mappings from windows to olson tzids""" +from icalendar.timezone.windows_to_olson import WINDOWS_TO_OLSON +import pytest +from icalendar import vDatetime +from datetime import datetime + + +def test_windows_timezone(tzp): + """test that an example""" + dt = vDatetime.from_ical('20170507T181920', 'Eastern Standard Time'), + expected = tzp.localize(datetime(2017, 5, 7, 18, 19, 20), 'America/New_York') + + +@pytest.mark.parametrize("olson_id", WINDOWS_TO_OLSON.values()) +def test_olson_names(tzp, olson_id): + """test if all mappings actually map to valid tzids""" + assert tzp.timezone(olson_id) is not None diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index 8fa6b064..1763d987 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -7,7 +7,6 @@ from icalendar.prop import vDatetime, vDDDTypes from icalendar.timezone.windows_to_olson import WINDOWS_TO_OLSON import pytest -import pytz from copy import deepcopy from dateutil import tz @@ -543,39 +542,3 @@ def test_vDDDTypes_equivalance(map, v_type, other): def test_inequality_with_different_types(v_type): assert v_type != 42 assert v_type != 'test' - -class TestPropertyValues(unittest.TestCase): - - def test_vDDDLists_timezone(self): - """Test vDDDLists with timezone information. - """ - from .. import Event - vevent = Event() - at = pytz.timezone('Europe/Vienna') - dt1 = at.localize(datetime(2013, 1, 1)) - dt2 = at.localize(datetime(2013, 1, 2)) - dt3 = at.localize(datetime(2013, 1, 3)) - vevent.add('rdate', [dt1, dt2]) - vevent.add('exdate', dt3) - ical = vevent.to_ical() - - self.assertTrue( - b'RDATE;TZID=Europe/Vienna:20130101T000000,20130102T000000' in ical - ) - self.assertTrue(b'EXDATE;TZID=Europe/Vienna:20130103T000000' in ical) - - -class TestWindowsOlsonMapping(unittest.TestCase): - """Test the mappings from windows to olson tzids""" - - def test_windows_timezone(self): - """test that an example""" - self.assertEqual( - vDatetime.from_ical('20170507T181920', 'Eastern Standard Time'), - pytz.timezone('America/New_York').localize(datetime(2017, 5, 7, 18, 19, 20)) - ) - - def test_all(self): - """test if all mappings actually map to valid pytz tzids""" - for olson in WINDOWS_TO_OLSON.values(): - pytz.timezone(olson) From 9af4a8d74720cc4942f2bc41539b7b3b86c31c0a Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 21:59:53 +0100 Subject: [PATCH 16/70] refactor: move test --- .../tests/prop/test_identity_and_equality.py | 43 +++++++++++++++++++ src/icalendar/tests/test_unit_prop.py | 34 --------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/icalendar/tests/prop/test_identity_and_equality.py b/src/icalendar/tests/prop/test_identity_and_equality.py index e69de29b..8e5682d0 100644 --- a/src/icalendar/tests/prop/test_identity_and_equality.py +++ b/src/icalendar/tests/prop/test_identity_and_equality.py @@ -0,0 +1,43 @@ +"""Test the identity and equality between properties.""" +from icalendar import vDDDTypes +from datetime import datetime, date, time +from icalendar.timezone.zoneinfo import zoneinfo +import pytz +from dateutil import tz +import pytest +from copy import deepcopy + + + +vDDDTypes_list = [ + vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=zoneinfo.ZoneInfo("Europe/London"))), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7)), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=tz.UTC)), + vDDDTypes(date(year=2022, month=7, day=22)), + vDDDTypes(date(year=2022, month=7, day=23)), + vDDDTypes(time(hour=22, minute=7, second=2)) +] + +def identity(x): + return x + +@pytest.mark.parametrize("map", [ + deepcopy, + identity, + hash, +]) +@pytest.mark.parametrize("v_type", vDDDTypes_list) +@pytest.mark.parametrize("other", vDDDTypes_list) +def test_vDDDTypes_equivalance(map, v_type, other): + if v_type is other: + assert map(v_type) == map(other), f"identity implies equality: {map.__name__}()" + assert not (map(v_type) != map(other)), f"identity implies equality: {map.__name__}()" + else: + assert map(v_type) != map(other), f"expected inequality: {map.__name__}()" + assert not (map(v_type) == map(other)), f"expected inequality: {map.__name__}()" + +@pytest.mark.parametrize("v_type", vDDDTypes_list) +def test_inequality_with_different_types(v_type): + assert v_type != 42 + assert v_type != 'test' diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index 1763d987..19a464b2 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -508,37 +508,3 @@ def test_prop_TypesFactory(self): factory.from_ical('cn', b'Rasmussen\\, Max M\xc3\xb8ller'), 'Rasmussen, Max M\xf8ller' ) - - - -vDDDTypes_list = [ - vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))), - vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7)), - vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=tz.UTC)), - vDDDTypes(date(year=2022, month=7, day=22)), - vDDDTypes(date(year=2022, month=7, day=23)), - vDDDTypes(time(hour=22, minute=7, second=2)) -] - -def identity(x): - return x - -@pytest.mark.parametrize("map", [ - deepcopy, - identity, - hash, -]) -@pytest.mark.parametrize("v_type", vDDDTypes_list) -@pytest.mark.parametrize("other", vDDDTypes_list) -def test_vDDDTypes_equivalance(map, v_type, other): - if v_type is other: - assert map(v_type) == map(other), f"identity implies equality: {map.__name__}()" - assert not (map(v_type) != map(other)), f"identity implies equality: {map.__name__}()" - else: - assert map(v_type) != map(other), f"expected inequality: {map.__name__}()" - assert not (map(v_type) == map(other)), f"expected inequality: {map.__name__}()" - -@pytest.mark.parametrize("v_type", vDDDTypes_list) -def test_inequality_with_different_types(v_type): - assert v_type != 42 - assert v_type != 'test' From 6ee184c83d5765c6f918d0b7498797ce4d7af0a4 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:04:41 +0100 Subject: [PATCH 17/70] move vBinary Tests --- src/icalendar/tests/test_unit_prop.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index 19a464b2..3d505faf 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -13,30 +13,7 @@ class TestProp(unittest.TestCase): - def test_prop_vBinary(self): - from ..prop import vBinary - txt = b'This is gibberish' - txt_ical = b'VGhpcyBpcyBnaWJiZXJpc2g=' - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) - - # The roundtrip test - txt = b'Binary data \x13 \x56' - txt_ical = b'QmluYXJ5IGRhdGEgEyBW' - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) - - self.assertIsInstance(vBinary('txt').params, Parameters) - self.assertEqual( - vBinary('txt').params, {'VALUE': 'BINARY', 'ENCODING': 'BASE64'} - ) - - # Long data should not have line breaks, as that would interfere - txt = b'a' * 99 - txt_ical = b'YWFh' * 33 - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) def test_prop_vBoolean(self): from ..prop import vBoolean From 1452fcf48bdbb2ebd65a04168c53a57e61d423f5 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:06:52 +0100 Subject: [PATCH 18/70] move vBoolean test --- src/icalendar/tests/test_unit_prop.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index 3d505faf..f30d4bc9 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -15,16 +15,6 @@ class TestProp(unittest.TestCase): - def test_prop_vBoolean(self): - from ..prop import vBoolean - - self.assertEqual(vBoolean(True).to_ical(), b'TRUE') - self.assertEqual(vBoolean(0).to_ical(), b'FALSE') - - # The roundtrip test - self.assertEqual(vBoolean.from_ical(vBoolean(True).to_ical()), True) - self.assertEqual(vBoolean.from_ical('true'), True) - def test_prop_vCalAddress(self): from ..prop import vCalAddress txt = b'MAILTO:maxm@mxm.dk' From 40dd7bb46e73c0d56fbdcd239d3714759ef42718 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:11:27 +0100 Subject: [PATCH 19/70] create vBinary, vBoolean, vCalAddress tests --- src/icalendar/tests/prop/test_vBinary.py | 29 ++++++++++++++++++++ src/icalendar/tests/prop/test_vBoolean.py | 11 ++++++++ src/icalendar/tests/prop/test_vCalAddress.py | 19 +++++++++++++ src/icalendar/tests/test_unit_prop.py | 11 +------- 4 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 src/icalendar/tests/prop/test_vBinary.py create mode 100644 src/icalendar/tests/prop/test_vBoolean.py create mode 100644 src/icalendar/tests/prop/test_vCalAddress.py diff --git a/src/icalendar/tests/prop/test_vBinary.py b/src/icalendar/tests/prop/test_vBinary.py new file mode 100644 index 00000000..7fde7579 --- /dev/null +++ b/src/icalendar/tests/prop/test_vBinary.py @@ -0,0 +1,29 @@ +"""Test vBinary""" +from icalendar import vBinary +from icalendar.parser import Parameters + + +def test_text(): + txt = b'This is gibberish' + txt_ical = b'VGhpcyBpcyBnaWJiZXJpc2g=' + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) + +def test_binary(): + txt = b'Binary data \x13 \x56' + txt_ical = b'QmluYXJ5IGRhdGEgEyBW' + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) + +def test_param(): + assert isinstance(vBinary('txt').params, Parameters) + assert ( + vBinary('txt').params == {'VALUE': 'BINARY', 'ENCODING': 'BASE64'} + ) + +def test_long_data(): + """Long data should not have line breaks, as that would interfere""" + txt = b'a' * 99 + txt_ical = b'YWFh' * 33 + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) diff --git a/src/icalendar/tests/prop/test_vBoolean.py b/src/icalendar/tests/prop/test_vBoolean.py new file mode 100644 index 00000000..f42a66bd --- /dev/null +++ b/src/icalendar/tests/prop/test_vBoolean.py @@ -0,0 +1,11 @@ +from icalendar.prop import vBoolean + +def test_true(): + assert (vBoolean(True).to_ical() == b'TRUE') + +def test_false(): + assert (vBoolean(0).to_ical() == b'FALSE') + +def test_roundtrip(): + assert (vBoolean.from_ical(vBoolean(True).to_ical()) == True) + assert (vBoolean.from_ical('true') == True) diff --git a/src/icalendar/tests/prop/test_vCalAddress.py b/src/icalendar/tests/prop/test_vCalAddress.py new file mode 100644 index 00000000..6a8ff803 --- /dev/null +++ b/src/icalendar/tests/prop/test_vCalAddress.py @@ -0,0 +1,19 @@ +from icalendar.prop import vCalAddress +from icalendar.parser import Parameters + +txt = b'MAILTO:maxm@mxm.dk' +a = vCalAddress(txt) +a.params['cn'] = 'Max M' + + +def test_to_ical(): + assert a.to_ical() == txt + + +def test_params(): + assert isinstance(a.params, Parameters) + assert a.params == {'CN': 'Max M'} + + +def test_from_ical(): + assert vCalAddress.from_ical(txt) == 'MAILTO:maxm@mxm.dk' diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index f30d4bc9..07816d3a 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -15,16 +15,7 @@ class TestProp(unittest.TestCase): - def test_prop_vCalAddress(self): - from ..prop import vCalAddress - txt = b'MAILTO:maxm@mxm.dk' - a = vCalAddress(txt) - a.params['cn'] = 'Max M' - - self.assertEqual(a.to_ical(), txt) - self.assertIsInstance(a.params, Parameters) - self.assertEqual(a.params, {'CN': 'Max M'}) - self.assertEqual(vCalAddress.from_ical(txt), 'MAILTO:maxm@mxm.dk') + def test_prop_vFloat(self): from ..prop import vFloat From 7a1aeeecc367059b981b41ce4fb6db64b8638c73 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:17:40 +0100 Subject: [PATCH 20/70] move vDDDTypes tests --- .../{test_unit_prop.py => prop/test_unit.py} | 22 ------------------ src/icalendar/tests/prop/test_vDDDTypes.py | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) rename src/icalendar/tests/{test_unit_prop.py => prop/test_unit.py} (96%) create mode 100644 src/icalendar/tests/prop/test_vDDDTypes.py diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/prop/test_unit.py similarity index 96% rename from src/icalendar/tests/test_unit_prop.py rename to src/icalendar/tests/prop/test_unit.py index 07816d3a..1e82e361 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/prop/test_unit.py @@ -13,10 +13,6 @@ class TestProp(unittest.TestCase): - - - - def test_prop_vFloat(self): from ..prop import vFloat self.assertEqual(vFloat(1.0).to_ical(), b'1.0') @@ -53,24 +49,6 @@ def test_prop_vDDDLists(self): dt_list = vDDDLists([datetime(2000, 1, 1), datetime(2000, 11, 11)]) self.assertEqual(dt_list.to_ical(), b'20000101T000000,20001111T000000') - def test_prop_vDDDTypes(self): - from ..prop import vDDDTypes - - self.assertTrue(isinstance(vDDDTypes.from_ical('20010101T123000'), - datetime)) - - self.assertEqual(vDDDTypes.from_ical('20010101T123000Z'), - pytz.utc.localize(datetime(2001, 1, 1, 12, 30))) - - self.assertTrue(isinstance(vDDDTypes.from_ical('20010101'), date)) - - self.assertEqual(vDDDTypes.from_ical('P31D'), timedelta(31)) - - self.assertEqual(vDDDTypes.from_ical('-P31D'), timedelta(-31)) - - # Bad input - self.assertRaises(ValueError, vDDDTypes, 42) - def test_prop_vDate(self): from ..prop import vDate diff --git a/src/icalendar/tests/prop/test_vDDDTypes.py b/src/icalendar/tests/prop/test_vDDDTypes.py new file mode 100644 index 00000000..970b3479 --- /dev/null +++ b/src/icalendar/tests/prop/test_vDDDTypes.py @@ -0,0 +1,23 @@ +from icalendar.prop import vDDDTypes +from datetime import date, datetime, timedelta +import pytest + + +def test_instance(): + assert isinstance(vDDDTypes.from_ical('20010101T123000'), datetime) + assert isinstance(vDDDTypes.from_ical('20010101'), date) + + +def test_datetime_with_timezone(tzp): + assert vDDDTypes.from_ical('20010101T123000Z') == \ + tzp.localize_utc(datetime(2001, 1, 1, 12, 30)) + + +def test_timedelta(): + assert vDDDTypes.from_ical('P31D') == timedelta(31) + assert vDDDTypes.from_ical('-P31D') == timedelta(-31) + + +def test_bad_input(): + with pytest.raises(ValueError): + vDDDTypes(42) From 19d4a9da41f29cf0be94adb8922f17315c0e4eb2 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:25:41 +0100 Subject: [PATCH 21/70] move vDatetime tests --- src/icalendar/tests/prop/test_unit.py | 36 ------------------- src/icalendar/tests/prop/test_vDatetime.py | 42 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 src/icalendar/tests/prop/test_vDatetime.py diff --git a/src/icalendar/tests/prop/test_unit.py b/src/icalendar/tests/prop/test_unit.py index 1e82e361..81ed389b 100644 --- a/src/icalendar/tests/prop/test_unit.py +++ b/src/icalendar/tests/prop/test_unit.py @@ -59,42 +59,6 @@ def test_prop_vDate(self): self.assertRaises(ValueError, vDate, 'd') - def test_prop_vDatetime(self): - from ..prop import vDatetime - - dt = datetime(2001, 1, 1, 12, 30, 0) - self.assertEqual(vDatetime(dt).to_ical(), b'20010101T123000') - - self.assertEqual(vDatetime.from_ical('20000101T120000'), - datetime(2000, 1, 1, 12, 0)) - - dutc = pytz.utc.localize(datetime(2001, 1, 1, 12, 30, 0)) - self.assertEqual(vDatetime(dutc).to_ical(), b'20010101T123000Z') - - dutc = pytz.utc.localize(datetime(1899, 1, 1, 12, 30, 0)) - self.assertEqual(vDatetime(dutc).to_ical(), b'18990101T123000Z') - - self.assertEqual(vDatetime.from_ical('20010101T000000'), - datetime(2001, 1, 1, 0, 0)) - - self.assertRaises(ValueError, vDatetime.from_ical, '20010101T000000A') - - utc = vDatetime.from_ical('20010101T000000Z') - self.assertEqual(vDatetime(utc).to_ical(), b'20010101T000000Z') - - # 1 minute before transition to DST - dat = vDatetime.from_ical('20120311T015959', 'America/Denver') - self.assertEqual(dat.strftime('%Y%m%d%H%M%S %z'), - '20120311015959 -0700') - - # After transition to DST - dat = vDatetime.from_ical('20120311T030000', 'America/Denver') - self.assertEqual(dat.strftime('%Y%m%d%H%M%S %z'), - '20120311030000 -0600') - - dat = vDatetime.from_ical('20101010T000000', 'Europe/Vienna') - self.assertEqual(vDatetime(dat).to_ical(), b'20101010T000000') - def test_prop_vDuration(self): from ..prop import vDuration diff --git a/src/icalendar/tests/prop/test_vDatetime.py b/src/icalendar/tests/prop/test_vDatetime.py new file mode 100644 index 00000000..489edff4 --- /dev/null +++ b/src/icalendar/tests/prop/test_vDatetime.py @@ -0,0 +1,42 @@ +from icalendar.prop import vDatetime +import pytest +from datetime import datetime + + +def test_to_ical(): + assert vDatetime(datetime(2001, 1, 1, 12, 30, 0)).to_ical() == b'20010101T123000' + +def test_from_ical(): + assert vDatetime.from_ical('20000101T120000') == datetime(2000, 1, 1, 12, 0) + assert vDatetime.from_ical('20010101T000000') == datetime(2001, 1, 1, 0, 0) + +def test_to_ical_utc(tzp): + dutc = tzp.localize_utc(datetime(2001, 1, 1, 12, 30, 0)) + assert vDatetime(dutc).to_ical() == b'20010101T123000Z' + +def test_to_ical_utc_1899(tzp): + dutc = tzp.localize_utc(datetime(1899, 1, 1, 12, 30, 0)) + assert vDatetime(dutc).to_ical() == b'18990101T123000Z' + + +def test_bad_ical(): + with pytest.raises(ValueError): + vDatetime.from_ical('20010101T000000A') + + +def test_roundtrip(): + utc = vDatetime.from_ical('20010101T000000Z') + assert vDatetime(utc).to_ical() == b'20010101T000000Z' + + +def test_transition(tzp): + # 1 minute before transition to DST + dat = vDatetime.from_ical('20120311T015959', 'America/Denver') + assert dat.strftime('%Y%m%d%H%M%S %z') =='20120311015959 -0700' + + # After transition to DST + dat = vDatetime.from_ical('20120311T030000', 'America/Denver') + assert dat.strftime('%Y%m%d%H%M%S %z') == '20120311030000 -0600' + + dat = vDatetime.from_ical('20101010T000000', 'Europe/Vienna') + assert vDatetime(dat).to_ical() == b'20101010T000000' From 01e95295c18e094ddaa6ff13c0c63d873ae665c3 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:31:31 +0100 Subject: [PATCH 22/70] Move vPeriod tests --- src/icalendar/tests/prop/test_unit.py | 46 ---------------------- src/icalendar/tests/prop/test_vPeriod.py | 49 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 src/icalendar/tests/prop/test_vPeriod.py diff --git a/src/icalendar/tests/prop/test_unit.py b/src/icalendar/tests/prop/test_unit.py index 81ed389b..a997e55c 100644 --- a/src/icalendar/tests/prop/test_unit.py +++ b/src/icalendar/tests/prop/test_unit.py @@ -95,52 +95,6 @@ def test_prop_vDuration(self): self.assertEqual(duration.to_ical(), b'-P1DT5H') self.assertEqual(duration.to_ical(), b'-P1DT5H') - - def test_prop_vPeriod(self): - from ..prop import vPeriod - - # One day in exact datetimes - per = (datetime(2000, 1, 1), datetime(2000, 1, 2)) - self.assertEqual(vPeriod(per).to_ical(), - b'20000101T000000/20000102T000000') - - per = (datetime(2000, 1, 1), timedelta(days=31)) - self.assertEqual(vPeriod(per).to_ical(), b'20000101T000000/P31D') - - # Roundtrip - p = vPeriod.from_ical('20000101T000000/20000102T000000') - self.assertEqual( - p, - (datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 2, 0, 0)) - ) - self.assertEqual(vPeriod(p).to_ical(), - b'20000101T000000/20000102T000000') - - self.assertEqual(vPeriod.from_ical('20000101T000000/P31D'), - (datetime(2000, 1, 1, 0, 0), timedelta(31))) - - # Roundtrip with absolute time - p = vPeriod.from_ical('20000101T000000Z/20000102T000000Z') - self.assertEqual(vPeriod(p).to_ical(), - b'20000101T000000Z/20000102T000000Z') - - # And an error - self.assertRaises(ValueError, - vPeriod.from_ical, '20000101T000000/Psd31D') - - # Timezoned - dk = pytz.timezone('Europe/Copenhagen') - start = dk.localize(datetime(2000, 1, 1)) - end = dk.localize(datetime(2000, 1, 2)) - per = (start, end) - self.assertEqual(vPeriod(per).to_ical(), - b'20000101T000000/20000102T000000') - self.assertEqual(vPeriod(per).params['TZID'], - 'Europe/Copenhagen') - - p = vPeriod((dk.localize(datetime(2000, 1, 1)), timedelta(days=31))) - self.assertEqual(p.to_ical(), b'20000101T000000/P31D') - def test_prop_vWeekday(self): from ..prop import vWeekday diff --git a/src/icalendar/tests/prop/test_vPeriod.py b/src/icalendar/tests/prop/test_vPeriod.py new file mode 100644 index 00000000..ba381fd5 --- /dev/null +++ b/src/icalendar/tests/prop/test_vPeriod.py @@ -0,0 +1,49 @@ +import unittest +from icalendar.prop import vPeriod +from datetime import datetime, timedelta + + +class TestProp(unittest.TestCase): + + def test_one_day(self): + # One day in exact datetimes + per = (datetime(2000, 1, 1), datetime(2000, 1, 2)) + self.assertEqual(vPeriod(per).to_ical(), + b'20000101T000000/20000102T000000') + + per = (datetime(2000, 1, 1), timedelta(days=31)) + self.assertEqual(vPeriod(per).to_ical(), b'20000101T000000/P31D') + + def test_roundtrip(self): + p = vPeriod.from_ical('20000101T000000/20000102T000000') + self.assertEqual( + p, + (datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 2, 0, 0)) + ) + self.assertEqual(vPeriod(p).to_ical(), + b'20000101T000000/20000102T000000') + + self.assertEqual(vPeriod.from_ical('20000101T000000/P31D'), + (datetime(2000, 1, 1, 0, 0), timedelta(31))) + + def test_round_trip_with_absolute_time(self): + p = vPeriod.from_ical('20000101T000000Z/20000102T000000Z') + self.assertEqual(vPeriod(p).to_ical(), + b'20000101T000000Z/20000102T000000Z') + + def test_bad_input(self): + self.assertRaises(ValueError, + vPeriod.from_ical, '20000101T000000/Psd31D') + + +def test_timezoned(tzp): + start = tzp.localize(datetime(2000, 1, 1), 'Europe/Copenhagen') + end = tzp.localize(datetime(2000, 1, 2), 'Europe/Copenhagen') + per = (start, end) + assert vPeriod(per).to_ical() == b'20000101T000000/20000102T000000' + assert vPeriod(per).params['TZID'] == 'Europe/Copenhagen' + + +def test_timezoned_with_timedelta(tzp): + p = vPeriod((tzp.localize(datetime(2000, 1, 1), 'Europe/Copenhagen'), timedelta(days=31))) + assert p.to_ical() == b'20000101T000000/P31D' From e4debef7a8b290d059e36885356197ce5649d9d5 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 22:34:16 +0100 Subject: [PATCH 23/70] Make tests work --- src/icalendar/tests/prop/test_unit.py | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/icalendar/tests/prop/test_unit.py b/src/icalendar/tests/prop/test_unit.py index a997e55c..eecd42bf 100644 --- a/src/icalendar/tests/prop/test_unit.py +++ b/src/icalendar/tests/prop/test_unit.py @@ -14,19 +14,19 @@ class TestProp(unittest.TestCase): def test_prop_vFloat(self): - from ..prop import vFloat + from icalendar.prop import vFloat self.assertEqual(vFloat(1.0).to_ical(), b'1.0') self.assertEqual(vFloat.from_ical('42'), 42.0) self.assertEqual(vFloat(42).to_ical(), b'42.0') def test_prop_vInt(self): - from ..prop import vInt + from icalendar.prop import vInt self.assertEqual(vInt(42).to_ical(), b'42') self.assertEqual(vInt.from_ical('13'), 13) self.assertRaises(ValueError, vInt.from_ical, '1s3') def test_prop_vDDDLists(self): - from ..prop import vDDDLists + from icalendar.prop import vDDDLists dt_list = vDDDLists.from_ical('19960402T010000Z') self.assertTrue(isinstance(dt_list, list)) @@ -50,7 +50,7 @@ def test_prop_vDDDLists(self): self.assertEqual(dt_list.to_ical(), b'20000101T000000,20001111T000000') def test_prop_vDate(self): - from ..prop import vDate + from icalendar.prop import vDate self.assertEqual(vDate(date(2001, 1, 1)).to_ical(), b'20010101') self.assertEqual(vDate(date(1899, 1, 1)).to_ical(), b'18990101') @@ -60,7 +60,7 @@ def test_prop_vDate(self): self.assertRaises(ValueError, vDate, 'd') def test_prop_vDuration(self): - from ..prop import vDuration + from icalendar.prop import vDuration self.assertEqual(vDuration(timedelta(11)).to_ical(), b'P11D') self.assertEqual(vDuration(timedelta(-14)).to_ical(), b'-P14D') @@ -96,7 +96,7 @@ def test_prop_vDuration(self): self.assertEqual(duration.to_ical(), b'-P1DT5H') def test_prop_vWeekday(self): - from ..prop import vWeekday + from icalendar.prop import vWeekday self.assertEqual(vWeekday('mo').to_ical(), b'MO') self.assertRaises(ValueError, vWeekday, 'erwer') @@ -108,14 +108,14 @@ def test_prop_vWeekday(self): self.assertEqual(vWeekday('-tu').to_ical(), b'-TU') def test_prop_vFrequency(self): - from ..prop import vFrequency + from icalendar.prop import vFrequency self.assertRaises(ValueError, vFrequency, 'bad test') self.assertEqual(vFrequency('daily').to_ical(), b'DAILY') self.assertEqual(vFrequency('daily').from_ical('MONTHLY'), 'MONTHLY') def test_prop_vRecur(self): - from ..prop import vRecur + from icalendar.prop import vRecur # Let's see how close we can get to one from the rfc: # FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30 @@ -208,7 +208,7 @@ def test_prop_vRecur(self): b'FREQ=MONTHLY;BYEASTER=-3;BYOTHER=TEXT') def test_prop_vText(self): - from ..prop import vText + from icalendar.prop import vText self.assertEqual(vText('Simple text').to_ical(), b'Simple text') @@ -242,7 +242,7 @@ def test_prop_vText(self): # with the official U+FFFD REPLACEMENT CHARACTER. def test_prop_vTime(self): - from ..prop import vTime + from icalendar.prop import vTime self.assertEqual(vTime(12, 30, 0).to_ical(), '123000') self.assertEqual(vTime.from_ical('123000'), time(12, 30)) @@ -251,7 +251,7 @@ def test_prop_vTime(self): self.assertRaises(ValueError, vTime.from_ical, '263000') def test_prop_vUri(self): - from ..prop import vUri + from icalendar.prop import vUri self.assertEqual(vUri('http://www.example.com/').to_ical(), b'http://www.example.com/') @@ -259,7 +259,7 @@ def test_prop_vUri(self): 'http://www.example.com/') def test_prop_vGeo(self): - from ..prop import vGeo + from icalendar.prop import vGeo # Pass a list self.assertEqual(vGeo([1.2, 3.0]).to_ical(), '1.2;3.0') @@ -275,7 +275,7 @@ def test_prop_vGeo(self): self.assertRaises(ValueError, vGeo, 'g') def test_prop_vUTCOffset(self): - from ..prop import vUTCOffset + from icalendar.prop import vUTCOffset self.assertEqual(vUTCOffset(timedelta(hours=2)).to_ical(), '+0200') @@ -315,7 +315,7 @@ def test_prop_vUTCOffset(self): self.assertRaises(ValueError, vUTCOffset.from_ical, '+2400') def test_prop_vInline(self): - from ..prop import vInline + from icalendar.prop import vInline self.assertEqual(vInline('Some text'), 'Some text') self.assertEqual(vInline.from_ical('Some text'), 'Some text') @@ -326,7 +326,7 @@ def test_prop_vInline(self): self.assertEqual(t2.params, {'CN': 'Test Osterone'}) def test_prop_vCategory(self): - from ..prop import vCategory + from icalendar.prop import vCategory catz = ['cat 1', 'cat 2', 'cat 3'] v_cat = vCategory(catz) @@ -335,7 +335,7 @@ def test_prop_vCategory(self): self.assertEqual(vCategory.from_ical(v_cat.to_ical()), catz) def test_prop_TypesFactory(self): - from ..prop import TypesFactory + from icalendar.prop import TypesFactory # To get a type you can use it like this. factory = TypesFactory() From 50c583c655d5471f120fe741fb2966e379a8c8e1 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 11:21:09 +0100 Subject: [PATCH 24/70] Test that timezone names are understood --- .../tests/test_pytz_zoneinfo_integration.py | 15 +++++++++++++++ src/icalendar/timezone/zoneinfo.py | 4 ++++ 2 files changed, 19 insertions(+) create mode 100644 src/icalendar/tests/test_pytz_zoneinfo_integration.py diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py new file mode 100644 index 00000000..c797c037 --- /dev/null +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -0,0 +1,15 @@ +"""This tests the switch to different timezone implementations. + +These are mostly located in icalendar.timezone. +""" +import pytz +from icalendar.timezone.zoneinfo import zoneinfo, ZONEINFO +from icalendar.timezone.pytz import PYTZ +import pytest + + +@pytest.mark.parametrize("tz_name", pytz.all_timezones + list(zoneinfo.available_timezones())) +@pytest.mark.parametrize("tzp_", [PYTZ(), ZONEINFO()]) +def test_timezone_names_are_known(tz_name, tzp_): + """Make sure that all timezones are understood.""" + assert tzp_.knows_timezone_id(tz_name), f"{tzp_.__class__.__name__} should know {tz_name}" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index b683e2b7..ab3dde31 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -11,6 +11,7 @@ class ZONEINFO: """Provide icalendar with timezones from zoneinfo.""" utc = zoneinfo.ZoneInfo("UTC") + _available_timezones = zoneinfo.available_timezones() def localize(self, dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime: """Localize a datetime to a timezone.""" @@ -27,6 +28,9 @@ def timezone(self, name: str) -> Optional[tzinfo]: except ValueError: pass + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + return id in self._available_timezones __all__ = ["ZONEINFO"] From 864a14486143c8b31c36c76a11463d549c8fdb5c Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 11:27:50 +0100 Subject: [PATCH 25/70] skip timezone name comparism for now --- src/icalendar/tests/test_pytz_zoneinfo_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py index c797c037..5e4e2030 100644 --- a/src/icalendar/tests/test_pytz_zoneinfo_integration.py +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -12,4 +12,5 @@ @pytest.mark.parametrize("tzp_", [PYTZ(), ZONEINFO()]) def test_timezone_names_are_known(tz_name, tzp_): """Make sure that all timezones are understood.""" + pytest.skip() assert tzp_.knows_timezone_id(tz_name), f"{tzp_.__class__.__name__} should know {tz_name}" From e45ce94b7e20f953cc0246fbe1af814a7ccf473a Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 11:46:00 +0100 Subject: [PATCH 26/70] Rename fix_pytz_rrule_until -> fix_rrule_until --- src/icalendar/cal.py | 2 +- src/icalendar/timezone/pytz.py | 2 +- src/icalendar/timezone/tzp.py | 4 ++-- src/icalendar/timezone/zoneinfo.py | 7 +++++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index a976981d..17d65efe 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -571,7 +571,7 @@ def _extract_offsets(component, tzname): rrulestr = component['RRULE'].to_ical().decode('utf-8') rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) - tzp.fix_pytz_rrule_until(rrule, component) + tzp.fix_rrule_until(rrule, component) # constructing the timezone requires UTC transition times. # here we construct local times without tzinfo, the offset to UTC diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 96af85ba..dcbbdff3 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -24,7 +24,7 @@ def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" return id in pytz.all_timezones - def fix_pytz_rrule_until(self, rrule, component): + def fix_rrule_until(self, rrule, component): """Make sure the until value works.""" if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): # pytz.timezones don't know any transition dates after 2038 diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index e91c7748..e6099060 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -53,9 +53,9 @@ def cache_timezone_component(self, component: cal.VTIMEZONE) -> None: if not self.__provider.knows_timezone_id(component['TZID']): self.__tz_cache.setdefault(component['TZID'], component.to_tz()) - def fix_pytz_rrule_until(self, rrule, component) -> None: + def fix_rrule_until(self, rrule, component) -> None: """Make sure the until value works.""" - self.__provider.fix_pytz_rrule_until(rrule, component) + self.__provider.fix_rrule_until(rrule, component) def create_timezone(self, name: str, transition_times, transition_info) -> datetime.tzinfo: """Create a timezone from given information.""" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index ab3dde31..40b04e1a 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -32,5 +32,12 @@ def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" return id in self._available_timezones + def fix_rrule_until(self, rrule, component): + """Make sure the until value works.""" + if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): + # zoninfo does not know any transition dates after 2038 + rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + + __all__ = ["ZONEINFO"] From 1c795eddbb14518fad4e533b3466563e86b026ad Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 11:59:40 +0100 Subject: [PATCH 27/70] Add tzdata package to add-on timezones See https://stackoverflow.com/a/78580486/1320237 --- setup.py | 1 + src/icalendar/tests/test_pytz_zoneinfo_integration.py | 3 ++- src/icalendar/timezone/zoneinfo.py | 10 +++++++++- tox.ini | 1 - 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 0834d253..edbaa4f2 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ # install requirements depending on python version # see https://www.python.org/dev/peps/pep-0508/#environment-markers 'backports.zoneinfo; python_version == "3.7" or python_version == "3.8"', + 'tzdata' ] diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py index 5e4e2030..c3cdf1b1 100644 --- a/src/icalendar/tests/test_pytz_zoneinfo_integration.py +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -12,5 +12,6 @@ @pytest.mark.parametrize("tzp_", [PYTZ(), ZONEINFO()]) def test_timezone_names_are_known(tz_name, tzp_): """Make sure that all timezones are understood.""" - pytest.skip() + if tz_name in ("Factory", "localtime"): + pytest.skip() assert tzp_.knows_timezone_id(tz_name), f"{tzp_.__class__.__name__} should know {tz_name}" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 40b04e1a..09e314c3 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -25,7 +25,7 @@ def timezone(self, name: str) -> Optional[tzinfo]: """Return a timezone with a name or None if we cannot find it.""" try: return zoneinfo.ZoneInfo(name) - except ValueError: + except zoneinfo.ZoneInfoNotFoundError: pass def knows_timezone_id(self, id: str) -> bool: @@ -38,6 +38,14 @@ def fix_rrule_until(self, rrule, component): # zoninfo does not know any transition dates after 2038 rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + def create_timezone(self, name: str, transition_times, transition_info): + """Create a pytz timezone file given information.""" + cls = type(name, (DstTzInfo,), { + 'zone': name, + '_utc_transition_times': transition_times, + '_transition_info': transition_info + }) + return cls() __all__ = ["ZONEINFO"] diff --git a/tox.ini b/tox.ini index 14bb24ac..54e7050e 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,6 @@ deps = pytest coverage hypothesis - backports.zoneinfo ; python_version<'3.9' commands = coverage run --source=src/icalendar --omit=*/tests/hypothesis/* --omit=*/tests/fuzzed/* --module pytest [] coverage report From 300e6d1ad871397c5559eb5adb86e6ddf4eeddb8 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:05:00 +0100 Subject: [PATCH 28/70] log changes --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 94833eba..0a9c94f7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,7 @@ Breaking changes: New features: - Create GitHub releases for each tag. +- Allow using ``zoneinfo`` as a timezone implementations Bug fixes: From 22db734d375e8db68f2ab211af5b7757539fd812 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:26:17 +0100 Subject: [PATCH 29/70] Create provider interface and move function --- src/icalendar/prop.py | 18 +++++++++++++++- src/icalendar/timezone/__init__.py | 18 +--------------- src/icalendar/timezone/provider.py | 33 ++++++++++++++++++++++++++++++ src/icalendar/timezone/pytz.py | 17 +++++++++------ src/icalendar/timezone/tzp.py | 26 +++++++++++++++-------- src/icalendar/timezone/zoneinfo.py | 16 +++++++++------ 6 files changed, 89 insertions(+), 39 deletions(-) create mode 100644 src/icalendar/timezone/provider.py diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 8e35d5f7..795400fd 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -48,7 +48,6 @@ from icalendar.caselessdict import CaselessDict from icalendar.parser import Parameters from icalendar.parser import escape_char -from icalendar.timezone import tzid_from_dt from icalendar.parser import unescape_char from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.parser_tools import SEQUENCE_TYPES @@ -61,6 +60,9 @@ import re import time as _time +from typing import Optional + + DURATION_REGEX = re.compile(r'([-+]?)P(?:(\d+)W)?(?:(\d+)D)?' r'(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$') @@ -81,6 +83,20 @@ DSTDIFF = DSTOFFSET - STDOFFSET +def tzid_from_dt(dt: datetime) -> Optional[str]: + """Retrieve the timezone id from the datetime object.""" + 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'): + # dateutil implementation, but this is broken + # See https://github.com/collective/icalendar/issues/333 for details + tzid = dt.tzinfo.tzname(dt) + return tzid + + class FixedOffset(tzinfo): """Fixed offset in minutes east from UTC. """ diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index 7b70933d..4a58a3db 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -1,22 +1,6 @@ """This package contains all functionality for timezones.""" from .tzp import TZP -from datetime import datetime -from typing import Optional tzp = TZP("pytz") -def tzid_from_dt(dt: datetime) -> Optional[str]: - """Retrieve the timezone id from the datetime object.""" - 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'): - # dateutil implementation, but this is broken - # See https://github.com/collective/icalendar/issues/333 for details - tzid = dt.tzinfo.tzname(dt) - return tzid - - -__all__ = ["tzp", "tzid_from_dt"] +__all__ = ["tzp"] diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py new file mode 100644 index 00000000..22a511d0 --- /dev/null +++ b/src/icalendar/timezone/provider.py @@ -0,0 +1,33 @@ +"""The interface for timezone implementations.""" +from __future__ import annotations +from abc import ABC, abstractmethod +from icalendar import prop +from dateutil.rrule import rrule +from datetime import datetime, tzinfo + +class TZProvider(ABC): + """Interface for timezone implementations.""" + + @abstractmethod + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + + @abstractmethod + def localize(self, dt: datetime, tz: tzinfo) -> datetime: + """Localize a datetime to a timezone.""" + + @abstractmethod + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + + @abstractmethod + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + + @abstractmethod + def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + """Create a pytz timezone file given information.""" + + @abstractmethod + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index dcbbdff3..1ec9d615 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,11 +1,16 @@ """Use pytz timezones.""" +from __future__ import annotations import pytz from datetime import datetime, tzinfo from pytz.tzinfo import DstTzInfo from typing import Optional +from .provider import TZProvider +from icalendar import prop +from dateutil.rrule import rrule -class PYTZ: + +class PYTZ(TZProvider): """Provide icalendar with timezones from pytz.""" def localize_utc(self, dt: datetime) -> datetime: @@ -24,15 +29,15 @@ def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" return id in pytz.all_timezones - def fix_rrule_until(self, rrule, component): - """Make sure the until value works.""" - if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + if not {'UNTIL', 'COUNT'}.intersection(ical_rrule.keys()): # pytz.timezones don't know any transition dates after 2038 # either rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) - def create_timezone(self, name: str, transition_times, transition_info): - """Create a pytz timezone file given information.""" + def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + """Create a pytz timezone from the given information.""" cls = type(name, (DstTzInfo,), { 'zone': name, '_utc_transition_times': transition_times, diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index e6099060..3b4567fe 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -3,6 +3,7 @@ from .. import cal from typing import Optional, Union from .windows_to_olson import WINDOWS_TO_OLSON +from .provider import TZProvider class TZP: @@ -13,28 +14,35 @@ class TZP: All of icalendar will then use this timezone implementation. """ - def __init__(self, provider_name:str): + def __init__(self, provider:Union[str, TZProvider]): """Create a new timezone implementation proxy.""" - provider = getattr(self, f"use_{provider_name}", None) - if provider is None: - raise ValueError(f"Unknown provider {provider_name}. Use 'pytz' or 'zoneinfo'.") - provider() + self.use(provider) def use_pytz(self) -> None: """Use pytz as the timezone provider.""" from .pytz import PYTZ - self.use(PYTZ()) + self._use(PYTZ()) def use_zoneinfo(self) -> None: """Use zoneinfo as timezone provider.""" from .zoneinfo import ZONEINFO - self.use(ZONEINFO()) + self._use(ZONEINFO()) - def use(self, provider) -> None: - """Use another timezone implementation.""" + def _use(self, provider:TZProvider) -> None: + """Use a timezone implementation.""" self.__tz_cache = {} self.__provider = provider + def use(self, provider:Union[str, TZProvider]): + """Switch to a different timezone provider.""" + if isinstance(provider, str): + provider = getattr(self, f"use_{provider}", None) + if provider is None: + raise ValueError(f"Unknown provider {provider_name}. Use 'pytz' or 'zoneinfo'.") + provider() + else: + self._use(provider) + def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: """Return the datetime in UTC. diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 09e314c3..bc60dd0e 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -1,13 +1,17 @@ """Use zoneinfo timezones""" +from __future__ import annotations try: import zoneinfo except: from backports import zoneinfo +from icalendar import prop +from dateutil.rrule import rrule from datetime import datetime, tzinfo from typing import Optional +from .provider import TZProvider -class ZONEINFO: +class ZONEINFO(TZProvider): """Provide icalendar with timezones from zoneinfo.""" utc = zoneinfo.ZoneInfo("UTC") @@ -32,14 +36,14 @@ def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" return id in self._available_timezones - def fix_rrule_until(self, rrule, component): - """Make sure the until value works.""" - if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + if not {'UNTIL', 'COUNT'}.intersection(ical_rrule.keys()): # zoninfo does not know any transition dates after 2038 rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) - def create_timezone(self, name: str, transition_times, transition_info): - """Create a pytz timezone file given information.""" + def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + """Create a timezone from the given information.""" cls = type(name, (DstTzInfo,), { 'zone': name, '_utc_transition_times': transition_times, From c0a13c35bf5698c613bd81b662ee547d49f8a059 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:28:23 +0100 Subject: [PATCH 30/70] Fix refactoring mistake --- src/icalendar/cal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 17d65efe..4e78d883 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -571,7 +571,7 @@ def _extract_offsets(component, tzname): rrulestr = component['RRULE'].to_ical().decode('utf-8') rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) - tzp.fix_rrule_until(rrule, component) + tzp.fix_rrule_until(rrule, component['RRULE']) # constructing the timezone requires UTC transition times. # here we construct local times without tzinfo, the offset to UTC From 628869797f49422c76f8bba36b93328abb0f0d90 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:35:44 +0100 Subject: [PATCH 31/70] Use default provider --- src/icalendar/tests/conftest.py | 9 +++++---- src/icalendar/timezone/__init__.py | 2 +- src/icalendar/timezone/tzp.py | 8 +++++++- src/icalendar/timezone/zoneinfo.py | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index c9e41018..ad02e94b 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -173,11 +173,12 @@ def calendar_with_resources(): return c -@pytest.fixture() -def tzp(): +@pytest.fixture(params=["pytz", "zoneinfo"]) +def tzp(request): """The time zone provider.""" - _tzp.use_pytz() # todo: parametrize - return _tzp + _tzp.use(request.param) # todo: parametrize + yield _tzp + _tzp.use_default() @pytest.fixture(params=["pytz", "zoneinfo"]) diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index 4a58a3db..0215dc86 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -1,6 +1,6 @@ """This package contains all functionality for timezones.""" from .tzp import TZP -tzp = TZP("pytz") +tzp = TZP() __all__ = ["tzp"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 3b4567fe..6dbbec81 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -6,6 +6,8 @@ from .provider import TZProvider +DEFAULT_TIMEZONE_PROVIDER = "pytz" + class TZP: """This is the timezone provider proxy. @@ -14,7 +16,7 @@ class TZP: All of icalendar will then use this timezone implementation. """ - def __init__(self, provider:Union[str, TZProvider]): + def __init__(self, provider:Union[str, TZProvider]=DEFAULT_TIMEZONE_PROVIDER): """Create a new timezone implementation proxy.""" self.use(provider) @@ -43,6 +45,10 @@ def use(self, provider:Union[str, TZProvider]): else: self._use(provider) + def use_default(self): + """Use the default timezone provider.""" + self.use(DEFAULT_TIMEZONE_PROVIDER) + def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: """Return the datetime in UTC. diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index bc60dd0e..db590477 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -40,7 +40,7 @@ def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: """Make sure the until value works for the rrule generated from the ical_rrule.""" if not {'UNTIL', 'COUNT'}.intersection(ical_rrule.keys()): # zoninfo does not know any transition dates after 2038 - rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + rrule._until = datetime(2038, 12, 31, tzinfo=self.utc) def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: """Create a timezone from the given information.""" From 942490e7885966447b674348fa7ea2ad6bb80159 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:46:09 +0100 Subject: [PATCH 32/70] fix mistakes in tests --- src/icalendar/tests/test_timezoned.py | 11 +++++------ src/icalendar/tests/test_unit_cal.py | 5 ++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 481e539d..6614c2ac 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -59,23 +59,22 @@ def test_create_to_ical(tzp): cal.add_component(tzc) event = icalendar.Event() - tz = tzp.timezone("Europe/Vienna") event.add( 'dtstart', - tz.localize(datetime.datetime(2012, 2, 13, 10, 00, 00))) + tzp.localize(datetime.datetime(2012, 2, 13, 10, 00, 00), "Europe/Vienna")) event.add( 'dtend', - tz.localize(datetime.datetime(2012, 2, 17, 18, 00, 00))) + tzp.localize(datetime.datetime(2012, 2, 17, 18, 00, 00), "Europe/Vienna")) event.add( 'dtstamp', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) event.add( 'created', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) event.add('uid', '123456') event.add( 'last-modified', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) event.add('summary', 'artsprint 2012') # event.add('rrule', 'FREQ=YEARLY;INTERVAL=1;COUNT=10') event.add('description', 'sprinting at the artsprint') diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index 8b9acee1..67c78af1 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -202,10 +202,9 @@ def test_cal_Component_add(comp, tzp): """Test the for timezone correctness: dtstart should preserve it's timezone, created, dtstamp and last-modified must be in UTC. """ - vienna = tzp.timezone("Europe/Vienna") - comp.add('dtstart', vienna.localize(datetime(2010, 10, 10, 10, 0, 0))) + comp.add('dtstart', tzp.localize(datetime(2010, 10, 10, 10, 0, 0), "Europe/Vienna")) comp.add('created', datetime(2010, 10, 10, 12, 0, 0)) - comp.add('dtstamp', vienna.localize(datetime(2010, 10, 10, 14, 0, 0))) + comp.add('dtstamp', tzp.localize(datetime(2010, 10, 10, 14, 0, 0), "Europe/Vienna")) comp.add('last-modified', tzp.localize_utc( datetime(2010, 10, 10, 16, 0, 0))) From e1b1127d4054d1cc8cf223262d6369f6c55c37f6 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 5 Jun 2024 12:46:36 +0100 Subject: [PATCH 33/70] Fix test dtstamp conversion to UTC --- src/icalendar/timezone/pytz.py | 5 ++--- src/icalendar/timezone/zoneinfo.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 1ec9d615..08224d8d 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -17,9 +17,8 @@ def localize_utc(self, dt: datetime) -> datetime: """Return the datetime in UTC.""" if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: return dt.astimezone(pytz.utc) - else: - # assume UTC for naive datetime instances - return pytz.utc.localize(dt) + # assume UTC for naive datetime instances + return pytz.utc.localize(dt) def localize(self, dt: datetime, tz: tzinfo) -> datetime: """Localize a datetime to a timezone.""" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index db590477..1df94fc7 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -23,6 +23,8 @@ def localize(self, dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime: def localize_utc(self, dt: datetime) -> datetime: """Return the datetime in UTC.""" + if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: + return dt.astimezone(self.utc) return self.localize(dt, self.utc) def timezone(self, name: str) -> Optional[tzinfo]: From 0e11a2341fdf9e5bfa031d2352b3f136f5c05837 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Thu, 6 Jun 2024 13:21:19 +0100 Subject: [PATCH 34/70] Test all failing tests with pytz and zoneinfo --- src/icalendar/tests/conftest.py | 21 ++++++++++----------- src/icalendar/tests/test_equality.py | 10 +++++----- src/icalendar/tests/test_unit_cal.py | 6 +++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index ad02e94b..712d0521 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -12,7 +12,6 @@ from icalendar.timezone import tzp as _tzp from icalendar.timezone import TZP - class DataSource: '''A collection of parsed ICS elements (e.g calendars, timezones, events)''' def __init__(self, data_source_folder, parser): @@ -57,15 +56,15 @@ def multiple(self): EVENTS = DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) @pytest.fixture() -def calendars(): +def calendars(tzp): return CALENDARS @pytest.fixture() -def timezones(): +def timezones(tzp): return TIMEZONES @pytest.fixture() -def events(): +def events(tzp): return EVENTS @pytest.fixture(params=[ @@ -74,7 +73,7 @@ def events(): pytz.timezone('UTC'), tz.UTC, tz.gettz('UTC')]) -def utc(request): +def utc(request, tzp): return request.param @pytest.fixture(params=[ @@ -82,7 +81,7 @@ def utc(request): lambda dt, tzname: dt.replace(tzinfo=tz.gettz(tzname)), lambda dt, tzname: dt.replace(tzinfo=zoneinfo.ZoneInfo(tzname)) ]) -def in_timezone(request): +def in_timezone(request, tzp): return request.param @@ -97,7 +96,7 @@ def in_timezone(request): ) ] @pytest.fixture(params=ICS_FILES) -def ics_file(request): +def ics_file(request, tzp): """An example ICS file.""" data, key = request.param print(key) @@ -133,7 +132,7 @@ def vUTCOffset_ignore_exceptions(): @pytest.fixture() -def event_component(): +def event_component(tzp): """Return an event component.""" c = Component() c.name = 'VEVENT' @@ -141,14 +140,14 @@ def event_component(): @pytest.fixture() -def c(): +def c(tzp): """Return an empty component.""" c = Component() return c comp = c @pytest.fixture() -def calendar_component(): +def calendar_component(tzp): """Return an empty component.""" c = Component() c.name = 'VCALENDAR' @@ -167,7 +166,7 @@ def filled_event_component(c, calendar_component): @pytest.fixture() -def calendar_with_resources(): +def calendar_with_resources(tzp): c = Calendar() c['resources'] = 'Chair, Table, "Room: 42"' return c diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py index b18394af..55c8ae93 100644 --- a/src/icalendar/tests/test_equality.py +++ b/src/icalendar/tests/test_equality.py @@ -17,20 +17,20 @@ def assert_not_equal(actual_value, expected_value): assert actual_value != expected_value -def test_parsed_calendars_are_equal_if_parsed_again(ics_file): +def test_parsed_calendars_are_equal_if_parsed_again(ics_file, tzp): """Ensure that a calendar equals the same calendar.""" copy_of_calendar = ics_file.__class__.from_ical(ics_file.to_ical()) assert_equal(copy_of_calendar, ics_file) -def test_parsed_calendars_are_equal_if_from_same_source(ics_file): +def test_parsed_calendars_are_equal_if_from_same_source(ics_file, tzp): """Ensure that a calendar equals the same calendar.""" cal1 = ics_file.__class__.from_ical(ics_file.raw_ics) cal2 = ics_file.__class__.from_ical(ics_file.raw_ics) assert_equal(cal1, cal2) -def test_copies_are_equal(ics_file): +def test_copies_are_equal(ics_file, tzp): """Ensure that copies are equal.""" copy1 = ics_file.copy(); copy1.subcomponents = ics_file.subcomponents copy2 = ics_file.copy(); copy2.subcomponents = ics_file.subcomponents[:] @@ -39,13 +39,13 @@ def test_copies_are_equal(ics_file): assert_equal(copy2, ics_file) -def test_copy_does_not_copy_subcomponents(calendars): +def test_copy_does_not_copy_subcomponents(calendars, tzp): """If we copy the subcomponents, assumptions around copies will be broken.""" assert calendars.timezoned.subcomponents assert not calendars.timezoned.copy().subcomponents -def test_deep_copies_are_equal(ics_file): +def test_deep_copies_are_equal(ics_file, tzp): """Ensure that deep copies are equal.""" try: assert_equal(copy.deepcopy(ics_file), copy.deepcopy(ics_file)) diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index 67c78af1..43701d78 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -248,7 +248,7 @@ def test_cal_Component_add_property_parameter(comp): @comp_prop -def test_cal_Component_from_ical(component_name, property_name): +def test_cal_Component_from_ical(component_name, property_name, tzp): """Check for proper handling of TZID parameter of datetime properties""" component_str = 'BEGIN:' + component_name + '\n' component_str += property_name + ';TZID=America/Denver:' @@ -258,7 +258,7 @@ def test_cal_Component_from_ical(component_name, property_name): @comp_prop -def test_cal_Component_from_ical_2(component_name, property_name): +def test_cal_Component_from_ical_2(component_name, property_name, tzp): """Check for proper handling of TZID parameter of datetime properties""" component_str = 'BEGIN:' + component_name + '\n' component_str += property_name + ':' @@ -414,7 +414,7 @@ def test_cal_ignore_errors_parsing(calendars, vUTCOffset_ignore_exceptions): 'issue_526_calendar_with_event_subset', ], repeat=2) ) -def test_comparing_calendars(calendars, calendar, other_calendar): +def test_comparing_calendars(calendars, calendar, other_calendar, tzp): are_calendars_equal = calendars[calendar] == calendars[other_calendar] are_calendars_actually_equal = calendar == other_calendar assert are_calendars_equal == are_calendars_actually_equal From b75be6586ba0ca4bd4c341702dd714361d3e3749 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 13:24:25 +0100 Subject: [PATCH 35/70] Parametrize tests to run with zoneinfo and pytz --- src/icalendar/cal.py | 29 +++++-- src/icalendar/tests/conftest.py | 73 ++++++++++------ src/icalendar/tests/test_parsing.py | 5 +- .../tests/test_pytz_zoneinfo_integration.py | 49 +++++++++++ src/icalendar/tests/test_timezoned.py | 22 ++++- src/icalendar/tests/test_unit_cal.py | 3 +- src/icalendar/tests/test_with_doctest.py | 7 +- .../tests/timezones/pacific_fiji.ics | 42 +++++++++ src/icalendar/timezone/provider.py | 6 +- src/icalendar/timezone/pytz.py | 11 ++- src/icalendar/timezone/tzp.py | 42 +++++++-- src/icalendar/timezone/zoneinfo.py | 85 +++++++++++++++++-- 12 files changed, 311 insertions(+), 63 deletions(-) create mode 100644 src/icalendar/tests/timezones/pacific_fiji.ics diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 4e78d883..727f3151 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -14,7 +14,7 @@ from icalendar.prop import TypesFactory from icalendar.prop import vText, vDDDLists from icalendar.timezone import tzp - +from typing import Tuple, List import dateutil.rrule, dateutil.tz @@ -610,13 +610,31 @@ def _make_unique_tzname(tzname, tznames): tznames.add(tzname) return tzname - def to_tz(self): + def to_tz(self, tzp=tzp): """convert this VTIMEZONE component to a timezone object """ + return tzp.create_timezone(self) + + @property + def tz_name(self) -> str: + """Return the name of the timezone component. + + Please note that the names of the timezone is different from this name + and may change with winter/summer time. + """ try: - zone = str(self['TZID']) + return str(self['TZID']) except UnicodeEncodeError: - zone = self['TZID'].encode('ascii', 'replace') + return self['TZID'].encode('ascii', 'replace') + + def get_transitions(self) -> Tuple[List[datetime], List[Tuple[timedelta, timedelta, str]]]: + """Return a tuple of (transition_times, transition_info) + + - transition_times = [datetime, ...] + - transition_info = [(TZOFFSETTO, dts_offset, tzname)] + + """ + zone = self.tz_name transitions = [] dst = {} tznames = set() @@ -672,8 +690,7 @@ def to_tz(self): break assert dst_offset is not False transition_info.append((osto, dst_offset, name)) - - return tzp.create_timezone(zone, transition_times, transition_info) + return transition_times, transition_info class TimezoneStandard(Component): diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 712d0521..d6d346c9 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -24,7 +24,11 @@ def keys(self): def __getitem__(self, attribute): """Parse a file and return the result stored in the attribute.""" - source_file = attribute + '.ics' + if attribute.endswith(".ics"): + source_file = attribute + attribute = attribute[:-4] + else: + source_file = attribute + '.ics' source_path = os.path.join(self._data_source_folder, source_file) if not os.path.isfile(source_path): raise AttributeError(f"{source_path} does not exist.") @@ -36,6 +40,12 @@ def __getitem__(self, attribute): self.__dict__[attribute] = source return source + def __contains__(self, key): + """key in self.keys()""" + if key.endswith(".ics"): + key = key[:-4] + return key in self.keys() + def __getattr__(self, key): return self[key] @@ -49,23 +59,20 @@ def multiple(self): HERE = os.path.dirname(__file__) CALENDARS_FOLDER = os.path.join(HERE, 'calendars') -CALENDARS = DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) TIMEZONES_FOLDER = os.path.join(HERE, 'timezones') -TIMEZONES = DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) EVENTS_FOLDER = os.path.join(HERE, 'events') -EVENTS = DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) -@pytest.fixture() +@pytest.fixture(scope="package") def calendars(tzp): - return CALENDARS + return DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) -@pytest.fixture() +@pytest.fixture(scope="package") def timezones(tzp): - return TIMEZONES + return DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) -@pytest.fixture() +@pytest.fixture(scope="package") def events(tzp): - return EVENTS + return DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) @pytest.fixture(params=[ pytz.utc, @@ -85,22 +92,26 @@ def in_timezone(request, tzp): return request.param +# exclude broken calendars here +ICS_FILES_EXCLUDE = ( + "big_bad_calendar.ics", "issue_104_broken_calendar.ics", "small_bad_calendar.ics", + "multiple_calendar_components.ics", "pr_480_summary_with_colon.ics", + "parsing_error_in_UTC_offset.ics", "parsing_error.ics", +) ICS_FILES = [ - (data, key) - for data in [CALENDARS, TIMEZONES, EVENTS] - for key in data.keys() if key not in - ( # exclude broken calendars here - "big_bad_calendar", "issue_104_broken_calendar", "small_bad_calendar", - "multiple_calendar_components", "pr_480_summary_with_colon", - "parsing_error_in_UTC_offset", "parsing_error", - ) + file_name for file_name in + os.listdir(CALENDARS_FOLDER) + os.listdir(TIMEZONES_FOLDER) + os.listdir(EVENTS_FOLDER) + if file_name not in ICS_FILES_EXCLUDE ] @pytest.fixture(params=ICS_FILES) -def ics_file(request, tzp): +def ics_file(tzp, calendars, timezones, events, request): """An example ICS file.""" - data, key = request.param - print(key) - return data[key] + ics_file = request.param + print("example file:", ics_file) + for data in calendars, timezones, events: + if ics_file in data: + return data[ics_file] + raise ValueError(f"Could not find file {ics_file}.") FUZZ_V1 = [os.path.join(CALENDARS_FOLDER, key) for key in os.listdir(CALENDARS_FOLDER) if "fuzz-testcase" in key] @@ -172,10 +183,16 @@ def calendar_with_resources(tzp): return c -@pytest.fixture(params=["pytz", "zoneinfo"]) -def tzp(request): +@pytest.fixture(params=["pytz", "zoneinfo"], scope="package") +def tzp_name(request): + """The name of the timezone provider.""" + return request.param + + +@pytest.fixture(scope="package") +def tzp(tzp_name): """The time zone provider.""" - _tzp.use(request.param) # todo: parametrize + _tzp.use(tzp_name) yield _tzp _tzp.use_default() @@ -189,3 +206,9 @@ def other_tzp(request, tzp): """ tzp = TZP(request.param) return tzp + +@pytest.fixture() +def pytz_only(tzp): + """Skip tests that are not running under pytz.""" + if not tzp.uses_pytz(): + pytest.skip("Not using pytz. Skipping this test.") diff --git a/src/icalendar/tests/test_parsing.py b/src/icalendar/tests/test_parsing.py index fdc43adc..adc08c3d 100644 --- a/src/icalendar/tests/test_parsing.py +++ b/src/icalendar/tests/test_parsing.py @@ -116,7 +116,7 @@ def test_tzid_parsed_properly_issue_53(timezones): https://github.com/collective/icalendar/issues/53 ''' assert timezones.issue_53_tzid_parsed_properly['tzid'].to_ical() == b'America/New_York' - + def test_timezones_to_ical_is_inverse_of_from_ical(timezones): '''Issue #55 - Parse error on utc-offset with seconds value see https://github.com/collective/icalendar/issues/55''' @@ -141,7 +141,7 @@ def test_no_tzid_when_utc(utc, date, expected_output): event = Event() event.add('dtstart', date) assert expected_output in event.to_ical() - + def test_vBinary_base64_encoded_issue_82(): '''Issue #82 - vBinary __repr__ called rather than to_ical from container types @@ -186,4 +186,3 @@ def test_escaped_characters_read(event_name, expected_cn, expected_ics, events): event = events[event_name] assert event['ORGANIZER'].params['CN'] == expected_cn assert event['ORGANIZER'].to_ical() == expected_ics.encode('utf-8') - diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py index c3cdf1b1..baf28a36 100644 --- a/src/icalendar/tests/test_pytz_zoneinfo_integration.py +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -4,8 +4,13 @@ """ import pytz from icalendar.timezone.zoneinfo import zoneinfo, ZONEINFO +from dateutil.tz.tz import _tzicalvtz from icalendar.timezone.pytz import PYTZ import pytest +import copy +import pickle +from dateutil.rrule import rrule, MONTHLY +from datetime import datetime @pytest.mark.parametrize("tz_name", pytz.all_timezones + list(zoneinfo.available_timezones())) @@ -15,3 +20,47 @@ def test_timezone_names_are_known(tz_name, tzp_): if tz_name in ("Factory", "localtime"): pytest.skip() assert tzp_.knows_timezone_id(tz_name), f"{tzp_.__class__.__name__} should know {tz_name}" + + +@pytest.mark.parametrize("func", [pickle.dumps, copy.copy, copy.deepcopy]) +@pytest.mark.parametrize("obj", [_tzicalvtz("id"), rrule(freq=MONTHLY, count=4, dtstart=datetime(2028, 10, 1), cache=True)]) +def test_can_pickle_timezone(func, tzp, obj): + """Check that re can serialize and copy timezones.""" + func(obj) + + +def test_copied_rrule_is_the_same(): + """When we copy an rrule, we want it to be the same after this.""" + r = rrule(freq=MONTHLY, count=4, dtstart=datetime(2028, 10, 1), cache=True) + assert str(copy.deepcopy(r)) == str(r) + + +def test_tzp_properly_switches(tzp, tzp_name): + """We want the default implementation to switch.""" + assert (tzp_name == "pytz") == tzp.uses_pytz() + + +def test_tzp_is_pytz_only(tzp, tzp_name, pytz_only): + """We want the default implementation to switch.""" + assert tzp_name == "pytz" + assert tzp.uses_pytz() + + +def test_cache_reuse_timezone_cache(tzp, timezones): + """Make sure we do not reuse the timezones created when we switch the provider.""" + tzp.cache_timezone_component(timezones.pacific_fiji) + tzp1 = tzp.timezone("custom_Pacific/Fiji") + assert tzp1 is tzp.timezone("custom_Pacific/Fiji") + tzp.cache_timezone_component(timezones.pacific_fiji) + assert tzp1 is tzp.timezone("custom_Pacific/Fiji"), "Cache is not replaced." + + +@pytest.mark.parametrize("new_tzp_name", ["pytz", "zoneinfo"]) +def test_cache_is_emptied_when_tzp_is_switched(tzp, timezones, new_tzp_name): + """Make sure we do not reuse the timezones created when we switch the provider.""" + tzp.cache_timezone_component(timezones.pacific_fiji) + tz1 = tzp.timezone("custom_Pacific/Fiji") + tzp.use(new_tzp_name) + tzp.cache_timezone_component(timezones.pacific_fiji) + tz2 = tzp.timezone("custom_Pacific/Fiji") + assert tz1 is not tz2 diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 6614c2ac..7a7e648f 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -4,6 +4,7 @@ import dateutil.parser import icalendar import os +from icalendar.prop import tzid_from_dt def test_create_from_ical(calendars, other_tzp): @@ -128,9 +129,16 @@ def test_tzinfo_dateutil(): def test_create_america_new_york(calendars, tzp): """testing America/New_York, the most complex example from the RFC""" cal = calendars.america_new_york + dt = cal.walk('VEVENT')[0]['DTSTART'][0].dt + assert tzid_from_dt(dt) in ('custom_America/New_York', "EDT") - tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - assert str(tz) == 'custom_America/New_York' + +def test_america_new_york_with_pytz(calendars, tzp, pytz_only): + """Create a custom timezone with pytz and test the transition times.""" + print(tzp) + cal = calendars.america_new_york + dt = cal.walk('VEVENT')[0]['DTSTART'][0].dt + tz = dt.tzinfo tz_new_york = tzp.timezone('America/New_York') # for reasons (tm) the locally installed version of the time zone # database isn't always complete, therefore we only compare some @@ -151,7 +159,7 @@ def test_create_america_new_york(calendars, tzp): assert (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST') in tz._tzinfos.keys() -def test_create_pacific_fiji(calendars): +def test_create_pacific_fiji(calendars, pytz_only): """testing Pacific/Fiji, another pretty complex example with more than one RDATE property per subcomponent""" cal = calendars.pacific_fiji @@ -290,12 +298,18 @@ def test_same_start_date_and_offset(calendars): assert d.strftime('%c') == 'Fri Feb 24 12:00:00 2017' def test_rdate(calendars): + """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE + """ + cal = calendars.timezone_rdate + vevent = cal.walk('VEVENT')[0] + assert tzid_from_dt(vevent['DTSTART'].dt) in ('posix/Europe/Vaduz', "CET") + +def test_rdate_pytz(calendars, pytz_only): """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE """ cal = calendars.timezone_rdate vevent = cal.walk('VEVENT')[0] tz = vevent['DTSTART'].dt.tzinfo - assert str(tz) == 'posix/Europe/Vaduz' assert tz._utc_transition_times[:6] == [ datetime.datetime(1901, 12, 13, 20, 45, 38), datetime.datetime(1941, 5, 5, 0, 0, 0), diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index 43701d78..007d38b0 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -9,6 +9,7 @@ import re from icalendar.cal import Component, Calendar, Event, ComponentFactory from icalendar import prop, cal +from icalendar.prop import tzid_from_dt def test_cal_Component(calendar_component): @@ -254,7 +255,7 @@ def test_cal_Component_from_ical(component_name, property_name, tzp): component_str += property_name + ';TZID=America/Denver:' component_str += '20120404T073000\nEND:' + component_name component = Component.from_ical(component_str) - assert str(component[property_name].dt.tzinfo.zone) == "America/Denver" + assert tzid_from_dt(component[property_name].dt) == "America/Denver" @comp_prop diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index d536ff9c..dbc9744b 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -1,6 +1,6 @@ """This file tests the source code provided by the documentation. -See +See - doctest documentation: https://docs.python.org/3/library/doctest.html - Issue 443: https://github.com/collective/icalendar/issues/443 @@ -58,10 +58,7 @@ def test_files_is_included(filename): assert any(path.endswith(filename) for path in DOCUMENT_PATHS) @pytest.mark.parametrize("document", DOCUMENT_PATHS) -def test_documentation_file(document): +def test_documentation_file(document, pytz_only): """This test runs doctest on a documentation file.""" test_result = doctest.testfile(document, module_relative=False) assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}" - - - diff --git a/src/icalendar/tests/timezones/pacific_fiji.ics b/src/icalendar/tests/timezones/pacific_fiji.ics new file mode 100644 index 00000000..d5b87735 --- /dev/null +++ b/src/icalendar/tests/timezones/pacific_fiji.ics @@ -0,0 +1,42 @@ +BEGIN:VTIMEZONE +TZID:custom_Pacific/Fiji +TZURL:http://tzurl.org/zoneinfo/Pacific/Fiji +X-LIC-LOCATION:Pacific/Fiji +BEGIN:DAYLIGHT +TZOFFSETFROM:+1200 +TZOFFSETTO:+1300 +DTSTART:20101024T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=21,22,23,24,25,26,27;BYDAY=SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+1300 +TZOFFSETTO:+1200 +DTSTART:20140119T020000 +RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=18,19,20,21,22,23,24;BYDAY=SU +END:STANDARD +BEGIN:STANDARD +TZOFFSETFROM:+115544 +TZOFFSETTO:+1200 +DTSTART:19151026T000000 +RDATE:19151026T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+1200 +TZOFFSETTO:+1300 +DTSTART:19981101T020000 +RDATE:19981101T020000 +RDATE:19991107T020000 +RDATE:20091129T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+1300 +TZOFFSETTO:+1200 +DTSTART:19990228T030000 +RDATE:19990228T030000 +RDATE:20000227T030000 +RDATE:20100328T030000 +RDATE:20110306T030000 +RDATE:20120122T030000 +RDATE:20130120T030000 +END:STANDARD +END:VTIMEZONE diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py index 22a511d0..8b3efe88 100644 --- a/src/icalendar/timezone/provider.py +++ b/src/icalendar/timezone/provider.py @@ -1,6 +1,6 @@ """The interface for timezone implementations.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import ABC, abstractmethod, abstractproperty from icalendar import prop from dateutil.rrule import rrule from datetime import datetime, tzinfo @@ -8,6 +8,10 @@ class TZProvider(ABC): """Interface for timezone implementations.""" + @abstractproperty + def name(self) -> str: + """The name of the implementation.""" + @abstractmethod def localize_utc(self, dt: datetime) -> datetime: """Return the datetime in UTC.""" diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index 08224d8d..abb90581 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -1,6 +1,7 @@ """Use pytz timezones.""" from __future__ import annotations import pytz +from .. import cal from datetime import datetime, tzinfo from pytz.tzinfo import DstTzInfo from typing import Optional @@ -13,6 +14,8 @@ class PYTZ(TZProvider): """Provide icalendar with timezones from pytz.""" + name = "pytz" + def localize_utc(self, dt: datetime) -> datetime: """Return the datetime in UTC.""" if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: @@ -35,8 +38,10 @@ def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: # either rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) - def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + def create_timezone(self, tz: cal.Timezone) -> tzinfo: """Create a pytz timezone from the given information.""" + transition_times, transition_info = tz.get_transitions() + name = tz.tz_name cls = type(name, (DstTzInfo,), { 'zone': name, '_utc_transition_times': transition_times, @@ -51,5 +56,9 @@ def timezone(self, name: str) -> Optional[tzinfo]: except pytz.UnknownTimeZoneError: pass + def uses_pytz(self) -> bool: + """Whether we use pytz at all.""" + return True + __all__ = ["PYTZ"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 6dbbec81..b40622e8 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -4,10 +4,13 @@ from typing import Optional, Union from .windows_to_olson import WINDOWS_TO_OLSON from .provider import TZProvider +from icalendar import prop +from dateutil.rrule import rrule DEFAULT_TIMEZONE_PROVIDER = "pytz" + class TZP: """This is the timezone provider proxy. @@ -62,18 +65,28 @@ def localize(self, dt: datetime.datetime, tz: Union[datetime.tzinfo, str]) -> da tz = self.timezone(tz) return self.__provider.localize(dt, tz) - def cache_timezone_component(self, component: cal.VTIMEZONE) -> None: - """Cache a timezone component.""" - if not self.__provider.knows_timezone_id(component['TZID']): - self.__tz_cache.setdefault(component['TZID'], component.to_tz()) + def cache_timezone_component(self, timezone_component: cal.VTIMEZONE) -> None: + """Cache the timezone that is created from a timezone component + if it is not already known. + + This can influence the result from timezone(): Once cached, the + custom timezone is returned from timezone(). + """ + tzid = timezone_component['TZID'] + if not self.__provider.knows_timezone_id(tzid) \ + and tzid not in self.__tz_cache: + self.__tz_cache[tzid] = timezone_component.to_tz(self) - def fix_rrule_until(self, rrule, component) -> None: + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: """Make sure the until value works.""" - self.__provider.fix_rrule_until(rrule, component) + self.__provider.fix_rrule_until(rrule, ical_rrule) - def create_timezone(self, name: str, transition_times, transition_info) -> datetime.tzinfo: - """Create a timezone from given information.""" - return self.__provider.create_timezone(name, transition_times, transition_info) + def create_timezone(self, timezone_component: cal.Timezone) -> datetime.tzinfo: + """Create a timezone from a timezone component. + + This component will not be cached. + """ + return self.__provider.create_timezone(timezone_component) def timezone(self, id: str) -> Optional[datetime.tzinfo]: """Return a timezone with an id or None if we cannot find it.""" @@ -85,5 +98,16 @@ def timezone(self, id: str) -> Optional[datetime.tzinfo]: tz = self.__provider.timezone(WINDOWS_TO_OLSON[clean_id]) return tz or self.__provider.timezone(id) or self.__tz_cache.get(id) + def uses_pytz(self) -> bool: + """Whether we use pytz at all.""" + return self.__provider.uses_pytz() + + @property + def name(self) -> str: + """The name of the timezone component used.""" + return self.__provider.name + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.name)})" __all__ = ["TZP"] diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 1df94fc7..37999e0a 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -5,15 +5,23 @@ except: from backports import zoneinfo from icalendar import prop -from dateutil.rrule import rrule +from dateutil.rrule import rrule, rruleset from datetime import datetime, tzinfo from typing import Optional from .provider import TZProvider +from .. import cal +from io import StringIO +from dateutil.tz import tzical +from dateutil.tz.tz import _tzicalvtz +from copyreg import pickle +import functools +import copy class ZONEINFO(TZProvider): """Provide icalendar with timezones from zoneinfo.""" + name = "zoneinfo" utc = zoneinfo.ZoneInfo("UTC") _available_timezones = zoneinfo.available_timezones() @@ -44,14 +52,75 @@ def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: # zoninfo does not know any transition dates after 2038 rrule._until = datetime(2038, 12, 31, tzinfo=self.utc) - def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + def create_timezone(self, tz: cal.Timezone) -> tzinfo: """Create a timezone from the given information.""" - cls = type(name, (DstTzInfo,), { - 'zone': name, - '_utc_transition_times': transition_times, - '_transition_info': transition_info - }) - return cls() + try: + return self._create_timezone(tz) + except ValueError: + # We might have a custom component in there. + # see https://github.com/python/cpython/issues/120217 + tz = copy.deepcopy(tz) + for sub in tz.walk(): + for attr in list(sub.keys()): + if attr.lower().startswith("x-"): + sub.pop(attr) + return self._create_timezone(tz) + + def _create_timezone(self, tz: cal.Timezone) -> tzinfo: + """Create a timezone and maybe fail""" + file = StringIO(tz.to_ical().decode("UTF-8", "replace")) + return tzical(file).get() + + def uses_pytz(self) -> bool: + """Whether we use pytz at all.""" + return False + + +def pickle_tzicalvtz(tzicalvtz:tz._tzicalvtz): + """Because we use dateutil.tzical, we need to make it pickle-able.""" + return _tzicalvtz, (tzicalvtz._tzid, tzicalvtz._comps) + +pickle(_tzicalvtz, pickle_tzicalvtz) + + +def pickle_rrule_with_cache(self: rrule): + """Make sure we can also pickle rrules that cache. + + This is mainly copied from rrule.replace. + """ + new_kwargs = {"interval": self._interval, + "count": self._count, + "dtstart": self._dtstart, + "freq": self._freq, + "until": self._until, + "wkst": self._wkst, + "cache": False if self._cache is None else True } + new_kwargs.update(self._original_rule) + # from https://stackoverflow.com/a/64915638/1320237 + return functools.partial(rrule, new_kwargs.pop("freq"), **new_kwargs), () + +pickle(rrule, pickle_rrule_with_cache) + +def pickle_rruleset_with_cache(rs: rruleset): + """Pickle an rruleset.""" + # self._rrule = [] + # self._rdate = [] + # self._exrule = [] + # self._exdate = [] + return unpickle_rruleset_with_cache, ( + rs._rrule, rs._rdate, rs._exrule, + rs._exdate, False if rs._cache is None else True + ) + +def unpickle_rruleset_with_cache(rrule, rdate, exrule, exdate, cache): + """unpickling the rruleset.""" + rs = rruleset(cache) + for o in rrule: rs.rrule(o) + for o in rdate: rs.rdate(o) + for o in exrule: rs.exrule(o) + for o in exdate: rs.exdate(o) + return rs +pickle(rruleset, pickle_rruleset_with_cache) __all__ = ["ZONEINFO"] From 9035a2204a9a6956f173f73793fc083951b78ee5 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 13:44:28 +0100 Subject: [PATCH 36/70] Make timezones with / work --- src/icalendar/tests/test_parsing.py | 6 ++++-- src/icalendar/timezone/tzp.py | 29 ++++++++++++++++++++--------- src/icalendar/timezone/zoneinfo.py | 3 +++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/icalendar/tests/test_parsing.py b/src/icalendar/tests/test_parsing.py index adc08c3d..940f2457 100644 --- a/src/icalendar/tests/test_parsing.py +++ b/src/icalendar/tests/test_parsing.py @@ -170,8 +170,10 @@ def test_creates_event_with_base64_encoded_attachment_issue_82(events): ]) def test_handles_unique_tzid(calendars, in_timezone, calendar_name): calendar = calendars[calendar_name] - start_dt = calendar.walk('VEVENT')[0]['dtstart'].dt - end_dt = calendar.walk('VEVENT')[0]['dtend'].dt + event = calendar.walk('VEVENT')[0] + print(vars(event)) + start_dt = event['dtstart'].dt + end_dt = event['dtend'].dt assert start_dt == in_timezone(datetime(2022, 10, 21, 20, 0, 0), 'Europe/Stockholm') assert end_dt == in_timezone(datetime(2022, 10, 21, 21, 0, 0), 'Europe/Stockholm') diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index b40622e8..8f3839ec 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -72,10 +72,12 @@ def cache_timezone_component(self, timezone_component: cal.VTIMEZONE) -> None: This can influence the result from timezone(): Once cached, the custom timezone is returned from timezone(). """ - tzid = timezone_component['TZID'] - if not self.__provider.knows_timezone_id(tzid) \ - and tzid not in self.__tz_cache: - self.__tz_cache[tzid] = timezone_component.to_tz(self) + _unclean_id = timezone_component['TZID'] + id = self.clean_timezone_id(_unclean_id) + if not self.__provider.knows_timezone_id(id) \ + and not self.__provider.knows_timezone_id(_unclean_id) \ + and id not in self.__tz_cache: + self.__tz_cache[id] = timezone_component.to_tz(self) def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: """Make sure the until value works.""" @@ -88,15 +90,24 @@ def create_timezone(self, timezone_component: cal.Timezone) -> datetime.tzinfo: """ return self.__provider.create_timezone(timezone_component) + def clean_timezone_id(self, tzid: str) -> str: + """Return a clean version of the timezone id. + + Timezone ids can be a bit unclean, starting with a / for example. + Internally, we should use this to identify timezones. + """ + return tzid.strip("/") + def timezone(self, id: str) -> Optional[datetime.tzinfo]: """Return a timezone with an id or None if we cannot find it.""" - clean_id = id.strip("/") - tz = self.__provider.timezone(clean_id) + _unclean_id = id + id = self.clean_timezone_id(id) + tz = self.__provider.timezone(id) if tz is not None: return tz - if clean_id in WINDOWS_TO_OLSON: - tz = self.__provider.timezone(WINDOWS_TO_OLSON[clean_id]) - return tz or self.__provider.timezone(id) or self.__tz_cache.get(id) + if id in WINDOWS_TO_OLSON: + tz = self.__provider.timezone(WINDOWS_TO_OLSON[id]) + return tz or self.__provider.timezone(_unclean_id) or self.__tz_cache.get(id) def uses_pytz(self) -> bool: """Whether we use pytz at all.""" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 37999e0a..37f59ff3 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -41,6 +41,9 @@ def timezone(self, name: str) -> Optional[tzinfo]: return zoneinfo.ZoneInfo(name) except zoneinfo.ZoneInfoNotFoundError: pass + except ValueError: + # ValueError: ZoneInfo keys may not be absolute paths, got: /Europe/CUSTOM + pass def knows_timezone_id(self, id: str) -> bool: """Whether the timezone is already cached by the implementation.""" From 2457f3646c7121115771aee7930b57b2b4cbf006 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 4 Jun 2024 12:30:06 +0100 Subject: [PATCH 37/70] Make documentation build under Python 3.12 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 54e7050e..7572fe7a 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ commands = [testenv:docs] deps = -r {toxinidir}/requirements_docs.txt + setuptools changedir = docs allowlist_externals = make commands = From c9f425b25bcd51dc93bd384b18910102c3449ce1 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 17:10:35 +0100 Subject: [PATCH 38/70] Add methods to access examples faster --- src/icalendar/cal.py | 30 ++++++++++++++++++++++++++++ src/icalendar/tests/test_examples.py | 25 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 727f3151..7804bcc5 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -3,6 +3,7 @@ These are the defined components. """ +from __future__ import annotations from datetime import datetime, timedelta from icalendar.caselessdict import CaselessDict from icalendar.parser import Contentline @@ -16,8 +17,21 @@ from icalendar.timezone import tzp from typing import Tuple, List import dateutil.rrule, dateutil.tz +import os +def get_example(component_directory: str, example_name: str) -> bytes: + """Return an example and raise an error if it is absent.""" + here = os.path.dirname(__file__) + examples = os.path.join(here, "tests", component_directory) + if not example_name.endswith(".ics"): + example_name = example_name + ".ics" + example_file = os.path.join(examples, example_name) + if not os.path.isfile(example_file): + raise ValueError(f"Example {example_name} for {component_directory} not found. You can use one of {', '.join(os.listdir(examples))}") + with open(example_file, "rb") as f: + return f.read() + ###################################### # The component factory @@ -492,6 +506,12 @@ class Event(Component): ) ignore_exceptions = True + @classmethod + def example(cls, name) -> Event: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("events", name)) + + class Todo(Component): @@ -544,6 +564,11 @@ class Timezone(Component): required = ('TZID',) # it also requires one of components DAYLIGHT and STANDARD singletons = ('TZID', 'LAST-MODIFIED', 'TZURL',) + @classmethod + def example(cls, name) -> Calendar: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("timezones", name)) + @staticmethod def _extract_offsets(component, tzname): """extract offsets and transition times from a VTIMEZONE component @@ -728,6 +753,11 @@ class Calendar(Component): required = ('PRODID', 'VERSION', ) singletons = ('PRODID', 'VERSION', 'CALSCALE', 'METHOD') + @classmethod + def example(cls, name) -> Calendar: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("calendars", name)) + # These are read only singleton, so one instance is enough for the module types_factory = TypesFactory() component_factory = ComponentFactory() diff --git a/src/icalendar/tests/test_examples.py b/src/icalendar/tests/test_examples.py index 0c1c132e..3443d066 100644 --- a/src/icalendar/tests/test_examples.py +++ b/src/icalendar/tests/test_examples.py @@ -1,7 +1,7 @@ '''tests ensuring that *the* way of doing things works''' import datetime -from icalendar import Calendar, Event +from icalendar import Calendar, Event, Timezone import pytest @@ -37,3 +37,26 @@ def test_creating_calendar_with_unicode_fields(calendars, utc): cal.add_component(event2) assert cal.to_ical() == calendars.created_calendar_with_unicode_fields.raw_ics + + +@pytest.mark.parametrize("component,example", + [ + (Calendar, "example"), + (Calendar, "example.ics"), + (Event, "event_with_rsvp"), + (Timezone, "pacific_fiji"), + ] +) +def test_component_has_examples(tzp, calendars, timezones, events, component, example): + """Check that the examples function works.""" + mapping = {Calendar: calendars, Event: events, Timezone: timezones} + example_component = component.example(example) + expected_component = mapping[component][example] + assert example_component == expected_component + + +def test_invalid_examples_lists_the_others(): + """We need a bit of guidance here.""" + with pytest.raises(ValueError) as e: + Calendar.example("does not exist") + assert "example.ics" in str(e.value) From 9e89a029819daff8c061a7ffef8828f8ff16dd93 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 17:49:41 +0100 Subject: [PATCH 39/70] Switch documentation to zoneinfo --- README.rst | 17 +++++++++++++++++ docs/usage.rst | 5 ++--- src/icalendar/__init__.py | 3 +++ src/icalendar/tests/conftest.py | 8 ++++++++ src/icalendar/tests/test_with_doctest.py | 12 +++++++++++- src/icalendar/timezone/__init__.py | 10 +++++++++- src/icalendar/timezone/provider.py | 8 ++++++++ src/icalendar/timezone/pytz.py | 6 +++++- src/icalendar/timezone/tzp.py | 4 ++++ src/icalendar/timezone/zoneinfo.py | 6 +++++- 10 files changed, 72 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 0ae54fdf..b0efab62 100644 --- a/README.rst +++ b/README.rst @@ -80,6 +80,23 @@ We expect the ``master`` branch with versions ``5+`` receive the latest updates and the ``4.x`` branch the subset of security and bug fixes only. We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. +``icalendar`` Version 6 +~~~~~~~~~~~~~~~~~~~~~~~ + +Version 6 of ``icalendar`` switches the timezone implementation from ``pytz`` to ``zoneinfo``. + + >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt + >>> dt.tzinfo + zoneinfo.ZoneInfo(key='Europe/Vienna') + +If you would like to continue to use ``pytz`` and receive the latest updates, you +can switch back: + + >>> icalendar.use_pytz() + >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt + >>> dt.tzinfo + + Related projects ================ diff --git a/docs/usage.rst b/docs/usage.rst index 919d9f0f..c00518e0 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -210,9 +210,8 @@ Python type:: >>> vDatetime.from_ical('20050404T080000') datetime.datetime(2005, 4, 4, 8, 0) - >>> dt = vDatetime.from_ical('20050404T080000Z') - >>> repr(dt)[:62] - 'datetime.datetime(2005, 4, 4, 8, 0, tzinfo=)' + >>> vDatetime.from_ical('20050404T080000Z') + datetime.datetime(2005, 4, 4, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC')) You can also choose to use the decoded() method, which will return a decoded value directly:: diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 622d114e..1dcab793 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -46,3 +46,6 @@ q_split, q_join, ) + +# Switching the timezone provider +from icalendar.timezone import use_pytz, use_zoneinfo diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index d6d346c9..d11a9d0f 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -207,8 +207,16 @@ def other_tzp(request, tzp): tzp = TZP(request.param) return tzp + @pytest.fixture() def pytz_only(tzp): """Skip tests that are not running under pytz.""" if not tzp.uses_pytz(): pytest.skip("Not using pytz. Skipping this test.") + + +@pytest.fixture() +def zoneinfo_only(tzp): + """Skip tests that are not running under pytz.""" + if not tzp.uses_zoneinfo(): + pytest.skip("Not using zoneinfo. Skipping this test.") diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index dbc9744b..12053603 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -14,6 +14,15 @@ import os import pytest import importlib +try: + from backports import zoneinfo + # we make the tests nicer + class ZoneInfo(zoneinfo.ZoneInfo): + def __repr__(self): + return f"zoneinfo.ZoneInfo(key={repr(self.key)})" + zoneinfo.ZoneInfo = ZoneInfo +except ImportError: + pass HERE = os.path.dirname(__file__) or "." ICALENDAR_PATH = os.path.dirname(HERE) @@ -58,7 +67,8 @@ def test_files_is_included(filename): assert any(path.endswith(filename) for path in DOCUMENT_PATHS) @pytest.mark.parametrize("document", DOCUMENT_PATHS) -def test_documentation_file(document, pytz_only): +def test_documentation_file(document, zoneinfo_only): """This test runs doctest on a documentation file.""" + test_result = doctest.testfile(document, module_relative=False) assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}" diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index 0215dc86..dc751808 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -3,4 +3,12 @@ tzp = TZP() -__all__ = ["tzp"] +def use_pytz(): + """Use pytz as the implementation that looks up and creates timezones.""" + tzp.use_pytz() + +def use_zoneinfo(): + """Use zoneinfo as the implementation that looks up and creates timezones.""" + tzp.use_zoneinfo() + +__all__ = ["tzp", "use_pytz", "use_zoneinfo"] diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py index 8b3efe88..18697b9a 100644 --- a/src/icalendar/timezone/provider.py +++ b/src/icalendar/timezone/provider.py @@ -35,3 +35,11 @@ def create_timezone(self, name: str, transition_times, transition_info) -> tzinf @abstractmethod def timezone(self, name: str) -> Optional[tzinfo]: """Return a timezone with a name or None if we cannot find it.""" + + @abstractmethod + def uses_pytz(self) -> bool: + """Whether we use pytz.""" + + @abstractmethod + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py index abb90581..b4dbeb7f 100644 --- a/src/icalendar/timezone/pytz.py +++ b/src/icalendar/timezone/pytz.py @@ -57,8 +57,12 @@ def timezone(self, name: str) -> Optional[tzinfo]: pass def uses_pytz(self) -> bool: - """Whether we use pytz at all.""" + """Whether we use pytz.""" return True + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return False + __all__ = ["PYTZ"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 8f3839ec..1b3aa707 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -113,6 +113,10 @@ def uses_pytz(self) -> bool: """Whether we use pytz at all.""" return self.__provider.uses_pytz() + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return self.__provider.uses_zoneinfo() + @property def name(self) -> str: """The name of the timezone component used.""" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 37f59ff3..856b7c3d 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -75,9 +75,13 @@ def _create_timezone(self, tz: cal.Timezone) -> tzinfo: return tzical(file).get() def uses_pytz(self) -> bool: - """Whether we use pytz at all.""" + """Whether we use pytz.""" return False + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return True + def pickle_tzicalvtz(tzicalvtz:tz._tzicalvtz): """Because we use dateutil.tzical, we need to make it pickle-able.""" From 21211cc958849b103ca4872dd6316379d741891b Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 18:04:46 +0100 Subject: [PATCH 40/70] Make a nicer reading --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index b0efab62..3d394ede 100644 --- a/README.rst +++ b/README.rst @@ -80,10 +80,10 @@ We expect the ``master`` branch with versions ``5+`` receive the latest updates and the ``4.x`` branch the subset of security and bug fixes only. We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. -``icalendar`` Version 6 -~~~~~~~~~~~~~~~~~~~~~~~ +Version 6 +~~~~~~~~~ -Version 6 of ``icalendar`` switches the timezone implementation from ``pytz`` to ``zoneinfo``. +Version 6 of ``icalendar`` switches the timezone implementation to ``zoneinfo``. >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt >>> dt.tzinfo From b920876d8c5a8e3fa39063250309f0996ba9030b Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 18:15:56 +0100 Subject: [PATCH 41/70] move renaming backports.zoneinfo.ZoneInfo to zoneinfo.ZoneInfo into conftest.py --- src/icalendar/tests/conftest.py | 13 +++++++++---- src/icalendar/tests/test_with_doctest.py | 9 --------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index d11a9d0f..840b7398 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -1,13 +1,18 @@ +try: + from backports import zoneinfo + # we make the tests nicer + class ZoneInfo(zoneinfo.ZoneInfo): + def __repr__(self): + return f"zoneinfo.ZoneInfo(key={repr(self.key)})" + zoneinfo.ZoneInfo = ZoneInfo +except ImportError: + pass import os import pytest import icalendar import pytz from datetime import datetime from dateutil import tz -try: - import zoneinfo -except ModuleNotFoundError: - from backports import zoneinfo from icalendar.cal import Component, Calendar, Event, ComponentFactory from icalendar.timezone import tzp as _tzp from icalendar.timezone import TZP diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index 12053603..6a6e52e1 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -14,15 +14,6 @@ import os import pytest import importlib -try: - from backports import zoneinfo - # we make the tests nicer - class ZoneInfo(zoneinfo.ZoneInfo): - def __repr__(self): - return f"zoneinfo.ZoneInfo(key={repr(self.key)})" - zoneinfo.ZoneInfo = ZoneInfo -except ImportError: - pass HERE = os.path.dirname(__file__) or "." ICALENDAR_PATH = os.path.dirname(HERE) From a7d8185fab33ee815b4513229e71b21ac8b3495c Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 18:19:32 +0100 Subject: [PATCH 42/70] fix test run --- src/icalendar/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 840b7398..31a58fe6 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -6,7 +6,7 @@ def __repr__(self): return f"zoneinfo.ZoneInfo(key={repr(self.key)})" zoneinfo.ZoneInfo = ZoneInfo except ImportError: - pass + import zoneinfo import os import pytest import icalendar From 9bea7f7982986555f13f40494bfe31bab60ecc0b Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 7 Jun 2024 18:26:53 +0100 Subject: [PATCH 43/70] Make tests run for pypy3, too --- README.rst | 26 +++++++++++++++++--------- docs/usage.rst | 2 +- src/icalendar/tests/conftest.py | 10 +++++----- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index 3d394ede..2501631e 100644 --- a/README.rst +++ b/README.rst @@ -71,14 +71,7 @@ long-term compatibility with projects conflicts partially with providing and usi the latest Python versions bring. Since we pour more `effort into maintaining and developing icalendar `__, -we split the project into two: - -- `Branch 4.x `__ with maximum compatibility to Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. -- `Branch master `__ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. - -We expect the ``master`` branch with versions ``5+`` receive the latest updates and features, -and the ``4.x`` branch the subset of security and bug fixes only. -We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. +this is an overview of the versions: Version 6 ~~~~~~~~~ @@ -87,7 +80,7 @@ Version 6 of ``icalendar`` switches the timezone implementation to ``zoneinfo``. >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt >>> dt.tzinfo - zoneinfo.ZoneInfo(key='Europe/Vienna') + ZoneInfo(key='Europe/Vienna') If you would like to continue to use ``pytz`` and receive the latest updates, you can switch back: @@ -97,6 +90,21 @@ can switch back: >>> dt.tzinfo +`Branch master `__ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. +We expect the ``master`` branch with versions ``6+`` to receive the latest updates and features. + +Version 5 +~~~~~~~~~ + +Version 5 contains the ``pytz`` only + +Version 4 +~~~~~~~~~ + +Version 4 is on `Branch 4.x `__ with maximum compatibility to Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. +The ``4.x`` branch only receives security and bug fixes if someone makes the effort. +We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. + Related projects ================ diff --git a/docs/usage.rst b/docs/usage.rst index c00518e0..4194577c 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -211,7 +211,7 @@ Python type:: datetime.datetime(2005, 4, 4, 8, 0) >>> vDatetime.from_ical('20050404T080000Z') - datetime.datetime(2005, 4, 4, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC')) + datetime.datetime(2005, 4, 4, 8, 0, tzinfo=ZoneInfo(key='UTC')) You can also choose to use the decoded() method, which will return a decoded value directly:: diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 31a58fe6..e1e00269 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -1,12 +1,12 @@ try: from backports import zoneinfo - # we make the tests nicer - class ZoneInfo(zoneinfo.ZoneInfo): - def __repr__(self): - return f"zoneinfo.ZoneInfo(key={repr(self.key)})" - zoneinfo.ZoneInfo = ZoneInfo except ImportError: import zoneinfo +# we make it nicer for doctests +class ZoneInfo(zoneinfo.ZoneInfo): + def __repr__(self): + return f"ZoneInfo(key={repr(self.key)})" +zoneinfo.ZoneInfo = ZoneInfo import os import pytest import icalendar From 864987cc9c8148f288e79d0ae3eb2eb831dd05f6 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:14:02 +0100 Subject: [PATCH 44/70] include suggestion Co-authored-by: Steve Piercy --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0a9c94f7..4f367eb7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,7 +17,7 @@ Breaking changes: New features: - Create GitHub releases for each tag. -- Allow using ``zoneinfo`` as a timezone implementations +- Allow using ``zoneinfo`` as a timezone implementation. Bug fixes: From 9bea8a007c28ce5debdc6a5b770ec2cb37ffc545 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:25:06 +0100 Subject: [PATCH 45/70] Update link in README Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2501631e..c156084d 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ can switch back: >>> dt.tzinfo -`Branch master `__ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. +`Branch master `_ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. We expect the ``master`` branch with versions ``6+`` to receive the latest updates and features. Version 5 From 4eb94f5b888d66f5d69790ce5dfc53b8fb461323 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:29:17 +0100 Subject: [PATCH 46/70] address commit https://github.com/collective/icalendar/pull/623#discussion_r1632213752 --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c156084d..8a7613d2 100644 --- a/README.rst +++ b/README.rst @@ -96,7 +96,9 @@ We expect the ``master`` branch with versions ``6+`` to receive the latest updat Version 5 ~~~~~~~~~ -Version 5 contains the ``pytz`` only +Version 5 uses only the ``pytz`` timezone implementation, and not ``zoneinfo``. +No updates will be released for this. +Please use version 6 and switch to use ``pytz`` as documented above. Version 4 ~~~~~~~~~ From 83eb74b49dd8d960282f03392014151cb6b818c1 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:38:25 +0100 Subject: [PATCH 47/70] Add test to map to olson address comment https://github.com/collective/icalendar/pull/623#discussion_r1632220685 --- src/icalendar/tests/prop/test_windows_to_olson_mapping.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/icalendar/tests/prop/test_windows_to_olson_mapping.py b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py index 61efbd85..dfb2b9a7 100644 --- a/src/icalendar/tests/prop/test_windows_to_olson_mapping.py +++ b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py @@ -6,9 +6,11 @@ def test_windows_timezone(tzp): - """test that an example""" - dt = vDatetime.from_ical('20170507T181920', 'Eastern Standard Time'), + """Test that the timezone is mapped correctly to olson.""" + dt = vDatetime.from_ical('20170507T181920', 'Eastern Standard Time') expected = tzp.localize(datetime(2017, 5, 7, 18, 19, 20), 'America/New_York') + assert dt.tzinfo == dt.tzinfo + assert dt == expected @pytest.mark.parametrize("olson_id", WINDOWS_TO_OLSON.values()) From ee313747cd67331f231f225ef42c6efa9b3295e0 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:43:36 +0100 Subject: [PATCH 48/70] improve docstrings address comment https://github.com/collective/icalendar/pull/623#discussion_r1632224076 --- src/icalendar/tests/test_pytz_zoneinfo_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py index baf28a36..c9f70b9a 100644 --- a/src/icalendar/tests/test_pytz_zoneinfo_integration.py +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -25,7 +25,7 @@ def test_timezone_names_are_known(tz_name, tzp_): @pytest.mark.parametrize("func", [pickle.dumps, copy.copy, copy.deepcopy]) @pytest.mark.parametrize("obj", [_tzicalvtz("id"), rrule(freq=MONTHLY, count=4, dtstart=datetime(2028, 10, 1), cache=True)]) def test_can_pickle_timezone(func, tzp, obj): - """Check that re can serialize and copy timezones.""" + """Check that we can serialize and copy timezones.""" func(obj) @@ -47,7 +47,7 @@ def test_tzp_is_pytz_only(tzp, tzp_name, pytz_only): def test_cache_reuse_timezone_cache(tzp, timezones): - """Make sure we do not reuse the timezones created when we switch the provider.""" + """Make sure we do not cache the timezones twice and change them.""" tzp.cache_timezone_component(timezones.pacific_fiji) tzp1 = tzp.timezone("custom_Pacific/Fiji") assert tzp1 is tzp.timezone("custom_Pacific/Fiji") From 86af7567ae373a33ffbb2a2b2df6e404d6af1deb Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:44:46 +0100 Subject: [PATCH 49/70] Update src/icalendar/tests/conftest.py Co-authored-by: Steve Piercy --- src/icalendar/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index e1e00269..27833231 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -196,7 +196,7 @@ def tzp_name(request): @pytest.fixture(scope="package") def tzp(tzp_name): - """The time zone provider.""" + """The timezone provider.""" _tzp.use(tzp_name) yield _tzp _tzp.use_default() From 307a28bbd9e951a5b2a36f3d23d8a5b2aa6958a5 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:45:08 +0100 Subject: [PATCH 50/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8a7613d2..c735701f 100644 --- a/README.rst +++ b/README.rst @@ -103,7 +103,7 @@ Please use version 6 and switch to use ``pytz`` as documented above. Version 4 ~~~~~~~~~ -Version 4 is on `Branch 4.x `__ with maximum compatibility to Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. +Version 4 is on `Branch 4.x `_ with maximum compatibility with Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. The ``4.x`` branch only receives security and bug fixes if someone makes the effort. We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. From a4c9ecd24f686f9f8d4a53de828889ba461515c5 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:45:27 +0100 Subject: [PATCH 51/70] Update src/icalendar/timezone/tzp.py Co-authored-by: Steve Piercy --- src/icalendar/timezone/tzp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 1b3aa707..28a8a3b1 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -29,7 +29,7 @@ def use_pytz(self) -> None: self._use(PYTZ()) def use_zoneinfo(self) -> None: - """Use zoneinfo as timezone provider.""" + """Use zoneinfo as the timezone provider.""" from .zoneinfo import ZONEINFO self._use(ZONEINFO()) From c9f6c907eb5b9d66086741095dd37e305d6483de Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:49:59 +0100 Subject: [PATCH 52/70] document tests see https://github.com/collective/icalendar/pull/623#discussion_r1632221715 --- src/icalendar/tests/test_equality.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py index 55c8ae93..0f55008d 100644 --- a/src/icalendar/tests/test_equality.py +++ b/src/icalendar/tests/test_equality.py @@ -18,13 +18,20 @@ def assert_not_equal(actual_value, expected_value): def test_parsed_calendars_are_equal_if_parsed_again(ics_file, tzp): - """Ensure that a calendar equals the same calendar.""" + """Ensure that a calendar equals the same calendar. + + ics -> calendar -> ics -> same calendar + """ copy_of_calendar = ics_file.__class__.from_ical(ics_file.to_ical()) assert_equal(copy_of_calendar, ics_file) def test_parsed_calendars_are_equal_if_from_same_source(ics_file, tzp): - """Ensure that a calendar equals the same calendar.""" + """Ensure that a calendar equals the same calendar. + + ics -> calendar + ics -> same calendar + """ cal1 = ics_file.__class__.from_ical(ics_file.raw_ics) cal2 = ics_file.__class__.from_ical(ics_file.raw_ics) assert_equal(cal1, cal2) From e5785ec966b5c2458a0beec2e765cfbad8ca1913 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 12:51:05 +0100 Subject: [PATCH 53/70] Update src/icalendar/cal.py Co-authored-by: Steve Piercy --- src/icalendar/cal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 7804bcc5..02a1fa6d 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -644,7 +644,7 @@ def to_tz(self, tzp=tzp): def tz_name(self) -> str: """Return the name of the timezone component. - Please note that the names of the timezone is different from this name + Please note that the names of the timezone are different from this name and may change with winter/summer time. """ try: From 0d158c6beb8e84a1287d58b2a65593d91a4e0cfa Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 17:26:59 +0100 Subject: [PATCH 54/70] Update src/icalendar/timezone/tzp.py Co-authored-by: Steve Piercy --- src/icalendar/timezone/tzp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 28a8a3b1..c2831517 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -52,7 +52,7 @@ def use_default(self): """Use the default timezone provider.""" self.use(DEFAULT_TIMEZONE_PROVIDER) - def localize_utc(self, dt: datetime.datetime)-> datetime.datetime: + def localize_utc(self, dt: datetime.datetime) -> datetime.datetime: """Return the datetime in UTC. If the datetime has no timezone, UTC is set. From 5146c087c55fc0f47a1d48e648fd5d79cf191d46 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 17:27:29 +0100 Subject: [PATCH 55/70] Update src/icalendar/timezone/tzp.py Co-authored-by: Steve Piercy --- src/icalendar/timezone/tzp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index c2831517..698fdbcb 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -55,7 +55,7 @@ def use_default(self): def localize_utc(self, dt: datetime.datetime) -> datetime.datetime: """Return the datetime in UTC. - If the datetime has no timezone, UTC is set. + If the datetime has no timezone, set UTC as its timezone. """ return self.__provider.localize_utc(dt) From 5762ac30e8cbba94a361d0c3fb93085a7ba606dc Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Mon, 10 Jun 2024 17:27:46 +0100 Subject: [PATCH 56/70] Update src/icalendar/tests/test_timezoned.py Co-authored-by: Steve Piercy --- src/icalendar/tests/test_timezoned.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 7a7e648f..1744fc9e 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -140,7 +140,7 @@ def test_america_new_york_with_pytz(calendars, tzp, pytz_only): dt = cal.walk('VEVENT')[0]['DTSTART'][0].dt tz = dt.tzinfo tz_new_york = tzp.timezone('America/New_York') - # for reasons (tm) the locally installed version of the time zone + # for reasons (tm) the locally installed version of the timezone # database isn't always complete, therefore we only compare some # transition times ny_transition_times = [] From 0cef73b542a196f42e4a88ae536b6dff8e63e905 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 11 Jun 2024 22:51:18 +0100 Subject: [PATCH 57/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c735701f..ef0960f9 100644 --- a/README.rst +++ b/README.rst @@ -98,7 +98,7 @@ Version 5 Version 5 uses only the ``pytz`` timezone implementation, and not ``zoneinfo``. No updates will be released for this. -Please use version 6 and switch to use ``pytz`` as documented above. +Please use version 6 and switch to use ``zoneinfo`` as documented above. Version 4 ~~~~~~~~~ From 9a86aa2b4a9a8bb2883fe46b99a095ebf45e8217 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 11 Jun 2024 22:51:41 +0100 Subject: [PATCH 58/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index ef0960f9..90261c44 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ can switch back: >>> dt.tzinfo -`Branch master `_ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. +Version 6 is on `branch master `_ with compatibility to Python versions ``3.7+`` and ``PyPy3``. We expect the ``master`` branch with versions ``6+`` to receive the latest updates and features. Version 5 From 3e021a69956fe9890b07f52ee917332f4373cd12 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 11 Jun 2024 22:51:58 +0100 Subject: [PATCH 59/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 90261c44..cd2bfc68 100644 --- a/README.rst +++ b/README.rst @@ -103,7 +103,7 @@ Please use version 6 and switch to use ``zoneinfo`` as documented above. Version 4 ~~~~~~~~~ -Version 4 is on `Branch 4.x `_ with maximum compatibility with Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. +Version 4 is on `branch 4.x `_ with maximum compatibility with Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. The ``4.x`` branch only receives security and bug fixes if someone makes the effort. We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. From 5fd11f3f5bf1c58850f0368bfd0436aade64fc6e Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 12 Jun 2024 10:14:06 +0100 Subject: [PATCH 60/70] Speed up tests with scoped cache This also maintains an order of test cases being printed --- src/icalendar/tests/conftest.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 27833231..a5bd79ed 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -67,15 +67,15 @@ def multiple(self): TIMEZONES_FOLDER = os.path.join(HERE, 'timezones') EVENTS_FOLDER = os.path.join(HERE, 'events') -@pytest.fixture(scope="package") +@pytest.fixture(scope="module") def calendars(tzp): return DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) -@pytest.fixture(scope="package") +@pytest.fixture(scope="module") def timezones(tzp): return DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) -@pytest.fixture(scope="package") +@pytest.fixture(scope="module") def events(tzp): return DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) @@ -188,13 +188,7 @@ def calendar_with_resources(tzp): return c -@pytest.fixture(params=["pytz", "zoneinfo"], scope="package") -def tzp_name(request): - """The name of the timezone provider.""" - return request.param - - -@pytest.fixture(scope="package") +@pytest.fixture(scope="module") def tzp(tzp_name): """The timezone provider.""" _tzp.use(tzp_name) @@ -225,3 +219,22 @@ def zoneinfo_only(tzp): """Skip tests that are not running under pytz.""" if not tzp.uses_zoneinfo(): pytest.skip("Not using zoneinfo. Skipping this test.") + + +def pytest_generate_tests(metafunc): + """Parametrize without skipping: + + tzp_name will be parametrized according to the use of + - pytz_only + - zoneinfo_only + + See https://docs.pytest.org/en/6.2.x/example/parametrize.html#deferring-the-setup-of-parametrized-resources + """ + if "tzp_name" in metafunc.fixturenames: + tzp_names = ["pytz", "zoneinfo"] + if "zoneinfo_only" in metafunc.fixturenames: + tzp_names.remove("pytz") + if "pytz_only" in metafunc.fixturenames: + tzp_names.remove("zoneinfo") + assert tzp_names, "Use pytz_only or zoneinfo_only but not both!" + metafunc.parametrize("tzp_name", tzp_names, scope="module") From b3fc6f863a22daf3ecbbaa65d246280845124abc Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 12 Jun 2024 10:45:06 +0100 Subject: [PATCH 61/70] Test the timezone offsets with pytz and zoneinfo --- src/icalendar/tests/test_timezoned.py | 299 +++++++++++++++++--------- 1 file changed, 193 insertions(+), 106 deletions(-) diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 1744fc9e..9ab410b1 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -159,6 +159,113 @@ def test_america_new_york_with_pytz(calendars, tzp, pytz_only): assert (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST') in tz._tzinfos.keys() +fiji_transition_times = [ + datetime.datetime(1915, 10, 25, 12, 4), + datetime.datetime(1998, 10, 31, 14, 0), + datetime.datetime(1999, 2, 27, 14, 0), + datetime.datetime(1999, 11, 6, 14, 0), + datetime.datetime(2000, 2, 26, 14, 0), + datetime.datetime(2009, 11, 28, 14, 0), + datetime.datetime(2010, 3, 27, 14, 0), + datetime.datetime(2010, 10, 23, 14, 0), + datetime.datetime(2011, 3, 5, 14, 0), + datetime.datetime(2011, 10, 22, 14, 0), + datetime.datetime(2012, 1, 21, 14, 0), + datetime.datetime(2012, 10, 20, 14, 0), + datetime.datetime(2013, 1, 19, 14, 0), + datetime.datetime(2013, 10, 26, 14, 0), + datetime.datetime(2014, 1, 18, 13, 0), + datetime.datetime(2014, 10, 25, 14, 0), + datetime.datetime(2015, 1, 17, 13, 0), + datetime.datetime(2015, 10, 24, 14, 0), + datetime.datetime(2016, 1, 23, 13, 0), + datetime.datetime(2016, 10, 22, 14, 0), + datetime.datetime(2017, 1, 21, 13, 0), + datetime.datetime(2017, 10, 21, 14, 0), + datetime.datetime(2018, 1, 20, 13, 0), + datetime.datetime(2018, 10, 20, 14, 0), + datetime.datetime(2019, 1, 19, 13, 0), + datetime.datetime(2019, 10, 26, 14, 0), + datetime.datetime(2020, 1, 18, 13, 0), + datetime.datetime(2020, 10, 24, 14, 0), + datetime.datetime(2021, 1, 23, 13, 0), + datetime.datetime(2021, 10, 23, 14, 0), + datetime.datetime(2022, 1, 22, 13, 0), + datetime.datetime(2022, 10, 22, 14, 0), + datetime.datetime(2023, 1, 21, 13, 0), + datetime.datetime(2023, 10, 21, 14, 0), + datetime.datetime(2024, 1, 20, 13, 0), + datetime.datetime(2024, 10, 26, 14, 0), + datetime.datetime(2025, 1, 18, 13, 0), + datetime.datetime(2025, 10, 25, 14, 0), + datetime.datetime(2026, 1, 17, 13, 0), + datetime.datetime(2026, 10, 24, 14, 0), + datetime.datetime(2027, 1, 23, 13, 0), + datetime.datetime(2027, 10, 23, 14, 0), + datetime.datetime(2028, 1, 22, 13, 0), + datetime.datetime(2028, 10, 21, 14, 0), + datetime.datetime(2029, 1, 20, 13, 0), + datetime.datetime(2029, 10, 20, 14, 0), + datetime.datetime(2030, 1, 19, 13, 0), + datetime.datetime(2030, 10, 26, 14, 0), + datetime.datetime(2031, 1, 18, 13, 0), + datetime.datetime(2031, 10, 25, 14, 0), + datetime.datetime(2032, 1, 17, 13, 0), + datetime.datetime(2032, 10, 23, 14, 0), + datetime.datetime(2033, 1, 22, 13, 0), + datetime.datetime(2033, 10, 22, 14, 0), + datetime.datetime(2034, 1, 21, 13, 0), + datetime.datetime(2034, 10, 21, 14, 0), + datetime.datetime(2035, 1, 20, 13, 0), + datetime.datetime(2035, 10, 20, 14, 0), + datetime.datetime(2036, 1, 19, 13, 0), + datetime.datetime(2036, 10, 25, 14, 0), + datetime.datetime(2037, 1, 17, 13, 0), + datetime.datetime(2037, 10, 24, 14, 0), + datetime.datetime(2038, 1, 23, 13, 0), + datetime.datetime(2038, 10, 23, 14, 0) +] + +fiji_transition_info = ( + [( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' + )] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') + ] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' + )] + + 25 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' + )] + + [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + )] +) + def test_create_pacific_fiji(calendars, pytz_only): """testing Pacific/Fiji, another pretty complex example with more than one RDATE property per subcomponent""" @@ -166,112 +273,8 @@ def test_create_pacific_fiji(calendars, pytz_only): tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo assert str(tz) == 'custom_Pacific/Fiji' - assert tz._utc_transition_times == [ - datetime.datetime(1915, 10, 25, 12, 4), - datetime.datetime(1998, 10, 31, 14, 0), - datetime.datetime(1999, 2, 27, 14, 0), - datetime.datetime(1999, 11, 6, 14, 0), - datetime.datetime(2000, 2, 26, 14, 0), - datetime.datetime(2009, 11, 28, 14, 0), - datetime.datetime(2010, 3, 27, 14, 0), - datetime.datetime(2010, 10, 23, 14, 0), - datetime.datetime(2011, 3, 5, 14, 0), - datetime.datetime(2011, 10, 22, 14, 0), - datetime.datetime(2012, 1, 21, 14, 0), - datetime.datetime(2012, 10, 20, 14, 0), - datetime.datetime(2013, 1, 19, 14, 0), - datetime.datetime(2013, 10, 26, 14, 0), - datetime.datetime(2014, 1, 18, 13, 0), - datetime.datetime(2014, 10, 25, 14, 0), - datetime.datetime(2015, 1, 17, 13, 0), - datetime.datetime(2015, 10, 24, 14, 0), - datetime.datetime(2016, 1, 23, 13, 0), - datetime.datetime(2016, 10, 22, 14, 0), - datetime.datetime(2017, 1, 21, 13, 0), - datetime.datetime(2017, 10, 21, 14, 0), - datetime.datetime(2018, 1, 20, 13, 0), - datetime.datetime(2018, 10, 20, 14, 0), - datetime.datetime(2019, 1, 19, 13, 0), - datetime.datetime(2019, 10, 26, 14, 0), - datetime.datetime(2020, 1, 18, 13, 0), - datetime.datetime(2020, 10, 24, 14, 0), - datetime.datetime(2021, 1, 23, 13, 0), - datetime.datetime(2021, 10, 23, 14, 0), - datetime.datetime(2022, 1, 22, 13, 0), - datetime.datetime(2022, 10, 22, 14, 0), - datetime.datetime(2023, 1, 21, 13, 0), - datetime.datetime(2023, 10, 21, 14, 0), - datetime.datetime(2024, 1, 20, 13, 0), - datetime.datetime(2024, 10, 26, 14, 0), - datetime.datetime(2025, 1, 18, 13, 0), - datetime.datetime(2025, 10, 25, 14, 0), - datetime.datetime(2026, 1, 17, 13, 0), - datetime.datetime(2026, 10, 24, 14, 0), - datetime.datetime(2027, 1, 23, 13, 0), - datetime.datetime(2027, 10, 23, 14, 0), - datetime.datetime(2028, 1, 22, 13, 0), - datetime.datetime(2028, 10, 21, 14, 0), - datetime.datetime(2029, 1, 20, 13, 0), - datetime.datetime(2029, 10, 20, 14, 0), - datetime.datetime(2030, 1, 19, 13, 0), - datetime.datetime(2030, 10, 26, 14, 0), - datetime.datetime(2031, 1, 18, 13, 0), - datetime.datetime(2031, 10, 25, 14, 0), - datetime.datetime(2032, 1, 17, 13, 0), - datetime.datetime(2032, 10, 23, 14, 0), - datetime.datetime(2033, 1, 22, 13, 0), - datetime.datetime(2033, 10, 22, 14, 0), - datetime.datetime(2034, 1, 21, 13, 0), - datetime.datetime(2034, 10, 21, 14, 0), - datetime.datetime(2035, 1, 20, 13, 0), - datetime.datetime(2035, 10, 20, 14, 0), - datetime.datetime(2036, 1, 19, 13, 0), - datetime.datetime(2036, 10, 25, 14, 0), - datetime.datetime(2037, 1, 17, 13, 0), - datetime.datetime(2037, 10, 24, 14, 0), - datetime.datetime(2038, 1, 23, 13, 0), - datetime.datetime(2038, 10, 23, 14, 0) - ] - assert tz._transition_info == ( - [( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' - )] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') - ] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - )] + - 25 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' - )] + - [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - )] - ) - + assert tz._utc_transition_times == fiji_transition_times + assert tz._transition_info == fiji_transition_info assert ( datetime.timedelta(0, 46800), datetime.timedelta(0, 3600), @@ -283,6 +286,90 @@ def test_create_pacific_fiji(calendars, pytz_only): 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' ) in tz._tzinfos.keys() + +# these are the expected offsets before and after the fiji_transition_times +fiji_expected_offsets = [ + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), +] + + +def test_transition_times_fiji(tzp, timezones): + """The transition times are computed.""" + tz = timezones.pacific_fiji.to_tz(tzp) + offsets = [] # [(before, after), ...] + for i, transition_time in enumerate(fiji_transition_times): + before_after_offset = [] + for offset in (datetime.timedelta(hours=-1), datetime.timedelta(hours=+1)): + time_in_timezone = tzp.localize(transition_time + offset, tz) + utc_offset = time_in_timezone.utcoffset() + before_after_offset.append(utc_offset) + offsets.append(tuple(before_after_offset)) + assert offsets == fiji_expected_offsets + + def test_same_start_date(calendars): """testing if we can handle VTIMEZONEs whose different components have the same start DTIMEs.""" From abe94fbb87d3a36723a84863090ba5a299dbaa10 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 18 Jun 2024 19:27:17 +0100 Subject: [PATCH 62/70] use copyreg.pickle --- src/icalendar/timezone/zoneinfo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index 856b7c3d..e3665c30 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -13,7 +13,7 @@ from io import StringIO from dateutil.tz import tzical from dateutil.tz.tz import _tzicalvtz -from copyreg import pickle +import copyreg import functools import copy @@ -87,7 +87,7 @@ def pickle_tzicalvtz(tzicalvtz:tz._tzicalvtz): """Because we use dateutil.tzical, we need to make it pickle-able.""" return _tzicalvtz, (tzicalvtz._tzid, tzicalvtz._comps) -pickle(_tzicalvtz, pickle_tzicalvtz) +copyreg.pickle(_tzicalvtz, pickle_tzicalvtz) def pickle_rrule_with_cache(self: rrule): @@ -106,7 +106,7 @@ def pickle_rrule_with_cache(self: rrule): # from https://stackoverflow.com/a/64915638/1320237 return functools.partial(rrule, new_kwargs.pop("freq"), **new_kwargs), () -pickle(rrule, pickle_rrule_with_cache) +copyreg.pickle(rrule, pickle_rrule_with_cache) def pickle_rruleset_with_cache(rs: rruleset): """Pickle an rruleset.""" @@ -128,6 +128,6 @@ def unpickle_rruleset_with_cache(rrule, rdate, exrule, exdate, cache): for o in exdate: rs.exdate(o) return rs -pickle(rruleset, pickle_rruleset_with_cache) +copyreg.pickle(rruleset, pickle_rruleset_with_cache) __all__ = ["ZONEINFO"] From 6478c12d4ede10e407cb5d02029f124d72075fc2 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 18 Jun 2024 20:12:52 +0100 Subject: [PATCH 63/70] Modify changelog --- CHANGES.rst | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4f367eb7..40b78624 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,45 @@ Changelog ========= + +6.0.0 (unreleased) +------------------ + +Minor changes: + +- Test that all code works with both ``pytz`` and ``zoneinfo``. + +Breaking changes: + +- Use ``zoneinfo`` for ``icalendar`` objects created from strings, + see `Issue #609 `_. + + This is an tested extension of the functionality, not a restriction: + If you create ``icalendar`` objects with ``pytz`` timezones in your code, + ``icalendar`` will continue to work in the same way. + Your code is not affected. + + ``zoneinfo`` will be used for those **objects that** ``icalendar`` + **creates itself**. + This happens for example when parsing an ``.ics`` file, strings or bytes with + ``from_ical()``. + + If you rely on ``icalendar`` providing timezones from ``pytz``, you can add + one line to your code to get the behavior of versions below 6: + + .. code:: Python + + import icalendar + icalendar.use_pytz() + +New features: + +- ... + +Bug fixes: + +- ... + 5.0.13 (unreleased) ------------------- @@ -17,7 +56,6 @@ Breaking changes: New features: - Create GitHub releases for each tag. -- Allow using ``zoneinfo`` as a timezone implementation. Bug fixes: From a799e4ca23199944eaba95775618410b45abbba9 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Tue, 18 Jun 2024 20:27:02 +0100 Subject: [PATCH 64/70] Use pathlib instead of os.path --- src/icalendar/tests/conftest.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index a5bd79ed..932d1830 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -7,7 +7,6 @@ class ZoneInfo(zoneinfo.ZoneInfo): def __repr__(self): return f"ZoneInfo(key={repr(self.key)})" zoneinfo.ZoneInfo = ZoneInfo -import os import pytest import icalendar import pytz @@ -16,16 +15,19 @@ def __repr__(self): from icalendar.cal import Component, Calendar, Event, ComponentFactory from icalendar.timezone import tzp as _tzp from icalendar.timezone import TZP +from pathlib import Path +import itertools + class DataSource: '''A collection of parsed ICS elements (e.g calendars, timezones, events)''' - def __init__(self, data_source_folder, parser): + def __init__(self, data_source_folder:Path, parser): self._parser = parser self._data_source_folder = data_source_folder def keys(self): """Return all the files that could be used.""" - return [file[:-4] for file in os.listdir(self._data_source_folder) if file.lower().endswith(".ics")] + return [p.stem for p in self._data_source_folder.iterdir() if p.suffix.lower() == ".ics"] def __getitem__(self, attribute): """Parse a file and return the result stored in the attribute.""" @@ -34,10 +36,10 @@ def __getitem__(self, attribute): attribute = attribute[:-4] else: source_file = attribute + '.ics' - source_path = os.path.join(self._data_source_folder, source_file) - if not os.path.isfile(source_path): + source_path = self._data_source_folder / source_file + if not source_path.is_file(): raise AttributeError(f"{source_path} does not exist.") - with open(source_path, 'rb') as f: + with source_path.open('rb') as f: raw_ics = f.read() source = self._parser(raw_ics) if not isinstance(source, list): @@ -62,10 +64,10 @@ def multiple(self): """Return a list of all components parsed.""" return self.__class__(self._data_source_folder, lambda data: self._parser(data, multiple=True)) -HERE = os.path.dirname(__file__) -CALENDARS_FOLDER = os.path.join(HERE, 'calendars') -TIMEZONES_FOLDER = os.path.join(HERE, 'timezones') -EVENTS_FOLDER = os.path.join(HERE, 'events') +HERE = Path(__file__).parent +CALENDARS_FOLDER = HERE / 'calendars' +TIMEZONES_FOLDER = HERE / 'timezones' +EVENTS_FOLDER = HERE / 'events' @pytest.fixture(scope="module") def calendars(tzp): @@ -104,9 +106,9 @@ def in_timezone(request, tzp): "parsing_error_in_UTC_offset.ics", "parsing_error.ics", ) ICS_FILES = [ - file_name for file_name in - os.listdir(CALENDARS_FOLDER) + os.listdir(TIMEZONES_FOLDER) + os.listdir(EVENTS_FOLDER) - if file_name not in ICS_FILES_EXCLUDE + file.name for file in + itertools.chain(CALENDARS_FOLDER.iterdir(), TIMEZONES_FOLDER.iterdir(), EVENTS_FOLDER.iterdir()) + if file.name not in ICS_FILES_EXCLUDE ] @pytest.fixture(params=ICS_FILES) def ics_file(tzp, calendars, timezones, events, request): @@ -119,7 +121,7 @@ def ics_file(tzp, calendars, timezones, events, request): raise ValueError(f"Could not find file {ics_file}.") -FUZZ_V1 = [os.path.join(CALENDARS_FOLDER, key) for key in os.listdir(CALENDARS_FOLDER) if "fuzz-testcase" in key] +FUZZ_V1 = [key for key in CALENDARS_FOLDER.iterdir() if "fuzz-testcase" in str(key)] @pytest.fixture(params=FUZZ_V1) def fuzz_v1_calendar(request): """Clusterfuzz calendars.""" From 96b4e76cb51c5376d9333bb243334cf8c99e37a4 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 11:25:02 +0100 Subject: [PATCH 65/70] Modify doctests and add more examples --- README.rst | 99 ++++++++++++++++++++++-- src/icalendar/tests/conftest.py | 29 +++++-- src/icalendar/tests/test_with_doctest.py | 16 +++- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index cd2bfc68..77446d20 100644 --- a/README.rst +++ b/README.rst @@ -45,15 +45,23 @@ files. Quick Guide ----------- +``icalendar`` enables you to **create**, **inspect** and **modify** +calendaring information with Python. + To **install** the package, run:: pip install icalendar + +Inspect Files +~~~~~~~~~~~~~ + You can open an ``.ics`` file and see all the events:: >>> import icalendar - >>> path_to_ics_file = "src/icalendar/tests/calendars/example.ics" - >>> with open(path_to_ics_file) as f: + >>> from pathlib import Path + >>> ics_path = Path("src/icalendar/tests/calendars/example.ics") + >>> with ics_path.open() as f: ... calendar = icalendar.Calendar.from_ical(f.read()) >>> for event in calendar.walk('VEVENT'): ... print(event.get("SUMMARY")) @@ -61,7 +69,74 @@ You can open an ``.ics`` file and see all the events:: Orthodox Christmas International Women's Day -Using this package, you can also create calendars from scratch or edit existing ones. +Modify Content +~~~~~~~~~~~~~~ + +Such a calendar can then be edited and saved again. + +.. code:: python + + >>> calendar["X-WR-CALNAME"] = "My Modified Calendar" # modify + >>> print(calendar.to_ical()[:129]) # save modification + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:collective/icalendar + CALSCALE:GREGORIAN + METHOD:PUBLISH + X-WR-CALNAME:My Modified Calendar + + +Create Events, TODOs, Journals, Alarms, ... +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``icalendar`` supports the creation and parsing of all kinds of objects +in the standard. + +.. code:: python + + >>> icalendar.Event() # events + VEVENT({}) + >>> icalendar.FreeBusy() # free/busy times + VFREEBUSY({}) + >>> icalendar.Todo() # Todo list entries + VTODO({}) + >>> icalendar.Alarm() # Alarms e.g. for events + VALARM({}) + >>> icalendar.Journal() # Journal entries + VJOURNAL({}) + + +Have a look at `more examples +`_. + +Use Timezones of your choice +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +With ``icalendar``, you can localize your events to take place in different +timezones. +``zoneinfo``, ``dateutil.tz`` and ``pytz`` are compatible with ``icalendar``. +This example creates an event that uses all of the timezone implementations +with the same result: + +.. code:: python + + >>> import pytz, zoneinfo, dateutil.tz # timezone libraries + >>> import datetime, icalendar + >>> e = icalendar.Event() + >>> tz = dateutil.tz.tzstr("Europe/London") + >>> e["X-DT-DATEUTIL"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> tz = pytz.timezone("Europe/London") + >>> e["X-DT-USE-PYTZ"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> tz = zoneinfo.ZoneInfo("Europe/London") + >>> e["X-DT-ZONEINFO"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> print(e.to_ical()) # the libraries yield the same result + BEGIN:VEVENT + X-DT-DATEUTIL;TZID=Europe/London:20240619T100100 + X-DT-USE-PYTZ;TZID=Europe/London:20240619T100100 + X-DT-ZONEINFO;TZID=Europe/London:20240619T100100 + END:VEVENT + + Versions and Compatibility -------------------------- @@ -70,20 +145,30 @@ Versions and Compatibility long-term compatibility with projects conflicts partially with providing and using the features that the latest Python versions bring. -Since we pour more `effort into maintaining and developing icalendar `__, -this is an overview of the versions: +Volunteers pour `effort into maintaining and developing icalendar +`__. +Below, you can find an overview of the versions and how we maintain them. Version 6 ~~~~~~~~~ Version 6 of ``icalendar`` switches the timezone implementation to ``zoneinfo``. +This only affects you if you parse ``icalendar`` objects with ``from_ical()``. +The functionality is extended and is tested since 6.0.0 with both timezone +implementations: ``pytz`` and ``zoneinfo``. + +By default and since 6.0.0, ``zoneinfo`` timezones are created. + +.. code:: python >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt >>> dt.tzinfo ZoneInfo(key='Europe/Vienna') -If you would like to continue to use ``pytz`` and receive the latest updates, you -can switch back: +If you would like to continue to receive ``pytz`` timezones in as parse results, +you can receive all the latest updates, and switch back to version 5.x behavior: + +.. code:: python >>> icalendar.use_pytz() >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 932d1830..a1891b90 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -2,11 +2,6 @@ from backports import zoneinfo except ImportError: import zoneinfo -# we make it nicer for doctests -class ZoneInfo(zoneinfo.ZoneInfo): - def __repr__(self): - return f"ZoneInfo(key={repr(self.key)})" -zoneinfo.ZoneInfo = ZoneInfo import pytest import icalendar import pytz @@ -17,6 +12,7 @@ def __repr__(self): from icalendar.timezone import TZP from pathlib import Path import itertools +import sys class DataSource: @@ -240,3 +236,26 @@ def pytest_generate_tests(metafunc): tzp_names.remove("zoneinfo") assert tzp_names, "Use pytz_only or zoneinfo_only but not both!" metafunc.parametrize("tzp_name", tzp_names, scope="module") + + +class DoctestZoneInfo(zoneinfo.ZoneInfo): + """Constent ZoneInfo representation for tests.""" + def __repr__(self): + return f"ZoneInfo(key={repr(self.key)})" + + +def test_print(obj): + """doctest print""" + if isinstance(obj, bytes): + obj = obj.decode("UTF-8") + print(str(obj).strip().replace("\r\n", "\n").replace("\r", "\n")) + + +@pytest.fixture() +def env_for_doctest(monkeypatch): + """Modify the environment to make doctests run.""" + monkeypatch.setitem(sys.modules, "zoneinfo", zoneinfo) + monkeypatch.setattr(zoneinfo, "ZoneInfo", DoctestZoneInfo) + from icalendar.timezone.zoneinfo import ZONEINFO + monkeypatch.setattr(ZONEINFO, "utc", zoneinfo.ZoneInfo("UTC")) + return {"print": test_print} diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index 65b625b6..59660fbc 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -60,9 +60,19 @@ def test_docstring_of_python_file(module_name): def test_files_is_included(filename): assert any(path.endswith(filename) for path in DOCUMENT_PATHS) + @pytest.mark.parametrize("document", DOCUMENT_PATHS) -def test_documentation_file(document, zoneinfo_only): - """This test runs doctest on a documentation file.""" +def test_documentation_file(document, zoneinfo_only, env_for_doctest): + """This test runs doctest on a documentation file. - test_result = doctest.testfile(document, module_relative=False) + functions are also replaced to work. + """ + test_result = doctest.testfile(document, module_relative=False, globs=env_for_doctest) assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}" + + +def test_can_import_zoneinfo(env_for_doctest): + """Allow importing zoneinfo for tests.""" + import pytz + import zoneinfo + from dateutil import tz From fa4db2339971ae600b695194e6c3c8db1b117cd0 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 17:18:59 +0100 Subject: [PATCH 66/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 77446d20..e040837d 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Create Events, TODOs, Journals, Alarms, ... ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``icalendar`` supports the creation and parsing of all kinds of objects -in the standard. +in the iCalendar (RFC 5545) standard. .. code:: python From 5c4c11ef24c1553707da2079b871ba24d5b12d4e Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 17:19:15 +0100 Subject: [PATCH 67/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e040837d..02785a09 100644 --- a/README.rst +++ b/README.rst @@ -155,7 +155,7 @@ Version 6 Version 6 of ``icalendar`` switches the timezone implementation to ``zoneinfo``. This only affects you if you parse ``icalendar`` objects with ``from_ical()``. The functionality is extended and is tested since 6.0.0 with both timezone -implementations: ``pytz`` and ``zoneinfo``. +implementations ``pytz`` and ``zoneinfo``. By default and since 6.0.0, ``zoneinfo`` timezones are created. From 2ee613722552154f2854fcd3b720dcbcb8137117 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 17:19:26 +0100 Subject: [PATCH 68/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 02785a09..b5c2b5de 100644 --- a/README.rst +++ b/README.rst @@ -146,7 +146,7 @@ long-term compatibility with projects conflicts partially with providing and usi the latest Python versions bring. Volunteers pour `effort into maintaining and developing icalendar -`__. +`_. Below, you can find an overview of the versions and how we maintain them. Version 6 From 50f9765bb467f19b0f4f08f90be5232c3fcafc46 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 17:19:38 +0100 Subject: [PATCH 69/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b5c2b5de..26d587ce 100644 --- a/README.rst +++ b/README.rst @@ -109,7 +109,7 @@ in the iCalendar (RFC 5545) standard. Have a look at `more examples `_. -Use Timezones of your choice +Use timezones of your choice ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ With ``icalendar``, you can localize your events to take place in different From 99084fbb0c3759a37e459b376319fcf396af89aa Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 19 Jun 2024 17:19:47 +0100 Subject: [PATCH 70/70] Update README.rst Co-authored-by: Steve Piercy --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 26d587ce..8ce4dae2 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,7 @@ files. .. _`pytz`: https://pypi.org/project/pytz/ .. _`BSD`: https://github.com/collective/icalendar/issues/2 -Quick Guide +Quick start guide ----------- ``icalendar`` enables you to **create**, **inspect** and **modify**