diff --git a/CHANGES.rst b/CHANGES.rst index c3f61cf8..4dcf56bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,44 @@ Changelog ========= +6.0.0 (unreleased) +------------------ + +Minor changes: + +- Test that all code works with both ``pytz`` and ``zoneinfo``. + +Breaking changes: + +- Use ``zoneinfo`` for ``icalendar`` objects created from strings, + see `Issue #609 `_. + + This is an tested extension of the functionality, not a restriction: + If you create ``icalendar`` objects with ``pytz`` timezones in your code, + ``icalendar`` will continue to work in the same way. + Your code is not affected. + + ``zoneinfo`` will be used for those **objects that** ``icalendar`` + **creates itself**. + This happens for example when parsing an ``.ics`` file, strings or bytes with + ``from_ical()``. + + If you rely on ``icalendar`` providing timezones from ``pytz``, you can add + one line to your code to get the behavior of versions below 6: + + .. code:: Python + + import icalendar + icalendar.use_pytz() + +New features: + +- ... + +Bug fixes: + +- ... + 5.0.13 (2024-06-20) ------------------- diff --git a/README.rst b/README.rst index 0ae54fdf..8ce4dae2 100644 --- a/README.rst +++ b/README.rst @@ -42,18 +42,26 @@ files. .. _`pytz`: https://pypi.org/project/pytz/ .. _`BSD`: https://github.com/collective/icalendar/issues/2 -Quick Guide +Quick start guide ----------- +``icalendar`` enables you to **create**, **inspect** and **modify** +calendaring information with Python. + To **install** the package, run:: pip install icalendar + +Inspect Files +~~~~~~~~~~~~~ + You can open an ``.ics`` file and see all the events:: >>> import icalendar - >>> path_to_ics_file = "src/icalendar/tests/calendars/example.ics" - >>> with open(path_to_ics_file) as f: + >>> from pathlib import Path + >>> ics_path = Path("src/icalendar/tests/calendars/example.ics") + >>> with ics_path.open() as f: ... calendar = icalendar.Calendar.from_ical(f.read()) >>> for event in calendar.walk('VEVENT'): ... print(event.get("SUMMARY")) @@ -61,7 +69,74 @@ You can open an ``.ics`` file and see all the events:: Orthodox Christmas International Women's Day -Using this package, you can also create calendars from scratch or edit existing ones. +Modify Content +~~~~~~~~~~~~~~ + +Such a calendar can then be edited and saved again. + +.. code:: python + + >>> calendar["X-WR-CALNAME"] = "My Modified Calendar" # modify + >>> print(calendar.to_ical()[:129]) # save modification + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:collective/icalendar + CALSCALE:GREGORIAN + METHOD:PUBLISH + X-WR-CALNAME:My Modified Calendar + + +Create Events, TODOs, Journals, Alarms, ... +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``icalendar`` supports the creation and parsing of all kinds of objects +in the iCalendar (RFC 5545) standard. + +.. code:: python + + >>> icalendar.Event() # events + VEVENT({}) + >>> icalendar.FreeBusy() # free/busy times + VFREEBUSY({}) + >>> icalendar.Todo() # Todo list entries + VTODO({}) + >>> icalendar.Alarm() # Alarms e.g. for events + VALARM({}) + >>> icalendar.Journal() # Journal entries + VJOURNAL({}) + + +Have a look at `more examples +`_. + +Use timezones of your choice +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +With ``icalendar``, you can localize your events to take place in different +timezones. +``zoneinfo``, ``dateutil.tz`` and ``pytz`` are compatible with ``icalendar``. +This example creates an event that uses all of the timezone implementations +with the same result: + +.. code:: python + + >>> import pytz, zoneinfo, dateutil.tz # timezone libraries + >>> import datetime, icalendar + >>> e = icalendar.Event() + >>> tz = dateutil.tz.tzstr("Europe/London") + >>> e["X-DT-DATEUTIL"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> tz = pytz.timezone("Europe/London") + >>> e["X-DT-USE-PYTZ"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> tz = zoneinfo.ZoneInfo("Europe/London") + >>> e["X-DT-ZONEINFO"] = icalendar.vDatetime(datetime.datetime(2024, 6, 19, 10, 1, tzinfo=tz)) + >>> print(e.to_ical()) # the libraries yield the same result + BEGIN:VEVENT + X-DT-DATEUTIL;TZID=Europe/London:20240619T100100 + X-DT-USE-PYTZ;TZID=Europe/London:20240619T100100 + X-DT-ZONEINFO;TZID=Europe/London:20240619T100100 + END:VEVENT + + Versions and Compatibility -------------------------- @@ -70,14 +145,51 @@ Versions and Compatibility long-term compatibility with projects conflicts partially with providing and using the features that the latest Python versions bring. -Since we pour more `effort into maintaining and developing icalendar `__, -we split the project into two: +Volunteers pour `effort into maintaining and developing icalendar +`_. +Below, you can find an overview of the versions and how we maintain them. + +Version 6 +~~~~~~~~~ + +Version 6 of ``icalendar`` switches the timezone implementation to ``zoneinfo``. +This only affects you if you parse ``icalendar`` objects with ``from_ical()``. +The functionality is extended and is tested since 6.0.0 with both timezone +implementations ``pytz`` and ``zoneinfo``. + +By default and since 6.0.0, ``zoneinfo`` timezones are created. + +.. code:: python + + >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt + >>> dt.tzinfo + ZoneInfo(key='Europe/Vienna') + +If you would like to continue to receive ``pytz`` timezones in as parse results, +you can receive all the latest updates, and switch back to version 5.x behavior: + +.. code:: python + + >>> icalendar.use_pytz() + >>> dt = icalendar.Calendar.example("timezoned").walk("VEVENT")[0]["DTSTART"].dt + >>> dt.tzinfo + + +Version 6 is on `branch master `_ with compatibility to Python versions ``3.7+`` and ``PyPy3``. +We expect the ``master`` branch with versions ``6+`` to receive the latest updates and features. + +Version 5 +~~~~~~~~~ + +Version 5 uses only the ``pytz`` timezone implementation, and not ``zoneinfo``. +No updates will be released for this. +Please use version 6 and switch to use ``zoneinfo`` as documented above. -- `Branch 4.x `__ with maximum compatibility to Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. -- `Branch master `__ with the compatibility to Python versions ``3.7+`` and ``PyPy3``. +Version 4 +~~~~~~~~~ -We expect the ``master`` branch with versions ``5+`` receive the latest updates and features, -and the ``4.x`` branch the subset of security and bug fixes only. +Version 4 is on `branch 4.x `_ with maximum compatibility with Python versions ``2.7`` and ``3.4+``, ``PyPy2`` and ``PyPy3``. +The ``4.x`` branch only receives security and bug fixes if someone makes the effort. We recommend migrating to later Python versions and also providing feedback if you depend on the ``4.x`` features. Related projects diff --git a/docs/usage.rst b/docs/usage.rst index 919d9f0f..4194577c 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -210,9 +210,8 @@ Python type:: >>> vDatetime.from_ical('20050404T080000') datetime.datetime(2005, 4, 4, 8, 0) - >>> dt = vDatetime.from_ical('20050404T080000Z') - >>> repr(dt)[:62] - 'datetime.datetime(2005, 4, 4, 8, 0, tzinfo=)' + >>> vDatetime.from_ical('20050404T080000Z') + datetime.datetime(2005, 4, 4, 8, 0, tzinfo=ZoneInfo(key='UTC')) You can also choose to use the decoded() method, which will return a decoded value directly:: diff --git a/setup.py b/setup.py index 0834d253..edbaa4f2 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ # install requirements depending on python version # see https://www.python.org/dev/peps/pep-0508/#environment-markers 'backports.zoneinfo; python_version == "3.7" or python_version == "3.8"', + 'tzdata' ] diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 602570e4..8b7203d6 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -46,3 +46,6 @@ q_split, q_join, ) + +# Switching the timezone provider +from icalendar.timezone import use_pytz, use_zoneinfo diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index f405a618..02a1fa6d 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -3,6 +3,7 @@ These are the defined components. """ +from __future__ import annotations from datetime import datetime, timedelta from icalendar.caselessdict import CaselessDict from icalendar.parser import Contentline @@ -13,12 +14,23 @@ from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.prop import TypesFactory from icalendar.prop import vText, vDDDLists -from icalendar.timezone_cache import _timezone_cache - -import pytz +from icalendar.timezone import tzp +from typing import Tuple, List import dateutil.rrule, dateutil.tz -from pytz.tzinfo import DstTzInfo +import os + +def get_example(component_directory: str, example_name: str) -> bytes: + """Return an example and raise an error if it is absent.""" + here = os.path.dirname(__file__) + examples = os.path.join(here, "tests", component_directory) + if not example_name.endswith(".ics"): + example_name = example_name + ".ics" + example_file = os.path.join(examples, example_name) + if not os.path.isfile(example_file): + raise ValueError(f"Example {example_name} for {component_directory} not found. You can use one of {', '.join(os.listdir(examples))}") + with open(example_file, "rb") as f: + return f.read() ###################################### @@ -178,11 +190,7 @@ def add(self, name, value, parameters=None, encode=1): if isinstance(value, datetime) and\ name.lower() in ('dtstamp', 'created', 'last-modified'): # RFC expects UTC for those... force value conversion. - if getattr(value, 'tzinfo', False) and value.tzinfo is not None: - value = value.astimezone(pytz.utc) - else: - # assume UTC for naive datetime instances - value = pytz.utc.localize(value) + value = tzp.localize_utc(value) # encode value if encode and isinstance(value, list) \ @@ -367,11 +375,8 @@ def from_ical(cls, st, multiple=False): comps.append(component) else: stack[-1].add_component(component) - if vals == 'VTIMEZONE' and \ - 'TZID' in component and \ - component['TZID'] not in pytz.all_timezones and \ - component['TZID'] not in _timezone_cache: - _timezone_cache[component['TZID']] = component.to_tz() + if vals == 'VTIMEZONE' and 'TZID' in component: + tzp.cache_timezone_component(component) # we are adding properties to the current top of the stack else: factory = types_factory.for_property(name) @@ -501,6 +506,12 @@ class Event(Component): ) ignore_exceptions = True + @classmethod + def example(cls, name) -> Event: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("events", name)) + + class Todo(Component): @@ -553,6 +564,11 @@ class Timezone(Component): required = ('TZID',) # it also requires one of components DAYLIGHT and STANDARD singletons = ('TZID', 'LAST-MODIFIED', 'TZURL',) + @classmethod + def example(cls, name) -> Calendar: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("timezones", name)) + @staticmethod def _extract_offsets(component, tzname): """extract offsets and transition times from a VTIMEZONE component @@ -580,12 +596,9 @@ def _extract_offsets(component, tzname): rrulestr = component['RRULE'].to_ical().decode('utf-8') rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) - if not {'UNTIL', 'COUNT'}.intersection(component['RRULE'].keys()): - # pytz.timezones don't know any transition dates after 2038 - # either - rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + tzp.fix_rrule_until(rrule, component['RRULE']) - # constructing the pytz-timezone requires UTC transition times. + # constructing the timezone requires UTC transition times. # here we construct local times without tzinfo, the offset to UTC # gets subtracted in to_tz(). transtimes = [dt.replace (tzinfo=None) for dt in rrule] @@ -622,13 +635,31 @@ def _make_unique_tzname(tzname, tznames): tznames.add(tzname) return tzname - def to_tz(self): - """convert this VTIMEZONE component to a pytz.timezone object + def to_tz(self, tzp=tzp): + """convert this VTIMEZONE component to a timezone object + """ + return tzp.create_timezone(self) + + @property + def tz_name(self) -> str: + """Return the name of the timezone component. + + Please note that the names of the timezone are different from this name + and may change with winter/summer time. """ try: - zone = str(self['TZID']) + return str(self['TZID']) except UnicodeEncodeError: - zone = self['TZID'].encode('ascii', 'replace') + return self['TZID'].encode('ascii', 'replace') + + def get_transitions(self) -> Tuple[List[datetime], List[Tuple[timedelta, timedelta, str]]]: + """Return a tuple of (transition_times, transition_info) + + - transition_times = [datetime, ...] + - transition_info = [(TZOFFSETTO, dts_offset, tzname)] + + """ + zone = self.tz_name transitions = [] dst = {} tznames = set() @@ -684,14 +715,7 @@ def to_tz(self): break assert dst_offset is not False transition_info.append((osto, dst_offset, name)) - - cls = type(zone, (DstTzInfo,), { - 'zone': zone, - '_utc_transition_times': transition_times, - '_transition_info': transition_info - }) - - return cls() + return transition_times, transition_info class TimezoneStandard(Component): @@ -729,6 +753,11 @@ class Calendar(Component): required = ('PRODID', 'VERSION', ) singletons = ('PRODID', 'VERSION', 'CALSCALE', 'METHOD') + @classmethod + def example(cls, name) -> Calendar: + """Return the calendar example with the given name.""" + return cls.from_ical(get_example("calendars", name)) + # These are read only singleton, so one instance is enough for the module types_factory = TypesFactory() component_factory = ComponentFactory() diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index a8a3091f..ddfcf2d3 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -46,19 +46,6 @@ def unescape_char(text): .replace(b'\\\\', b'\\') -def tzid_from_dt(dt): - tzid = None - if hasattr(dt.tzinfo, 'zone'): - tzid = dt.tzinfo.zone # pytz implementation - elif hasattr(dt.tzinfo, 'key'): - tzid = dt.tzinfo.key # ZoneInfo implementation - elif hasattr(dt.tzinfo, 'tzname'): - # dateutil implementation, but this is broken - # See https://github.com/collective/icalendar/issues/333 for details - tzid = dt.tzinfo.tzname(dt) - return tzid - - def foldline(line, limit=75, fold_sep='\r\n '): """Make a string folded as defined in RFC5545 Lines of text SHOULD NOT be longer than 75 octets, excluding the line diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 5c4c10f4..795400fd 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -48,21 +48,21 @@ from icalendar.caselessdict import CaselessDict from icalendar.parser import Parameters from icalendar.parser import escape_char -from icalendar.parser import tzid_from_dt from icalendar.parser import unescape_char from icalendar.parser_tools import DEFAULT_ENCODING from icalendar.parser_tools import SEQUENCE_TYPES from icalendar.parser_tools import to_unicode from icalendar.parser_tools import from_unicode -from icalendar.timezone_cache import _timezone_cache -from icalendar.windows_to_olson import WINDOWS_TO_OLSON import base64 import binascii -import pytz +from .timezone import tzp import re import time as _time +from typing import Optional + + DURATION_REGEX = re.compile(r'([-+]?)P(?:(\d+)W)?(?:(\d+)D)?' r'(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$') @@ -83,6 +83,20 @@ DSTDIFF = DSTOFFSET - STDOFFSET +def tzid_from_dt(dt: datetime) -> Optional[str]: + """Retrieve the timezone id from the datetime object.""" + tzid = None + if hasattr(dt.tzinfo, 'zone'): + tzid = dt.tzinfo.zone # pytz implementation + elif hasattr(dt.tzinfo, 'key'): + tzid = dt.tzinfo.key # ZoneInfo implementation + elif hasattr(dt.tzinfo, 'tzname'): + # dateutil implementation, but this is broken + # See https://github.com/collective/icalendar/issues/333 for details + tzid = dt.tzinfo.tzname(dt) + return tzid + + class FixedOffset(tzinfo): """Fixed offset in minutes east from UTC. """ @@ -400,9 +414,9 @@ def from_ical(ical): class vDatetime(TimeBase): """Render and generates icalendar datetime format. - vDatetime is timezone aware and uses the pytz library, an implementation of - the Olson database in Python. When a vDatetime object is created from an - ical string, you can pass a valid pytz timezone identifier. When a + vDatetime is timezone aware and uses a timezone library. + When a vDatetime object is created from an + ical string, you can pass a valid timezone identifier. When a vDatetime object is created from a python datetime object, it uses the tzinfo component, if present. Otherwise an timezone-naive object is created. Be aware that there are certain limitations with timezone naive @@ -428,14 +442,7 @@ def to_ical(self): def from_ical(ical, timezone=None): tzinfo = None if timezone: - try: - tzinfo = pytz.timezone(timezone.strip('/')) - except pytz.UnknownTimeZoneError: - if timezone in WINDOWS_TO_OLSON: - tzinfo = pytz.timezone( - WINDOWS_TO_OLSON.get(timezone.strip('/'))) - else: - tzinfo = _timezone_cache.get(timezone, None) + tzinfo = tzp.timezone(timezone) try: timetuple = ( @@ -447,11 +454,11 @@ def from_ical(ical, timezone=None): int(ical[13:15]), # second ) if tzinfo: - return tzinfo.localize(datetime(*timetuple)) + return tzp.localize(datetime(*timetuple), tzinfo) elif not ical[15:]: return datetime(*timetuple) elif ical[15:16] == 'Z': - return pytz.utc.localize(datetime(*timetuple)) + return tzp.localize_utc(datetime(*timetuple)) else: raise ValueError(ical) except Exception: diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index ee864797..a1891b90 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -1,33 +1,41 @@ -import os +try: + from backports import zoneinfo +except ImportError: + import zoneinfo import pytest import icalendar import pytz from datetime import datetime from dateutil import tz -try: - import zoneinfo -except ModuleNotFoundError: - from backports import zoneinfo from icalendar.cal import Component, Calendar, Event, ComponentFactory +from icalendar.timezone import tzp as _tzp +from icalendar.timezone import TZP +from pathlib import Path +import itertools +import sys class DataSource: '''A collection of parsed ICS elements (e.g calendars, timezones, events)''' - def __init__(self, data_source_folder, parser): + def __init__(self, data_source_folder:Path, parser): self._parser = parser self._data_source_folder = data_source_folder def keys(self): """Return all the files that could be used.""" - return [file[:-4] for file in os.listdir(self._data_source_folder) if file.lower().endswith(".ics")] + return [p.stem for p in self._data_source_folder.iterdir() if p.suffix.lower() == ".ics"] def __getitem__(self, attribute): """Parse a file and return the result stored in the attribute.""" - source_file = attribute + '.ics' - source_path = os.path.join(self._data_source_folder, source_file) - if not os.path.isfile(source_path): + if attribute.endswith(".ics"): + source_file = attribute + attribute = attribute[:-4] + else: + source_file = attribute + '.ics' + source_path = self._data_source_folder / source_file + if not source_path.is_file(): raise AttributeError(f"{source_path} does not exist.") - with open(source_path, 'rb') as f: + with source_path.open('rb') as f: raw_ics = f.read() source = self._parser(raw_ics) if not isinstance(source, list): @@ -35,6 +43,12 @@ def __getitem__(self, attribute): self.__dict__[attribute] = source return source + def __contains__(self, key): + """key in self.keys()""" + if key.endswith(".ics"): + key = key[:-4] + return key in self.keys() + def __getattr__(self, key): return self[key] @@ -46,25 +60,22 @@ def multiple(self): """Return a list of all components parsed.""" return self.__class__(self._data_source_folder, lambda data: self._parser(data, multiple=True)) -HERE = os.path.dirname(__file__) -CALENDARS_FOLDER = os.path.join(HERE, 'calendars') -CALENDARS = DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) -TIMEZONES_FOLDER = os.path.join(HERE, 'timezones') -TIMEZONES = DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) -EVENTS_FOLDER = os.path.join(HERE, 'events') -EVENTS = DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) +HERE = Path(__file__).parent +CALENDARS_FOLDER = HERE / 'calendars' +TIMEZONES_FOLDER = HERE / 'timezones' +EVENTS_FOLDER = HERE / 'events' -@pytest.fixture() -def calendars(): - return CALENDARS +@pytest.fixture(scope="module") +def calendars(tzp): + return DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) -@pytest.fixture() -def timezones(): - return TIMEZONES +@pytest.fixture(scope="module") +def timezones(tzp): + return DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) -@pytest.fixture() -def events(): - return EVENTS +@pytest.fixture(scope="module") +def events(tzp): + return DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) @pytest.fixture(params=[ pytz.utc, @@ -72,7 +83,7 @@ def events(): pytz.timezone('UTC'), tz.UTC, tz.gettz('UTC')]) -def utc(request): +def utc(request, tzp): return request.param @pytest.fixture(params=[ @@ -80,29 +91,33 @@ def utc(request): lambda dt, tzname: dt.replace(tzinfo=tz.gettz(tzname)), lambda dt, tzname: dt.replace(tzinfo=zoneinfo.ZoneInfo(tzname)) ]) -def in_timezone(request): +def in_timezone(request, tzp): return request.param +# exclude broken calendars here +ICS_FILES_EXCLUDE = ( + "big_bad_calendar.ics", "issue_104_broken_calendar.ics", "small_bad_calendar.ics", + "multiple_calendar_components.ics", "pr_480_summary_with_colon.ics", + "parsing_error_in_UTC_offset.ics", "parsing_error.ics", +) ICS_FILES = [ - (data, key) - for data in [CALENDARS, TIMEZONES, EVENTS] - for key in data.keys() if key not in - ( # exclude broken calendars here - "big_bad_calendar", "issue_104_broken_calendar", "small_bad_calendar", - "multiple_calendar_components", "pr_480_summary_with_colon", - "parsing_error_in_UTC_offset", "parsing_error", - ) + file.name for file in + itertools.chain(CALENDARS_FOLDER.iterdir(), TIMEZONES_FOLDER.iterdir(), EVENTS_FOLDER.iterdir()) + if file.name not in ICS_FILES_EXCLUDE ] @pytest.fixture(params=ICS_FILES) -def ics_file(request): +def ics_file(tzp, calendars, timezones, events, request): """An example ICS file.""" - data, key = request.param - print(key) - return data[key] + ics_file = request.param + print("example file:", ics_file) + for data in calendars, timezones, events: + if ics_file in data: + return data[ics_file] + raise ValueError(f"Could not find file {ics_file}.") -FUZZ_V1 = [os.path.join(CALENDARS_FOLDER, key) for key in os.listdir(CALENDARS_FOLDER) if "fuzz-testcase" in key] +FUZZ_V1 = [key for key in CALENDARS_FOLDER.iterdir() if "fuzz-testcase" in str(key)] @pytest.fixture(params=FUZZ_V1) def fuzz_v1_calendar(request): """Clusterfuzz calendars.""" @@ -116,7 +131,7 @@ def x_sometime(): yield icalendar.cal.types_factory.types_map.pop('X-SOMETIME') - + @pytest.fixture() def factory(): """Return a new component factory.""" @@ -131,7 +146,7 @@ def vUTCOffset_ignore_exceptions(): @pytest.fixture() -def event_component(): +def event_component(tzp): """Return an event component.""" c = Component() c.name = 'VEVENT' @@ -139,14 +154,14 @@ def event_component(): @pytest.fixture() -def c(): +def c(tzp): """Return an empty component.""" c = Component() return c comp = c @pytest.fixture() -def calendar_component(): +def calendar_component(tzp): """Return an empty component.""" c = Component() c.name = 'VCALENDAR' @@ -165,7 +180,82 @@ def filled_event_component(c, calendar_component): @pytest.fixture() -def calendar_with_resources(): +def calendar_with_resources(tzp): c = Calendar() c['resources'] = 'Chair, Table, "Room: 42"' return c + + +@pytest.fixture(scope="module") +def tzp(tzp_name): + """The timezone provider.""" + _tzp.use(tzp_name) + yield _tzp + _tzp.use_default() + + +@pytest.fixture(params=["pytz", "zoneinfo"]) +def other_tzp(request, tzp): + """This is annother timezone provider. + + The purpose here is to cross test: pytz <-> zoneinfo. + tzp as parameter makes sure we test the cross product. + """ + tzp = TZP(request.param) + return tzp + + +@pytest.fixture() +def pytz_only(tzp): + """Skip tests that are not running under pytz.""" + if not tzp.uses_pytz(): + pytest.skip("Not using pytz. Skipping this test.") + + +@pytest.fixture() +def zoneinfo_only(tzp): + """Skip tests that are not running under pytz.""" + if not tzp.uses_zoneinfo(): + pytest.skip("Not using zoneinfo. Skipping this test.") + + +def pytest_generate_tests(metafunc): + """Parametrize without skipping: + + tzp_name will be parametrized according to the use of + - pytz_only + - zoneinfo_only + + See https://docs.pytest.org/en/6.2.x/example/parametrize.html#deferring-the-setup-of-parametrized-resources + """ + if "tzp_name" in metafunc.fixturenames: + tzp_names = ["pytz", "zoneinfo"] + if "zoneinfo_only" in metafunc.fixturenames: + tzp_names.remove("pytz") + if "pytz_only" in metafunc.fixturenames: + tzp_names.remove("zoneinfo") + assert tzp_names, "Use pytz_only or zoneinfo_only but not both!" + metafunc.parametrize("tzp_name", tzp_names, scope="module") + + +class DoctestZoneInfo(zoneinfo.ZoneInfo): + """Constent ZoneInfo representation for tests.""" + def __repr__(self): + return f"ZoneInfo(key={repr(self.key)})" + + +def test_print(obj): + """doctest print""" + if isinstance(obj, bytes): + obj = obj.decode("UTF-8") + print(str(obj).strip().replace("\r\n", "\n").replace("\r", "\n")) + + +@pytest.fixture() +def env_for_doctest(monkeypatch): + """Modify the environment to make doctests run.""" + monkeypatch.setitem(sys.modules, "zoneinfo", zoneinfo) + monkeypatch.setattr(zoneinfo, "ZoneInfo", DoctestZoneInfo) + from icalendar.timezone.zoneinfo import ZONEINFO + monkeypatch.setattr(ZONEINFO, "utc", zoneinfo.ZoneInfo("UTC")) + return {"print": test_print} diff --git a/src/icalendar/tests/prop/test_identity_and_equality.py b/src/icalendar/tests/prop/test_identity_and_equality.py new file mode 100644 index 00000000..8e5682d0 --- /dev/null +++ b/src/icalendar/tests/prop/test_identity_and_equality.py @@ -0,0 +1,43 @@ +"""Test the identity and equality between properties.""" +from icalendar import vDDDTypes +from datetime import datetime, date, time +from icalendar.timezone.zoneinfo import zoneinfo +import pytz +from dateutil import tz +import pytest +from copy import deepcopy + + + +vDDDTypes_list = [ + vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=zoneinfo.ZoneInfo("Europe/London"))), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7)), + vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=tz.UTC)), + vDDDTypes(date(year=2022, month=7, day=22)), + vDDDTypes(date(year=2022, month=7, day=23)), + vDDDTypes(time(hour=22, minute=7, second=2)) +] + +def identity(x): + return x + +@pytest.mark.parametrize("map", [ + deepcopy, + identity, + hash, +]) +@pytest.mark.parametrize("v_type", vDDDTypes_list) +@pytest.mark.parametrize("other", vDDDTypes_list) +def test_vDDDTypes_equivalance(map, v_type, other): + if v_type is other: + assert map(v_type) == map(other), f"identity implies equality: {map.__name__}()" + assert not (map(v_type) != map(other)), f"identity implies equality: {map.__name__}()" + else: + assert map(v_type) != map(other), f"expected inequality: {map.__name__}()" + assert not (map(v_type) == map(other)), f"expected inequality: {map.__name__}()" + +@pytest.mark.parametrize("v_type", vDDDTypes_list) +def test_inequality_with_different_types(v_type): + assert v_type != 42 + assert v_type != 'test' diff --git a/src/icalendar/tests/prop/test_property_values.py b/src/icalendar/tests/prop/test_property_values.py new file mode 100644 index 00000000..193c7ba8 --- /dev/null +++ b/src/icalendar/tests/prop/test_property_values.py @@ -0,0 +1,17 @@ +"""Test that composed values are properly converted.""" +from icalendar import Event +from datetime import datetime + + +def test_vDDDLists_timezone(tzp): + """Test vDDDLists with timezone information.""" + vevent = Event() + dt1 = tzp.localize(datetime(2013, 1, 1), 'Europe/Vienna') + dt2 = tzp.localize(datetime(2013, 1, 2), 'Europe/Vienna') + dt3 = tzp.localize(datetime(2013, 1, 3), 'Europe/Vienna') + vevent.add('rdate', [dt1, dt2]) + vevent.add('exdate', dt3) + ical = vevent.to_ical() + + assert b'RDATE;TZID=Europe/Vienna:20130101T000000,20130102T000000' in ical + assert b'EXDATE;TZID=Europe/Vienna:20130103T000000' in ical diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/prop/test_unit.py similarity index 58% rename from src/icalendar/tests/test_unit_prop.py rename to src/icalendar/tests/prop/test_unit.py index 3c36a202..9228e6fd 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/prop/test_unit.py @@ -5,79 +5,29 @@ from icalendar.parser import Parameters import unittest from icalendar.prop import vDatetime, vDDDTypes -from icalendar.windows_to_olson import WINDOWS_TO_OLSON +from icalendar.timezone.windows_to_olson import WINDOWS_TO_OLSON import pytest -import pytz from copy import deepcopy from dateutil import tz class TestProp(unittest.TestCase): - def test_prop_vBinary(self): - from ..prop import vBinary - - txt = b'This is gibberish' - txt_ical = b'VGhpcyBpcyBnaWJiZXJpc2g=' - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) - - # The roundtrip test - txt = b'Binary data \x13 \x56' - txt_ical = b'QmluYXJ5IGRhdGEgEyBW' - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) - - self.assertIsInstance(vBinary('txt').params, Parameters) - self.assertEqual( - vBinary('txt').params, {'VALUE': 'BINARY', 'ENCODING': 'BASE64'} - ) - - # Long data should not have line breaks, as that would interfere - txt = b'a' * 99 - txt_ical = b'YWFh' * 33 - self.assertEqual(vBinary(txt).to_ical(), txt_ical) - self.assertEqual(vBinary.from_ical(txt_ical), txt) - - def test_prop_vBoolean(self): - from ..prop import vBoolean - - self.assertEqual(vBoolean(True).to_ical(), b'TRUE') - self.assertEqual(vBoolean(0).to_ical(), b'FALSE') - - # The roundtrip test - self.assertEqual(vBoolean.from_ical(vBoolean(True).to_ical()), True) - self.assertEqual(vBoolean.from_ical('true'), True) - - # Error: key not exists - self.assertRaises(ValueError, vBoolean.from_ical, 'ture') - - def test_prop_vCalAddress(self): - from ..prop import vCalAddress - txt = b'MAILTO:maxm@mxm.dk' - a = vCalAddress(txt) - a.params['cn'] = 'Max M' - - self.assertEqual(a.to_ical(), txt) - self.assertIsInstance(a.params, Parameters) - self.assertEqual(a.params, {'CN': 'Max M'}) - self.assertEqual(vCalAddress.from_ical(txt), 'MAILTO:maxm@mxm.dk') - def test_prop_vFloat(self): - from ..prop import vFloat + from icalendar.prop import vFloat self.assertEqual(vFloat(1.0).to_ical(), b'1.0') self.assertEqual(vFloat.from_ical('42'), 42.0) self.assertEqual(vFloat(42).to_ical(), b'42.0') self.assertRaises(ValueError, vFloat.from_ical, '1s3') def test_prop_vInt(self): - from ..prop import vInt + from icalendar.prop import vInt self.assertEqual(vInt(42).to_ical(), b'42') self.assertEqual(vInt.from_ical('13'), 13) self.assertRaises(ValueError, vInt.from_ical, '1s3') def test_prop_vDDDLists(self): - from ..prop import vDDDLists + from icalendar.prop import vDDDLists dt_list = vDDDLists.from_ical('19960402T010000Z') self.assertTrue(isinstance(dt_list, list)) @@ -100,32 +50,8 @@ def test_prop_vDDDLists(self): dt_list = vDDDLists([datetime(2000, 1, 1), datetime(2000, 11, 11)]) self.assertEqual(dt_list.to_ical(), b'20000101T000000,20001111T000000') - def test_prop_vDDDTypes(self): - from ..prop import vDDDTypes - - self.assertTrue(isinstance(vDDDTypes.from_ical('20010101T123000'), - datetime)) - - self.assertEqual(vDDDTypes.from_ical('20010101T123000Z'), - pytz.utc.localize(datetime(2001, 1, 1, 12, 30))) - - self.assertTrue(isinstance(vDDDTypes.from_ical('20010101'), date)) - - self.assertEqual(vDDDTypes.from_ical('123000'), time(12, 30)) - self.assertIsInstance(vDDDTypes.from_ical('123000'), time) - - self.assertEqual(vDDDTypes.from_ical('P31D'), timedelta(31)) - - self.assertEqual(vDDDTypes.from_ical('-P31D'), timedelta(-31)) - - invalid_period = (datetime(2000, 1, 1), datetime(2000, 1, 2), datetime(2000, 1, 2)) - self.assertRaises(ValueError, vDDDTypes(invalid_period).to_ical) - - # Bad input - self.assertRaises(ValueError, vDDDTypes, 42) - def test_prop_vDate(self): - from ..prop import vDate + from icalendar.prop import vDate self.assertEqual(vDate(date(2001, 1, 1)).to_ical(), b'20010101') self.assertEqual(vDate(date(1899, 1, 1)).to_ical(), b'18990101') @@ -135,44 +61,8 @@ def test_prop_vDate(self): self.assertRaises(ValueError, vDate, 'd') self.assertRaises(ValueError, vDate.from_ical, '200102') - def test_prop_vDatetime(self): - from ..prop import vDatetime - - dt = datetime(2001, 1, 1, 12, 30, 0) - self.assertEqual(vDatetime(dt).to_ical(), b'20010101T123000') - - self.assertEqual(vDatetime.from_ical('20000101T120000'), - datetime(2000, 1, 1, 12, 0)) - - dutc = pytz.utc.localize(datetime(2001, 1, 1, 12, 30, 0)) - self.assertEqual(vDatetime(dutc).to_ical(), b'20010101T123000Z') - - dutc = pytz.utc.localize(datetime(1899, 1, 1, 12, 30, 0)) - self.assertEqual(vDatetime(dutc).to_ical(), b'18990101T123000Z') - - self.assertEqual(vDatetime.from_ical('20010101T000000'), - datetime(2001, 1, 1, 0, 0)) - - self.assertRaises(ValueError, vDatetime.from_ical, '20010101T000000A') - - utc = vDatetime.from_ical('20010101T000000Z') - self.assertEqual(vDatetime(utc).to_ical(), b'20010101T000000Z') - - # 1 minute before transition to DST - dat = vDatetime.from_ical('20120311T015959', 'America/Denver') - self.assertEqual(dat.strftime('%Y%m%d%H%M%S %z'), - '20120311015959 -0700') - - # After transition to DST - dat = vDatetime.from_ical('20120311T030000', 'America/Denver') - self.assertEqual(dat.strftime('%Y%m%d%H%M%S %z'), - '20120311030000 -0600') - - dat = vDatetime.from_ical('20101010T000000', 'Europe/Vienna') - self.assertEqual(vDatetime(dat).to_ical(), b'20101010T000000') - def test_prop_vDuration(self): - from ..prop import vDuration + from icalendar.prop import vDuration self.assertEqual(vDuration(timedelta(11)).to_ical(), b'P11D') self.assertEqual(vDuration(timedelta(-14)).to_ical(), b'-P14D') @@ -207,65 +97,8 @@ def test_prop_vDuration(self): self.assertEqual(duration.to_ical(), b'-P1DT5H') self.assertEqual(duration.to_ical(), b'-P1DT5H') - - def test_prop_vPeriod(self): - from ..prop import vPeriod - - # One day in exact datetimes - per = (datetime(2000, 1, 1), datetime(2000, 1, 2)) - self.assertEqual(vPeriod(per).to_ical(), - b'20000101T000000/20000102T000000') - - # Error: one of the params is not instance of date/datetime - per = ('20000101T000000', datetime(2000, 1, 2)) - self.assertRaises(ValueError, vPeriod, per) - - per = (datetime(2000, 1, 1), '20000102T000000') - self.assertRaises(ValueError, vPeriod, per) - - # Error: first params > second params - per = (datetime(2000, 1, 2), datetime(2000, 1, 1)) - self.assertRaises(ValueError, vPeriod, per) - - per = (datetime(2000, 1, 1), timedelta(days=31)) - self.assertEqual(vPeriod(per).to_ical(), b'20000101T000000/P31D') - - # Roundtrip - p = vPeriod.from_ical('20000101T000000/20000102T000000') - self.assertEqual( - p, - (datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 2, 0, 0)) - ) - self.assertEqual(vPeriod(p).to_ical(), - b'20000101T000000/20000102T000000') - - self.assertEqual(vPeriod.from_ical('20000101T000000/P31D'), - (datetime(2000, 1, 1, 0, 0), timedelta(31))) - - # Roundtrip with absolute time - p = vPeriod.from_ical('20000101T000000Z/20000102T000000Z') - self.assertEqual(vPeriod(p).to_ical(), - b'20000101T000000Z/20000102T000000Z') - - # And an error - self.assertRaises(ValueError, - vPeriod.from_ical, '20000101T000000/Psd31D') - - # Timezoned - dk = pytz.timezone('Europe/Copenhagen') - start = dk.localize(datetime(2000, 1, 1)) - end = dk.localize(datetime(2000, 1, 2)) - per = (start, end) - self.assertEqual(vPeriod(per).to_ical(), - b'20000101T000000/20000102T000000') - self.assertEqual(vPeriod(per).params['TZID'], - 'Europe/Copenhagen') - - p = vPeriod((dk.localize(datetime(2000, 1, 1)), timedelta(days=31))) - self.assertEqual(p.to_ical(), b'20000101T000000/P31D') - def test_prop_vWeekday(self): - from ..prop import vWeekday + from icalendar.prop import vWeekday self.assertEqual(vWeekday('mo').to_ical(), b'MO') self.assertRaises(ValueError, vWeekday, 'erwer') @@ -277,14 +110,14 @@ def test_prop_vWeekday(self): self.assertEqual(vWeekday('-tu').to_ical(), b'-TU') def test_prop_vFrequency(self): - from ..prop import vFrequency + from icalendar.prop import vFrequency self.assertRaises(ValueError, vFrequency, 'bad test') self.assertEqual(vFrequency('daily').to_ical(), b'DAILY') self.assertEqual(vFrequency('daily').from_ical('MONTHLY'), 'MONTHLY') def test_prop_vRecur(self): - from ..prop import vRecur + from icalendar.prop import vRecur # Let's see how close we can get to one from the rfc: # FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30 @@ -345,7 +178,7 @@ def test_prop_vRecur(self): ) r = vRecur.from_ical('FREQ=WEEKLY;INTERVAL=1;BYWEEKDAY=TH') - + self.assertEqual( r, {'FREQ': ['WEEKLY'], 'INTERVAL': [1], 'BYWEEKDAY': ['TH']} @@ -377,7 +210,7 @@ def test_prop_vRecur(self): b'FREQ=MONTHLY;BYEASTER=-3;BYOTHER=TEXT') def test_prop_vText(self): - from ..prop import vText + from icalendar.prop import vText self.assertEqual(vText('Simple text').to_ical(), b'Simple text') @@ -411,7 +244,7 @@ def test_prop_vText(self): # with the official U+FFFD REPLACEMENT CHARACTER. def test_prop_vTime(self): - from ..prop import vTime + from icalendar.prop import vTime self.assertEqual(vTime(12, 30, 0).to_ical(), '123000') self.assertEqual(vTime.from_ical('123000'), time(12, 30)) @@ -422,7 +255,7 @@ def test_prop_vTime(self): self.assertRaises(ValueError, vTime, '263000') def test_prop_vUri(self): - from ..prop import vUri + from icalendar.prop import vUri self.assertEqual(vUri('http://www.example.com/').to_ical(), b'http://www.example.com/') @@ -430,7 +263,7 @@ def test_prop_vUri(self): 'http://www.example.com/') def test_prop_vGeo(self): - from ..prop import vGeo + from icalendar.prop import vGeo # Pass a list self.assertEqual(vGeo([1.2, 3.0]).to_ical(), '1.2;3.0') @@ -447,7 +280,7 @@ def test_prop_vGeo(self): self.assertRaises(ValueError, vGeo.from_ical, '1s3;1s3') def test_prop_vUTCOffset(self): - from ..prop import vUTCOffset + from icalendar.prop import vUTCOffset self.assertEqual(vUTCOffset(timedelta(hours=2)).to_ical(), '+0200') @@ -489,7 +322,7 @@ def test_prop_vUTCOffset(self): self.assertRaises(ValueError, vUTCOffset, '0:00:00') def test_prop_vInline(self): - from ..prop import vInline + from icalendar.prop import vInline self.assertEqual(vInline('Some text'), 'Some text') self.assertEqual(vInline('Some text').to_ical(), b'Some text') @@ -501,7 +334,7 @@ def test_prop_vInline(self): self.assertEqual(t2.params, {'CN': 'Test Osterone'}) def test_prop_vCategory(self): - from ..prop import vCategory + from icalendar.prop import vCategory catz = ['cat 1', 'cat 2', 'cat 3'] v_cat = vCategory(catz) @@ -510,7 +343,7 @@ def test_prop_vCategory(self): self.assertEqual(vCategory.from_ical(v_cat.to_ical()), catz) def test_prop_TypesFactory(self): - from ..prop import TypesFactory + from icalendar.prop import TypesFactory # To get a type you can use it like this. factory = TypesFactory() @@ -537,73 +370,3 @@ def test_prop_TypesFactory(self): factory.from_ical('cn', b'Rasmussen\\, Max M\xc3\xb8ller'), 'Rasmussen, Max M\xf8ller' ) - - - -vDDDTypes_list = [ - vDDDTypes(pytz.timezone('EST').localize(datetime(year=2022, month=7, day=22, hour=12, minute=7))), - vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7)), - vDDDTypes(datetime(year=2022, month=7, day=22, hour=12, minute=7, tzinfo=tz.UTC)), - vDDDTypes(date(year=2022, month=7, day=22)), - vDDDTypes(date(year=2022, month=7, day=23)), - vDDDTypes(time(hour=22, minute=7, second=2)) -] - -def identity(x): - return x - -@pytest.mark.parametrize("map", [ - deepcopy, - identity, - hash, -]) -@pytest.mark.parametrize("v_type", vDDDTypes_list) -@pytest.mark.parametrize("other", vDDDTypes_list) -def test_vDDDTypes_equivalance(map, v_type, other): - if v_type is other: - assert map(v_type) == map(other), f"identity implies equality: {map.__name__}()" - assert not (map(v_type) != map(other)), f"identity implies equality: {map.__name__}()" - else: - assert map(v_type) != map(other), f"expected inequality: {map.__name__}()" - assert not (map(v_type) == map(other)), f"expected inequality: {map.__name__}()" - -@pytest.mark.parametrize("v_type", vDDDTypes_list) -def test_inequality_with_different_types(v_type): - assert v_type != 42 - assert v_type != 'test' - -class TestPropertyValues(unittest.TestCase): - - def test_vDDDLists_timezone(self): - """Test vDDDLists with timezone information. - """ - from .. import Event - vevent = Event() - at = pytz.timezone('Europe/Vienna') - dt1 = at.localize(datetime(2013, 1, 1)) - dt2 = at.localize(datetime(2013, 1, 2)) - dt3 = at.localize(datetime(2013, 1, 3)) - vevent.add('rdate', [dt1, dt2]) - vevent.add('exdate', dt3) - ical = vevent.to_ical() - - self.assertTrue( - b'RDATE;TZID=Europe/Vienna:20130101T000000,20130102T000000' in ical - ) - self.assertTrue(b'EXDATE;TZID=Europe/Vienna:20130103T000000' in ical) - - -class TestWindowsOlsonMapping(unittest.TestCase): - """Test the mappings from windows to olson tzids""" - - def test_windows_timezone(self): - """test that an example""" - self.assertEqual( - vDatetime.from_ical('20170507T181920', 'Eastern Standard Time'), - pytz.timezone('America/New_York').localize(datetime(2017, 5, 7, 18, 19, 20)) - ) - - def test_all(self): - """test if all mappings actually map to valid pytz tzids""" - for olson in WINDOWS_TO_OLSON.values(): - pytz.timezone(olson) diff --git a/src/icalendar/tests/prop/test_vBinary.py b/src/icalendar/tests/prop/test_vBinary.py new file mode 100644 index 00000000..7fde7579 --- /dev/null +++ b/src/icalendar/tests/prop/test_vBinary.py @@ -0,0 +1,29 @@ +"""Test vBinary""" +from icalendar import vBinary +from icalendar.parser import Parameters + + +def test_text(): + txt = b'This is gibberish' + txt_ical = b'VGhpcyBpcyBnaWJiZXJpc2g=' + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) + +def test_binary(): + txt = b'Binary data \x13 \x56' + txt_ical = b'QmluYXJ5IGRhdGEgEyBW' + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) + +def test_param(): + assert isinstance(vBinary('txt').params, Parameters) + assert ( + vBinary('txt').params == {'VALUE': 'BINARY', 'ENCODING': 'BASE64'} + ) + +def test_long_data(): + """Long data should not have line breaks, as that would interfere""" + txt = b'a' * 99 + txt_ical = b'YWFh' * 33 + assert (vBinary(txt).to_ical() == txt_ical) + assert (vBinary.from_ical(txt_ical) == txt) diff --git a/src/icalendar/tests/prop/test_vBoolean.py b/src/icalendar/tests/prop/test_vBoolean.py new file mode 100644 index 00000000..c9fc433a --- /dev/null +++ b/src/icalendar/tests/prop/test_vBoolean.py @@ -0,0 +1,17 @@ +from icalendar.prop import vBoolean +import pytest + +def test_true(): + assert (vBoolean(True).to_ical() == b'TRUE') + +def test_false(): + assert (vBoolean(0).to_ical() == b'FALSE') + +def test_roundtrip(): + assert (vBoolean.from_ical(vBoolean(True).to_ical()) == True) + assert (vBoolean.from_ical('true') == True) + +def test_error(): + """Error: key not exists""" + with pytest.raises(ValueError): + vBoolean.from_ical('ture') diff --git a/src/icalendar/tests/prop/test_vCalAddress.py b/src/icalendar/tests/prop/test_vCalAddress.py new file mode 100644 index 00000000..6a8ff803 --- /dev/null +++ b/src/icalendar/tests/prop/test_vCalAddress.py @@ -0,0 +1,19 @@ +from icalendar.prop import vCalAddress +from icalendar.parser import Parameters + +txt = b'MAILTO:maxm@mxm.dk' +a = vCalAddress(txt) +a.params['cn'] = 'Max M' + + +def test_to_ical(): + assert a.to_ical() == txt + + +def test_params(): + assert isinstance(a.params, Parameters) + assert a.params == {'CN': 'Max M'} + + +def test_from_ical(): + assert vCalAddress.from_ical(txt) == 'MAILTO:maxm@mxm.dk' diff --git a/src/icalendar/tests/prop/test_vDDDTypes.py b/src/icalendar/tests/prop/test_vDDDTypes.py new file mode 100644 index 00000000..4a54723a --- /dev/null +++ b/src/icalendar/tests/prop/test_vDDDTypes.py @@ -0,0 +1,32 @@ +from icalendar.prop import vDDDTypes +from datetime import date, datetime, timedelta, time +import pytest + + +def test_instance(): + assert isinstance(vDDDTypes.from_ical('20010101T123000'), datetime) + assert isinstance(vDDDTypes.from_ical('20010101'), date) + + +def test_datetime_with_timezone(tzp): + assert vDDDTypes.from_ical('20010101T123000Z') == \ + tzp.localize_utc(datetime(2001, 1, 1, 12, 30)) + + +def test_timedelta(): + assert vDDDTypes.from_ical('P31D') == timedelta(31) + assert vDDDTypes.from_ical('-P31D') == timedelta(-31) + + +def test_bad_input(): + with pytest.raises(ValueError): + vDDDTypes(42) + +def test_time_from_string(): + assert vDDDTypes.from_ical('123000') == time(12, 30) + assert isinstance(vDDDTypes.from_ical('123000'), time) + +def test_invalid_period_to_ical(): + invalid_period = (datetime(2000, 1, 1), datetime(2000, 1, 2), datetime(2000, 1, 2)) + with pytest.raises(ValueError): + vDDDTypes(invalid_period).to_ical() diff --git a/src/icalendar/tests/prop/test_vDatetime.py b/src/icalendar/tests/prop/test_vDatetime.py new file mode 100644 index 00000000..489edff4 --- /dev/null +++ b/src/icalendar/tests/prop/test_vDatetime.py @@ -0,0 +1,42 @@ +from icalendar.prop import vDatetime +import pytest +from datetime import datetime + + +def test_to_ical(): + assert vDatetime(datetime(2001, 1, 1, 12, 30, 0)).to_ical() == b'20010101T123000' + +def test_from_ical(): + assert vDatetime.from_ical('20000101T120000') == datetime(2000, 1, 1, 12, 0) + assert vDatetime.from_ical('20010101T000000') == datetime(2001, 1, 1, 0, 0) + +def test_to_ical_utc(tzp): + dutc = tzp.localize_utc(datetime(2001, 1, 1, 12, 30, 0)) + assert vDatetime(dutc).to_ical() == b'20010101T123000Z' + +def test_to_ical_utc_1899(tzp): + dutc = tzp.localize_utc(datetime(1899, 1, 1, 12, 30, 0)) + assert vDatetime(dutc).to_ical() == b'18990101T123000Z' + + +def test_bad_ical(): + with pytest.raises(ValueError): + vDatetime.from_ical('20010101T000000A') + + +def test_roundtrip(): + utc = vDatetime.from_ical('20010101T000000Z') + assert vDatetime(utc).to_ical() == b'20010101T000000Z' + + +def test_transition(tzp): + # 1 minute before transition to DST + dat = vDatetime.from_ical('20120311T015959', 'America/Denver') + assert dat.strftime('%Y%m%d%H%M%S %z') =='20120311015959 -0700' + + # After transition to DST + dat = vDatetime.from_ical('20120311T030000', 'America/Denver') + assert dat.strftime('%Y%m%d%H%M%S %z') == '20120311030000 -0600' + + dat = vDatetime.from_ical('20101010T000000', 'Europe/Vienna') + assert vDatetime(dat).to_ical() == b'20101010T000000' diff --git a/src/icalendar/tests/prop/test_vPeriod.py b/src/icalendar/tests/prop/test_vPeriod.py new file mode 100644 index 00000000..5b951774 --- /dev/null +++ b/src/icalendar/tests/prop/test_vPeriod.py @@ -0,0 +1,65 @@ +import unittest +from icalendar.prop import vPeriod +from datetime import datetime, timedelta +import pytest + + +class TestProp(unittest.TestCase): + + def test_one_day(self): + # One day in exact datetimes + per = (datetime(2000, 1, 1), datetime(2000, 1, 2)) + self.assertEqual(vPeriod(per).to_ical(), + b'20000101T000000/20000102T000000') + + per = (datetime(2000, 1, 1), timedelta(days=31)) + self.assertEqual(vPeriod(per).to_ical(), b'20000101T000000/P31D') + + def test_roundtrip(self): + p = vPeriod.from_ical('20000101T000000/20000102T000000') + self.assertEqual( + p, + (datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 2, 0, 0)) + ) + self.assertEqual(vPeriod(p).to_ical(), + b'20000101T000000/20000102T000000') + + self.assertEqual(vPeriod.from_ical('20000101T000000/P31D'), + (datetime(2000, 1, 1, 0, 0), timedelta(31))) + + def test_round_trip_with_absolute_time(self): + p = vPeriod.from_ical('20000101T000000Z/20000102T000000Z') + self.assertEqual(vPeriod(p).to_ical(), + b'20000101T000000Z/20000102T000000Z') + + def test_bad_input(self): + self.assertRaises(ValueError, + vPeriod.from_ical, '20000101T000000/Psd31D') + + +def test_timezoned(tzp): + start = tzp.localize(datetime(2000, 1, 1), 'Europe/Copenhagen') + end = tzp.localize(datetime(2000, 1, 2), 'Europe/Copenhagen') + per = (start, end) + assert vPeriod(per).to_ical() == b'20000101T000000/20000102T000000' + assert vPeriod(per).params['TZID'] == 'Europe/Copenhagen' + + +def test_timezoned_with_timedelta(tzp): + p = vPeriod((tzp.localize(datetime(2000, 1, 1), 'Europe/Copenhagen'), timedelta(days=31))) + assert p.to_ical() == b'20000101T000000/P31D' + + +@pytest.mark.parametrize( + "params", + [ + ('20000101T000000', datetime(2000, 1, 2)), + (datetime(2000, 1, 1), '20000102T000000'), + (datetime(2000, 1, 2), datetime(2000, 1, 1)), + (datetime(2000, 1, 2), timedelta(-1)), + ] +) +def test_invalid_parameters(params): + """The parameters are of wrong type or of wrong order.""" + with pytest.raises(ValueError): + vPeriod(params) diff --git a/src/icalendar/tests/prop/test_windows_to_olson_mapping.py b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py new file mode 100644 index 00000000..dfb2b9a7 --- /dev/null +++ b/src/icalendar/tests/prop/test_windows_to_olson_mapping.py @@ -0,0 +1,19 @@ +"""Test the mappings from windows to olson tzids""" +from icalendar.timezone.windows_to_olson import WINDOWS_TO_OLSON +import pytest +from icalendar import vDatetime +from datetime import datetime + + +def test_windows_timezone(tzp): + """Test that the timezone is mapped correctly to olson.""" + dt = vDatetime.from_ical('20170507T181920', 'Eastern Standard Time') + expected = tzp.localize(datetime(2017, 5, 7, 18, 19, 20), 'America/New_York') + assert dt.tzinfo == dt.tzinfo + assert dt == expected + + +@pytest.mark.parametrize("olson_id", WINDOWS_TO_OLSON.values()) +def test_olson_names(tzp, olson_id): + """test if all mappings actually map to valid tzids""" + assert tzp.timezone(olson_id) is not None diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py index b18394af..0f55008d 100644 --- a/src/icalendar/tests/test_equality.py +++ b/src/icalendar/tests/test_equality.py @@ -17,20 +17,27 @@ def assert_not_equal(actual_value, expected_value): assert actual_value != expected_value -def test_parsed_calendars_are_equal_if_parsed_again(ics_file): - """Ensure that a calendar equals the same calendar.""" +def test_parsed_calendars_are_equal_if_parsed_again(ics_file, tzp): + """Ensure that a calendar equals the same calendar. + + ics -> calendar -> ics -> same calendar + """ copy_of_calendar = ics_file.__class__.from_ical(ics_file.to_ical()) assert_equal(copy_of_calendar, ics_file) -def test_parsed_calendars_are_equal_if_from_same_source(ics_file): - """Ensure that a calendar equals the same calendar.""" +def test_parsed_calendars_are_equal_if_from_same_source(ics_file, tzp): + """Ensure that a calendar equals the same calendar. + + ics -> calendar + ics -> same calendar + """ cal1 = ics_file.__class__.from_ical(ics_file.raw_ics) cal2 = ics_file.__class__.from_ical(ics_file.raw_ics) assert_equal(cal1, cal2) -def test_copies_are_equal(ics_file): +def test_copies_are_equal(ics_file, tzp): """Ensure that copies are equal.""" copy1 = ics_file.copy(); copy1.subcomponents = ics_file.subcomponents copy2 = ics_file.copy(); copy2.subcomponents = ics_file.subcomponents[:] @@ -39,13 +46,13 @@ def test_copies_are_equal(ics_file): assert_equal(copy2, ics_file) -def test_copy_does_not_copy_subcomponents(calendars): +def test_copy_does_not_copy_subcomponents(calendars, tzp): """If we copy the subcomponents, assumptions around copies will be broken.""" assert calendars.timezoned.subcomponents assert not calendars.timezoned.copy().subcomponents -def test_deep_copies_are_equal(ics_file): +def test_deep_copies_are_equal(ics_file, tzp): """Ensure that deep copies are equal.""" try: assert_equal(copy.deepcopy(ics_file), copy.deepcopy(ics_file)) diff --git a/src/icalendar/tests/test_examples.py b/src/icalendar/tests/test_examples.py index 0c1c132e..3443d066 100644 --- a/src/icalendar/tests/test_examples.py +++ b/src/icalendar/tests/test_examples.py @@ -1,7 +1,7 @@ '''tests ensuring that *the* way of doing things works''' import datetime -from icalendar import Calendar, Event +from icalendar import Calendar, Event, Timezone import pytest @@ -37,3 +37,26 @@ def test_creating_calendar_with_unicode_fields(calendars, utc): cal.add_component(event2) assert cal.to_ical() == calendars.created_calendar_with_unicode_fields.raw_ics + + +@pytest.mark.parametrize("component,example", + [ + (Calendar, "example"), + (Calendar, "example.ics"), + (Event, "event_with_rsvp"), + (Timezone, "pacific_fiji"), + ] +) +def test_component_has_examples(tzp, calendars, timezones, events, component, example): + """Check that the examples function works.""" + mapping = {Calendar: calendars, Event: events, Timezone: timezones} + example_component = component.example(example) + expected_component = mapping[component][example] + assert example_component == expected_component + + +def test_invalid_examples_lists_the_others(): + """We need a bit of guidance here.""" + with pytest.raises(ValueError) as e: + Calendar.example("does not exist") + assert "example.ics" in str(e.value) diff --git a/src/icalendar/tests/test_issue_116.py b/src/icalendar/tests/test_issue_116.py index 2df8451a..c795f00e 100644 --- a/src/icalendar/tests/test_issue_116.py +++ b/src/icalendar/tests/test_issue_116.py @@ -3,7 +3,6 @@ import datetime import icalendar import os -import pytz import pytest from dateutil import tz diff --git a/src/icalendar/tests/test_parsing.py b/src/icalendar/tests/test_parsing.py index fdc43adc..940f2457 100644 --- a/src/icalendar/tests/test_parsing.py +++ b/src/icalendar/tests/test_parsing.py @@ -116,7 +116,7 @@ def test_tzid_parsed_properly_issue_53(timezones): https://github.com/collective/icalendar/issues/53 ''' assert timezones.issue_53_tzid_parsed_properly['tzid'].to_ical() == b'America/New_York' - + def test_timezones_to_ical_is_inverse_of_from_ical(timezones): '''Issue #55 - Parse error on utc-offset with seconds value see https://github.com/collective/icalendar/issues/55''' @@ -141,7 +141,7 @@ def test_no_tzid_when_utc(utc, date, expected_output): event = Event() event.add('dtstart', date) assert expected_output in event.to_ical() - + def test_vBinary_base64_encoded_issue_82(): '''Issue #82 - vBinary __repr__ called rather than to_ical from container types @@ -170,8 +170,10 @@ def test_creates_event_with_base64_encoded_attachment_issue_82(events): ]) def test_handles_unique_tzid(calendars, in_timezone, calendar_name): calendar = calendars[calendar_name] - start_dt = calendar.walk('VEVENT')[0]['dtstart'].dt - end_dt = calendar.walk('VEVENT')[0]['dtend'].dt + event = calendar.walk('VEVENT')[0] + print(vars(event)) + start_dt = event['dtstart'].dt + end_dt = event['dtend'].dt assert start_dt == in_timezone(datetime(2022, 10, 21, 20, 0, 0), 'Europe/Stockholm') assert end_dt == in_timezone(datetime(2022, 10, 21, 21, 0, 0), 'Europe/Stockholm') @@ -186,4 +188,3 @@ def test_escaped_characters_read(event_name, expected_cn, expected_ics, events): event = events[event_name] assert event['ORGANIZER'].params['CN'] == expected_cn assert event['ORGANIZER'].to_ical() == expected_ics.encode('utf-8') - diff --git a/src/icalendar/tests/test_period.py b/src/icalendar/tests/test_period.py index 03df873f..ccb334ad 100644 --- a/src/icalendar/tests/test_period.py +++ b/src/icalendar/tests/test_period.py @@ -5,7 +5,6 @@ - https://github.com/pimutils/khal/issues/152#issuecomment-933635248 """ import pytest -import pytz from icalendar.prop import vDDDTypes import datetime @@ -55,9 +54,9 @@ def test_tzid_is_part_of_the_parameters(calendars): assert event["RDATE"].params["TZID"] == "America/Vancouver" -def test_tzid_is_part_of_the_period_values(calendars): +def test_tzid_is_part_of_the_period_values(calendars, tzp): """The TZID should be set in the datetime.""" event = list(calendars.period_with_timezone.walk("VEVENT"))[0] start, end = event["RDATE"].dts[0].dt - assert start == pytz.timezone("America/Vancouver").localize(datetime.datetime(2023, 12, 13, 12)) - assert end == pytz.timezone("America/Vancouver").localize(datetime.datetime(2023, 12, 13, 15)) + assert start == tzp.localize(datetime.datetime(2023, 12, 13, 12), "America/Vancouver") + assert end == tzp.localize(datetime.datetime(2023, 12, 13, 15), "America/Vancouver") diff --git a/src/icalendar/tests/test_pytz_zoneinfo_integration.py b/src/icalendar/tests/test_pytz_zoneinfo_integration.py new file mode 100644 index 00000000..c9f70b9a --- /dev/null +++ b/src/icalendar/tests/test_pytz_zoneinfo_integration.py @@ -0,0 +1,66 @@ +"""This tests the switch to different timezone implementations. + +These are mostly located in icalendar.timezone. +""" +import pytz +from icalendar.timezone.zoneinfo import zoneinfo, ZONEINFO +from dateutil.tz.tz import _tzicalvtz +from icalendar.timezone.pytz import PYTZ +import pytest +import copy +import pickle +from dateutil.rrule import rrule, MONTHLY +from datetime import datetime + + +@pytest.mark.parametrize("tz_name", pytz.all_timezones + list(zoneinfo.available_timezones())) +@pytest.mark.parametrize("tzp_", [PYTZ(), ZONEINFO()]) +def test_timezone_names_are_known(tz_name, tzp_): + """Make sure that all timezones are understood.""" + if tz_name in ("Factory", "localtime"): + pytest.skip() + assert tzp_.knows_timezone_id(tz_name), f"{tzp_.__class__.__name__} should know {tz_name}" + + +@pytest.mark.parametrize("func", [pickle.dumps, copy.copy, copy.deepcopy]) +@pytest.mark.parametrize("obj", [_tzicalvtz("id"), rrule(freq=MONTHLY, count=4, dtstart=datetime(2028, 10, 1), cache=True)]) +def test_can_pickle_timezone(func, tzp, obj): + """Check that we can serialize and copy timezones.""" + func(obj) + + +def test_copied_rrule_is_the_same(): + """When we copy an rrule, we want it to be the same after this.""" + r = rrule(freq=MONTHLY, count=4, dtstart=datetime(2028, 10, 1), cache=True) + assert str(copy.deepcopy(r)) == str(r) + + +def test_tzp_properly_switches(tzp, tzp_name): + """We want the default implementation to switch.""" + assert (tzp_name == "pytz") == tzp.uses_pytz() + + +def test_tzp_is_pytz_only(tzp, tzp_name, pytz_only): + """We want the default implementation to switch.""" + assert tzp_name == "pytz" + assert tzp.uses_pytz() + + +def test_cache_reuse_timezone_cache(tzp, timezones): + """Make sure we do not cache the timezones twice and change them.""" + tzp.cache_timezone_component(timezones.pacific_fiji) + tzp1 = tzp.timezone("custom_Pacific/Fiji") + assert tzp1 is tzp.timezone("custom_Pacific/Fiji") + tzp.cache_timezone_component(timezones.pacific_fiji) + assert tzp1 is tzp.timezone("custom_Pacific/Fiji"), "Cache is not replaced." + + +@pytest.mark.parametrize("new_tzp_name", ["pytz", "zoneinfo"]) +def test_cache_is_emptied_when_tzp_is_switched(tzp, timezones, new_tzp_name): + """Make sure we do not reuse the timezones created when we switch the provider.""" + tzp.cache_timezone_component(timezones.pacific_fiji) + tz1 = tzp.timezone("custom_Pacific/Fiji") + tzp.use(new_tzp_name) + tzp.cache_timezone_component(timezones.pacific_fiji) + tz2 = tzp.timezone("custom_Pacific/Fiji") + assert tz1 is not tz2 diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index d5b33ac6..9ab410b1 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -4,484 +4,412 @@ import dateutil.parser import icalendar import os -import pytz -try: - import zoneinfo -except: - from backports import zoneinfo - -HERE = os.path.dirname(__file__) -CALENDARS_DIRECTORY = os.path.join(HERE, 'calendars') - -class TestTimezoned(unittest.TestCase): - - def test_create_from_ical_zoneinfo(self): - with open(os.path.join(CALENDARS_DIRECTORY, 'timezoned.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - self.assertEqual( - cal['prodid'].to_ical(), - b"-//Plone.org//NONSGML plone.app.event//EN" - ) - - timezones = cal.walk('VTIMEZONE') - self.assertEqual(len(timezones), 1) - - tz = timezones[0] - self.assertEqual(tz['tzid'].to_ical(), b"Europe/Vienna") - - std = tz.walk('STANDARD')[0] - self.assertEqual( - std.decoded('TZOFFSETFROM'), - datetime.timedelta(0, 7200) - ) - - ev1 = cal.walk('VEVENT')[0] - self.assertEqual( - ev1.decoded('DTSTART'), - datetime.datetime(2012, 2, 13, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo('Europe/Vienna')) - ) - self.assertEqual( - ev1.decoded('DTSTAMP'), - datetime.datetime(2010, 10, 10, 9, 10, 10, tzinfo=zoneinfo.ZoneInfo('UTC')) - ) - - def test_create_from_ical_pytz(self): - with open(os.path.join(CALENDARS_DIRECTORY, 'timezoned.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - self.assertEqual( - cal['prodid'].to_ical(), - b"-//Plone.org//NONSGML plone.app.event//EN" - ) - - timezones = cal.walk('VTIMEZONE') - self.assertEqual(len(timezones), 1) - - tz = timezones[0] - self.assertEqual(tz['tzid'].to_ical(), b"Europe/Vienna") - - std = tz.walk('STANDARD')[0] - self.assertEqual( - std.decoded('TZOFFSETFROM'), - datetime.timedelta(0, 7200) - ) - - ev1 = cal.walk('VEVENT')[0] - self.assertEqual( - ev1.decoded('DTSTART'), - pytz.timezone('Europe/Vienna').localize( - datetime.datetime(2012, 2, 13, 10, 0, 0) - ) - ) - self.assertEqual( - ev1.decoded('DTSTAMP'), - pytz.utc.localize( - datetime.datetime(2010, 10, 10, 9, 10, 10) - ) - ) - - def test_create_to_ical_pytz(self): - cal = icalendar.Calendar() - - cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") - cal.add('version', "2.0") - cal.add('x-wr-calname', "test create calendar") - cal.add('x-wr-caldesc', "icalendar tests") - cal.add('x-wr-relcalid', "12345") - cal.add('x-wr-timezone', "Europe/Vienna") - - tzc = icalendar.Timezone() - tzc.add('tzid', 'Europe/Vienna') - tzc.add('x-lic-location', 'Europe/Vienna') - - tzs = icalendar.TimezoneStandard() - tzs.add('tzname', 'CET') - tzs.add('dtstart', datetime.datetime(1970, 10, 25, 3, 0, 0)) - tzs.add('rrule', {'freq': 'yearly', 'bymonth': 10, 'byday': '-1su'}) - tzs.add('TZOFFSETFROM', datetime.timedelta(hours=2)) - tzs.add('TZOFFSETTO', datetime.timedelta(hours=1)) - - tzd = icalendar.TimezoneDaylight() - tzd.add('tzname', 'CEST') - tzd.add('dtstart', datetime.datetime(1970, 3, 29, 2, 0, 0)) - tzs.add('rrule', {'freq': 'yearly', 'bymonth': 3, 'byday': '-1su'}) - tzd.add('TZOFFSETFROM', datetime.timedelta(hours=1)) - tzd.add('TZOFFSETTO', datetime.timedelta(hours=2)) - - tzc.add_component(tzs) - tzc.add_component(tzd) - cal.add_component(tzc) - - event = icalendar.Event() - tz = pytz.timezone("Europe/Vienna") - event.add( - 'dtstart', - tz.localize(datetime.datetime(2012, 2, 13, 10, 00, 00))) - event.add( - 'dtend', - tz.localize(datetime.datetime(2012, 2, 17, 18, 00, 00))) - event.add( - 'dtstamp', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - event.add( - 'created', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - event.add('uid', '123456') - event.add( - 'last-modified', - tz.localize(datetime.datetime(2010, 10, 10, 10, 10, 10))) - event.add('summary', 'artsprint 2012') - # event.add('rrule', 'FREQ=YEARLY;INTERVAL=1;COUNT=10') - event.add('description', 'sprinting at the artsprint') - event.add('location', 'aka bild, wien') - event.add('categories', 'first subject') - event.add('categories', 'second subject') - event.add('attendee', 'häns') - event.add('attendee', 'franz') - event.add('attendee', 'sepp') - event.add('contact', 'Max Mustermann, 1010 Wien') - event.add('url', 'https://plone.org') - cal.add_component(event) - - test_out = b'|'.join(cal.to_ical().splitlines()) - test_out = test_out.decode('utf-8') - - vtimezone_lines = "BEGIN:VTIMEZONE|TZID:Europe/Vienna|X-LIC-LOCATION:" - "Europe/Vienna|BEGIN:STANDARD|DTSTART:19701025T03" - "0000|RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10|RRULE:FREQ=YEARLY;B" - "YDAY=-1SU;BYMONTH=3|TZNAME:CET|TZOFFSETFROM:+0200|TZOFFSETTO:+01" - "00|END:STANDARD|BEGIN:DAYLIGHT|DTSTART:19700329T" - "020000|TZNAME:CEST|TZOFFSETFROM:+0100|TZOFFSETTO:+0200|END:DAYLI" - "GHT|END:VTIMEZONE" - self.assertTrue(vtimezone_lines in test_out) - - test_str = "DTSTART;TZID=Europe/Vienna:20120213T100000" - self.assertTrue(test_str in test_out) - self.assertTrue("ATTENDEE:sepp" in test_out) - - # ical standard expects DTSTAMP and CREATED in UTC - self.assertTrue("DTSTAMP:20101010T081010Z" in test_out) - self.assertTrue("CREATED:20101010T081010Z" in test_out) - - def test_create_to_ical_zoneinfo(self): - cal = icalendar.Calendar() - - cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") - cal.add('version', "2.0") - cal.add('x-wr-calname', "test create calendar") - cal.add('x-wr-caldesc', "icalendar tests") - cal.add('x-wr-relcalid', "12345") - cal.add('x-wr-timezone', "Europe/Vienna") - - tzc = icalendar.Timezone() - tzc.add('tzid', 'Europe/Vienna') - tzc.add('x-lic-location', 'Europe/Vienna') - - tzs = icalendar.TimezoneStandard() - tzs.add('tzname', 'CET') - tzs.add('dtstart', datetime.datetime(1970, 10, 25, 3, 0, 0)) - tzs.add('rrule', {'freq': 'yearly', 'bymonth': 10, 'byday': '-1su'}) - tzs.add('TZOFFSETFROM', datetime.timedelta(hours=2)) - tzs.add('TZOFFSETTO', datetime.timedelta(hours=1)) - - tzd = icalendar.TimezoneDaylight() - tzd.add('tzname', 'CEST') - tzd.add('dtstart', datetime.datetime(1970, 3, 29, 2, 0, 0)) - tzs.add('rrule', {'freq': 'yearly', 'bymonth': 3, 'byday': '-1su'}) - tzd.add('TZOFFSETFROM', datetime.timedelta(hours=1)) - tzd.add('TZOFFSETTO', datetime.timedelta(hours=2)) - - tzc.add_component(tzs) - tzc.add_component(tzd) - cal.add_component(tzc) - - event = icalendar.Event() - tz = zoneinfo.ZoneInfo("Europe/Vienna") - event.add( - 'dtstart', - datetime.datetime(2012, 2, 13, 10, 00, 00, tzinfo=tz)) - event.add( - 'dtend', - datetime.datetime(2012, 2, 17, 18, 00, 00, tzinfo=tz)) - event.add( - 'dtstamp', - datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) - event.add( - 'created', - datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) - event.add('uid', '123456') - event.add( - 'last-modified', - datetime.datetime(2010, 10, 10, 10, 10, 10, tzinfo=tz)) - event.add('summary', 'artsprint 2012') - # event.add('rrule', 'FREQ=YEARLY;INTERVAL=1;COUNT=10') - event.add('description', 'sprinting at the artsprint') - event.add('location', 'aka bild, wien') - event.add('categories', 'first subject') - event.add('categories', 'second subject') - event.add('attendee', 'häns') - event.add('attendee', 'franz') - event.add('attendee', 'sepp') - event.add('contact', 'Max Mustermann, 1010 Wien') - event.add('url', 'http://plone.org') - cal.add_component(event) - - test_out = b'|'.join(cal.to_ical().splitlines()) - test_out = test_out.decode('utf-8') - - vtimezone_lines = "BEGIN:VTIMEZONE|TZID:Europe/Vienna|X-LIC-LOCATION:" - "Europe/Vienna|BEGIN:STANDARD|DTSTART:19701025T03" - "0000|RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10|RRULE:FREQ=YEARLY;B" - "YDAY=-1SU;BYMONTH=3|TZNAME:CET|TZOFFSETFROM:+0200|TZOFFSETTO:+01" - "00|END:STANDARD|BEGIN:DAYLIGHT|DTSTART:19700329T" - "020000|TZNAME:CEST|TZOFFSETFROM:+0100|TZOFFSETTO:+0200|END:DAYLI" - "GHT|END:VTIMEZONE" - self.assertTrue(vtimezone_lines in test_out) - - test_str = "DTSTART;TZID=Europe/Vienna:20120213T100000" - self.assertTrue(test_str in test_out) - self.assertTrue("ATTENDEE:sepp" in test_out) - - # ical standard expects DTSTAMP and CREATED in UTC - self.assertTrue("DTSTAMP:20101010T081010Z" in test_out) - self.assertTrue("CREATED:20101010T081010Z" in test_out) - - - def test_tzinfo_dateutil(self): - # Test for issues #77, #63 - # references: #73,7430b66862346fe3a6a100ab25e35a8711446717 - date = dateutil.parser.parse('2012-08-30T22:41:00Z') - date2 = dateutil.parser.parse('2012-08-30T22:41:00 +02:00') - self.assertTrue(date.tzinfo.__module__.startswith('dateutil.tz')) - self.assertTrue(date2.tzinfo.__module__.startswith('dateutil.tz')) - - # make sure, it's parsed properly and doesn't throw an error - self.assertTrue(icalendar.vDDDTypes(date).to_ical() - == b'20120830T224100Z') - self.assertTrue(icalendar.vDDDTypes(date2).to_ical() - == b'20120830T224100') - - -class TestTimezoneCreation(unittest.TestCase): - - def test_create_america_new_york(self): - """testing America/New_York, the most complex example from the - RFC""" - with open(os.path.join(CALENDARS_DIRECTORY, 'america_new_york.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_America/New_York') - pytz_new_york = pytz.timezone('America/New_York') - # for reasons (tm) the locally installed version of the time zone - # database isn't always complete, therefore we only compare some - # transition times - ny_transition_times = [] - ny_transition_info = [] - for num, date in enumerate(pytz_new_york._utc_transition_times): - if datetime.datetime(1967, 4, 30, 7, 0)\ - <= date <= datetime.datetime(2037, 11, 1, 6, 0): - ny_transition_times.append(date) - ny_transition_info.append(pytz_new_york._transition_info[num]) - self.assertEqual(tz._utc_transition_times[:142], ny_transition_times) - self.assertEqual(tz._transition_info[0:142], ny_transition_info) - self.assertIn( - ( - datetime.timedelta(-1, 72000), - datetime.timedelta(0, 3600), 'EDT' - ), - tz._tzinfos.keys() - ) - self.assertIn( - (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST'), - tz._tzinfos.keys() - ) - - def test_create_pacific_fiji(self): - """testing Pacific/Fiji, another pretty complex example with more than - one RDATE property per subcomponent""" - self.maxDiff = None - - with open(os.path.join(CALENDARS_DIRECTORY, 'pacific_fiji.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - - tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo - self.assertEqual(str(tz), 'custom_Pacific/Fiji') - self.assertEqual(tz._utc_transition_times, - [datetime.datetime(1915, 10, 25, 12, 4), - datetime.datetime(1998, 10, 31, 14, 0), - datetime.datetime(1999, 2, 27, 14, 0), - datetime.datetime(1999, 11, 6, 14, 0), - datetime.datetime(2000, 2, 26, 14, 0), - datetime.datetime(2009, 11, 28, 14, 0), - datetime.datetime(2010, 3, 27, 14, 0), - datetime.datetime(2010, 10, 23, 14, 0), - datetime.datetime(2011, 3, 5, 14, 0), - datetime.datetime(2011, 10, 22, 14, 0), - datetime.datetime(2012, 1, 21, 14, 0), - datetime.datetime(2012, 10, 20, 14, 0), - datetime.datetime(2013, 1, 19, 14, 0), - datetime.datetime(2013, 10, 26, 14, 0), - datetime.datetime(2014, 1, 18, 13, 0), - datetime.datetime(2014, 10, 25, 14, 0), - datetime.datetime(2015, 1, 17, 13, 0), - datetime.datetime(2015, 10, 24, 14, 0), - datetime.datetime(2016, 1, 23, 13, 0), - datetime.datetime(2016, 10, 22, 14, 0), - datetime.datetime(2017, 1, 21, 13, 0), - datetime.datetime(2017, 10, 21, 14, 0), - datetime.datetime(2018, 1, 20, 13, 0), - datetime.datetime(2018, 10, 20, 14, 0), - datetime.datetime(2019, 1, 19, 13, 0), - datetime.datetime(2019, 10, 26, 14, 0), - datetime.datetime(2020, 1, 18, 13, 0), - datetime.datetime(2020, 10, 24, 14, 0), - datetime.datetime(2021, 1, 23, 13, 0), - datetime.datetime(2021, 10, 23, 14, 0), - datetime.datetime(2022, 1, 22, 13, 0), - datetime.datetime(2022, 10, 22, 14, 0), - datetime.datetime(2023, 1, 21, 13, 0), - datetime.datetime(2023, 10, 21, 14, 0), - datetime.datetime(2024, 1, 20, 13, 0), - datetime.datetime(2024, 10, 26, 14, 0), - datetime.datetime(2025, 1, 18, 13, 0), - datetime.datetime(2025, 10, 25, 14, 0), - datetime.datetime(2026, 1, 17, 13, 0), - datetime.datetime(2026, 10, 24, 14, 0), - datetime.datetime(2027, 1, 23, 13, 0), - datetime.datetime(2027, 10, 23, 14, 0), - datetime.datetime(2028, 1, 22, 13, 0), - datetime.datetime(2028, 10, 21, 14, 0), - datetime.datetime(2029, 1, 20, 13, 0), - datetime.datetime(2029, 10, 20, 14, 0), - datetime.datetime(2030, 1, 19, 13, 0), - datetime.datetime(2030, 10, 26, 14, 0), - datetime.datetime(2031, 1, 18, 13, 0), - datetime.datetime(2031, 10, 25, 14, 0), - datetime.datetime(2032, 1, 17, 13, 0), - datetime.datetime(2032, 10, 23, 14, 0), - datetime.datetime(2033, 1, 22, 13, 0), - datetime.datetime(2033, 10, 22, 14, 0), - datetime.datetime(2034, 1, 21, 13, 0), - datetime.datetime(2034, 10, 21, 14, 0), - datetime.datetime(2035, 1, 20, 13, 0), - datetime.datetime(2035, 10, 20, 14, 0), - datetime.datetime(2036, 1, 19, 13, 0), - datetime.datetime(2036, 10, 25, 14, 0), - datetime.datetime(2037, 1, 17, 13, 0), - datetime.datetime(2037, 10, 24, 14, 0), - datetime.datetime(2038, 1, 23, 13, 0), - datetime.datetime(2038, 10, 23, 14, 0)] - - ) - self.assertEqual( - tz._transition_info, - [( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' - )] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') - ] + - 3 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - )] + - 25 * [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - ), ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' - )] + - [( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' - )] - ) - - self.assertIn( - ( - datetime.timedelta(0, 46800), - datetime.timedelta(0, 3600), - 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' - ), - tz._tzinfos.keys() - ) - self.assertIn( - ( - datetime.timedelta(0, 43200), - datetime.timedelta(0), - 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' - ), - tz._tzinfos.keys() - ) - - def test_same_start_date(self): - """testing if we can handle VTIMEZONEs whose different components - have the same start DTIMEs.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') - - def test_same_start_date_and_offset(self): - """testing if we can handle VTIMEZONEs whose different components - have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start_and_offset.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - d = cal.subcomponents[1]['DTSTART'].dt - self.assertEqual(d.strftime('%c'), 'Fri Feb 24 12:00:00 2017') - - def test_rdate(self): - """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE - """ - with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_rdate.ics'), 'rb') as fp: - data = fp.read() - cal = icalendar.Calendar.from_ical(data) - vevent = cal.walk('VEVENT')[0] - tz = vevent['DTSTART'].dt.tzinfo - self.assertEqual(str(tz), 'posix/Europe/Vaduz') - self.assertEqual( - tz._utc_transition_times[:6], - [ - datetime.datetime(1901, 12, 13, 20, 45, 38), - datetime.datetime(1941, 5, 5, 0, 0, 0), - datetime.datetime(1941, 10, 6, 0, 0, 0), - datetime.datetime(1942, 5, 4, 0, 0, 0), - datetime.datetime(1942, 10, 5, 0, 0, 0), - datetime.datetime(1981, 3, 29, 1, 0), - ]) - self.assertEqual( - tz._transition_info[:6], - [ - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), - (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), - ] - ) +from icalendar.prop import tzid_from_dt + + +def test_create_from_ical(calendars, other_tzp): + """Create a calendar from a .ics file.""" + cal = calendars.timezoned + + assert cal['prodid'].to_ical() == b"-//Plone.org//NONSGML plone.app.event//EN" + + timezones = cal.walk('VTIMEZONE') + assert len(timezones) == 1 + + tz = timezones[0] + assert tz['tzid'].to_ical() == b"Europe/Vienna" + + std = tz.walk('STANDARD')[0] + assert std.decoded('TZOFFSETFROM') == datetime.timedelta(0, 7200) + + ev1 = cal.walk('VEVENT')[0] + assert ev1.decoded('DTSTART') == other_tzp.localize(datetime.datetime(2012, 2, 13, 10, 0, 0), 'Europe/Vienna') + assert ev1.decoded('DTSTAMP') == other_tzp.localize(datetime.datetime(2010, 10, 10, 9, 10, 10), 'UTC') + + +def test_create_to_ical(tzp): + cal = icalendar.Calendar() + + cal.add('prodid', "-//Plone.org//NONSGML plone.app.event//EN") + cal.add('version', "2.0") + cal.add('x-wr-calname', "test create calendar") + cal.add('x-wr-caldesc', "icalendar tests") + cal.add('x-wr-relcalid', "12345") + cal.add('x-wr-timezone', "Europe/Vienna") + + tzc = icalendar.Timezone() + tzc.add('tzid', 'Europe/Vienna') + tzc.add('x-lic-location', 'Europe/Vienna') + + tzs = icalendar.TimezoneStandard() + tzs.add('tzname', 'CET') + tzs.add('dtstart', datetime.datetime(1970, 10, 25, 3, 0, 0)) + tzs.add('rrule', {'freq': 'yearly', 'bymonth': 10, 'byday': '-1su'}) + tzs.add('TZOFFSETFROM', datetime.timedelta(hours=2)) + tzs.add('TZOFFSETTO', datetime.timedelta(hours=1)) + + tzd = icalendar.TimezoneDaylight() + tzd.add('tzname', 'CEST') + tzd.add('dtstart', datetime.datetime(1970, 3, 29, 2, 0, 0)) + tzs.add('rrule', {'freq': 'yearly', 'bymonth': 3, 'byday': '-1su'}) + tzd.add('TZOFFSETFROM', datetime.timedelta(hours=1)) + tzd.add('TZOFFSETTO', datetime.timedelta(hours=2)) + + tzc.add_component(tzs) + tzc.add_component(tzd) + cal.add_component(tzc) + + event = icalendar.Event() + event.add( + 'dtstart', + tzp.localize(datetime.datetime(2012, 2, 13, 10, 00, 00), "Europe/Vienna")) + event.add( + 'dtend', + tzp.localize(datetime.datetime(2012, 2, 17, 18, 00, 00), "Europe/Vienna")) + event.add( + 'dtstamp', + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) + event.add( + 'created', + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) + event.add('uid', '123456') + event.add( + 'last-modified', + tzp.localize(datetime.datetime(2010, 10, 10, 10, 10, 10), "Europe/Vienna")) + event.add('summary', 'artsprint 2012') + # event.add('rrule', 'FREQ=YEARLY;INTERVAL=1;COUNT=10') + event.add('description', 'sprinting at the artsprint') + event.add('location', 'aka bild, wien') + event.add('categories', 'first subject') + event.add('categories', 'second subject') + event.add('attendee', 'häns') + event.add('attendee', 'franz') + event.add('attendee', 'sepp') + event.add('contact', 'Max Mustermann, 1010 Wien') + event.add('url', 'https://plone.org') + cal.add_component(event) + + test_out = b'|'.join(cal.to_ical().splitlines()) + test_out = test_out.decode('utf-8') + + vtimezone_lines = "BEGIN:VTIMEZONE|TZID:Europe/Vienna|X-LIC-LOCATION:" + "Europe/Vienna|BEGIN:STANDARD|DTSTART:19701025T03" + "0000|RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10|RRULE:FREQ=YEARLY;B" + "YDAY=-1SU;BYMONTH=3|TZNAME:CET|TZOFFSETFROM:+0200|TZOFFSETTO:+01" + "00|END:STANDARD|BEGIN:DAYLIGHT|DTSTART:19700329T" + "020000|TZNAME:CEST|TZOFFSETFROM:+0100|TZOFFSETTO:+0200|END:DAYLI" + "GHT|END:VTIMEZONE" + assert vtimezone_lines in test_out + + test_str = "DTSTART;TZID=Europe/Vienna:20120213T100000" + assert (test_str in test_out) + assert ("ATTENDEE:sepp" in test_out) + + # ical standard expects DTSTAMP and CREATED in UTC + assert ("DTSTAMP:20101010T081010Z" in test_out) + assert ("CREATED:20101010T081010Z" in test_out) + + +def test_tzinfo_dateutil(): + """Test for issues #77, #63 + references: #73,7430b66862346fe3a6a100ab25e35a8711446717 + """ + date = dateutil.parser.parse('2012-08-30T22:41:00Z') + date2 = dateutil.parser.parse('2012-08-30T22:41:00 +02:00') + assert (date.tzinfo.__module__.startswith('dateutil.tz')) + assert (date2.tzinfo.__module__.startswith('dateutil.tz')) + + # make sure, it's parsed properly and doesn't throw an error + assert (icalendar.vDDDTypes(date).to_ical() + == b'20120830T224100Z') + assert (icalendar.vDDDTypes(date2).to_ical() + == b'20120830T224100') + + +def test_create_america_new_york(calendars, tzp): + """testing America/New_York, the most complex example from the RFC""" + cal = calendars.america_new_york + dt = cal.walk('VEVENT')[0]['DTSTART'][0].dt + assert tzid_from_dt(dt) in ('custom_America/New_York', "EDT") + + +def test_america_new_york_with_pytz(calendars, tzp, pytz_only): + """Create a custom timezone with pytz and test the transition times.""" + print(tzp) + cal = calendars.america_new_york + dt = cal.walk('VEVENT')[0]['DTSTART'][0].dt + tz = dt.tzinfo + tz_new_york = tzp.timezone('America/New_York') + # for reasons (tm) the locally installed version of the timezone + # database isn't always complete, therefore we only compare some + # transition times + ny_transition_times = [] + ny_transition_info = [] + for num, date in enumerate(tz_new_york._utc_transition_times): + if datetime.datetime(1967, 4, 30, 7, 0)\ + <= date <= datetime.datetime(2037, 11, 1, 6, 0): + ny_transition_times.append(date) + ny_transition_info.append(tz_new_york._transition_info[num]) + assert tz._utc_transition_times[:142] == ny_transition_times + assert tz._transition_info[0:142] == ny_transition_info + assert ( + datetime.timedelta(-1, 72000), + datetime.timedelta(0, 3600), 'EDT' + ) in tz._tzinfos.keys() + assert (datetime.timedelta(-1, 68400), datetime.timedelta(0), 'EST') in tz._tzinfos.keys() + + +fiji_transition_times = [ + datetime.datetime(1915, 10, 25, 12, 4), + datetime.datetime(1998, 10, 31, 14, 0), + datetime.datetime(1999, 2, 27, 14, 0), + datetime.datetime(1999, 11, 6, 14, 0), + datetime.datetime(2000, 2, 26, 14, 0), + datetime.datetime(2009, 11, 28, 14, 0), + datetime.datetime(2010, 3, 27, 14, 0), + datetime.datetime(2010, 10, 23, 14, 0), + datetime.datetime(2011, 3, 5, 14, 0), + datetime.datetime(2011, 10, 22, 14, 0), + datetime.datetime(2012, 1, 21, 14, 0), + datetime.datetime(2012, 10, 20, 14, 0), + datetime.datetime(2013, 1, 19, 14, 0), + datetime.datetime(2013, 10, 26, 14, 0), + datetime.datetime(2014, 1, 18, 13, 0), + datetime.datetime(2014, 10, 25, 14, 0), + datetime.datetime(2015, 1, 17, 13, 0), + datetime.datetime(2015, 10, 24, 14, 0), + datetime.datetime(2016, 1, 23, 13, 0), + datetime.datetime(2016, 10, 22, 14, 0), + datetime.datetime(2017, 1, 21, 13, 0), + datetime.datetime(2017, 10, 21, 14, 0), + datetime.datetime(2018, 1, 20, 13, 0), + datetime.datetime(2018, 10, 20, 14, 0), + datetime.datetime(2019, 1, 19, 13, 0), + datetime.datetime(2019, 10, 26, 14, 0), + datetime.datetime(2020, 1, 18, 13, 0), + datetime.datetime(2020, 10, 24, 14, 0), + datetime.datetime(2021, 1, 23, 13, 0), + datetime.datetime(2021, 10, 23, 14, 0), + datetime.datetime(2022, 1, 22, 13, 0), + datetime.datetime(2022, 10, 22, 14, 0), + datetime.datetime(2023, 1, 21, 13, 0), + datetime.datetime(2023, 10, 21, 14, 0), + datetime.datetime(2024, 1, 20, 13, 0), + datetime.datetime(2024, 10, 26, 14, 0), + datetime.datetime(2025, 1, 18, 13, 0), + datetime.datetime(2025, 10, 25, 14, 0), + datetime.datetime(2026, 1, 17, 13, 0), + datetime.datetime(2026, 10, 24, 14, 0), + datetime.datetime(2027, 1, 23, 13, 0), + datetime.datetime(2027, 10, 23, 14, 0), + datetime.datetime(2028, 1, 22, 13, 0), + datetime.datetime(2028, 10, 21, 14, 0), + datetime.datetime(2029, 1, 20, 13, 0), + datetime.datetime(2029, 10, 20, 14, 0), + datetime.datetime(2030, 1, 19, 13, 0), + datetime.datetime(2030, 10, 26, 14, 0), + datetime.datetime(2031, 1, 18, 13, 0), + datetime.datetime(2031, 10, 25, 14, 0), + datetime.datetime(2032, 1, 17, 13, 0), + datetime.datetime(2032, 10, 23, 14, 0), + datetime.datetime(2033, 1, 22, 13, 0), + datetime.datetime(2033, 10, 22, 14, 0), + datetime.datetime(2034, 1, 21, 13, 0), + datetime.datetime(2034, 10, 21, 14, 0), + datetime.datetime(2035, 1, 20, 13, 0), + datetime.datetime(2035, 10, 20, 14, 0), + datetime.datetime(2036, 1, 19, 13, 0), + datetime.datetime(2036, 10, 25, 14, 0), + datetime.datetime(2037, 1, 17, 13, 0), + datetime.datetime(2037, 10, 24, 14, 0), + datetime.datetime(2038, 1, 23, 13, 0), + datetime.datetime(2038, 10, 23, 14, 0) +] + +fiji_transition_info = ( + [( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19151026T000000_+115544_+1200' + )] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200') + ] + + 3 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' + )] + + 25 * [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + ), ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_20140119T020000_+1300_+1200' + )] + + [( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_20101024T020000_+1200_+1300' + )] +) + +def test_create_pacific_fiji(calendars, pytz_only): + """testing Pacific/Fiji, another pretty complex example with more than + one RDATE property per subcomponent""" + cal = calendars.pacific_fiji + + tz = cal.walk('VEVENT')[0]['DTSTART'][0].dt.tzinfo + assert str(tz) == 'custom_Pacific/Fiji' + assert tz._utc_transition_times == fiji_transition_times + assert tz._transition_info == fiji_transition_info + assert ( + datetime.timedelta(0, 46800), + datetime.timedelta(0, 3600), + 'custom_Pacific/Fiji_19981101T020000_+1200_+1300' + ) in tz._tzinfos.keys() + assert ( + datetime.timedelta(0, 43200), + datetime.timedelta(0), + 'custom_Pacific/Fiji_19990228T030000_+1300_+1200' + ) in tz._tzinfos.keys() + + +# these are the expected offsets before and after the fiji_transition_times +fiji_expected_offsets = [ + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), + (datetime.timedelta(hours=13), datetime.timedelta(hours=13)), + (datetime.timedelta(hours=12), datetime.timedelta(hours=12)), +] + + +def test_transition_times_fiji(tzp, timezones): + """The transition times are computed.""" + tz = timezones.pacific_fiji.to_tz(tzp) + offsets = [] # [(before, after), ...] + for i, transition_time in enumerate(fiji_transition_times): + before_after_offset = [] + for offset in (datetime.timedelta(hours=-1), datetime.timedelta(hours=+1)): + time_in_timezone = tzp.localize(transition_time + offset, tz) + utc_offset = time_in_timezone.utcoffset() + before_after_offset.append(utc_offset) + offsets.append(tuple(before_after_offset)) + assert offsets == fiji_expected_offsets + + +def test_same_start_date(calendars): + """testing if we can handle VTIMEZONEs whose different components + have the same start DTIMEs.""" + cal = calendars.timezone_same_start + d = cal.subcomponents[1]['DTSTART'].dt + assert d.strftime('%c') == 'Fri Feb 24 12:00:00 2017' + +def test_same_start_date_and_offset(calendars): + """testing if we can handle VTIMEZONEs whose different components + have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" + cal = calendars.timezone_same_start_and_offset + d = cal.subcomponents[1]['DTSTART'].dt + assert d.strftime('%c') == 'Fri Feb 24 12:00:00 2017' + +def test_rdate(calendars): + """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE + """ + cal = calendars.timezone_rdate + vevent = cal.walk('VEVENT')[0] + assert tzid_from_dt(vevent['DTSTART'].dt) in ('posix/Europe/Vaduz', "CET") + +def test_rdate_pytz(calendars, pytz_only): + """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE + """ + cal = calendars.timezone_rdate + vevent = cal.walk('VEVENT')[0] + tz = vevent['DTSTART'].dt.tzinfo + assert tz._utc_transition_times[:6] == [ + datetime.datetime(1901, 12, 13, 20, 45, 38), + datetime.datetime(1941, 5, 5, 0, 0, 0), + datetime.datetime(1941, 10, 6, 0, 0, 0), + datetime.datetime(1942, 5, 4, 0, 0, 0), + datetime.datetime(1942, 10, 5, 0, 0, 0), + datetime.datetime(1981, 3, 29, 1, 0), + ] + assert tz._transition_info[:6] == [ + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + (datetime.timedelta(0, 3600), datetime.timedelta(0), 'CET'), + (datetime.timedelta(0, 7200), datetime.timedelta(0, 3600), 'CEST'), + ] diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index 1a05244d..007d38b0 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -6,10 +6,10 @@ import pytest import icalendar -import pytz import re from icalendar.cal import Component, Calendar, Event, ComponentFactory from icalendar import prop, cal +from icalendar.prop import tzid_from_dt def test_cal_Component(calendar_component): @@ -199,15 +199,14 @@ def test_inline_free_busy_inline(c): assert isinstance(freebusy[0][1], timedelta) -def test_cal_Component_add(comp): +def test_cal_Component_add(comp, tzp): """Test the for timezone correctness: dtstart should preserve it's timezone, created, dtstamp and last-modified must be in UTC. """ - vienna = pytz.timezone("Europe/Vienna") - comp.add('dtstart', vienna.localize(datetime(2010, 10, 10, 10, 0, 0))) + comp.add('dtstart', tzp.localize(datetime(2010, 10, 10, 10, 0, 0), "Europe/Vienna")) comp.add('created', datetime(2010, 10, 10, 12, 0, 0)) - comp.add('dtstamp', vienna.localize(datetime(2010, 10, 10, 14, 0, 0))) - comp.add('last-modified', pytz.utc.localize( + comp.add('dtstamp', tzp.localize(datetime(2010, 10, 10, 14, 0, 0), "Europe/Vienna")) + comp.add('last-modified', tzp.localize_utc( datetime(2010, 10, 10, 16, 0, 0))) lines = comp.to_ical().splitlines() @@ -250,17 +249,17 @@ def test_cal_Component_add_property_parameter(comp): @comp_prop -def test_cal_Component_from_ical(component_name, property_name): +def test_cal_Component_from_ical(component_name, property_name, tzp): """Check for proper handling of TZID parameter of datetime properties""" component_str = 'BEGIN:' + component_name + '\n' component_str += property_name + ';TZID=America/Denver:' component_str += '20120404T073000\nEND:' + component_name component = Component.from_ical(component_str) - assert str(component[property_name].dt.tzinfo.zone) == "America/Denver" + assert tzid_from_dt(component[property_name].dt) == "America/Denver" @comp_prop -def test_cal_Component_from_ical_2(component_name, property_name): +def test_cal_Component_from_ical_2(component_name, property_name, tzp): """Check for proper handling of TZID parameter of datetime properties""" component_str = 'BEGIN:' + component_name + '\n' component_str += property_name + ':' @@ -416,7 +415,7 @@ def test_cal_ignore_errors_parsing(calendars, vUTCOffset_ignore_exceptions): 'issue_526_calendar_with_event_subset', ], repeat=2) ) -def test_comparing_calendars(calendars, calendar, other_calendar): +def test_comparing_calendars(calendars, calendar, other_calendar, tzp): are_calendars_equal = calendars[calendar] == calendars[other_calendar] are_calendars_actually_equal = calendar == other_calendar assert are_calendars_equal == are_calendars_actually_equal diff --git a/src/icalendar/tests/test_with_doctest.py b/src/icalendar/tests/test_with_doctest.py index b6513032..59660fbc 100644 --- a/src/icalendar/tests/test_with_doctest.py +++ b/src/icalendar/tests/test_with_doctest.py @@ -60,8 +60,19 @@ def test_docstring_of_python_file(module_name): def test_files_is_included(filename): assert any(path.endswith(filename) for path in DOCUMENT_PATHS) + @pytest.mark.parametrize("document", DOCUMENT_PATHS) -def test_documentation_file(document): - """This test runs doctest on a documentation file.""" - test_result = doctest.testfile(document, module_relative=False) +def test_documentation_file(document, zoneinfo_only, env_for_doctest): + """This test runs doctest on a documentation file. + + functions are also replaced to work. + """ + test_result = doctest.testfile(document, module_relative=False, globs=env_for_doctest) assert test_result.failed == 0, f"{test_result.failed} errors in {os.path.basename(document)}" + + +def test_can_import_zoneinfo(env_for_doctest): + """Allow importing zoneinfo for tests.""" + import pytz + import zoneinfo + from dateutil import tz diff --git a/src/icalendar/tests/timezones/pacific_fiji.ics b/src/icalendar/tests/timezones/pacific_fiji.ics new file mode 100644 index 00000000..d5b87735 --- /dev/null +++ b/src/icalendar/tests/timezones/pacific_fiji.ics @@ -0,0 +1,42 @@ +BEGIN:VTIMEZONE +TZID:custom_Pacific/Fiji +TZURL:http://tzurl.org/zoneinfo/Pacific/Fiji +X-LIC-LOCATION:Pacific/Fiji +BEGIN:DAYLIGHT +TZOFFSETFROM:+1200 +TZOFFSETTO:+1300 +DTSTART:20101024T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=21,22,23,24,25,26,27;BYDAY=SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+1300 +TZOFFSETTO:+1200 +DTSTART:20140119T020000 +RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=18,19,20,21,22,23,24;BYDAY=SU +END:STANDARD +BEGIN:STANDARD +TZOFFSETFROM:+115544 +TZOFFSETTO:+1200 +DTSTART:19151026T000000 +RDATE:19151026T000000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETFROM:+1200 +TZOFFSETTO:+1300 +DTSTART:19981101T020000 +RDATE:19981101T020000 +RDATE:19991107T020000 +RDATE:20091129T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+1300 +TZOFFSETTO:+1200 +DTSTART:19990228T030000 +RDATE:19990228T030000 +RDATE:20000227T030000 +RDATE:20100328T030000 +RDATE:20110306T030000 +RDATE:20120122T030000 +RDATE:20130120T030000 +END:STANDARD +END:VTIMEZONE diff --git a/src/icalendar/timezone/__init__.py b/src/icalendar/timezone/__init__.py new file mode 100644 index 00000000..dc751808 --- /dev/null +++ b/src/icalendar/timezone/__init__.py @@ -0,0 +1,14 @@ +"""This package contains all functionality for timezones.""" +from .tzp import TZP + +tzp = TZP() + +def use_pytz(): + """Use pytz as the implementation that looks up and creates timezones.""" + tzp.use_pytz() + +def use_zoneinfo(): + """Use zoneinfo as the implementation that looks up and creates timezones.""" + tzp.use_zoneinfo() + +__all__ = ["tzp", "use_pytz", "use_zoneinfo"] diff --git a/src/icalendar/timezone/provider.py b/src/icalendar/timezone/provider.py new file mode 100644 index 00000000..18697b9a --- /dev/null +++ b/src/icalendar/timezone/provider.py @@ -0,0 +1,45 @@ +"""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 + +class TZProvider(ABC): + """Interface for timezone implementations.""" + + @abstractproperty + def name(self) -> str: + """The name of the implementation.""" + + @abstractmethod + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + + @abstractmethod + def localize(self, dt: datetime, tz: tzinfo) -> datetime: + """Localize a datetime to a timezone.""" + + @abstractmethod + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + + @abstractmethod + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + + @abstractmethod + def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo: + """Create a pytz timezone file given information.""" + + @abstractmethod + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" + + @abstractmethod + def uses_pytz(self) -> bool: + """Whether we use pytz.""" + + @abstractmethod + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" diff --git a/src/icalendar/timezone/pytz.py b/src/icalendar/timezone/pytz.py new file mode 100644 index 00000000..b4dbeb7f --- /dev/null +++ b/src/icalendar/timezone/pytz.py @@ -0,0 +1,68 @@ +"""Use pytz timezones.""" +from __future__ import annotations +import pytz +from .. import cal +from datetime import datetime, tzinfo +from pytz.tzinfo import DstTzInfo +from typing import Optional +from .provider import TZProvider +from icalendar import prop +from dateutil.rrule import rrule + + + +class PYTZ(TZProvider): + """Provide icalendar with timezones from pytz.""" + + name = "pytz" + + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: + return dt.astimezone(pytz.utc) + # assume UTC for naive datetime instances + return pytz.utc.localize(dt) + + def localize(self, dt: datetime, tz: tzinfo) -> datetime: + """Localize a datetime to a timezone.""" + return tz.localize(dt) + + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + return id in pytz.all_timezones + + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + if not {'UNTIL', 'COUNT'}.intersection(ical_rrule.keys()): + # pytz.timezones don't know any transition dates after 2038 + # either + rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC) + + def create_timezone(self, tz: cal.Timezone) -> tzinfo: + """Create a pytz timezone from the given information.""" + transition_times, transition_info = tz.get_transitions() + name = tz.tz_name + cls = type(name, (DstTzInfo,), { + 'zone': name, + '_utc_transition_times': transition_times, + '_transition_info': transition_info + }) + return cls() + + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" + try: + return pytz.timezone(name) + except pytz.UnknownTimeZoneError: + pass + + def uses_pytz(self) -> bool: + """Whether we use pytz.""" + return True + + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return False + + +__all__ = ["PYTZ"] diff --git a/src/icalendar/timezone/tzp.py b/src/icalendar/timezone/tzp.py new file mode 100644 index 00000000..698fdbcb --- /dev/null +++ b/src/icalendar/timezone/tzp.py @@ -0,0 +1,128 @@ +from __future__ import annotations +import datetime +from .. import cal +from typing import Optional, Union +from .windows_to_olson import WINDOWS_TO_OLSON +from .provider import TZProvider +from icalendar import prop +from dateutil.rrule import rrule + + +DEFAULT_TIMEZONE_PROVIDER = "pytz" + + +class TZP: + """This is the timezone provider proxy. + + If you would like to have another timezone implementation, + you can create a new one and pass it to this proxy. + All of icalendar will then use this timezone implementation. + """ + + def __init__(self, provider:Union[str, TZProvider]=DEFAULT_TIMEZONE_PROVIDER): + """Create a new timezone implementation proxy.""" + self.use(provider) + + def use_pytz(self) -> None: + """Use pytz as the timezone provider.""" + from .pytz import PYTZ + self._use(PYTZ()) + + def use_zoneinfo(self) -> None: + """Use zoneinfo as the timezone provider.""" + from .zoneinfo import ZONEINFO + self._use(ZONEINFO()) + + def _use(self, provider:TZProvider) -> None: + """Use a timezone implementation.""" + self.__tz_cache = {} + self.__provider = provider + + def use(self, provider:Union[str, TZProvider]): + """Switch to a different timezone provider.""" + if isinstance(provider, str): + provider = getattr(self, f"use_{provider}", None) + if provider is None: + raise ValueError(f"Unknown provider {provider_name}. Use 'pytz' or 'zoneinfo'.") + provider() + else: + self._use(provider) + + def use_default(self): + """Use the default timezone provider.""" + self.use(DEFAULT_TIMEZONE_PROVIDER) + + def localize_utc(self, dt: datetime.datetime) -> datetime.datetime: + """Return the datetime in UTC. + + If the datetime has no timezone, set UTC as its timezone. + """ + return self.__provider.localize_utc(dt) + + def localize(self, dt: datetime.datetime, tz: Union[datetime.tzinfo, str]) -> datetime.datetime: + """Localize a datetime to a timezone.""" + if isinstance(tz, str): + tz = self.timezone(tz) + return self.__provider.localize(dt, tz) + + def cache_timezone_component(self, timezone_component: cal.VTIMEZONE) -> None: + """Cache the timezone that is created from a timezone component + if it is not already known. + + This can influence the result from timezone(): Once cached, the + custom timezone is returned from timezone(). + """ + _unclean_id = timezone_component['TZID'] + id = self.clean_timezone_id(_unclean_id) + if not self.__provider.knows_timezone_id(id) \ + and not self.__provider.knows_timezone_id(_unclean_id) \ + and id not in self.__tz_cache: + self.__tz_cache[id] = timezone_component.to_tz(self) + + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works.""" + self.__provider.fix_rrule_until(rrule, ical_rrule) + + def create_timezone(self, timezone_component: cal.Timezone) -> datetime.tzinfo: + """Create a timezone from a timezone component. + + This component will not be cached. + """ + return self.__provider.create_timezone(timezone_component) + + def clean_timezone_id(self, tzid: str) -> str: + """Return a clean version of the timezone id. + + Timezone ids can be a bit unclean, starting with a / for example. + Internally, we should use this to identify timezones. + """ + return tzid.strip("/") + + def timezone(self, id: str) -> Optional[datetime.tzinfo]: + """Return a timezone with an id or None if we cannot find it.""" + _unclean_id = id + id = self.clean_timezone_id(id) + tz = self.__provider.timezone(id) + if tz is not None: + return tz + if id in WINDOWS_TO_OLSON: + tz = self.__provider.timezone(WINDOWS_TO_OLSON[id]) + return tz or self.__provider.timezone(_unclean_id) or self.__tz_cache.get(id) + + def uses_pytz(self) -> bool: + """Whether we use pytz at all.""" + return self.__provider.uses_pytz() + + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return self.__provider.uses_zoneinfo() + + @property + def name(self) -> str: + """The name of the timezone component used.""" + return self.__provider.name + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.name)})" + +__all__ = ["TZP"] diff --git a/src/icalendar/windows_to_olson.py b/src/icalendar/timezone/windows_to_olson.py similarity index 100% rename from src/icalendar/windows_to_olson.py rename to src/icalendar/timezone/windows_to_olson.py diff --git a/src/icalendar/timezone/zoneinfo.py b/src/icalendar/timezone/zoneinfo.py new file mode 100644 index 00000000..e3665c30 --- /dev/null +++ b/src/icalendar/timezone/zoneinfo.py @@ -0,0 +1,133 @@ +"""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 +from datetime import datetime, tzinfo +from typing import Optional +from .provider import TZProvider +from .. import cal +from io import StringIO +from dateutil.tz import tzical +from dateutil.tz.tz import _tzicalvtz +import copyreg +import functools +import copy + + +class ZONEINFO(TZProvider): + """Provide icalendar with timezones from zoneinfo.""" + + name = "zoneinfo" + utc = zoneinfo.ZoneInfo("UTC") + _available_timezones = zoneinfo.available_timezones() + + def localize(self, dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime: + """Localize a datetime to a timezone.""" + return dt.replace(tzinfo=tz) + + def localize_utc(self, dt: datetime) -> datetime: + """Return the datetime in UTC.""" + if getattr(dt, 'tzinfo', False) and dt.tzinfo is not None: + return dt.astimezone(self.utc) + return self.localize(dt, self.utc) + + def timezone(self, name: str) -> Optional[tzinfo]: + """Return a timezone with a name or None if we cannot find it.""" + try: + return zoneinfo.ZoneInfo(name) + except zoneinfo.ZoneInfoNotFoundError: + pass + except ValueError: + # ValueError: ZoneInfo keys may not be absolute paths, got: /Europe/CUSTOM + pass + + def knows_timezone_id(self, id: str) -> bool: + """Whether the timezone is already cached by the implementation.""" + return id in self._available_timezones + + def fix_rrule_until(self, rrule:rrule, ical_rrule:prop.vRecur) -> None: + """Make sure the until value works for the rrule generated from the ical_rrule.""" + if not {'UNTIL', 'COUNT'}.intersection(ical_rrule.keys()): + # zoninfo does not know any transition dates after 2038 + rrule._until = datetime(2038, 12, 31, tzinfo=self.utc) + + def create_timezone(self, tz: cal.Timezone) -> tzinfo: + """Create a timezone from the given information.""" + try: + return self._create_timezone(tz) + except ValueError: + # We might have a custom component in there. + # see https://github.com/python/cpython/issues/120217 + tz = copy.deepcopy(tz) + for sub in tz.walk(): + for attr in list(sub.keys()): + if attr.lower().startswith("x-"): + sub.pop(attr) + return self._create_timezone(tz) + + def _create_timezone(self, tz: cal.Timezone) -> tzinfo: + """Create a timezone and maybe fail""" + file = StringIO(tz.to_ical().decode("UTF-8", "replace")) + return tzical(file).get() + + def uses_pytz(self) -> bool: + """Whether we use pytz.""" + return False + + def uses_zoneinfo(self) -> bool: + """Whether we use zoneinfo.""" + return True + + +def pickle_tzicalvtz(tzicalvtz:tz._tzicalvtz): + """Because we use dateutil.tzical, we need to make it pickle-able.""" + return _tzicalvtz, (tzicalvtz._tzid, tzicalvtz._comps) + +copyreg.pickle(_tzicalvtz, pickle_tzicalvtz) + + +def pickle_rrule_with_cache(self: rrule): + """Make sure we can also pickle rrules that cache. + + This is mainly copied from rrule.replace. + """ + new_kwargs = {"interval": self._interval, + "count": self._count, + "dtstart": self._dtstart, + "freq": self._freq, + "until": self._until, + "wkst": self._wkst, + "cache": False if self._cache is None else True } + new_kwargs.update(self._original_rule) + # from https://stackoverflow.com/a/64915638/1320237 + return functools.partial(rrule, new_kwargs.pop("freq"), **new_kwargs), () + +copyreg.pickle(rrule, pickle_rrule_with_cache) + +def pickle_rruleset_with_cache(rs: rruleset): + """Pickle an rruleset.""" + # self._rrule = [] + # self._rdate = [] + # self._exrule = [] + # self._exdate = [] + return unpickle_rruleset_with_cache, ( + rs._rrule, rs._rdate, rs._exrule, + rs._exdate, False if rs._cache is None else True + ) + +def unpickle_rruleset_with_cache(rrule, rdate, exrule, exdate, cache): + """unpickling the rruleset.""" + rs = rruleset(cache) + for o in rrule: rs.rrule(o) + for o in rdate: rs.rdate(o) + for o in exrule: rs.exrule(o) + for o in exdate: rs.exdate(o) + return rs + +copyreg.pickle(rruleset, pickle_rruleset_with_cache) + +__all__ = ["ZONEINFO"] diff --git a/src/icalendar/timezone_cache.py b/src/icalendar/timezone_cache.py deleted file mode 100644 index 35e565bf..00000000 --- a/src/icalendar/timezone_cache.py +++ /dev/null @@ -1,2 +0,0 @@ -# we save all timezone with TZIDs unknown to the TZDB in here -_timezone_cache = {}