From 345b96cd12741f3d1e414c0fc5a5bc7ecba24ccc Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Fri, 22 Nov 2024 11:44:28 +0000 Subject: [PATCH] This adds generating VTIMEZONE components from tzinfo objects (#741) --- CHANGES.rst | 15 +- src/icalendar/cal.py | 381 +++++++- src/icalendar/prop.py | 38 +- .../tests/calendars/america_new_york.ics | 2 +- .../issue_722_missing_VTIMEZONE_custom.ics | 5 + .../calendars/issue_722_missing_timezones.ics | 20 + ...ssue_722_timezone_transition_ambiguity.ics | 44 + src/icalendar/tests/conftest.py | 32 +- src/icalendar/tests/test_equality.py | 6 +- src/icalendar/tests/test_examples.py | 7 + .../test_issue_722_generate_vtimezone.py | 413 +++++++++ .../tests/test_timezone_identification.py | 34 + src/icalendar/tests/test_timezoned.py | 6 +- src/icalendar/timezone/__init__.py | 12 +- .../timezone/equivalent_timezone_ids.py | 141 +++ .../equivalent_timezone_ids_result.py | 811 ++++++++++++++++++ src/icalendar/timezone/provider.py | 18 +- src/icalendar/timezone/tzid.py | 98 +++ src/icalendar/timezone/tzp.py | 8 +- src/icalendar/timezone/zoneinfo.py | 24 +- 20 files changed, 2039 insertions(+), 76 deletions(-) create mode 100644 src/icalendar/tests/calendars/issue_722_missing_VTIMEZONE_custom.ics create mode 100644 src/icalendar/tests/calendars/issue_722_missing_timezones.ics create mode 100644 src/icalendar/tests/calendars/issue_722_timezone_transition_ambiguity.ics create mode 100644 src/icalendar/tests/test_issue_722_generate_vtimezone.py create mode 100644 src/icalendar/tests/test_timezone_identification.py create mode 100644 src/icalendar/timezone/equivalent_timezone_ids.py create mode 100644 src/icalendar/timezone/equivalent_timezone_ids_result.py create mode 100644 src/icalendar/timezone/tzid.py diff --git a/CHANGES.rst b/CHANGES.rst index ba70ea38..1cab44b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,7 +6,8 @@ Changelog Minor changes: -- Added ``end``, ``start``, ``duration``, ``DTSTART``, ``DUE``, and ``DURATION`` attributes to ``Todo`` components. See `Issue 662`_. +- Add ``end``, ``start``, ``duration``, ``DTSTART``, ``DUE``, and ``DURATION`` attributes to ``Todo`` components. See `Issue 662`_. +- Add ``DTSTART``, ``TZOFFSETTO`` and ``TZOFFSETFROM`` properties to ``TimezoneStandard`` and ``TimezoneDaylight``. See `Issue 662`_. - Format test code with Ruff. See `Issue 672 `_. - Document the Debian package. See `Issue 701 `_. - Document ``vDatetime.from_ical`` @@ -14,6 +15,7 @@ Minor changes: - Document component classes with description from :rfc:`5545`. - Merge "File Structure" and "Overview" sections in the docs. See `Issue 626 `_. - Update code blocks in usage.rst with the correct lexer. +- Improve typing and fix typing issues Breaking changes: @@ -23,6 +25,8 @@ New features: - Add ``VALARM`` properties for :rfc:`9074`. See `Issue 657 `_ - Test compatibility with Python 3.13 +- Add ``Timezone.from_tzinfo()`` and ``Timezone.from_tzid()`` to create a ``Timezone`` component from a ``datetime.tzinfo`` timezone. See `Issue 722`_. +- Add ``icalendar.prop.tzid_from_tzinfo``. - Add ``icalendar.alarms`` module to calculate alarm times. See `Issue 716 `_. - Add ``Event.alarms`` and ``Todo.alarms`` to access alarm calculation. - Add ``Component.DTSTAMP`` and ``Component.LAST_MODIFIED`` properties for datetime in UTC. @@ -31,11 +35,20 @@ New features: - Add ``Alarm.ACKNOWLEDGED``, ``Alarm.TRIGGER``, ``Alarm.REPEAT``, and ``Alarm.DURATION`` properties as well as ``Alarm.triggers`` to calculate alarm triggers. - Add ``__doc__`` string documentation for ``vDate``, ``vBoolean``, ``vCalAddress``, ``vDuration``, ``vFloat``, ``vGeo``, ``vInt``, ``vPeriod``, ``vTime``, ``vUTCOffset`` and ``vUri``. See `Issue 742 `_. +- Add ``DTSTART``, ``TZOFFSETTO``, and ``TZOFFSETFROM`` to ``TimezoneStandard`` and ``TimezoneDaylight`` +- Use ``example`` methods of components without arguments. +- Add ``events``, ``timezones``, and ``todos`` property to ``Calendar`` for nicer access. +- To calculate which timezones are in use and add them to the ``Calendar`` when needed these methods are added: ``get_used_tzids``, ``get_missing_tzids``, and ``add_missing_timezones``. +- Identify the TZID of more timezones from dateutil. +- Identify totally unknown timezones using a UTC offset lookup tree generated in ``icalendar.timezone.equivalent_timezone_ids`` and stored in ``icalendar.timezone.equivalent_timezone_ids``. +- Add ``icalendar.timezone.tzid`` to identify a timezone's TZID. Bug fixes: - Add ``icalendar.timezone`` to the documentation. +.. _`Issue 722`: https://github.com/collective/icalendar/issues/722 + 6.0.1 (2024-10-13) ------------------ diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 8522d24f..bae86bd8 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -6,6 +6,9 @@ from __future__ import annotations import os +from collections import defaultdict +from datetime import date, datetime, timedelta, tzinfo +from typing import List, Optional, Tuple from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, List, NamedTuple, Optional, Tuple, Union @@ -15,6 +18,18 @@ from icalendar.caselessdict import CaselessDict from icalendar.parser import Contentline, Contentlines, Parameters, q_join, q_split from icalendar.parser_tools import DEFAULT_ENCODING +from icalendar.prop import ( + TypesFactory, + tzid_from_dt, + tzid_from_tzinfo, + vDDDLists, + vDDDTypes, + vDuration, + vText, + vUTCOffset, + vDatetime, +) +from icalendar.timezone import TZP, tzp from icalendar.prop import TypesFactory, vDDDLists, vDDDTypes, vDuration, vText from icalendar.timezone import tzp from icalendar.tools import is_date @@ -323,7 +338,7 @@ def set_inline(self, name, values, encode=1): ######################### # Handling of components - def add_component(self, component): + def add_component(self, component: Component): """Add a subcomponent to this component. """ self.subcomponents.append(component) @@ -338,7 +353,7 @@ def _walk(self, name, select): result += subcomponent._walk(name, select) return result - def walk(self, name=None, select=lambda c: True): + def walk(self, name=None, select=lambda c: True) -> list[Component]: """Recursively traverses component and subcomponents. Returns sequence of same. If name is passed, only components with name will be returned. @@ -355,7 +370,7 @@ def walk(self, name=None, select=lambda c: True): ##################### # Generation - def property_items(self, recursive=True, sorted=True): + def property_items(self, recursive=True, sorted=True) -> list[tuple[str, object]]: """Returns properties in this component and subcomponents as: [(name, value), ...] """ @@ -576,10 +591,23 @@ def is_thunderbird(self) -> bool: ####################################### # components defined in RFC 5545 -def create_single_property(prop:str, value_attr:str, value_type:tuple[type], type_def:type, doc:str): +def create_single_property( + prop:str, + value_attr:Optional[str], + value_type:tuple[type], + type_def:type, + doc:str, + vProp:type=vDDDTypes # noqa: N803 + ): """Create a single property getter and setter. - - This is a getter and setter for a property that only occurs once or not (None).""" + + :param prop: The name of the property. + :param value_attr: The name of the attribute to get the value from. + :param value_type: The type of the value. + :param type_def: The type of the property. + :param doc: The docstring of the property. + :param vProp: The type of the property from :mod:`icalendar.prop`. + """ def p_get(self : Component): default = object() @@ -588,7 +616,7 @@ def p_get(self : Component): return None if isinstance(result, list): raise InvalidCalendar(f"Multiple {prop} defined.") - value = getattr(result, value_attr, result) + value = result if value_attr is None else getattr(result, value_attr, result) if not isinstance(value, value_type): raise InvalidCalendar(f"{prop} must be either a {' or '.join(t.__name__ for t in value_type)}, not {value}.") return value @@ -599,7 +627,7 @@ def p_set(self:Component, value) -> None: return if not isinstance(value, value_type): raise TypeError(f"Use {' or '.join(t.__name__ for t in value_type)}, not {type(value).__name__}.") - self[prop] = vDDDTypes(value) + self[prop] = vProp(value) if prop in self.exclusive: for other_prop in self.exclusive: if other_prop != prop: @@ -723,7 +751,7 @@ def alarms(self) -> Alarms: return Alarms(self) @classmethod - def example(cls, name:str) -> Event: + def example(cls, name:str="rfc_9074_example_3") -> Event: """Return the calendar example with the given name.""" return cls.from_ical(get_example("events", name)) @@ -1017,7 +1045,6 @@ class FreeBusy(Component): ) multiple = ('ATTENDEE', 'COMMENT', 'FREEBUSY', 'RSTATUS',) - class Timezone(Component): """ A "VTIMEZONE" calendar component is a grouping of component @@ -1029,20 +1056,23 @@ class Timezone(Component): required = ('TZID',) # it also requires one of components DAYLIGHT and STANDARD singletons = ('TZID', 'LAST-MODIFIED', 'TZURL',) + _DEFAULT_FIRST_DATE = date(1970, 1, 1) + _DEFAULT_LAST_DATE = date(2038, 1, 1) + @classmethod - def example(cls, name: str) -> Calendar: - """Return the calendar example with the given name.""" + def example(cls, name: str="pacific_fiji") -> Calendar: + """Return the timezone example with the given name.""" return cls.from_ical(get_example("timezones", name)) @staticmethod - def _extract_offsets(component, tzname): + def _extract_offsets(component: TimezoneDaylight|TimezoneStandard, tzname:str): """extract offsets and transition times from a VTIMEZONE component :param component: a STANDARD or DAYLIGHT component :param tzname: the name of the zone """ - offsetfrom = component['TZOFFSETFROM'].td - offsetto = component['TZOFFSETTO'].td - dtstart = component['DTSTART'].dt + offsetfrom = component.TZOFFSETFROM + offsetto = component.TZOFFSETTO + dtstart = component.DTSTART # offsets need to be rounded to the next minute, we might loose up # to 30 seconds accuracy, but it can't be helped (datetime @@ -1100,9 +1130,20 @@ def _make_unique_tzname(tzname, tznames): tznames.add(tzname) return tzname - def to_tz(self, tzp=tzp): + def to_tz(self, tzp:TZP=tzp, lookup_tzid:bool=True): """convert this VTIMEZONE component to a timezone object + + :param tzp: timezone provider to use + :param lookup_tzid: whether to use the TZID property to look up existing + timezone definitions with tzp. + If it is False, a new timezone will be created. + If it is True, the existing timezone will be used + if it exists, otherwise a new timezone will be created. """ + if lookup_tzid: + tz = tzp.timezone(self.tz_name) + if tz is not None: + return tz return tzp.create_timezone(self) @property @@ -1182,6 +1223,153 @@ def get_transitions(self) -> Tuple[List[datetime], List[Tuple[timedelta, timedel transition_info.append((osto, dst_offset, name)) return transition_times, transition_info + # binary search + _from_tzinfo_skip_search = [ + timedelta(days=days) for days in (64, 32, 16, 8, 4, 2, 1) + ] + [ + # we know it happens in the night usually around 1am + timedelta(hours=4), + timedelta(hours=1), + # adding some minutes and seconds for faster search + timedelta(minutes=20), + timedelta(minutes=5), + timedelta(minutes=1), + timedelta(seconds=20), + timedelta(seconds=5), + timedelta(seconds=1), + ] + @classmethod + def from_tzinfo( + cls, + timezone: tzinfo, + tzid:Optional[str]=None, + first_date:date=_DEFAULT_FIRST_DATE, + last_date:date=_DEFAULT_LAST_DATE + ) -> Timezone: + """Return a VTIMEZONE component from a timezone object. + + This works with pytz and zoneinfo and any other timezone. + The offsets are calculated from the tzinfo object. + + Parameters: + + :param tzinfo: the timezone object + :param tzid: the tzid for this timezone. If None, it will be extracted from the tzinfo. + :param first_date: a datetime that is earlier than anything that happens in the calendar + :param last_date: a datetime that is later than anything that happens in the calendar + :raises ValueError: If we have no tzid and cannot extract one. + + .. note:: + This can take some time. Please cache the results. + """ + if tzid is None: + tzid = tzid_from_tzinfo(timezone) + if tzid is None: + raise ValueError(f"Cannot get TZID from {timezone}. Please set the tzid parameter.") + normalize = getattr(timezone, "normalize", lambda dt: dt) # pytz compatibility + first_datetime = datetime(first_date.year, first_date.month, first_date.day) # noqa: DTZ001 + last_datetime = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001 + if hasattr(timezone, "localize"): #pytz compatibility + first_datetime = timezone.localize(first_datetime) + last_datetime = timezone.localize(last_datetime) + else: + first_datetime = first_datetime.replace(tzinfo=timezone) + last_datetime = last_datetime.replace(tzinfo=timezone) + # from, to, tzname, is_standard -> start + offsets :dict[tuple[Optional[timedelta], timedelta, str, bool], list[datetime]] = defaultdict(list) + start = first_datetime + offset_to = None + while start < last_datetime: + offset_from = offset_to + end = start + offset_to = end.utcoffset() + for add_offset in cls._from_tzinfo_skip_search: + last_end = end # we need to save this as we might be left and right of the time change + end = normalize(end + add_offset) + try: + while end.utcoffset() == offset_to: + last_end = end + end = normalize(end + add_offset) + except OverflowError: + # zoninfo does not go all the way + break + # retract if we overshoot + end = last_end + # Now, start (inclusive) -> end (exclusive) are one timezone + is_standard = start.dst() == timedelta() + name = start.tzname() + if name is None: + name = str(offset_to) + key = (offset_from, offset_to, name, is_standard) + # first_key = (None,) + key[1:] + # if first_key in offsets: + # # remove the first one and claim it changes at that day + # offsets[first_key] = offsets.pop(first_key) + offsets[key].append(start.replace(tzinfo=None)) + start = normalize(end + cls._from_tzinfo_skip_search[-1]) + tz = cls() + tz.add("TZID", tzid) + tz.add("COMMENT", f"This timezone only works from {first_date} to {last_date}.") + for (offset_from, offset_to, tzname, is_standard), starts in offsets.items(): + first_start = min(starts) + starts.remove(first_start) + if first_start.date() == last_date: + first_start = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001 + subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight() + if offset_from is None: + offset_from = offset_to # noqa: PLW2901 + subcomponent.TZOFFSETFROM = offset_from + subcomponent.TZOFFSETTO = offset_to + subcomponent.add("TZNAME", tzname) + subcomponent.DTSTART = first_start + if starts: + subcomponent.add("RDATE", starts) + tz.add_component(subcomponent) + return tz + + @classmethod + def from_tzid( + cls, + tzid:str, + tzp:TZP=tzp, + first_date:date=_DEFAULT_FIRST_DATE, + last_date:date=_DEFAULT_LAST_DATE + ) -> Timezone: + """Create a VTIMEZONE from a tzid like ``"Europe/Berlin"``. + + :param tzid: the id of the timezone + :param tzp: the timezone provider + :param first_date: a datetime that is earlier than anything that happens in the calendar + :param last_date: a datetime that is later than anything that happens in the calendar + :raises ValueError: If the tzid is unknown. + + >>> from icalendar import Timezone + >>> tz = Timezone.from_tzid("Europe/Berlin") + >>> print(tz.to_ical()[:36]) + BEGIN:VTIMEZONE + TZID:Europe/Berlin + + .. note:: + This can take some time. Please cache the results. + """ + tz = tzp.timezone(tzid) + if tz is None: + raise ValueError(f"Unkown timezone {tzid}.") + return cls.from_tzinfo(tz, tzid, first_date, last_date) + + @property + def standard(self) -> list[TimezoneStandard]: + """The STANDARD subcomponents as a list.""" + return self.walk("STANDARD") + + @property + def daylight(self) -> list[TimezoneDaylight]: + """The DAYLIGHT subcomponents as a list. + + These are for the daylight saving time. + """ + return self.walk("DAYLIGHT") + class TimezoneStandard(Component): """ @@ -1195,6 +1383,45 @@ class TimezoneStandard(Component): singletons = ('DTSTART', 'TZOFFSETTO', 'TZOFFSETFROM',) multiple = ('COMMENT', 'RDATE', 'TZNAME', 'RRULE', 'EXDATE') + DTSTART = create_single_property( + "DTSTART", + "dt", + (datetime,), + datetime, + """The mandatory "DTSTART" property gives the effective onset date + and local time for the time zone sub-component definition. + "DTSTART" in this usage MUST be specified as a date with a local + time value.""" + ) + TZOFFSETTO = create_single_property( + "TZOFFSETTO", + "td", + (timedelta,), + timedelta, + """The mandatory "TZOFFSETTO" property gives the UTC offset for the + time zone sub-component (Standard Time or Daylight Saving Time) + when this observance is in use. + """, + vUTCOffset + ) + TZOFFSETFROM = create_single_property( + "TZOFFSETFROM", + "td", + (timedelta,), + timedelta, + """The mandatory "TZOFFSETFROM" property gives the UTC offset that is + in use when the onset of this time zone observance begins. + "TZOFFSETFROM" is combined with "DTSTART" to define the effective + onset for the time zone sub-component definition. For example, + the following represents the time at which the observance of + Standard Time took effect in Fall 1967 for New York City: + + DTSTART:19671029T020000 + TZOFFSETFROM:-0400 + """, + vUTCOffset + ) + class TimezoneDaylight(Component): """ @@ -1208,6 +1435,9 @@ class TimezoneDaylight(Component): singletons = TimezoneStandard.singletons multiple = TimezoneStandard.multiple + DTSTART = TimezoneStandard.DTSTART + TZOFFSETTO = TimezoneStandard.TZOFFSETTO + TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM class Alarm(Component): """ @@ -1403,10 +1633,125 @@ class Calendar(Component): singletons = ('PRODID', 'VERSION', 'CALSCALE', 'METHOD') @classmethod - def example(cls, name: str) -> Calendar: + def example(cls, name: str="example") -> Calendar: """Return the calendar example with the given name.""" return cls.from_ical(get_example("calendars", name)) + @property + def events(self) -> list[Event]: + """All event components in the calendar. + + This is a shortcut to get all events. + Modifications do not change the calendar. + Use :py:meth:`Component.add_component`. + + >>> from icalendar import Calendar + >>> calendar = Calendar.example() + >>> event = calendar.events[0] + >>> event.start + datetime.date(2022, 1, 1) + >>> print(event["SUMMARY"]) + New Year's Day + """ + return self.walk("VEVENT") + + @property + def todos(self) -> list[Todo]: + """All todo components in the calendar. + + This is a shortcut to get all todos. + Modifications do not change the calendar. + Use :py:meth:`Component.add_component`. + """ + return self.walk("VTODO") + + def get_used_tzids(self) -> set[str]: + """The set of TZIDs in use. + + This goes through the whole calendar to find all occurrences of + timezone information like the TZID parameter in all attributes. + + >>> from icalendar import Calendar + >>> calendar = Calendar.example("timezone_rdate") + >>> calendar.get_used_tzids() + {'posix/Europe/Vaduz'} + + Even if you use UTC, this will not show up. + """ + result = set() + for name, value in self.property_items(sorted=False): + if hasattr(value, "params"): + result.add(value.params.get("TZID")) + return result - {None} + + def get_missing_tzids(self) -> set[str]: + """The set of missing timezone component tzids. + + To create a :rfc:`5545` compatible calendar, + all of these timezones should be added. + """ + tzids = self.get_used_tzids() + for timezone in self.timezones: + tzids.remove(timezone.tz_name) + return tzids + + @property + def timezones(self) -> list[Timezone]: + """Return the timezones components in this calendar. + + >>> from icalendar import Calendar + >>> calendar = Calendar.example("pacific_fiji") + >>> [timezone.tz_name for timezone in calendar.timezones] + ['custom_Pacific/Fiji'] + + .. note:: + + This is a read-only property. + """ + return self.walk("VTIMEZONE") + + def add_missing_timezones( + self, + first_date:date=Timezone._DEFAULT_FIRST_DATE, + last_date:date=Timezone._DEFAULT_LAST_DATE, + ): + """Add all missing VTIMEZONE components. + + This adds all the timezone components that are required. + + .. note:: + + Timezones that are not known will not be added. + + :param first_date: earlier than anything that happens in the calendar + :param last_date: later than anything happening in the calendar + + >>> from icalendar import Calendar, Event + >>> from datetime import datetime + >>> from zoneinfo import ZoneInfo + >>> calendar = Calendar() + >>> event = Event() + >>> calendar.add_component(event) + >>> event.start = datetime(1990, 10, 11, 12, tzinfo=ZoneInfo("Europe/Berlin")) + >>> calendar.timezones + [] + >>> calendar.add_missing_timezones() + >>> calendar.timezones[0].tz_name + 'Europe/Berlin' + >>> calendar.get_missing_tzids() # check that all are added + set() + """ + for tzid in self.get_missing_tzids(): + try: + timezone = Timezone.from_tzid( + tzid, + first_date=first_date, + last_date=last_date + ) + except ValueError: + continue + self.add_component(timezone) + # These are read only singleton, so one instance is enough for the module types_factory = TypesFactory() component_factory = ComponentFactory() diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index ac2fcbae..4e826b2b 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -35,13 +35,14 @@ These types are mainly used for parsing and file generation. But you can set them directly. """ +from __future__ import annotations + import base64 import binascii import re -import time as _time -from datetime import date, datetime, time, timedelta, tzinfo -from enum import Enum, auto -from typing import Optional, Union +from datetime import date, datetime, time, timedelta +from enum import Enum +from typing import Union from icalendar.caselessdict import CaselessDict from icalendar.parser import Parameters, escape_char, unescape_char @@ -53,7 +54,7 @@ to_unicode, ) -from . import timezone as _timezone +from .timezone import tzid_from_dt, tzid_from_tzinfo, tzp DURATION_REGEX = re.compile(r'([-+]?)P(?:(\d+)W)?(?:(\d+)D)?' r'(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$') @@ -62,20 +63,6 @@ r'(?P[\w]{2})$') -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 vBinary: """Binary property values are base 64 encoded. """ @@ -585,7 +572,7 @@ def from_ical(ical, timezone=None): """ tzinfo = None if isinstance(timezone, str): - tzinfo = _timezone.tzp.timezone(timezone) + tzinfo = tzp.timezone(timezone) elif timezone is not None: tzinfo = timezone @@ -599,11 +586,11 @@ def from_ical(ical, timezone=None): int(ical[13:15]), # second ) if tzinfo: - return _timezone.tzp.localize(datetime(*timetuple), tzinfo) + return tzp.localize(datetime(*timetuple), tzinfo) elif not ical[15:]: return datetime(*timetuple) elif ical[15:16] == 'Z': - return _timezone.tzp.localize_utc(datetime(*timetuple)) + return tzp.localize_utc(datetime(*timetuple)) else: raise ValueError(ical) except Exception as e: @@ -1413,6 +1400,11 @@ def __eq__(self, other): return False return self.td == other.td + def __hash__(self): + return hash(self.td) + + def __repr__(self): + return f"vUTCOffset({self.td!r})" class vInline(str): """This is an especially dumb class that just holds raw unparsed text and @@ -1601,4 +1593,4 @@ def from_ical(self, name, value): "vCategory", "vDDDLists", "vDDDTypes", "vDate", "vDatetime", "vDuration", "vFloat", "vFrequency", "vGeo", "vInline", "vInt", "vMonth", "vPeriod", "vRecur", "vSkip", "vText", "vTime", - "vUTCOffset", "vUri", "vWeekday"] + "vUTCOffset", "vUri", "vWeekday", "tzid_from_tzinfo"] diff --git a/src/icalendar/tests/calendars/america_new_york.ics b/src/icalendar/tests/calendars/america_new_york.ics index b6812eeb..48338acd 100644 --- a/src/icalendar/tests/calendars/america_new_york.ics +++ b/src/icalendar/tests/calendars/america_new_york.ics @@ -55,7 +55,7 @@ END:VTIMEZONE BEGIN:VEVENT UID:noend123 DTSTART;TZID=custom_America/New_York;VALUE=DATE-TIME:20140829T080000 -DTSTART;TZID=custom_America/New_York;VALUE=DATE-TIME:20140829T100000 +DTEND;TZID=custom_America/New_York;VALUE=DATE-TIME:20140829T100000 SUMMARY:an event with a custom tz name END:VEVENT END:VCALENDAR diff --git a/src/icalendar/tests/calendars/issue_722_missing_VTIMEZONE_custom.ics b/src/icalendar/tests/calendars/issue_722_missing_VTIMEZONE_custom.ics new file mode 100644 index 00000000..f7738b1b --- /dev/null +++ b/src/icalendar/tests/calendars/issue_722_missing_VTIMEZONE_custom.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=CUSTOM_tzid;VALUE=DATE-TIME:20140829T080000 +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/issue_722_missing_timezones.ics b/src/icalendar/tests/calendars/issue_722_missing_timezones.ics new file mode 100644 index 00000000..225b64e4 --- /dev/null +++ b/src/icalendar/tests/calendars/issue_722_missing_timezones.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +DESCRIPTION:We leave the timezones out but we use common names so that they are added. +BEGIN:VEVENT +DTSTART;TZID=America/New_York;VALUE=DATE-TIME:20140829T080000 +DTEND;TZID=America/Los_Angeles;VALUE=DATE-TIME:20140829T080000 +RDATE;VALUE=PERIOD;TZID=Europe/Berlin:20240913T120000/PT2H +SUMMARY:an event with a custom tz name +END:VEVENT +BEGIN:VEVENT +RECURRENCE-ID;TZID=Europe/Moscow:20190309T020000 +END:VEVENT +BEGIN:VTODO +DUE;TZID=Asia/Singapore;VALUE=DATE-TIME:20140829T080000 +END:VTODO +BEGIN:VEVENT +RDATE;TZID=Mexico/General:20190309T020000 +RDATE;TZID=America/Noronha:20190309T020000 +DTSTAMP:19920901T130000Z +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/issue_722_timezone_transition_ambiguity.ics b/src/icalendar/tests/calendars/issue_722_timezone_transition_ambiguity.ics new file mode 100644 index 00000000..83a3cc17 --- /dev/null +++ b/src/icalendar/tests/calendars/issue_722_timezone_transition_ambiguity.ics @@ -0,0 +1,44 @@ +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:MyTimezone +BEGIN:STANDARD +COMMENT:The timezone starts at 2024-01-01 with +12h offset +TZOFFSETFROM:+1000 +TZOFFSETTO:+1200 +DTSTART:20240101T000000 +TZNAME:winter +END:STANDARD +BEGIN:DAYLIGHT +COMMENT:The timezone goes from +12h to +10h at 8 am +TZOFFSETFROM:+1200 +TZOFFSETTO:+1000 +DTSTART:20240505T080000 +TZNAME:summer +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:0 +SUMMARY:This event is clearly in winter +DTSTART;TZID=MyTimezone;VALUE=DATE-TIME:20240303T080000 +X-TZNAME:winter +END:VEVENT +BEGIN:VEVENT +UID:1 +SUMMARY:This event is clearly in summer +X-TZNAME:summer +DTSTART;TZID=MyTimezone;VALUE=DATE-TIME:20240803T080000 +END:VEVENT +BEGIN:VEVENT +UID:2 +SUMMARY:Transition is from 8am -> 6am, so 8:00:01 is summer +X-TZNAME:summer +DTSTART;TZID=MyTimezone;VALUE=DATE-TIME:20240505T080001 +END:VEVENT +BEGIN:VEVENT +UID:3 +SUMMARY:Transition is from 8am -> 6am, so 7:00:01 is winter + RFC5545 does not allow us to be at the later TZ. +X-TZNAME:winter +DTSTART;TZID=MyTimezone;VALUE=DATE-TIME:20240505T070001 +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 3062b6d2..ac61eb72 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -2,6 +2,7 @@ from backports import zoneinfo except ImportError: import zoneinfo +from typing import Generator import pytest import icalendar @@ -66,6 +67,7 @@ def __getitem__(self, attribute): source = self._parser(raw_ics) if not isinstance(source, list): source.raw_ics = raw_ics + source.source_file = source_file self.__dict__[attribute] = source return source @@ -229,7 +231,7 @@ def filled_event_component(c, calendar_component): return e -@pytest.fixture +@pytest.fixture() def calendar_with_resources(tzp): c = Calendar() c["resources"] = 'Chair, Table, "Room: 42"' @@ -237,7 +239,7 @@ def calendar_with_resources(tzp): @pytest.fixture(scope="module") -def tzp(tzp_name): +def tzp(tzp_name) -> Generator[TZP, None, None]: """The timezone provider.""" _tzp.use(tzp_name) yield _tzp @@ -251,21 +253,32 @@ def other_tzp(request, tzp): 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 + return TZP(request.param) @pytest.fixture -def pytz_only(tzp): +def pytz_only(tzp, tzp_name) -> str: """Skip tests that are not running under pytz.""" assert tzp.uses_pytz() - + return tzp_name @pytest.fixture -def zoneinfo_only(tzp, request, tzp_name): +def zoneinfo_only(tzp, request, tzp_name) -> str: """Skip tests that are not running under zoneinfo.""" assert tzp.uses_zoneinfo() + return tzp_name +@pytest.fixture +def no_pytz(tzp_name) -> str: + """Do not run tests with pytz.""" + assert tzp_name != "pytz" + return tzp_name + +@pytest.fixture +def no_zoneinfo(tzp_name) -> str: + """Do not run tests with zoneinfo.""" + assert tzp_name != "zoneinfo" + return tzp_name def pytest_generate_tests(metafunc): """Parametrize without skipping: @@ -273,6 +286,8 @@ def pytest_generate_tests(metafunc): tzp_name will be parametrized according to the use of - pytz_only - zoneinfo_only + - no_pytz + - no_zoneinfo See https://docs.pytest.org/en/6.2.x/example/parametrize.html#deferring-the-setup-of-parametrized-resources """ @@ -286,6 +301,9 @@ def pytest_generate_tests(metafunc): "zoneinfo_only" in metafunc.fixturenames and "pytz_only" in metafunc.fixturenames ), "Use pytz_only or zoneinfo_only but not both!" + for name in ["pytz", "zoneinfo"]: + if f"no_{name}" in metafunc.fixturenames and name in tzp_names: + tzp_names.remove(name) metafunc.parametrize("tzp_name", tzp_names, scope="module") diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py index 3a76cef3..ec761ee5 100644 --- a/src/icalendar/tests/test_equality.py +++ b/src/icalendar/tests/test_equality.py @@ -31,13 +31,13 @@ class UnknownTimeZoneError(Exception): def assert_equal(actual_value, expected_value): """Make sure both values are equal""" assert actual_value == expected_value - assert actual_value == expected_value + assert expected_value == actual_value def assert_not_equal(actual_value, expected_value): """Make sure both values are not equal""" assert actual_value != expected_value - assert actual_value != expected_value + assert expected_value != actual_value def test_parsed_calendars_are_equal_if_parsed_again(ics_file, tzp): @@ -83,6 +83,8 @@ def test_deep_copies_are_equal(ics_file, tzp): Ignore errors when a custom time zone is used. This is still covered by the parsing test. """ + if ics_file.source_file == "issue_722_timezone_transition_ambiguity.ics" and tzp.uses_zoneinfo(): + pytest.skip("This test fails for now.") with contextlib.suppress(UnknownTimeZoneError): assert_equal(copy.deepcopy(ics_file), copy.deepcopy(ics_file)) with contextlib.suppress(UnknownTimeZoneError): diff --git a/src/icalendar/tests/test_examples.py b/src/icalendar/tests/test_examples.py index 9f9b891b..d6b1e7f9 100644 --- a/src/icalendar/tests/test_examples.py +++ b/src/icalendar/tests/test_examples.py @@ -63,3 +63,10 @@ def test_invalid_examples_lists_the_others(): with pytest.raises(ValueError) as e: Calendar.example("does not exist") assert "example.ics" in str(e.value) + + +@pytest.mark.parametrize("component", [Calendar, Event, Timezone]) +def test_default_example(component): + """Check that we have a default example.""" + example = component.example() + assert isinstance(example, component) diff --git a/src/icalendar/tests/test_issue_722_generate_vtimezone.py b/src/icalendar/tests/test_issue_722_generate_vtimezone.py new file mode 100644 index 00000000..31e7f27f --- /dev/null +++ b/src/icalendar/tests/test_issue_722_generate_vtimezone.py @@ -0,0 +1,413 @@ +"""Generate VTIMEZONE components from actual timezone information. + +When we generate VTIMEZONE from actual tzinfo instances of +- dateutil +- zoneinfo +- pytz + +Then, we cannot assume that the future information stays the same but +we should be able to create tests that work for the past. +""" + +from datetime import date, datetime, timedelta +from re import findall + +import pytest +from dateutil.tz import gettz +try: + from zoneinfo import available_timezones +except ImportError: + from backports.zoneinfo import available_timezones + +from icalendar import Calendar, Component, Event, Timezone +from icalendar.timezone import tzid_from_tzinfo, tzids_from_tzinfo + +tzids = pytest.mark.parametrize("tzid", [ + "Europe/Berlin", + "Asia/Singapore", + "America/New_York", +]) + +def assert_components_equal(c1:Component, c2:Component): + """Print the diff of two components.""" + ML = 32 + ll1 = c1.to_ical().decode().splitlines() + ll2 = c2.to_ical().decode().splitlines() + pad = max(len(l) for l in ll1 if len(l) <=ML) + diff = 0 + for l1, l2 in zip(ll1, ll2): + a = len(l1) > 32 or len(l2) > 32 + print(a * " " + l1, " " * (pad - len(l1)), a* "\n->" + l2, " "*(pad - len(l2)), "\tdiff!" if l1 != l2 else "") + diff += l1 != l2 + assert not diff, f"{diff} lines differ" + +@tzids +def test_conversion_converges(tzp, tzid): + """tzinfo -> VTIMEZONE -> tzinfo -> VTIMEZONE + + We can assume that both generated VTIMEZONEs are equivalent. + """ + if tzp.uses_pytz(): + pytest.skip("pytz will not converge on the first run. This is problematic. PYTZ-TODO") + tzinfo1 = tzp.timezone(tzid) + assert tzinfo1 is not None + generated1 = Timezone.from_tzinfo(tzinfo1) + generated1["TZID"] = "test-generated" # change the TZID so we do not use an existing one + tzinfo2 = generated1.to_tz() + generated2 = Timezone.from_tzinfo(tzinfo2, "test-generated") + tzinfo3 = generated2.to_tz() + generated3 = Timezone.from_tzinfo(tzinfo2, "test-generated") + # pprint(generated1.get_transitions()) + # pprint(generated2.get_transitions()) + assert_components_equal(generated1, generated2) + assert_components_equal(generated2, generated3) + assert 2 <= len(generated1.standard + generated1.daylight) <= 3 + assert 2 <= len(generated2.standard + generated2.daylight) <= 3 + assert dict(generated1) == dict(generated2) + assert generated1.to_ical().decode() == generated2.to_ical().decode() + assert generated1.daylight == generated2.daylight + assert generated1.standard == generated2.standard + assert generated1 == generated2 + + + +@tzids +def both_tzps_generate_the_same_info(tzid, tzp): + """We want to make sure that we get the same info for all timezone implementations. + + We assume that + - the timezone implementations have the same info within the days we test + - the timezone transitions times do not change because they are before last_date + """ + # default generation + tz1 = Timezone.from_tzid(tzid, tzp, last_date=date(2024, 1, 1)) + tzp.use_zoneinfo() # we compare to zoneinfo + tz2 = Timezone.from_tzid(tzid, tzp, last_date=date(2024, 1, 1)) + assert_components_equal(tz1, tz2) + assert tz1 == tz2 + + +@tzids +def test_tzid_matches(tzid, tzp): + """Check the TZID.""" + tz = Timezone.from_tzinfo(tzp.timezone(tzid)) + assert tz["TZID"] == tzid + + +def test_do_not_convert_utc(tzp): + """We do not need to convert UTC but it should work.""" + utc = Timezone.from_tzid("UTC") + assert utc.daylight == [] + assert len(utc.standard) == 1 + standard = utc.standard[0] + assert standard["TZOFFSETFROM"].td == timedelta(0) + assert standard["TZOFFSETTO"].td == timedelta(0) + assert standard["TZNAME"] == "UTC" + + +def test_berlin_time(tzp): + """Test the Europe/Berlin timezone conversion.""" + tz = Timezone.from_tzid("Europe/Berlin") + # we should have two timezones in it + for x in tz.standard: + print(x.name, x["TZNAME"], x["TZOFFSETFROM"].td, x["TZOFFSETTO"].td) + print(x.to_ical().decode()) + assert len(tz.daylight) == 1 + assert len(tz.standard) in (1, 2), "We start in winter" + dst = tz.daylight[-1] + sta = tz.standard[-1] + assert dst["TZNAME"] == "CEST" # summer + assert sta["TZNAME"] == "CET" + assert dst["TZOFFSETFROM"].td == timedelta(hours=1) # summer + assert sta["TZOFFSETFROM"].td == timedelta(hours=2) + assert dst["TZOFFSETTO"].td == timedelta(hours=2) # summer + assert sta["TZOFFSETTO"].td == timedelta(hours=1) + + +def test_range_is_not_crossed(): + first_date = datetime(2023, 1, 1) + last_date = datetime(2024, 1, 1) + def check(dt): + assert first_date <= dt <= last_date + tz = Timezone.from_tzid("Europe/Berlin", last_date=last_date, first_date=first_date) + for sub in tz.standard + tz.daylight: + check(sub.DTSTART) + for rdate in sub.get("RDATE", []): + check(rdate) + + +@tzids +def test_use_the_original_timezone(tzid, tzp): + """When we get the timezone again, usually, we should use the + one of the library/tzp.""" + tzinfo1 = tzp.timezone(tzid) + assert tzinfo1 is not None + generated1 = Timezone.from_tzinfo(tzinfo1) + tzinfo2 = generated1.to_tz() + assert type(tzinfo1) == type(tzinfo2) + assert tzinfo1 == tzinfo2 + +@pytest.mark.parametrize( + ("tzid", "dt", "tzname"), + [ + ("Asia/Singapore", datetime(1970, 1, 1), "+0730"), + ("Asia/Singapore", datetime(1981, 12, 31), "+0730"), + ("Asia/Singapore", datetime(1981, 12, 31, 23, 10), "+0730"), + ("Asia/Singapore", datetime(1981, 12, 31, 23, 34), "+0730"), + ("Asia/Singapore", datetime(1981, 12, 31, 23, 59, 59), "+0730"), + + ("Asia/Singapore", datetime(1982, 1, 1), "+08"), + ("Asia/Singapore", datetime(1982, 1, 1, 0, 1), "+08"), + ("Asia/Singapore", datetime(1982, 1, 1, 0, 34), "+08"), + ("Asia/Singapore", datetime(1982, 1, 1, 1, 0), "+08"), + ("Asia/Singapore", datetime(1982, 1, 1, 1, 1), "+08"), + + ("Europe/Berlin", datetime(1970, 1, 1), "CET"), + ("Europe/Berlin", datetime(2024, 3, 31, 0, 0), "CET"), + ("Europe/Berlin", datetime(2024, 3, 31, 1, 0), "CET"), + ("Europe/Berlin", datetime(2024, 3, 31, 2, 0), "CET"), + ("Europe/Berlin", datetime(2024, 3, 31, 2, 59, 59), "CET"), + + ("Europe/Berlin", datetime(2024, 3, 31, 3, 0), "CEST"), + ("Europe/Berlin", datetime(2024, 3, 31, 3, 0, 1), "CEST"), + ("Europe/Berlin", datetime(2024, 3, 31, 4, 0), "CEST"), + ("Europe/Berlin", datetime(2024, 10, 27, 0, 0), "CEST"), + ("Europe/Berlin", datetime(2024, 10, 27, 1, 0), "CEST"), + ("Europe/Berlin", datetime(2024, 10, 27, 2, 0), "CEST"), + ("Europe/Berlin", datetime(2024, 10, 27, 2, 30), "CEST"), + ("Europe/Berlin", datetime(2024, 10, 27, 2, 59, 59), "CEST"), + + ("Europe/Berlin", datetime(2024, 10, 27, 3, 0), "CET"), + ("Europe/Berlin", datetime(2024, 10, 27, 3, 0, 1), "CET"), + ("Europe/Berlin", datetime(2024, 10, 27, 4, 0), "CET"), + + # transition times from https://www.zeitverschiebung.net/de/timezone/america--new_york + ("America/New_York", datetime(1970, 1, 1), "EST"), + # Daylight Saving Time + ("America/New_York", datetime(2024, 11, 3, 0, 0), "EDT"), + ("America/New_York", datetime(2024, 11, 3, 1, 0), "EDT"), + ("America/New_York", datetime(2024, 11, 3, 1, 59, 59), "EDT"), + # 03.11.2024 2:00am -> 1:00am Standard + # ("America/New_York", datetime(2024, 11, 3, 2, 0), "EDT"), + ("America/New_York", datetime(2024, 11, 3, 2, 0, 1), "EST"), + ("America/New_York", datetime(2024, 11, 3, 3, 0), "EST"), + ("America/New_York", datetime(2025, 3, 9, 1, 0), "EST"), + ("America/New_York", datetime(2025, 3, 9, 1, 59, 59), "EST"), + ("America/New_York", datetime(2025, 3, 9, 2, 0), "EST"), + # 09.03.2025 2:00am -> 3:00am Daylight Saving Time + ("America/New_York", datetime(2025, 3, 9, 3, 0), "EDT"), + ("America/New_York", datetime(2025, 3, 9, 3, 1, 1), "EDT"), + ("America/New_York", datetime(2025, 3, 9, 4, 0), "EDT"), + ] +) +def test_check_datetimes_around_transition_times(tzp, tzid, dt, tzname): + """We should make sure than the datetimes with the generated timezones + work as expected: They have the right UTC offset, dst and tzname. + """ + message = f"{tzid}: {dt} (expected in {tzname})" + expected_dt = tzp.localize(dt, tzid) + component = Timezone.from_tzinfo(tzp.timezone(tzid)) + generated_tzinfo = component.to_tz(tzp, lookup_tzid=False) + generated_dt = dt.replace(tzinfo=generated_tzinfo) + print(generated_tzinfo) + if tzp.uses_pytz(): + # generated_dt = generated_tzinfo.localize(dt) + generated_dt = generated_tzinfo.normalize(generated_dt) + if dt in ( + datetime(2024, 10, 27, 1, 0), + datetime(2024, 11, 3, 1, 59, 59), + datetime(2024, 11, 3, 1, 0), + datetime(2024, 11, 3, 0, 0), + datetime(2024, 10, 27, 2, 59, 59), + datetime(2024, 10, 27, 2, 30), + datetime(2024, 10, 27, 2, 0), + datetime(2024, 10, 27, 1, 0) + ): + pytest.skip("We do not know how to do this. PYTZ-TODO") + assert generated_dt.tzname() == expected_dt.tzname() == tzname, message + assert generated_dt.dst() == expected_dt.dst(), message + assert generated_dt.utcoffset() == expected_dt.utcoffset(), message + + +@pytest.mark.parametrize( + "uid", [0, 1, 2, 3] +) +def test_dateutil_timezone_when_time_is_going_backwards(calendars, tzp, uid): + """When going from Daylight Saving Time to Standard Time, times can be ambiguous. + For example, at 3:00 AM, the time falls back to 2:00 AM, repeating a full hour of times from 2:00 AM to 3:00 AM on the same date. + + By the RFC 5545, we cannot accommodate this case. All datetimes should + be BEFORE the transition if ambiguous. However, dateutil can + create a timezone that allows the event to be after this ambiguous time span, of course. + + Each event has its timezone saved in it. + """ + cal : Calendar = calendars.issue_722_timezone_transition_ambiguity + event : Event = cal.events[uid] + expected_tzname = str(event["X-TZNAME"]) + actual_tzname = event.start.tzname() + assert actual_tzname == expected_tzname, event["SUMMARY"] + + +def query_tzid(query:str, cal:Calendar) -> str: + """The tzid from the query.""" + try: + tzinfo = eval(query, {"cal": cal}) # noqa: S307 + except Exception as e: + raise ValueError(query) from e + return tzid_from_tzinfo(tzinfo) + +# these are queries for all the places that have a TZID +# according to RFC 5545 +queries = [ + "cal.events[0].start.tzinfo", # DTSTART + "cal.events[0].end.tzinfo", # DTEND + # EXDATE + "cal.todos[0].end.tzinfo", # DUE + "cal.events[0].get('RDATE').dts[0].dt[0].tzinfo", # RDATE + "cal.events[1].get('RECURRENCE-ID').dt.tzinfo", # RECURRENCE-ID + "cal.events[2].get('RDATE')[0].dts[0].dt.tzinfo", # RDATE multiple + "cal.events[2].get('RDATE')[1].dts[0].dt.tzinfo", # RDATE multiple +] + +@pytest.mark.parametrize("query", queries) +def test_add_missing_timezones_to_example(calendars, query): + """Add the missing timezones to the calendar.""" + cal = calendars.issue_722_missing_timezones + tzid = query_tzid(query, cal) + tzs = cal.get_missing_tzids() + assert tzid in tzs + +def test_queries_yield_unique_tzids(calendars): + """We make sure each query tests a unique place to find for the algorithm.""" + cal = calendars.issue_722_missing_timezones + tzids = set() + for query in queries: + tzid = query_tzid(query, cal) + print(query, "->", tzid) + tzids.add(tzid) + assert len(tzids) == len(queries) + +def test_we_do_not_miss_to_add_a_query(calendars): + """Make sure all tzids are actually queried.""" + cal = calendars.issue_722_missing_timezones + raw = cal.raw_ics.decode() + ids = set(findall("TZID=([a-zA-Z_/+-]+)", raw)) + assert cal.get_used_tzids() == ids, "We find all tzids and they are unique." + assert len(ids) == len(queries), "We query all the tzids." + +def test_unknown_tzid(calendars): + """If we have an unknown tzid with no timezone component.""" + cal = calendars.issue_722_missing_VTIMEZONE_custom + assert "CUSTOM_tzid" in cal.get_used_tzids() + assert "CUSTOM_tzid" in cal.get_missing_tzids() + +def test_custom_timezone_is_found_and_used(calendars): + """Check the custom timezone component is not missing.""" + cal = calendars.america_new_york + assert "custom_America/New_York" in cal.get_used_tzids() + assert "custom_America/New_York" not in cal.get_missing_tzids() + +def test_not_missing_anything(): + """Check that no timezone is missing.""" + cal = Calendar() + assert cal.get_missing_tzids() == set() + +def test_utc_is_not_missing(calendars): + """UTC should not be found missing.""" + cal = calendars.issue_722_missing_timezones + assert "UTC" not in cal.get_missing_tzids() + assert "UTC" not in cal.get_used_tzids() + +def test_dateutil_timezone_is_not_found_with_tzname(calendars, no_pytz): + """dateutil is an example of a timezone that has no tzid. + + In this test we make sure that the timezone is said to be missing. + """ + cal : Calendar = calendars.america_new_york + cal.subcomponents.remove(cal.timezones[0]) + assert cal.get_missing_tzids() == {"custom_America/New_York"} + assert "dateutil" in repr(cal.events[0].start.tzinfo.__class__) + + +@pytest.mark.parametrize("tzname", ["America/New_York", "Arctic/Longyearbyen"]) +# @pytest.mark.parametrize("component", ["STANDARD", "DAYLIGHT"]) +def test_dateutil_timezone_is_matched_with_tzname(tzname): + """dateutil is an example of a timezone that has no tzid. + + In this test we make sure that the timezone is matched by its + tzname() in the timezone in the STANDARD and DAYLIGHT components. + """ + cal = Calendar() + event = Event() + event.start = datetime(2024, 11, 12, tzinfo=gettz(tzname)) + print(dir(event.start.tzinfo)) + cal.add_component(event) + assert cal.get_missing_tzids() == {tzname} + cal.add_missing_timezones() + assert cal.get_missing_tzids() == set() + + +def test_dateutil_timezone_is_also_added(calendars): + """We find and add a dateutil timezone. + + This is important as we use those in the zoneinfo implementation. + """ + +@pytest.mark.parametrize( + "calendar", + [ + "example", + "america_new_york", # custom + "timezone_same_start", # known tzid + "period_with_timezone", # known tzid + ] +) +def test_timezone_is_not_missing(calendars, calendar): + """Check that these calendars have no timezone missing.""" + cal :Calendar= calendars[calendar] + timezones = cal.timezones[:] + assert set() == cal.get_missing_tzids() + cal.add_missing_timezones() + assert set() == cal.get_missing_tzids() + assert cal.timezones == timezones + +def test_add_missing_known_timezones(calendars): + """Add all timezones specified.""" + cal :Calendar= calendars.issue_722_missing_timezones + assert len(cal.timezones) == 0 + cal.add_missing_timezones() + assert len(cal.timezones) == len(queries), "all timezones are known" + assert len(cal.get_missing_tzids()) == 0 + +def test_cannot_add_unknown_timezone(calendars): + """I cannot add a timezone that is unknown.""" + cal :Calendar= calendars.issue_722_missing_VTIMEZONE_custom + assert len(cal.timezones) == 0 + assert cal.get_missing_tzids() == {"CUSTOM_tzid"} + cal.add_missing_timezones() + assert cal.timezones == [], "we cannot add this timezone" + assert cal.get_missing_tzids() == {"CUSTOM_tzid"} + + +def test_cannot_create_a_timezone_from_an_invalid_tzid(): + with pytest.raises(ValueError): + Timezone.from_tzid("invalid/tzid") + +def test_dates_before_and_after_are_considered(): + """When we add the timezones, we should check the calendar to see + if all dates really occur in the span we use. + + We should also consider a huge default range. + """ + pytest.skip("todo") + + +@pytest.mark.parametrize("tzid", available_timezones() - {"Factory", "localtime"}) +def test_we_can_identify_dateutil_timezones(tzid): + """dateutil and others were badly supported. + + But if we know their shortcodes, we should be able to identify them. + """ + tz = gettz(tzid) + assert tzid in tzids_from_tzinfo(tz) diff --git a/src/icalendar/tests/test_timezone_identification.py b/src/icalendar/tests/test_timezone_identification.py new file mode 100644 index 00000000..b637cb08 --- /dev/null +++ b/src/icalendar/tests/test_timezone_identification.py @@ -0,0 +1,34 @@ +"""Test that we can identify all timezones.""" + +import pytest +try: + from zoneinfo import ZoneInfo, available_timezones +except ImportError: + from backports.zoneinfo import ZoneInfo, available_timezones + +from icalendar.timezone import tzids_from_tzinfo, tzid_from_tzinfo + +tzids = available_timezones() - {"Factory", "localtime"} +with_tzid = pytest.mark.parametrize("tzid", tzids) + +@with_tzid +def test_can_identify_zoneinfo(tzid, zoneinfo_only): + """Check that all those zoneinfo timezones can be identified.""" + assert tzid in tzids_from_tzinfo(ZoneInfo(tzid)) + +@with_tzid +def test_can_identify_pytz(tzid, pytz_only): + """Check that all those pytz timezones can be identified.""" + import pytz + assert tzid in tzids_from_tzinfo(pytz.timezone(tzid)) + +@with_tzid +def test_can_identify_dateutil(tzid): + """Check that all those dateutil timezones can be identified.""" + from dateutil.tz import gettz + assert tzid in tzids_from_tzinfo(gettz(tzid)) + +def test_utc_is_identified(utc): + """Test UTC because it is handled in a special way.""" + assert "UTC" in tzids_from_tzinfo(utc) + assert tzid_from_tzinfo(utc) == "UTC" diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index abbdc2de..44591a26 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -135,7 +135,7 @@ 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 + dt = cal.events[0].start assert tzid_from_dt(dt) in ("custom_America/New_York", "EDT") @@ -143,7 +143,7 @@ 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 + dt = cal.events[0].start tz = dt.tzinfo tz_new_york = tzp.timezone("America/New_York") # for reasons (tm) the locally installed version of the timezone @@ -389,7 +389,7 @@ def test_create_pacific_fiji(calendars, pytz_only): def test_transition_times_fiji(tzp, timezones): """The transition times are computed.""" - tz = timezones.pacific_fiji.to_tz(tzp) + tz = timezones.pacific_fiji.to_tz(tzp, lookup_tzid=False) offsets = [] # [(before, after), ...] for i, transition_time in enumerate(fiji_transition_times): before_after_offset = [] diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py index e0da7837..6725e656 100644 --- a/src/icalendar/timezone/__init__.py +++ b/src/icalendar/timezone/__init__.py @@ -1,4 +1,5 @@ """This package contains all functionality for timezones.""" +from .tzid import tzid_from_dt, tzid_from_tzinfo, tzids_from_tzinfo from .tzp import TZP tzp = TZP() @@ -12,5 +13,12 @@ def use_zoneinfo(): """Use zoneinfo as the implementation that looks up and creates timezones.""" tzp.use_zoneinfo() - -__all__ = ["tzp", "use_pytz", "use_zoneinfo"] +__all__ = [ + "TZP", + "tzp", + "use_pytz", + "use_zoneinfo", + "tzid_from_tzinfo", + "tzid_from_dt", + "tzids_from_tzinfo" +] diff --git a/src/icalendar/timezone/equivalent_timezone_ids.py b/src/icalendar/timezone/equivalent_timezone_ids.py new file mode 100644 index 00000000..63da737e --- /dev/null +++ b/src/icalendar/timezone/equivalent_timezone_ids.py @@ -0,0 +1,141 @@ +"""This module helps identifying the timezone ids and where they differ. + +The algorithm: We use the tzname and the utcoffset for each hour from +1970 - 2030. +We make a big map. +If they are equivalent, they are equivalent within the time that is mostly used. + +You can regenerate the information from this module. + +See also: +- https://stackoverflow.com/questions/79185519/which-timezones-are-equivalent + +""" +from __future__ import annotations + +import contextlib +from collections import defaultdict +from datetime import datetime, timedelta, tzinfo +from multiprocessing import Pool, cpu_count +from pathlib import Path +from pprint import pprint +from time import time +from typing import Callable, NamedTuple, Optional + +from zoneinfo import ZoneInfo, available_timezones + +from pytz import AmbiguousTimeError, NonExistentTimeError + + +def check(dt, tz:tzinfo): + return (dt, tz.utcoffset(dt)) + +def checks(tz:tzinfo) -> tuple: + result = [] + for dt in DTS: + try: + result.append(check(dt, tz)) + except Exception as e: + print(e) + return result + + +START = datetime(1970, 1, 1) # noqa: DTZ001 +END = datetime(2000, 1, 1) # noqa: DTZ001 + +DTS = [] +dt = START +while dt <= END: + DTS.append(dt) + dt += timedelta(hours=25) # This must be big enough to be fast and small enough to identify the timeszones before it is the present year +del dt + +def main( + create_timezones:list[Callable[[str], tzinfo]], + name:str, + pool_size = cpu_count() + ): + """Generate a lookup table for timezone information if unknown timezones. + + We cannot create one lookup for all because they seem to be all equivalent + if we mix timezone implementations. + """ + print(create_timezones, name) + unsorted_tzids = available_timezones() + unsorted_tzids.remove("localtime") + unsorted_tzids.remove("Factory") + + class TZ(NamedTuple): + tz: tzinfo + id:str + + tzs = [ + TZ(create_timezone(tzid), tzid) + for create_timezone in create_timezones + for tzid in unsorted_tzids + ] + + def generate_tree( + tzs: list[TZ], + step:timedelta=timedelta(hours=1), + start:datetime=START, + end:datetime=END, + todo:Optional[set[str]]=None + ) -> tuple[datetime, dict[timedelta, set[str]]] | set[str]: # should be recursive + """Generate a lookup tree.""" + if todo is None: + todo = [tz.id for tz in tzs] + print(f"{len(todo)} left to compute") + print(len(tzs)) + if len(tzs) == 0: + raise ValueError("tzs cannot be empty") + if len(tzs) == 1: + todo.remove(tzs[0].id) + return {tzs[0].id} + while start < end: + offsets : dict[timedelta, list[TZ]] = defaultdict(list) + try: + for tz in tzs: + offsets[tz.tz.utcoffset(start)].append(tz) + except (NonExistentTimeError, AmbiguousTimeError): + start += step + continue + if len(offsets) == 1: + start += step + continue + lookup = {} + for offset, tzs in offsets.items(): + lookup[offset] = generate_tree(tzs=tzs, step=step, start=start + step, end=end, todo=todo) + return start, lookup + result = set() + for tz in tzs: + result.add(tz.id) + todo.remove(tz.id) + return result + + + lookup = generate_tree(tzs, step=timedelta(hours=33)) + + file = Path(__file__).parent / f"equivalent_timezone_ids_{name}.py" + print(f"The result is written to {file}.") + print("lookup = ", end="") + pprint(lookup) + with file.open("w") as f: + f.write(f"'''This file is automatically generated by {Path(__file__).name}'''\n") + f.write("import datetime\n\n") + f.write(f"\nlookup = ") + pprint(lookup, stream=f) + f.write("\n\n__all__ = ['lookup']\n") + + return lookup + +__all__ = ["main"] + +if __name__ == "__main__": + from dateutil.tz import gettz + from pytz import timezone + from zoneinfo import ZoneInfo + # add more timezone implementations if you like + main([ZoneInfo, timezone, gettz], "result", + pool_size=1 + ) diff --git a/src/icalendar/timezone/equivalent_timezone_ids_result.py b/src/icalendar/timezone/equivalent_timezone_ids_result.py new file mode 100644 index 00000000..dbdebf0b --- /dev/null +++ b/src/icalendar/timezone/equivalent_timezone_ids_result.py @@ -0,0 +1,811 @@ +'''This file is automatically generated by equivalent_timezone_ids.py''' +import datetime + + +lookup = (datetime.datetime(1970, 1, 2, 9, 0), + {datetime.timedelta(days=-1, seconds=43200): (datetime.datetime(1979, 10, 2, 6, 0), + {datetime.timedelta(days=-1, seconds=43200): (datetime.datetime(1993, 8, 23, 0, 0), + {datetime.timedelta(days=-1, seconds=43200): {'Etc/GMT+12'}, + datetime.timedelta(seconds=43200): {'Kwajalein', + 'Pacific/Kwajalein'}}), + datetime.timedelta(days=-1, seconds=46800): {'Pacific/Enderbury', + 'Pacific/Kanton'}}), + datetime.timedelta(days=-1, seconds=46800): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=46800): {'Etc/GMT+11', + 'Pacific/Apia', + 'Pacific/Fakaofo', + 'Pacific/Midway', + 'Pacific/Niue', + 'Pacific/Pago_Pago', + 'Pacific/Samoa', + 'US/Samoa'}, + datetime.timedelta(days=-1, seconds=50400): (datetime.datetime(1983, 10, 30, 9, 0), + {datetime.timedelta(days=-1, seconds=50400): {'America/Adak', + 'America/Atka', + 'US/Aleutian'}, + datetime.timedelta(days=-1, seconds=54000): {'America/Nome'}})}), + datetime.timedelta(days=-1, seconds=48000): {'Pacific/Kiritimati'}, + datetime.timedelta(days=-1, seconds=48600): {'Pacific/Rarotonga'}, + datetime.timedelta(days=-1, seconds=50400): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=50400): {'Etc/GMT+10', + 'HST', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Tahiti', + 'US/Hawaii'}, + datetime.timedelta(days=-1, seconds=54000): {'America/Anchorage', + 'US/Alaska'}}), + datetime.timedelta(days=-1, seconds=52200): {'Pacific/Marquesas'}, + datetime.timedelta(days=-1, seconds=54000): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=54000): (datetime.datetime(1973, 10, 29, 0, 0), + {datetime.timedelta(days=-1, seconds=54000): {'Etc/GMT+9', + 'Pacific/Gambier'}, + datetime.timedelta(days=-1, seconds=57600): {'America/Dawson'}}), + datetime.timedelta(days=-1, seconds=57600): {'America/Yakutat'}}), + datetime.timedelta(days=-1, seconds=55800): {'Pacific/Pitcairn'}, + datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1972, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1976, 4, 26, 6, 0), + {datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1980, 4, 28, 6, 0), + {datetime.timedelta(days=-1, seconds=57600): {'Etc/GMT+8'}, + datetime.timedelta(days=-1, seconds=61200): {'America/Whitehorse', + 'Canada/Yukon'}}), + datetime.timedelta(days=-1, seconds=61200): {'America/Ensenada', + 'America/Santa_Isabel', + 'America/Tijuana', + 'Mexico/BajaNorte'}}), + datetime.timedelta(days=-1, seconds=61200): {'America/Inuvik'}}), + datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1972, 10, 29, 15, 0), + {datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1974, 1, 7, 3, 0), + {datetime.timedelta(days=-1, seconds=57600): {'America/Fort_Nelson', + 'America/Vancouver', + 'Canada/Pacific'}, + datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1980, 4, 28, 6, 0), + {datetime.timedelta(days=-1, seconds=57600): {'America/Juneau'}, + datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1983, 10, 30, 9, 0), + {datetime.timedelta(days=-1, seconds=54000): {'America/Sitka'}, + datetime.timedelta(days=-1, seconds=57600): (datetime.datetime(1984, 4, 30, 6, 0), + {datetime.timedelta(days=-1, seconds=57600): {'America/Metlakatla'}, + datetime.timedelta(days=-1, seconds=61200): {'America/Los_Angeles', + 'PST8PDT', + 'US/Pacific'}})})})}), + datetime.timedelta(days=-1, seconds=61200): {'America/Dawson_Creek'}})}), + datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1972, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1996, 4, 7, 9, 0), + {datetime.timedelta(days=-1, seconds=61200): {'America/Creston', + 'America/Phoenix', + 'Etc/GMT+7', + 'MST', + 'US/Arizona'}, + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1999, 4, 4, 3, 0), + {datetime.timedelta(days=-1, seconds=61200): {'America/Hermosillo'}, + datetime.timedelta(days=-1, seconds=64800): {'America/Bahia_Banderas', + 'America/Mazatlan', + 'Mexico/BajaSur'}})}), + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1972, 10, 29, 15, 0), + {datetime.timedelta(days=-1, seconds=61200): (datetime.datetime(1999, 10, 31, 12, 0), + {datetime.timedelta(days=-1, seconds=61200): {'America/Edmonton', + 'America/Yellowknife', + 'Canada/Mountain'}, + datetime.timedelta(days=-1, seconds=64800): {'America/Cambridge_Bay'}}), + datetime.timedelta(days=-1, seconds=64800): {'America/Swift_Current'}})}), + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1974, 1, 7, 3, 0), + {datetime.timedelta(days=-1, seconds=61200): {'America/Boise'}, + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1992, 10, 25, 21, 0), + {datetime.timedelta(days=-1, seconds=61200): {'America/Denver', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/New_Salem', + 'America/Shiprock', + 'MST7MDT', + 'Navajo', + 'US/Mountain'}, + datetime.timedelta(days=-1, seconds=64800): {'America/North_Dakota/Center'}})})}), + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1970, 3, 30, 0, 0), + {datetime.timedelta(days=-1, seconds=61200): {'Chile/EasterIsland', + 'Pacific/Easter'}, + datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1972, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1973, 5, 1, 21, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1973, 11, 25, 12, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1973, 12, 5, 3, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1979, 2, 25, 15, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1981, 12, 24, 6, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1987, 5, 3, 21, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1988, 4, 3, 9, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1996, 4, 7, 9, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Regina', + 'Canada/Saskatchewan', + 'Etc/GMT+6'}, + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1998, 4, 6, 3, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Chihuahua', + 'America/Ciudad_Juarez', + 'America/Ojinaga'}, + datetime.timedelta(days=-1, seconds=68400): {'America/Mexico_City', + 'Mexico/General'}})}), + datetime.timedelta(days=-1, seconds=68400): {'America/Matamoros', + 'America/Monterrey'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/El_Salvador', + 'America/Tegucigalpa'}}), + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1982, 12, 3, 0, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Merida'}, + datetime.timedelta(days=-1, seconds=68400): {'America/Cancun'}})}), + datetime.timedelta(days=-1, seconds=68400): {'America/Costa_Rica'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/Belize'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/Guatemala'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/Managua'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/Rankin_Inlet', + 'America/Resolute'}}), + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1974, 1, 7, 3, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Rainy_River', + 'America/Winnipeg', + 'Canada/Central'}, + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1977, 10, 31, 0, 0), + {datetime.timedelta(days=-1, seconds=64800): (datetime.datetime(1991, 10, 27, 12, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Chicago', + 'America/Kentucky/Monticello', + 'CST6CDT', + 'US/Central'}, + datetime.timedelta(days=-1, seconds=68400): {'America/Indiana/Knox', + 'America/Knox_IN', + 'US/Indiana-Starke'}}), + datetime.timedelta(days=-1, seconds=68400): {'America/Indiana/Petersburg'}})})})}), + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1972, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1973, 4, 29, 3, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1973, 10, 29, 0, 0), + {datetime.timedelta(days=-1, seconds=64800): {'America/Menominee'}, + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1974, 1, 7, 3, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1979, 4, 29, 21, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1983, 5, 8, 18, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1985, 11, 2, 15, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1986, 1, 2, 3, 0), + {datetime.timedelta(days=-1, seconds=64800): {'Pacific/Galapagos'}, + datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1992, 5, 4, 6, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1992, 11, 29, 6, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Atikokan', + 'America/Cayman', + 'America/Coral_Harbour', + 'America/Panama', + 'EST', + 'Etc/GMT+5'}, + datetime.timedelta(days=-1, seconds=72000): {'America/Guayaquil'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Bogota'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Lima'}}), + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1993, 10, 18, 9, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Porto_Acre', + 'America/Rio_Branco', + 'Brazil/Acre'}, + datetime.timedelta(days=-1, seconds=72000): {'America/Eirunepe'}})}), + datetime.timedelta(days=-1, seconds=72000): {'America/Port-au-Prince'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Grand_Turk'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Jamaica', + 'Jamaica'}})}), + datetime.timedelta(days=-1, seconds=72000): {'America/Detroit', + 'US/Michigan'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Iqaluit', + 'America/Pangnirtung'}}), + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1970, 10, 25, 0, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Havana', + 'Cuba'}, + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1971, 4, 25, 21, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Fort_Wayne', + 'America/Indiana/Indianapolis', + 'America/Indiana/Tell_City', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'US/East-Indiana'}, + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1973, 4, 29, 3, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Indiana/Vevay'}, + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1974, 1, 7, 3, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1974, 4, 28, 12, 0), + {datetime.timedelta(days=-1, seconds=68400): (datetime.datetime(1976, 4, 26, 6, 0), + {datetime.timedelta(days=-1, seconds=68400): {'America/Indiana/Marengo'}, + datetime.timedelta(days=-1, seconds=72000): {'America/Kentucky/Louisville', + 'America/Louisville'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/Montreal', + 'America/Nassau', + 'America/Nipigon', + 'America/Thunder_Bay', + 'America/Toronto', + 'Canada/Eastern'}}), + datetime.timedelta(days=-1, seconds=72000): {'America/New_York', + 'EST5EDT', + 'US/Eastern'}})})})})}), + datetime.timedelta(days=-1, seconds=70200): {'America/Santo_Domingo'}, + datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1972, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1972, 10, 2, 3, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1974, 4, 28, 12, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1977, 6, 12, 18, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1980, 4, 6, 6, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1980, 5, 2, 9, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1983, 5, 1, 21, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1985, 11, 2, 15, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1991, 3, 31, 3, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Anguilla', + 'America/Antigua', + 'America/Aruba', + 'America/Blanc-Sablon', + 'America/Caracas', + 'America/Curacao', + 'America/Dominica', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lower_Princes', + 'America/Marigot', + 'America/Montserrat', + 'America/Port_of_Spain', + 'America/Puerto_Rico', + 'America/St_Barthelemy', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Tortola', + 'America/Virgin', + 'Etc/GMT+4'}, + datetime.timedelta(days=-1, seconds=75600): {'America/Thule'}}), + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1988, 10, 17, 0, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1993, 10, 18, 9, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1999, 10, 4, 0, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Porto_Velho', + 'America/Santarem'}, + datetime.timedelta(days=-1, seconds=75600): {'America/Boa_Vista'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Manaus', + 'Brazil/West'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Campo_Grande', + 'America/Cuiaba'}})}), + datetime.timedelta(days=-1, seconds=75600): {'Atlantic/Stanley'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Miquelon'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Martinique'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Barbados'}}), + datetime.timedelta(days=-1, seconds=75600): {'Atlantic/Bermuda'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Asuncion'}}), + datetime.timedelta(days=-1, seconds=75600): {'America/Glace_Bay'}}), + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1973, 4, 29, 3, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Moncton'}, + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1988, 4, 3, 9, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Halifax', + 'Canada/Atlantic'}, + datetime.timedelta(days=-1, seconds=79200): {'America/Goose_Bay'}})})}), + datetime.timedelta(days=-1, seconds=72900): {'America/Guyana'}, + datetime.timedelta(days=-1, seconds=73800): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(days=-1, seconds=73800): {'America/Paramaribo'}, + datetime.timedelta(days=-1, seconds=77400): {'America/St_Johns', + 'Canada/Newfoundland'}}), + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1970, 3, 30, 0, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Punta_Arenas', + 'America/Santiago', + 'Chile/Continental'}, + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1970, 4, 25, 3, 0), + {datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1974, 1, 23, 15, 0), + {datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1980, 4, 6, 6, 0), + {datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1985, 11, 2, 15, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Cayenne', + 'Etc/GMT+3'}, + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1988, 10, 17, 0, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Belem'}, + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1990, 10, 21, 6, 0), + {datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1995, 10, 15, 18, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Fortaleza', + 'America/Recife'}, + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1996, 10, 7, 6, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Maceio'}, + datetime.timedelta(days=-1, seconds=79200): {'America/Araguaina'}})}), + datetime.timedelta(days=-1, seconds=79200): {'America/Bahia', + 'America/Sao_Paulo', + 'Brazil/East'}})})}), + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1996, 1, 2, 3, 0), + {datetime.timedelta(days=-1, seconds=75600): {'America/Godthab', + 'America/Nuuk'}, + datetime.timedelta(0): {'America/Danmarkshavn'}})}), + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1982, 5, 1, 3, 0), + {datetime.timedelta(days=-1, seconds=72000): {'Antarctica/Palmer'}, + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1990, 3, 4, 6, 0), + {datetime.timedelta(days=-1, seconds=72000): (datetime.datetime(1990, 10, 15, 18, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Argentina/Jujuy', + 'America/Jujuy'}, + datetime.timedelta(days=-1, seconds=75600): {'America/Argentina/Mendoza', + 'America/Mendoza'}}), + datetime.timedelta(days=-1, seconds=75600): (datetime.datetime(1991, 3, 2, 6, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Argentina/La_Rioja', + 'America/Argentina/San_Juan'}, + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1991, 3, 3, 15, 0), + {datetime.timedelta(days=-1, seconds=72000): {'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Salta', + 'America/Argentina/Tucuman', + 'America/Catamarca', + 'America/Cordoba', + 'America/Rosario'}, + datetime.timedelta(days=-1, seconds=75600): {'America/Argentina/Buenos_Aires', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Ushuaia', + 'America/Buenos_Aires'}})}), + datetime.timedelta(days=-1, seconds=79200): {'America/Argentina/San_Luis'}})})}), + datetime.timedelta(days=-1, seconds=79200): {'America/Montevideo'}})}), + datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1975, 11, 25, 15, 0), + {datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1980, 4, 6, 6, 0), + {datetime.timedelta(days=-1, seconds=79200): (datetime.datetime(1985, 11, 2, 15, 0), + {datetime.timedelta(days=-1, seconds=79200): {'Atlantic/South_Georgia', + 'Etc/GMT+2'}, + datetime.timedelta(days=-1, seconds=82800): {'America/Noronha', + 'Brazil/DeNoronha'}}), + datetime.timedelta(days=-1, seconds=82800): {'America/Scoresbysund'}}), + datetime.timedelta(days=-1, seconds=82800): {'Atlantic/Cape_Verde'}}), + datetime.timedelta(days=-1, seconds=82800): (datetime.datetime(1975, 1, 2, 9, 0), + {datetime.timedelta(days=-1, seconds=82800): (datetime.datetime(1976, 4, 15, 6, 0), + {datetime.timedelta(days=-1, seconds=82800): (datetime.datetime(1977, 3, 27, 18, 0), + {datetime.timedelta(days=-1, seconds=82800): {'Etc/GMT+1'}, + datetime.timedelta(0): {'Atlantic/Azores'}}), + datetime.timedelta(0): {'Africa/El_Aaiun'}}), + datetime.timedelta(0): {'Africa/Bissau'}}), + datetime.timedelta(days=-1, seconds=83730): {'Africa/Monrovia'}, + datetime.timedelta(days=-1, seconds=83760): {'Africa/Monrovia'}, + datetime.timedelta(0): (datetime.datetime(1971, 4, 27, 6, 0), + {datetime.timedelta(0): (datetime.datetime(1974, 6, 25, 6, 0), + {datetime.timedelta(0): (datetime.datetime(1976, 12, 2, 6, 0), + {datetime.timedelta(days=-1, seconds=75600): {'Antarctica/Rothera'}, + datetime.timedelta(0): (datetime.datetime(1977, 3, 27, 18, 0), + {datetime.timedelta(0): (datetime.datetime(1977, 4, 3, 15, 0), + {datetime.timedelta(0): (datetime.datetime(1980, 4, 6, 6, 0), + {datetime.timedelta(0): (datetime.datetime(1981, 3, 29, 18, 0), + {datetime.timedelta(0): {'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Bamako', + 'Africa/Banjul', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Freetown', + 'Africa/Lome', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Antarctica/Troll', + 'Atlantic/Reykjavik', + 'Atlantic/St_Helena', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT-0', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'Iceland', + 'UCT', + 'UTC', + 'Universal', + 'Zulu'}, + datetime.timedelta(seconds=3600): {'Atlantic/Faeroe', + 'Atlantic/Faroe'}}), + datetime.timedelta(seconds=3600): {'Atlantic/Canary'}}), + datetime.timedelta(seconds=3600): {'WET'}}), + datetime.timedelta(seconds=3600): {'Atlantic/Madeira'}})}), + datetime.timedelta(seconds=3600): (datetime.datetime(1986, 1, 2, 3, 0), + {datetime.timedelta(0): {'Africa/Casablanca'}, + datetime.timedelta(seconds=3600): {'Africa/Ceuta'}})}), + datetime.timedelta(seconds=3600): {'Africa/Algiers'}}), + datetime.timedelta(seconds=3600): (datetime.datetime(1970, 6, 1, 6, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1971, 10, 31, 6, 0), + {datetime.timedelta(0): {'Eire', + 'Europe/Belfast', + 'Europe/Dublin', + 'Europe/Guernsey', + 'Europe/Isle_of_Man', + 'Europe/Jersey', + 'Europe/London', + 'GB', + 'GB-Eire'}, + datetime.timedelta(seconds=3600): (datetime.datetime(1974, 4, 14, 18, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1974, 5, 5, 9, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1976, 3, 28, 9, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1976, 9, 27, 6, 0), + {datetime.timedelta(0): {'Europe/Lisbon', + 'Portugal'}, + datetime.timedelta(seconds=3600): (datetime.datetime(1977, 4, 3, 15, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1977, 5, 1, 3, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1979, 4, 2, 9, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1979, 10, 14, 15, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1980, 4, 6, 6, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1981, 3, 29, 18, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1982, 3, 29, 3, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1983, 3, 27, 3, 0), + {datetime.timedelta(seconds=3600): (datetime.datetime(1985, 3, 31, 18, 0), + {datetime.timedelta(seconds=3600): {'Africa/Bangui', + 'Africa/Brazzaville', + 'Africa/Douala', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Luanda', + 'Africa/Malabo', + 'Africa/Niamey', + 'Africa/Porto-Novo', + 'Etc/GMT-1'}, + datetime.timedelta(seconds=7200): {'Europe/Andorra'}}), + datetime.timedelta(seconds=7200): {'Europe/Belgrade', + 'Europe/Ljubljana', + 'Europe/Podgorica', + 'Europe/Sarajevo', + 'Europe/Skopje', + 'Europe/Zagreb'}}), + datetime.timedelta(seconds=7200): {'Europe/Gibraltar'}}), + datetime.timedelta(seconds=7200): {'Europe/Busingen', + 'Europe/Vaduz', + 'Europe/Zurich'}}), + datetime.timedelta(seconds=7200): {'Arctic/Longyearbyen', + 'Atlantic/Jan_Mayen', + 'Europe/Berlin', + 'Europe/Budapest', + 'Europe/Copenhagen', + 'Europe/Oslo', + 'Europe/Stockholm', + 'Europe/Vienna'}}), + datetime.timedelta(seconds=7200): {'Africa/Ndjamena'}}), + datetime.timedelta(seconds=7200): {'Europe/Bratislava', + 'Europe/Prague'}}), + datetime.timedelta(seconds=7200): {'Africa/Tunis'}}), + datetime.timedelta(seconds=7200): {'CET', + 'Europe/Amsterdam', + 'Europe/Brussels', + 'Europe/Luxembourg', + 'Europe/Warsaw', + 'MET', + 'Poland'}})}), + datetime.timedelta(seconds=7200): {'Europe/Monaco', + 'Europe/Paris'}}), + datetime.timedelta(seconds=7200): {'Europe/Tirane'}}), + datetime.timedelta(seconds=7200): {'Europe/Madrid'}})}), + datetime.timedelta(seconds=7200): (datetime.datetime(1973, 3, 31, 6, 0), + {datetime.timedelta(seconds=3600): {'Europe/Rome', + 'Europe/San_Marino', + 'Europe/Vatican'}, + datetime.timedelta(seconds=7200): {'Europe/Malta'}})}), + datetime.timedelta(seconds=7200): (datetime.datetime(1970, 5, 2, 0, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1972, 6, 22, 9, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1973, 6, 3, 21, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1973, 6, 6, 15, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1974, 7, 7, 15, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1975, 4, 12, 18, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1975, 4, 14, 3, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1977, 4, 3, 15, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1979, 4, 1, 0, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1979, 5, 27, 9, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1981, 3, 29, 18, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1982, 1, 1, 12, 0), + {datetime.timedelta(seconds=3600): {'Africa/Tripoli', + 'Libya'}, + datetime.timedelta(seconds=7200): (datetime.datetime(1994, 3, 21, 9, 0), + {datetime.timedelta(seconds=3600): {'Africa/Windhoek'}, + datetime.timedelta(seconds=7200): {'Africa/Blantyre', + 'Africa/Bujumbura', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Kigali', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Etc/GMT-2'}})}), + datetime.timedelta(seconds=10800): {'Europe/Helsinki', + 'Europe/Mariehamn'}}), + datetime.timedelta(seconds=10800): {'Europe/Bucharest'}}), + datetime.timedelta(seconds=10800): {'Europe/Sofia'}}), + datetime.timedelta(seconds=10800): {'EET'}}), + datetime.timedelta(seconds=10800): {'Asia/Famagusta', + 'Asia/Nicosia', + 'Europe/Nicosia'}}), + datetime.timedelta(seconds=10800): {'Europe/Athens'}}), + datetime.timedelta(seconds=10800): (datetime.datetime(1996, 3, 16, 9, 0), + {datetime.timedelta(seconds=7200): {'Asia/Gaza', + 'Asia/Hebron'}, + datetime.timedelta(seconds=10800): {'Asia/Jerusalem', + 'Asia/Tel_Aviv', + 'Israel'}})}), + datetime.timedelta(seconds=10800): {'Asia/Amman'}}), + datetime.timedelta(seconds=10800): {'Asia/Istanbul', + 'Europe/Istanbul', + 'Turkey'}}), + datetime.timedelta(seconds=10800): {'Asia/Beirut'}}), + datetime.timedelta(seconds=10800): (datetime.datetime(1970, 10, 1, 15, 0), + {datetime.timedelta(seconds=7200): (datetime.datetime(1977, 9, 1, 21, 0), + {datetime.timedelta(seconds=7200): {'Asia/Damascus'}, + datetime.timedelta(seconds=10800): {'Africa/Cairo', + 'Egypt'}}), + datetime.timedelta(seconds=10800): {'Africa/Juba', + 'Africa/Khartoum'}})}), + datetime.timedelta(seconds=10800): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=10800): (datetime.datetime(1982, 5, 1, 3, 0), + {datetime.timedelta(seconds=10800): {'Africa/Addis_Ababa', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Kampala', + 'Africa/Mogadishu', + 'Africa/Nairobi', + 'Antarctica/Syowa', + 'Asia/Aden', + 'Asia/Kuwait', + 'Asia/Riyadh', + 'Etc/GMT-3', + 'Indian/Antananarivo', + 'Indian/Comoro', + 'Indian/Mayotte'}, + datetime.timedelta(seconds=14400): {'Asia/Baghdad'}}), + datetime.timedelta(seconds=14400): (datetime.datetime(1989, 3, 26, 21, 0), + {datetime.timedelta(seconds=10800): (datetime.datetime(1996, 9, 30, 9, 0), + {datetime.timedelta(seconds=7200): {'Europe/Riga'}, + datetime.timedelta(seconds=10800): (datetime.datetime(1998, 3, 30, 6, 0), + {datetime.timedelta(seconds=7200): {'Europe/Vilnius'}, + datetime.timedelta(seconds=10800): {'Europe/Kaliningrad', + 'Europe/Tallinn'}})}), + datetime.timedelta(seconds=14400): (datetime.datetime(1990, 3, 26, 6, 0), + {datetime.timedelta(seconds=10800): (datetime.datetime(1990, 7, 1, 21, 0), + {datetime.timedelta(seconds=7200): {'Europe/Simferopol'}, + datetime.timedelta(seconds=10800): {'Europe/Minsk'}}), + datetime.timedelta(seconds=14400): (datetime.datetime(1990, 5, 6, 12, 0), + {datetime.timedelta(seconds=10800): {'Europe/Chisinau', + 'Europe/Tiraspol'}, + datetime.timedelta(seconds=14400): (datetime.datetime(1990, 7, 1, 21, 0), + {datetime.timedelta(seconds=10800): {'Europe/Kiev', + 'Europe/Kyiv', + 'Europe/Uzhgorod', + 'Europe/Zaporozhye'}, + datetime.timedelta(seconds=14400): {'Europe/Moscow', + 'W-SU'}})})})})}), + datetime.timedelta(seconds=12600): {'Iran', 'Asia/Tehran'}, + datetime.timedelta(seconds=14400): (datetime.datetime(1972, 6, 1, 18, 0), + {datetime.timedelta(seconds=10800): {'Asia/Bahrain', + 'Asia/Qatar'}, + datetime.timedelta(seconds=14400): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=14400): (datetime.datetime(1982, 10, 10, 9, 0), + {datetime.timedelta(seconds=14400): {'Asia/Dubai', + 'Asia/Muscat', + 'Etc/GMT-4', + 'Indian/Mahe', + 'Indian/Reunion'}, + datetime.timedelta(seconds=18000): {'Indian/Mauritius'}}), + datetime.timedelta(seconds=18000): (datetime.datetime(1988, 3, 27, 12, 0), + {datetime.timedelta(seconds=14400): {'Europe/Saratov', + 'Europe/Volgograd'}, + datetime.timedelta(seconds=18000): (datetime.datetime(1989, 3, 26, 21, 0), + {datetime.timedelta(seconds=14400): (datetime.datetime(1991, 3, 31, 3, 0), + {datetime.timedelta(seconds=10800): (datetime.datetime(1991, 9, 30, 0, 0), + {datetime.timedelta(seconds=7200): {'Europe/Ulyanovsk'}, + datetime.timedelta(seconds=10800): {'Europe/Samara'}}), + datetime.timedelta(seconds=14400): {'Europe/Astrakhan', + 'Europe/Kirov'}}), + datetime.timedelta(seconds=18000): (datetime.datetime(1992, 9, 27, 0, 0), + {datetime.timedelta(seconds=10800): {'Asia/Tbilisi'}, + datetime.timedelta(seconds=14400): (datetime.datetime(1992, 9, 28, 9, 0), + {datetime.timedelta(seconds=10800): {'Asia/Yerevan'}, + datetime.timedelta(seconds=14400): {'Asia/Baku'}})})})})})}), + datetime.timedelta(seconds=16200): {'Asia/Kabul'}, + datetime.timedelta(seconds=18000): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=18000): (datetime.datetime(1981, 10, 1, 9, 0), + {datetime.timedelta(seconds=18000): (datetime.datetime(1996, 1, 2, 3, 0), + {datetime.timedelta(seconds=18000): {'Asia/Karachi', + 'Etc/GMT-5', + 'Indian/Kerguelen', + 'Indian/Maldives'}, + datetime.timedelta(seconds=21600): {'Indian/Chagos'}}), + datetime.timedelta(seconds=21600): (datetime.datetime(1994, 9, 25, 18, 0), + {datetime.timedelta(seconds=14400): {'Asia/Aqtau'}, + datetime.timedelta(seconds=18000): {'Asia/Atyrau'}})}), + datetime.timedelta(seconds=21600): (datetime.datetime(1981, 10, 1, 9, 0), + {datetime.timedelta(seconds=18000): (datetime.datetime(1992, 3, 29, 12, 0), + {datetime.timedelta(seconds=18000): {'Asia/Ashgabat', + 'Asia/Ashkhabad'}, + datetime.timedelta(seconds=21600): {'Asia/Yekaterinburg'}}), + datetime.timedelta(seconds=21600): (datetime.datetime(1989, 3, 26, 21, 0), + {datetime.timedelta(seconds=18000): {'Asia/Oral'}, + datetime.timedelta(seconds=21600): (datetime.datetime(1991, 3, 31, 3, 0), + {datetime.timedelta(seconds=18000): (datetime.datetime(1991, 9, 30, 0, 0), + {datetime.timedelta(seconds=14400): {'Asia/Aqtobe', + 'Asia/Qostanay'}, + datetime.timedelta(seconds=18000): {'Asia/Qyzylorda'}}), + datetime.timedelta(seconds=21600): {'Asia/Samarkand'}})})})}), + datetime.timedelta(seconds=19800): (datetime.datetime(1986, 1, 2, 3, 0), + {datetime.timedelta(seconds=19800): (datetime.datetime(1987, 10, 2, 3, 0), + {datetime.timedelta(seconds=19800): (datetime.datetime(1996, 5, 25, 12, 0), + {datetime.timedelta(seconds=19800): {'Asia/Calcutta', + 'Asia/Kolkata'}, + datetime.timedelta(seconds=23400): {'Asia/Colombo'}}), + datetime.timedelta(seconds=21600): {'Asia/Thimbu', + 'Asia/Thimphu'}}), + datetime.timedelta(seconds=20700): {'Asia/Kathmandu', + 'Asia/Katmandu'}}), + datetime.timedelta(seconds=21600): (datetime.datetime(1978, 1, 2, 6, 0), + {datetime.timedelta(seconds=21600): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=21600): {'Antarctica/Mawson', + 'Asia/Dacca', + 'Asia/Dhaka', + 'Asia/Kashgar', + 'Asia/Urumqi', + 'Etc/GMT-6'}, + datetime.timedelta(seconds=25200): (datetime.datetime(1991, 9, 1, 3, 0), + {datetime.timedelta(seconds=18000): {'Asia/Bishkek'}, + datetime.timedelta(seconds=21600): (datetime.datetime(1991, 9, 9, 9, 0), + {datetime.timedelta(seconds=18000): {'Asia/Dushanbe'}, + datetime.timedelta(seconds=21600): (datetime.datetime(1992, 1, 19, 9, 0), + {datetime.timedelta(seconds=18000): {'Asia/Tashkent'}, + datetime.timedelta(seconds=21600): {'Asia/Almaty', + 'Asia/Omsk'}})})})}), + datetime.timedelta(seconds=25200): {'Asia/Hovd'}}), + datetime.timedelta(seconds=23400): {'Asia/Rangoon', + 'Asia/Yangon', + 'Indian/Cocos'}, + datetime.timedelta(seconds=25200): (datetime.datetime(1978, 1, 2, 6, 0), + {datetime.timedelta(seconds=25200): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=25200): (datetime.datetime(1994, 2, 1, 6, 0), + {datetime.timedelta(0): {'Antarctica/Vostok'}, + datetime.timedelta(seconds=25200): {'Antarctica/Davis', + 'Asia/Bangkok', + 'Asia/Jakarta', + 'Asia/Phnom_Penh', + 'Asia/Vientiane', + 'Etc/GMT-7', + 'Indian/Christmas'}}), + datetime.timedelta(seconds=28800): (datetime.datetime(1993, 5, 24, 6, 0), + {datetime.timedelta(seconds=25200): {'Asia/Novosibirsk'}, + datetime.timedelta(seconds=28800): (datetime.datetime(1995, 5, 28, 12, 0), + {datetime.timedelta(seconds=25200): {'Asia/Barnaul'}, + datetime.timedelta(seconds=28800): {'Asia/Krasnoyarsk', + 'Asia/Novokuznetsk', + 'Asia/Tomsk'}})})}), + datetime.timedelta(seconds=28800): (datetime.datetime(1983, 4, 1, 15, 0), + {datetime.timedelta(seconds=32400): {'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator'}, + datetime.timedelta(seconds=36000): {'Asia/Choibalsan'}})}), + datetime.timedelta(seconds=27000): {'Asia/Kuala_Lumpur', + 'Asia/Singapore', + 'Singapore'}, + datetime.timedelta(seconds=28800): (datetime.datetime(1970, 4, 19, 15, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1974, 4, 2, 9, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1974, 10, 28, 9, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1975, 6, 13, 15, 0), + {datetime.timedelta(seconds=25200): {'Asia/Ho_Chi_Minh', + 'Asia/Saigon'}, + datetime.timedelta(seconds=28800): (datetime.datetime(1978, 3, 23, 0, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1986, 5, 4, 12, 0), + {datetime.timedelta(seconds=28800): (datetime.datetime(1988, 1, 2, 6, 0), + {datetime.timedelta(seconds=25200): {'Asia/Pontianak'}, + datetime.timedelta(seconds=28800): {'Antarctica/Casey', + 'Asia/Brunei', + 'Asia/Kuching', + 'Asia/Makassar', + 'Asia/Ujung_Pandang', + 'Etc/GMT-8'}}), + datetime.timedelta(seconds=32400): {'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Harbin', + 'Asia/Shanghai', + 'PRC'}}), + datetime.timedelta(seconds=32400): {'Asia/Irkutsk'}}), + datetime.timedelta(seconds=32400): {'Asia/Manila'}})}), + datetime.timedelta(seconds=32400): {'Australia/Perth', + 'Australia/West'}}), + datetime.timedelta(seconds=32400): {'Asia/Taipei', + 'ROC'}}), + datetime.timedelta(seconds=32400): {'Asia/Hong_Kong', + 'Asia/Macao', + 'Asia/Macau', + 'Hongkong'}}), + datetime.timedelta(seconds=31500): {'Australia/Eucla'}, + datetime.timedelta(seconds=32400): (datetime.datetime(1976, 5, 3, 3, 0), + {datetime.timedelta(seconds=28800): {'Asia/Dili'}, + datetime.timedelta(seconds=32400): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=32400): (datetime.datetime(1987, 5, 10, 18, 0), + {datetime.timedelta(seconds=32400): {'Asia/Jayapura', + 'Asia/Pyongyang', + 'Asia/Tokyo', + 'Etc/GMT-9', + 'Japan', + 'Pacific/Palau'}, + datetime.timedelta(seconds=36000): {'Asia/Seoul', + 'ROK'}}), + datetime.timedelta(seconds=36000): {'Asia/Chita', + 'Asia/Khandyga', + 'Asia/Yakutsk'}, + datetime.timedelta(seconds=43200): {'Asia/Ust-Nera'}})}), + datetime.timedelta(seconds=34200): (datetime.datetime(1971, 10, 31, 6, 0), + {datetime.timedelta(seconds=34200): {'Australia/Darwin', + 'Australia/North'}, + datetime.timedelta(seconds=37800): (datetime.datetime(1982, 3, 7, 3, 0), + {datetime.timedelta(seconds=34200): {'Australia/Adelaide', + 'Australia/South'}, + datetime.timedelta(seconds=37800): {'Australia/Broken_Hill', + 'Australia/Yancowinna'}})}), + datetime.timedelta(seconds=36000): (datetime.datetime(1970, 4, 26, 12, 0), + {datetime.timedelta(seconds=36000): (datetime.datetime(1971, 10, 31, 6, 0), + {datetime.timedelta(seconds=36000): (datetime.datetime(1981, 3, 2, 6, 0), + {datetime.timedelta(seconds=36000): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=36000): {'Antarctica/DumontDUrville', + 'Etc/GMT-10', + 'Pacific/Bougainville', + 'Pacific/Chuuk', + 'Pacific/Port_Moresby', + 'Pacific/Truk', + 'Pacific/Yap'}, + datetime.timedelta(seconds=39600): {'Asia/Vladivostok'}}), + datetime.timedelta(seconds=37800): {'Australia/LHI', + 'Australia/Lord_Howe'}}), + datetime.timedelta(seconds=39600): (datetime.datetime(1972, 10, 29, 15, 0), + {datetime.timedelta(seconds=36000): (datetime.datetime(1992, 10, 25, 21, 0), + {datetime.timedelta(seconds=36000): {'Australia/Brisbane', + 'Australia/Queensland'}, + datetime.timedelta(seconds=39600): {'Australia/Lindeman'}}), + datetime.timedelta(seconds=39600): (datetime.datetime(1982, 3, 7, 3, 0), + {datetime.timedelta(seconds=36000): {'Australia/Melbourne', + 'Australia/Victoria'}, + datetime.timedelta(seconds=39600): {'Australia/ACT', + 'Australia/Canberra', + 'Australia/NSW', + 'Australia/Sydney'}})})}), + datetime.timedelta(seconds=39600): {'Pacific/Guam', + 'Pacific/Saipan'}}), + datetime.timedelta(seconds=39600): (datetime.datetime(1970, 3, 9, 9, 0), + {datetime.timedelta(seconds=36000): {'Antarctica/Macquarie', + 'Australia/Currie', + 'Australia/Hobart', + 'Australia/Tasmania'}, + datetime.timedelta(seconds=39600): (datetime.datetime(1973, 12, 23, 0, 0), + {datetime.timedelta(seconds=39600): (datetime.datetime(1977, 12, 4, 9, 0), + {datetime.timedelta(seconds=39600): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=39600): {'Etc/GMT-11', + 'Pacific/Guadalcanal', + 'Pacific/Pohnpei', + 'Pacific/Ponape'}, + datetime.timedelta(seconds=43200): (datetime.datetime(1997, 3, 30, 21, 0), + {datetime.timedelta(seconds=39600): {'Asia/Sakhalin'}, + datetime.timedelta(seconds=43200): {'Asia/Magadan', + 'Asia/Srednekolymsk'}})}), + datetime.timedelta(seconds=43200): {'Pacific/Noumea'}}), + datetime.timedelta(seconds=43200): {'Pacific/Efate'}})}), + datetime.timedelta(seconds=41400): (datetime.datetime(1974, 10, 28, 9, 0), + {datetime.timedelta(seconds=41400): {'Pacific/Nauru'}, + datetime.timedelta(seconds=45000): {'Pacific/Norfolk'}}), + datetime.timedelta(seconds=43200): (datetime.datetime(1974, 11, 4, 6, 0), + {datetime.timedelta(seconds=43200): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=43200): (datetime.datetime(1998, 11, 1, 3, 0), + {datetime.timedelta(seconds=43200): (datetime.datetime(1999, 1, 2, 0, 0), + {datetime.timedelta(seconds=39600): {'Pacific/Kosrae'}, + datetime.timedelta(seconds=43200): {'Etc/GMT-12', + 'Pacific/Funafuti', + 'Pacific/Majuro', + 'Pacific/Tarawa', + 'Pacific/Wake', + 'Pacific/Wallis'}}), + datetime.timedelta(seconds=46800): {'Pacific/Fiji'}}), + datetime.timedelta(seconds=46800): {'Asia/Kamchatka'}}), + datetime.timedelta(seconds=46800): {'Antarctica/McMurdo', + 'Antarctica/South_Pole', + 'NZ', + 'Pacific/Auckland'}}), + datetime.timedelta(seconds=45900): {'NZ-CHAT', 'Pacific/Chatham'}, + datetime.timedelta(seconds=46800): (datetime.datetime(1981, 4, 1, 12, 0), + {datetime.timedelta(seconds=46800): (datetime.datetime(1999, 10, 8, 3, 0), + {datetime.timedelta(seconds=46800): {'Etc/GMT-13'}, + datetime.timedelta(seconds=50400): {'Pacific/Tongatapu'}}), + datetime.timedelta(seconds=50400): {'Asia/Anadyr'}}), + datetime.timedelta(seconds=50400): {'Etc/GMT-14'}}) + + +__all__ = ['lookup'] diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py index 8330c395..e6f400ed 100644 --- a/src/icalendar/timezone/provider.py +++ b/src/icalendar/timezone/provider.py @@ -1,14 +1,22 @@ """The interface for timezone implementations.""" from __future__ import annotations -from abc import ABC, abstractmethod, abstractproperty -from icalendar import prop -from dateutil.rrule import rrule -from datetime import datetime, tzinfo + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from datetime import datetime, tzinfo + + from dateutil.rrule import rrule + + from icalendar import prop + class TZProvider(ABC): """Interface for timezone implementations.""" - @abstractproperty + @property + @abstractmethod def name(self) -> str: """The name of the implementation.""" diff --git a/src/icalendar/timezone/tzid.py b/src/icalendar/timezone/tzid.py new file mode 100644 index 00000000..b39485dd --- /dev/null +++ b/src/icalendar/timezone/tzid.py @@ -0,0 +1,98 @@ +"""This module identifies timezones. + +Normally, timezones have ids. +This is a way to access the ids if you have a +datetime.tzinfo object. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional +import dateutil.tz.tz as tz + +if TYPE_CHECKING: + from datetime import datetime, tzinfo + + +def tzids_from_tzinfo(tzinfo: Optional[tzinfo]) -> tuple[str]: + """Get several timezone ids if we can identify the timezone. + + >>> import zoneinfo + >>> from icalendar.timezone.tzid import tzids_from_tzinfo + >>> tzids_from_tzinfo(zoneinfo.ZoneInfo("Africa/Accra")) + ('Africa/Accra',) + >>> from dateutil.tz import gettz + >>> tzids_from_tzinfo(gettz("Europe/Berlin")) + ('Arctic/Longyearbyen', 'Atlantic/Jan_Mayen', 'Europe/Berlin', 'Europe/Budapest', 'Europe/Copenhagen', 'Europe/Oslo', 'Europe/Stockholm', 'Europe/Vienna') + + """ # The example might need to change if you recreate the lookup tree + if tzinfo is None: + return () + if hasattr(tzinfo, 'zone'): + return (tzinfo.zone,) # pytz implementation + if hasattr(tzinfo, 'key'): + return (tzinfo.key,) # ZoneInfo implementation + if isinstance(tzinfo, tz._tzicalvtz): + return (tzinfo._tzid,) + if isinstance(tzinfo, tz.tzstr): + return (tzinfo._s,) + return tuple(sorted(tzinfo2tzids(tzinfo))) + + +def tzid_from_tzinfo(tzinfo: Optional[tzinfo]) -> Optional[str]: + """Retrieve the timezone id from the tzinfo object. + + Some timezones are equivalent. + Thus, we might return one ID that is equivelant to others. + """ + tzids = tzids_from_tzinfo(tzinfo) + if "UTC" in tzids: + return "UTC" + if not tzids: + return None + return tzids[0] + +def tzid_from_dt(dt: datetime) -> Optional[str]: + """Retrieve the timezone id from the datetime object.""" + tzid = tzid_from_tzinfo(dt.tzinfo) + if tzid is None: + return dt.tzname() + return tzid + + +def tzinfo2tzids(tzinfo: Optional[tzinfo]) -> set[str]: + """We return the tzids for a certain tzinfo object. + + With different datetimes, we match + (tzinfo.utcoffset(dt), tzinfo.tzname(dt)) + + If we could identify the timezone, you will receive a tuple + with at least one tzid. All tzids are equivalent which means + that they describe the same timezone. + + You should get results with any timezone implementation if it is known. + This one is especially useful for dateutil. + + In the following example, we can see that the timezone Africa/Accra + is equivalent to many others. + + >>> import zoneinfo + >>> from icalendar.timezone.tzid import tzinfo2tzids + >>> "Europe/Berlin" in tzinfo2tzids(zoneinfo.ZoneInfo("Europe/Berlin")) + True + + """ # The example might need to change if you recreate the lookup tree + if tzinfo is None: + return set() + from icalendar.timezone.equivalent_timezone_ids_result import lookup + + while 1: + if isinstance(lookup, set): + return lookup + dt, offset2lookup = lookup + offset = tzinfo.utcoffset(dt) + lookup = offset2lookup.get(offset) + if lookup is None: + return set() + return set() + +__all__ = ["tzid_from_tzinfo", "tzid_from_dt", "tzids_from_tzinfo"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py index 6c24b389..39dbb9ca 100644 --- a/src/icalendar/timezone/tzp.py +++ b/src/icalendar/timezone/tzp.py @@ -80,11 +80,11 @@ def cache_timezone_component(self, timezone_component: cal.Timezone) -> None: custom timezone is returned from timezone(). """ _unclean_id = timezone_component['TZID'] - id = self.clean_timezone_id(_unclean_id) - if not self.__provider.knows_timezone_id(id) \ + _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) + and _id not in self.__tz_cache: + self.__tz_cache[_id] = timezone_component.to_tz(self, lookup_tzid=False) def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: """Make sure the until value works.""" diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py index e3665c30..bc5c7aaf 100644 --- a/src/icalendar/timezone/zoneinfo.py +++ b/src/icalendar/timezone/zoneinfo.py @@ -1,21 +1,25 @@ """Use zoneinfo timezones""" from __future__ import annotations + try: import zoneinfo -except: - from backports import zoneinfo -from icalendar import prop -from dateutil.rrule import rrule, rruleset +except ImportError: + from backports import zoneinfo # type: ignore # noqa: PGH003 +import copy +import copyreg +import functools from datetime import datetime, tzinfo -from typing import Optional -from .provider import TZProvider -from .. import cal from io import StringIO +from typing import TYPE_CHECKING, Optional + +from dateutil.rrule import rrule, rruleset from dateutil.tz import tzical from dateutil.tz.tz import _tzicalvtz -import copyreg -import functools -import copy + +from .provider import TZProvider + +if TYPE_CHECKING: + from icalendar import cal, prop class ZONEINFO(TZProvider):