From 6b6695819be64db3c1f9ededfe81aea69087bf8b Mon Sep 17 00:00:00 2001 From: Peter Nilsson Date: Tue, 16 Jan 2024 10:58:54 +0100 Subject: [PATCH 1/7] add fold to constructor --- src/heliclockter/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/heliclockter/__init__.py b/src/heliclockter/__init__.py index a64f424..5f6246b 100644 --- a/src/heliclockter/__init__.py +++ b/src/heliclockter/__init__.py @@ -77,6 +77,7 @@ def __init__( microsecond: int = 0, *, tzinfo: _datetime.tzinfo, + fold: int = 0, ) -> None: pass @@ -92,6 +93,7 @@ def __init__( # pylint: disable=unused-argument second: int = 0, microsecond: int = 0, tzinfo: _datetime.tzinfo = None, + fold: int = 0, ) -> None: msg = f'{self.__class__} must have a timezone' assert tzinfo is not None and self.tzinfo is not None, msg @@ -167,6 +169,7 @@ def from_datetime(cls: Type[DateTimeTzT], dt: _datetime.datetime) -> DateTimeTzT second=dt.second, microsecond=dt.microsecond, tzinfo=dt.tzinfo, + fold=dt.fold, ).astimezone(tz=assumed_tz) else: @@ -182,6 +185,7 @@ def from_datetime(cls: Type[DateTimeTzT], dt: _datetime.datetime) -> DateTimeTzT second=dt.second, microsecond=dt.microsecond, tzinfo=dt.tzinfo, # type: ignore[arg-type] + fold=dt.fold, ) @classmethod @@ -271,6 +275,7 @@ def __deepcopy__(self: DateTimeTzT, memodict: object) -> DateTimeTzT: second=self.second, microsecond=self.microsecond, tzinfo=self.tzinfo, # type: ignore[arg-type] + fold=self.fold, ) From bd681b5ba3d58d33e488396e1b8ebde54fd3ff2f Mon Sep 17 00:00:00 2001 From: Peter Nilsson Date: Tue, 16 Jan 2024 11:14:35 +0100 Subject: [PATCH 2/7] add tests for fold --- tests/instantiation_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/instantiation_test.py b/tests/instantiation_test.py index fed67b1..927f8ea 100644 --- a/tests/instantiation_test.py +++ b/tests/instantiation_test.py @@ -272,3 +272,10 @@ def test_future_and_past_no_tz() -> None: with pytest.raises(DatetimeTzError, match=error_msg): datetime_tz.past(days=2) + + +@parameterized.expand([(0,), (1,)]) +def test_fold(fold: int) -> None: + dt = datetime_tz(2023, 10, 29, 2, 30, fold=fold, tzinfo=ZoneInfo("Europe/Berlin")) + iso_offset = "+01:00" if fold == 1 else '+02:00' + assert dt.isoformat().endswith(iso_offset) From c558c93e6460dd69b5bc861da283464bb374186d Mon Sep 17 00:00:00 2001 From: Peter Nilsson Date: Tue, 16 Jan 2024 12:01:40 +0100 Subject: [PATCH 3/7] add tests for utc fold --- tests/instantiation_test.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/instantiation_test.py b/tests/instantiation_test.py index 927f8ea..ac442e7 100644 --- a/tests/instantiation_test.py +++ b/tests/instantiation_test.py @@ -275,7 +275,13 @@ def test_future_and_past_no_tz() -> None: @parameterized.expand([(0,), (1,)]) -def test_fold(fold: int) -> None: +def test_fold_tz(fold: int) -> None: dt = datetime_tz(2023, 10, 29, 2, 30, fold=fold, tzinfo=ZoneInfo("Europe/Berlin")) - iso_offset = "+01:00" if fold == 1 else '+02:00' - assert dt.isoformat().endswith(iso_offset) + iso = "2023-10-29T02:30:00+01:00" if fold == 1 else '2023-10-29T02:30:00+02:00' + assert dt.isoformat() == iso + + +@parameterized.expand([(0,), (1,)]) +def test_fold_utc(fold: int) -> None: + dt = datetime_utc(2023, 10, 29, 2, 30, fold=fold, tzinfo=ZoneInfo("UTC")) + assert dt.isoformat() == "2023-10-29T02:30:00+00:00" From 82169409bef8425fd5c37ac3d8fc7885e892c5ab Mon Sep 17 00:00:00 2001 From: Niels Boehm Date: Sun, 21 Jan 2024 04:16:08 +0100 Subject: [PATCH 4/7] Add .vscode/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 260bf84..24c333c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__ *.pyc .idea/ +.vscode/ dist/ build/ venv/ From 3639ea6337b5e0013046bcbbb664a68e56b55fe3 Mon Sep 17 00:00:00 2001 From: Niels Boehm Date: Sat, 20 Jan 2024 19:02:03 +0100 Subject: [PATCH 5/7] Add conftest.py and make .shared imports relative We need to adjust `sys.path` to ensure the right package is tested at all times (rather than a potentially installed one). conftest.py is the ideal place to do this as pytest ensures a file with this name is imported first (you can also place pytest hooks there). At the same time this allows us to get rid of the absolute imports from tests.shared and use more robust relative imports instead. --- tests/conftest.py | 20 ++++++++++++++++++++ tests/instantiation_test.py | 2 +- tests/pydantic_parsing_test.py | 2 +- tests/pydantic_v1_parsing_test.py | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af2d785 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +""" +Configuration of test directory + +This is picked up and handled by pytest, which ensures this is imported first, +therefore we can adjust the import paths here. + +We ensure that heliclockter is picked up from the src directory of the project +in development rather than a potentially already installed package. +Inserting the path to the `src` directory (resolved to an absolute path) at the +beginning of `sys.path` is sufficient. +""" + +from __future__ import annotations as __annotations + +import importlib as __importlib +import sys as __sys +from pathlib import Path as __Path + +__sys.path.insert(0, str((__Path(__file__).parent / '..' / 'src').resolve())) +__importlib.import_module('heliclockter') diff --git a/tests/instantiation_test.py b/tests/instantiation_test.py index ac442e7..a10213e 100644 --- a/tests/instantiation_test.py +++ b/tests/instantiation_test.py @@ -16,7 +16,7 @@ tz_local, ) -from tests.shared import datetime_cet +from .shared import datetime_cet DatetimeT = Union[Type[datetime_tz], Type[datetime_cet], Type[datetime_utc]] diff --git a/tests/pydantic_parsing_test.py b/tests/pydantic_parsing_test.py index 6c3b145..039a1d7 100644 --- a/tests/pydantic_parsing_test.py +++ b/tests/pydantic_parsing_test.py @@ -8,7 +8,7 @@ from heliclockter import DateTimeTzT, datetime_local, datetime_tz, datetime_utc, timedelta -from tests.shared import datetime_cet +from .shared import datetime_cet class DatetimeTZModel(BaseModel): diff --git a/tests/pydantic_v1_parsing_test.py b/tests/pydantic_v1_parsing_test.py index cee5476..73b17e0 100644 --- a/tests/pydantic_v1_parsing_test.py +++ b/tests/pydantic_v1_parsing_test.py @@ -8,7 +8,7 @@ from heliclockter import DateTimeTzT, datetime_local, datetime_tz, datetime_utc, timedelta -from tests.shared import datetime_cet +from .shared import datetime_cet class DatetimeTZModel(BaseModel): From 31d591313b804fd612f64b77fc107cecb4ca2396 Mon Sep 17 00:00:00 2001 From: Niels Boehm Date: Sat, 20 Jan 2024 19:17:44 +0100 Subject: [PATCH 6/7] Add systemtz module with SystemTZ class `SystemTZ` is a subclass of `datetime.tzinfo` and is API compatible with `ZoneInfo`. Instances of the class represent the time zone the system (or the application) is configured to. It implements this via delegating to `time.*` functionality and is sensitive to and supports changing the time zone via setting the `TZ` environment variable and subsequently calling `time.tzset()` on systems that provide this function (not Windows). We also add a test suite with an extensive list of datetimes lots of which are potentially tricky corner cases to ensure that the implementation is not faulty. --- pyproject.toml | 5 +- src/heliclockter/systemtz.py | 230 +++++++++++ tests/correct_datetime_handling_test.py | 501 ++++++++++++++++++++++++ 3 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 src/heliclockter/systemtz.py create mode 100644 tests/correct_datetime_handling_test.py diff --git a/pyproject.toml b/pyproject.toml index 4e73ad9..159fc07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ version = {attr = 'heliclockter.__version__'} heliclockter = ['py.typed'] [project.optional-dependencies] -all = ['bandit', 'black', 'mypy', 'pydantic', 'pylint', 'pytest', 'parameterized', 'toml'] +all = ['bandit', 'black', 'mypy', 'pydantic', 'pylint', 'pytest', 'parameterized', 'toml', 'tzdata', 'tzlocal'] [tool.black] target-version = ['py39'] @@ -59,6 +59,9 @@ addopts = [ '--junitxml=.junit_report.xml', ] junit_family = 'xunit2' +markers = [ + "glibc_limitation: mark parameter set as affected by glibc limitations", +] [tool.mypy] mypy_path = './stubs/' diff --git a/src/heliclockter/systemtz.py b/src/heliclockter/systemtz.py new file mode 100644 index 0000000..5325663 --- /dev/null +++ b/src/heliclockter/systemtz.py @@ -0,0 +1,230 @@ +from __future__ import annotations as __annotations + +import datetime as _datetime +import time as _time +from calendar import timegm as _timegm + +try: + import tzlocal as _tzlocal +except ImportError: + _tzlocal = None + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime as DateTime + from datetime import timedelta as TimeDelta + from typing import Any, List, Optional, Tuple, Type + + +class SystemTZ(_datetime.tzinfo): + """ + A `tzinfo` subclass modeling the system timezone. + + This class allows `datetime` objects to be created containing the local + timezone information. It inherits from `tzinfo` and is compatible with + `ZoneInfo` objects. + + You can provide a custom `datetime.datetime` compatible class during + instantiation to have it return instances of that class rather than + ordinary `datetime.datetime` objects. + + You can also specify a name for the instance that will be used as return + values for `obj.__str__()` and `obj.__repr__()` instead of the defaults. + + The key methods are: + + - `fromutc()` - Convert a UTC datetime object to a local datetime object. + - `utcoffset()` - Return the timezone offset. + - `tzname()` - Return the timezone name. + - `dst()` - Return the daylight saving offset. + + The methods pull timezone information from the `time` module rather than + taking the information as arguments. + + Example: + >>> tz = SystemTZ() + >>> str(tz) + '' + """ + + def __init__( + self, + datetime_like_cls: Type[DateTime] = _datetime.datetime, + *args: Any, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._DateTime = datetime_like_cls + self._unix_epoch = self._DateTime(1970, 1, 1, tzinfo=_datetime.UTC) + self._zero_delta = self._unix_epoch - self._unix_epoch + self._TimeDelta = type(self._zero_delta) + self._name = str(name) if name else None + + def __str__(self) -> str: + if self._name: + return self._name + return '<' + self.__class__.__name__ + '>' + + def __repr__(self) -> str: + if self._name: + return self._name + args = [] # type: List[str] + if self._DateTime is not _datetime.datetime: + args.append(self._DateTime.__module__ + '.' + self._DateTime.__qualname__) + return '{}({})'.format(self.__class__.__qualname__, ', '.join(args)) + + def __eq__(self, other: Any) -> bool: + if other.__class__ is not self.__class__: + return NotImplemented + + return other._DateTime is self._DateTime + + @property + def key(self) -> Optional[str]: + """Return the key of the local timezone. + + This will return the name of the local timezone, like 'Europe/Amsterdam', + if the tzlocal module is available. Otherwise it will return None. + + Example: + >>> os.environ['TZ'] = 'Australia/Sydney' + >>> time.tzset() + >>> tz = SystemTZ() + >>> tz.key + 'Australia/Sydney' + """ + return _tzlocal.get_localzone_name() if _tzlocal else None + + def fromutc(self, dt: DateTime) -> DateTime: + """Convert a UTC datetime object to a local datetime object. + + Takes a datetime object that is in UTC time and converts it to the + local timezone, accounting for daylight savings time if necessary. + + Parameters: + dt (datetime.datetime): The UTC datetime object to convert. + + Returns: + datetime.datetime: The datetime converted to the local timezone. + + Example: + >>> os.environ['TZ'] = 'Europe/Warsaw' + >>> time.tzset() + >>> utc_dt = datetime.datetime(2022, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) + >>> tz = SystemTZ() + >>> local_dt = utc_dt.astimezone(tz) + >>> local_dt + datetime.datetime(2022, 1, 1, 13, 0, tzinfo=SystemTZ()) + """ + assert dt.tzinfo is self + + secs = _timegm((dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)) + t = _time.localtime(secs) + args = t[:6] + if not hasattr(self._DateTime, 'fold'): + return self._DateTime(*args, microsecond=dt.microsecond, tzinfo=self) + + if t.tm_isdst < 0: + return self._DateTime(*args, microsecond=dt.microsecond, tzinfo=self, fold=0) + secs0 = _time.mktime((*t[:8], not t.tm_isdst)) + if secs0 >= secs: + return self._DateTime(*args, microsecond=dt.microsecond, tzinfo=self, fold=0) + t0 = _time.localtime(secs0) + return self._DateTime( + *args, microsecond=dt.microsecond, tzinfo=self, fold=int(t.tm_gmtoff < t0.tm_gmtoff) + ) + + def _mktime(self, dt: DateTime) -> Tuple[_time.struct_time, float]: + assert dt.tzinfo is self + secs = _time.mktime((dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, 0, 1, -1)) + t = _time.localtime(secs) + if not hasattr(dt, 'fold'): + return t, secs + dt.microsecond / 1_000_000 + + if t.tm_isdst < 0: + return t, secs + dt.microsecond / 1_000_000 + + secs0 = _time.mktime((*t[:8], not t.tm_isdst)) + if secs0 == secs: + return t, secs + dt.microsecond / 1_000_000 + + t0 = _time.localtime(secs0) + if t.tm_gmtoff == t0.tm_gmtoff: + return t, secs + dt.microsecond / 1_000_000 + + if (t.tm_gmtoff > t0.tm_gmtoff) ^ bool(dt.fold): + return t, secs + dt.microsecond / 1_000_000 + return t0, secs0 + dt.microsecond / 1_000_000 + + def utcoffset(self, dt: Optional[DateTime]) -> TimeDelta: + """Return the timezone offset for the given datetime. + + Return the offset for the given datetime by + calculating the offset between it and UTC. + If dt is None, return the offset for the current time instead. + + Example: + >>> os.environ['TZ'] = 'Europe/Amsterdam' + >>> time.tzset() + >>> tz = SystemTZ() + >>> dt = datetime.datetime(2022, 1, 1, 12, 0, 0, tzinfo=tz) + >>> tz.utcoffset(dt) + datetime.timedelta(seconds=3600) + """ + # TODO: investigate if we have to round to whole minutes for Python < 3.6 + if dt is None: + return self._TimeDelta(seconds=_time.localtime().tm_gmtoff) + + return self._TimeDelta(seconds=self._mktime(dt)[0].tm_gmtoff) + + def tzname(self, dt: Optional[DateTime]) -> str: + """Return the timezone name for the given datetime. + + Return the name of the timezone for the given datetime, + unless dt is None, in which case return the name for the current time. + + Example: + >>> os.environ['TZ'] = 'America/New_York' + >>> time.tzset() + >>> tz = SystemTZ() + >>> dt = datetime.datetime(2022, 1, 1, 12, 0, 0, tzinfo=tz) + >>> tz.tzname(dt) + 'EST' + """ + if dt is None: + return _time.localtime().tm_zone + + return self._mktime(dt)[0].tm_zone + + def dst(self, dt: Optional[DateTime]) -> Optional[TimeDelta]: + """Return daylight saving time offset for given datetime. + + This method checks whether DST is in effect for a given datetime. If no + datetime is provided, it defaults to the current local time. If DST is + not in effect, it returns a zero duration. If DST is in effect, it + calculates the DST offset and returns it as a `datetime.timedelta`. + + Example: + >>> os.environ['TZ'] = 'Australia/Melbourne' + >>> time.tzset() + >>> tz = SystemTZ() + >>> dt = datetime.datetime(2022, 1, 1, 12, 0, 0, tzinfo=tz) + >>> tz.dst(dt) + datetime.timedelta(seconds=3600) + """ + if dt is None: + secs = _time.time() + t = _time.localtime(secs) + else: + t, secs = self._mktime(dt) + if t.tm_isdst < 0: + return None + + if not t.tm_isdst: + return self._zero_delta + secs0 = _time.mktime((*t[:8], 0)) + secs % 1 + dstoff = round(secs0 - secs) + # TODO: investigate if we have to round to whole minutes for Python < 3.6 + return self._TimeDelta(seconds=dstoff) diff --git a/tests/correct_datetime_handling_test.py b/tests/correct_datetime_handling_test.py new file mode 100644 index 0000000..7b3b7da --- /dev/null +++ b/tests/correct_datetime_handling_test.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 + +from __future__ import annotations as __annotations + +import platform +import time +from contextlib import contextmanager +from datetime import date as Date +from datetime import datetime as DateTime +from datetime import timedelta as TimeDelta +from functools import partial, wraps +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +import pytest + +from heliclockter import datetime_local, datetime_tz, tz_local +from heliclockter.systemtz import SystemTZ + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from datetime import tzinfo + from time import _TimeTuple as TimeTuple + from time import struct_time + from typing import ContextManager, Dict, List, Optional, Tuple, Union + + from pytest import FixtureRequest, MonkeyPatch + from _pytest.mark.structures import ParameterSet + + +@pytest.fixture() +def systemtz() -> tzinfo: + """Fixture that returns the system time zone as a tzinfo object. + + SystemTZ is a tzinfo implementation that uses the system timezone. + This allows tests to run against the local system timezone, possibly mocked + by the `welldefined_timezone()` fixture. + + Example: + ``` + def test_foobar(systemtz): + dt = datetime.datetime.now(systemtz) + assert dt.timetuple()[:4] == time.localtime()[:4] + ``` + """ + return SystemTZ() + + +def winkle_tz_out_of_zoneinfo( + zi: ZoneInfo, +) -> Tuple[int, int, int, Tuple[Optional[str], Optional[str]]]: + """Extract timezone information from a ZoneInfo object. + + Iterates through 2 years worth of timestamps to detect daylight saving time + transitions. Returns the UTC offset, DST offset, whether daylight savings + transitions happen (during the sampled interval) as well as standard and + daylight timezone names. + + Example: + >>> zi = zoneinfo.ZoneInfo('America/New_York') + >>> winkle_tz_out_of_zoneinfo(zi) + (18000, 14400, 1, ('EST', 'EDT')) + """ + assert isinstance(zi, ZoneInfo) + + log: List[Tuple[int, Optional[int], Optional[str], Optional[str]]] = [] + i_dstoff = i_stdname = i_dstname = -1 + now = round(time.time()) + for days in range(0, 365 + 366, 7): + dt = DateTime.fromtimestamp(now + days * 86400, tz=zi) + off = dt.utcoffset() + name = dt.tzname() + assert off is not None + dst = dt.dst() + if dst: + utcoff = round((off - dst).total_seconds()) + dstoff = round(off.total_seconds()) + stdname = log[-1][2] if log else None + dstname = name + else: + utcoff = round(off.total_seconds()) + dstoff = log[-1][1] if log else None + stdname = name + dstname = log[-1][3] if log else None + + if dstoff is not None and i_dstoff < 0: + i_dstoff = len(log) + if stdname and i_stdname < 0: + i_stdname = len(log) + if dstname and i_dstname < 0: + i_dstname = len(log) + + if not log or log[-1] != (utcoff, dstoff, stdname, dstname): + log.append((utcoff, dstoff, stdname, dstname)) + + if i_dstoff >= 0 and i_stdname >= 0 and i_dstname >= 0: + break + + if i_dstoff < 0: + return -log[0][0], -log[0][0], 0, (log[i_stdname][2], log[i_stdname][2]) + + return -log[0][0], -log[i_dstoff][1], 1, (log[i_stdname][2], log[i_dstname][3]) + + +@pytest.fixture(scope='session') +def tzset() -> Iterator[Callable[[], None]]: + """Fixture to prepare time.tzset and related functions for testing purposes. + + This fixture is responsible for setting up the environment for timezone + tests. It patches the `time.tzset` function if available (on Unix systems) + or emulates its behavior on platforms where `time.tzset` is not available + (like Windows). The fixture also modifies the `tzlocal` availability map to + ensure that all timezones from `zoneinfo.available_timezones()` are + considered available. + + The fixture yields a callable that, when invoked, will apply the patched or + emulated `tzset` function to the current environment. This is useful for + tests that need to simulate changes in the system's timezone settings. + + Example: + ``` + def test_my_function(tzset): + os.environ['TZ'] = 'Pacific/Niue' + tzset() + assert time.localtime(1641038400)[:4] == (2022, 1, 1, 1) + ``` + """ + from zoneinfo import available_timezones + + pytest.importorskip('tzlocal') + import tzlocal.utils + from tzlocal.windows_tz import tz_win as bogus_tzlocal_availability_map + + orig_tzset = getattr(time, 'tzset', None) + if orig_tzset: + + @wraps(orig_tzset) + def patched_tzset() -> None: + orig_tzset() + tzlocal.reload_localzone() + + else: + + @wraps(time.localtime) + def emulated_localtime(zi: ZoneInfo, secs: Optional[float] = None) -> struct_time: + if secs is None: + secs = time.time() + dt = DateTime.fromtimestamp(secs, tz=zi) + off = dt.utcoffset() + off = round(off.total_seconds()) if off else 0 + dst = dt.dst() + assert dst is not None, "not sure if we properly deal with unknown DST" + return time.struct_time( + ( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.weekday(), + dt.toordinal() - Date(dt.year, 1, 1).toordinal() + 1, + -1 if dst is None else int(bool(dst)), + ), + {'tm_gmtoff': off, 'tm_zone': dt.tzname()}, + ) + + @wraps(time.mktime) + def emulated_mktime(zi: ZoneInfo, t: Union[struct_time, TimeTuple]) -> float: + candidate_ts = None + dt = DateTime(*t[:6], tzinfo=zi) + ts_fold_0 = dt.timestamp() + if t[8] < 0 or bool(dt.dst()) == bool(t[8]): + return ts_fold_0 + if dt.dst(): + candidate_ts = ts_fold_0 + dt.dst().total_seconds() + dt = DateTime(*t[:6], tzinfo=zi, fold=1) + ts_fold_1 = dt.timestamp() + if bool(dt.dst()) == bool(t[8]): + return ts_fold_1 + if candidate_ts is not None: + return candidate_ts + if dt.dst(): + return ts_fold_1 + dt.dst().total_seconds() + return ts_fold_0 - (time.timezone - time.altzone) + + def emulated_tzset(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(tzlocal.utils, 'assert_tz_offset', lambda *_, **__: None) + zi = tzlocal.reload_localzone() + timezone, altzone, daylight, tzname = winkle_tz_out_of_zoneinfo(zi) + monkeypatch.setattr(time, 'timezone', timezone) + monkeypatch.setattr(time, 'altzone', altzone) + monkeypatch.setattr(time, 'daylight', daylight) + monkeypatch.setattr(time, 'tzname', tzname) + monkeypatch.setattr(time, 'localtime', partial(emulated_localtime, zi)) + monkeypatch.setattr(time, 'mktime', partial(emulated_mktime, zi)) + + with pytest.MonkeyPatch.context() as monkeypatch: + for tz in (tz for tz in available_timezones() if tz not in bogus_tzlocal_availability_map): + monkeypatch.setitem(bogus_tzlocal_availability_map, tz, NotImplemented) + modified_tzset = patched_tzset if orig_tzset else partial(emulated_tzset, monkeypatch) + monkeypatch.setattr(time, 'tzset', modified_tzset, raising=False) + monkeypatch.delenv('TZ', raising=False) # FIXME: use predefined TZ + modified_tzset() + yield modified_tzset + + +@pytest.fixture(scope='function') +def welldefined_timezone( + request: FixtureRequest, tzset: Callable[[], None], monkeypatch: MonkeyPatch +) -> Iterator[Callable[[str], ContextManager[ZoneInfo]]]: + """Ensures a well-defined and constant system timezone while testing. + + Ordinarily, you cannot easily test code that relies on the system timezone + and some `time` module functions and values, as they would be dependent on + the system running the test and its configured timezone. + + This fixture provides a context in which code relying on the standard + library's `time` module functionality, the `tzlocal` module or the `TZ` + environment variable can be tested under well-defined conditions. + + It achieves this by monkeypatching the `TZ` environment variable and calling + `tzset()` while itself depending on the fixture preparing `tzset()` to work + as desired in our test environment. + + By default, tests which depend on this fixture will appear to run in the + `Etc/UTC` system timezone. Furthermore, it provides a context manager that + allows convenient changes to the apparent system timezone. + + It can be used like this: + + ``` + def test_foobar(welldefined_timezone): + + # here, the apparent system timezone starts out as Etc/UTC + + with welldefined_timezone('Asia/Ho_Chi_Minh') as tz: + # code under test that runs as though + # the system timezone were Asia/Ho_Chi_Minh + ... + # It can optionally use the ZoneInfo object returned by the + # context manager. + + # here, the system timezone is restored to Etc/UTC + + with welldefined_timezone('America/Mexico_City') as tz: + # code under test that runs as though + # the system timezone were America/Mexico_City + ... + + # and here it's back to Etc/UTC again + ``` + + As a side feature, you can also parametrize this fixture itself with + several timezone names and tests that depend on this fixture will then be + tested will all of them in turn while outside of the explicitly used + context manager. + """ + + @contextmanager + def use_timezone(key: str) -> Iterator[ZoneInfo]: + zi = ZoneInfo(key) + assert zi.key == key + + try: + with monkeypatch.context() as ctx: + ctx.setenv('TZ', key) + tzset() + yield zi + finally: + tzset() + + key = getattr(request, 'param', 'Etc/UTC') + assert key + + tz = ZoneInfo(key) + assert tz.key == key + + try: + with monkeypatch.context() as ctx: + ctx.setenv('TZ', key) + tzset() + yield use_timezone + finally: + tzset() + + +POINTS_IN_TIME: Dict[ + str, Union[Tuple[str, int, Tuple[int, ...], int, int, str, Dict[str, int]], ParameterSet] +] = { + # fmt: off + 'america_st_johns_new_year': ('America/St_Johns', 1672543800, (2023, 1, 1, 0, 0, 0), -12600, 0, 'NST', {}), + 'america_st_johns_standard_time': ('America/St_Johns', 1676477096, (2023, 2, 15, 12, 34, 56), -12600, 0, 'NST', {}), + 'america_st_johns_before_dst': ('America/St_Johns', 1678598999, (2023, 3, 12, 1, 59, 59), -12600, 0, 'NST', {}), + 'america_st_johns_start_of_dst': ('America/St_Johns', 1678599000, (2023, 3, 12, 3, 0, 0), -9000, 3600, 'NDT', {}), + 'america_st_johns_during_dst': ('America/St_Johns', 1689433496, (2023, 7, 15, 12, 34, 56), -9000, 3600, 'NDT', {}), + 'america_st_johns_still_dst': ('America/St_Johns', 1699154999, (2023, 11, 5, 0, 59, 59), -9000, 3600, 'NDT', {}), + 'america_st_johns_fold_0': ('America/St_Johns', 1699155000, (2023, 11, 5, 1, 0, 0), -9000, 3600, 'NDT', {'fold': 0}), + 'america_st_johns_end_of_dst': ('America/St_Johns', 1699158599, (2023, 11, 5, 1, 59, 59), -9000, 3600, 'NDT', {'fold': 0}), + 'america_st_johns_start_of_fold': ('America/St_Johns', 1699158600, (2023, 11, 5, 1, 0, 0), -12600, 0, 'NST', {'fold': 1}), + 'america_st_johns_end_of_fold': ('America/St_Johns', 1699162199, (2023, 11, 5, 1, 59, 59), -12600, 0, 'NST', {'fold': 1}), + 'america_st_johns_after_fold': ('America/St_Johns', 1699162200, (2023, 11, 5, 2, 0, 0), -12600, 0, 'NST', {}), + 'america_st_johns_new_years_eve': ('America/St_Johns', 1704079799, (2023, 12, 31, 23, 59, 59), -12600, 0, 'NST', {}), + # Europe/Dublin designates their GMT usage during winter as DST so that their IST is the standard time + # with the net effect of their DST offset being negative + 'europe_dublin_new_year': ('Europe/Dublin', 1672531200, (2023, 1, 1, 0, 0, 0), 0, -3600, 'GMT', {}), + 'europe_dublin_during_dst': ('Europe/Dublin', 1676464496, (2023, 2, 15, 12, 34, 56), 0, -3600, 'GMT', {}), + 'europe_dublin_end_of_dst': ('Europe/Dublin', 1679792399, (2023, 3, 26, 0, 59, 59), 0, -3600, 'GMT', {}), + 'europe_dublin_after_dst': ('Europe/Dublin', 1679792400, (2023, 3, 26, 2, 0, 0), 3600, 0, 'IST', {}), + 'europe_dublin_standard_time': ('Europe/Dublin', 1689420896, (2023, 7, 15, 12, 34, 56), 3600, 0, 'IST', {}), + 'europe_dublin_before_dst': ('Europe/Dublin', 1698537599, (2023, 10, 29, 0, 59, 59), 3600, 0, 'IST', {}), + 'europe_dublin_fold_0': ('Europe/Dublin', 1698537600, (2023, 10, 29, 1, 0, 0), 3600, 0, 'IST', {'fold': 0}), + 'europe_dublin_before_fold': ('Europe/Dublin', 1698541199, (2023, 10, 29, 1, 59, 59), 3600, 0, 'IST', {'fold': 0}), + 'europe_dublin_start_of_dst': ('Europe/Dublin', 1698541200, (2023, 10, 29, 1, 0, 0), 0, -3600, 'GMT', {'fold': 1}), + 'europe_dublin_end_of_fold': ('Europe/Dublin', 1698544799, (2023, 10, 29, 1, 59, 59), 0, -3600, 'GMT', {'fold': 1}), + 'europe_dublin_after_fold': ('Europe/Dublin', 1698544800, (2023, 10, 29, 2, 0, 0), 0, -3600, 'GMT', {}), + 'europe_dublin_new_years_eve': ('Europe/Dublin', 1704067199, (2023, 12, 31, 23, 59, 59), 0, -3600, 'GMT', {}), + # Africa/El_Aaiun (and Africa/Casablanca) are special in that they use negative DST during Ramadan and which lasts only about a month + 'africa_el_aaiun_new_year': ('Africa/El_Aaiun', 1672527600, (2023, 1, 1, 0, 0, 0), 3600, 0, '+01', {}), + 'africa_el_aaiun_before_dst': ('Africa/El_Aaiun', 1679187599, (2023, 3, 19, 1, 59, 59), 3600, 0, '+01', {}), + 'africa_el_aaiun_fold_0': ('Africa/El_Aaiun', 1679187600, (2023, 3, 19, 2, 0, 0), 3600, 0, '+01', {'fold': 0}), + 'africa_el_aaiun_before_fold': ('Africa/El_Aaiun', 1679191199, (2023, 3, 19, 2, 59, 59), 3600, 0, '+01', {'fold': 0}), + 'africa_el_aaiun_start_of_dst': ('Africa/El_Aaiun', 1679191200, (2023, 3, 19, 2, 0, 0), 0, -3600, '+00', {'fold': 1}), + 'africa_el_aaiun_end_of_fold': ('Africa/El_Aaiun', 1679194799, (2023, 3, 19, 2, 59, 59), 0, -3600, '+00', {'fold': 1}), + 'africa_el_aaiun_after_fold': ('Africa/El_Aaiun', 1679194800, (2023, 3, 19, 3, 0, 0), 0, -3600, '+00', {}), + 'africa_el_aaiun_during_dst': ('Africa/El_Aaiun', 1680698096, (2023, 4, 5, 12, 34, 56), 0, -3600, '+00', {}), + 'africa_el_aaiun_end_of_dst': ('Africa/El_Aaiun', 1682215199, (2023, 4, 23, 1, 59, 59), 0, -3600, '+00', {}), + 'africa_el_aaiun_after_dst': ('Africa/El_Aaiun', 1682215200, (2023, 4, 23, 3, 0, 0), 3600, 0, '+01', {}), + 'africa_el_aaiun_standard_time': ('Africa/El_Aaiun', 1692099296, (2023, 8, 15, 12, 34, 56), 3600, 0, '+01', {}), + 'africa_el_aaiun_new_years_eve': ('Africa/El_Aaiun', 1704063599, (2023, 12, 31, 23, 59, 59), 3600, 0, '+01', {}), + # Australia/Lord_Howe island changes by only half an hour when transitioning to/from DST + 'australia_lord_howe_new_year': ('Australia/Lord_Howe', 1672491600, (2023, 1, 1, 0, 0, 0), 39600, 1800, '+11', {}), + 'australia_lord_howe_during_dst': ('Australia/Lord_Howe', 1676424896, (2023, 2, 15, 12, 34, 56), 39600, 1800, '+11', {}), + 'australia_lord_howe_still_dst': ('Australia/Lord_Howe', 1680359399, (2023, 4, 2, 1, 29, 59), 39600, 1800, '+11', {}), + 'australia_lord_howe_fold_0': ('Australia/Lord_Howe', 1680359400, (2023, 4, 2, 1, 30, 00), 39600, 1800, '+11', {'fold': 0}), + 'australia_lord_howe_end_of_dst': ('Australia/Lord_Howe', 1680361199, (2023, 4, 2, 1, 59, 59), 39600, 1800, '+11', {'fold': 0}), + 'australia_lord_howe_start_of_fold': ('Australia/Lord_Howe', 1680361200, (2023, 4, 2, 1, 30, 00), 37800, 0, '+1030', {'fold': 1}), + 'australia_lord_howe_end_of_fold': ('Australia/Lord_Howe', 1680362999, (2023, 4, 2, 1, 59, 59), 37800, 0, '+1030', {'fold': 1}), + 'australia_lord_howe_after_fold': ('Australia/Lord_Howe', 1680363000, (2023, 4, 2, 2, 0, 0), 37800, 0, '+1030', {}), + 'australia_lord_howe_standard_time': ('Australia/Lord_Howe', 1689386696, (2023, 7, 15, 12, 34, 56), 37800, 0, '+1030', {}), + 'australia_lord_howe_before_dst': ('Australia/Lord_Howe', 1696087799, (2023, 10, 1, 1, 59, 59), 37800, 0, '+1030', {}), + 'australia_lord_howe_start_of_dst': ('Australia/Lord_Howe', 1696087800, (2023, 10, 1, 2, 30, 0), 39600, 1800, '+11', {}), + 'australia_lord_howe_new_years_eve': ('Australia/Lord_Howe', 1704027599, (2023, 12, 31, 23, 59, 59), 39600, 1800, '+11', {}), + # Antarctica/Troll station is special in that it's currently the only location to make 2 hour time adjustments twice a year + 'antarctica_troll_new_year': ('Antarctica/Troll', 1672531200, (2023, 1, 1, 0, 0, 0), 0, 0, '+00', {}), + 'antarctica_troll_standard_time': ('Antarctica/Troll', 1676464496, (2023, 2, 15, 12, 34, 56), 0, 0, '+00', {}), + 'antarctica_troll_before_dst': ('Antarctica/Troll', 1679792399, (2023, 3, 26, 0, 59, 59), 0, 0, '+00', {}), + 'antarctica_troll_start_of_dst': ('Antarctica/Troll', 1679792400, (2023, 3, 26, 3, 0, 0), 7200, 7200, '+02', {}), + 'antarctica_troll_during_dst': ('Antarctica/Troll', 1689417296, (2023, 7, 15, 12, 34, 56), 7200, 7200, '+02', {}), + 'antarctica_troll_still_dst': ('Antarctica/Troll', 1698533999, (2023, 10, 29, 0, 59, 59), 7200, 7200, '+02', {}), + 'antarctica_troll_fold_0': ('Antarctica/Troll', 1698534000, (2023, 10, 29, 1, 0, 0), 7200, 7200, '+02', {'fold': 0}), + 'antarctica_troll_end_of_dst': ('Antarctica/Troll', 1698541199, (2023, 10, 29, 2, 59, 59), 7200, 7200, '+02', {'fold': 0}), + 'antarctica_troll_start_of_fold': ('Antarctica/Troll', 1698541200, (2023, 10, 29, 1, 0, 0), 0, 0, '+00', {'fold': 1}), + 'antarctica_troll_end_of_fold': ('Antarctica/Troll', 1698548399, (2023, 10, 29, 2, 59, 59), 0, 0, '+00', {'fold': 1}), + 'antarctica_troll_after_fold': ('Antarctica/Troll', 1698548400, (2023, 10, 29, 3, 0, 0), 0, 0, '+00', {}), + 'antarctica_troll_new_years_eve': ('Antarctica/Troll', 1704067199, (2023, 12, 31, 23, 59, 59), 0, 0, '+00', {}), + # America/Scoresbysund switches timezone rules at the same instant as entering DST in March 2024 resulting in a net offset change of 0 + 'america_scoresbysund_new_year': ('America/Scoresbysund', 1704070800, (2024, 1, 1, 0, 0, 0), -3600, 0, '-01', {}), + 'america_scoresbysund_standard_time': ('America/Scoresbysund', 1708004096, (2024, 2, 15, 12, 34, 56), -3600, 0, '-01', {}), + 'america_scoresbysund_before_dst': ('America/Scoresbysund', 1711846799, (2024, 3, 30, 23, 59, 59), -3600, 0, '-01', {}), + 'america_scoresbysund_start_of_dst': pytest.param( + 'America/Scoresbysund', 1711846800, (2024, 3, 31, 0, 0, 0), -3600, 3600, '-01', {}, + # glibc cannot properly handle irregular DST changes, its + # localtime() claiming DST not taking effect until months later + # (the total gmtoff it returns is still correct, though, + # which is sufficient for most practical cases), so we + # mark this parameter set for it to forgo the DST check + marks=pytest.mark.glibc_limitation + ), + 'america_scoresbysund_during_dst': ('America/Scoresbysund', 1721050496, (2024, 7, 15, 12, 34, 56), -3600, 3600, '-01', {}), + 'america_scoresbysund_still_dst': ('America/Scoresbysund', 1729987199, (2024, 10, 26, 22, 59, 59), -3600, 3600, '-01', {}), + 'america_scoresbysund_fold_0': ('America/Scoresbysund', 1729987200, (2024, 10, 26, 23, 0, 0), -3600, 3600, '-01', {'fold': 0}), + 'america_scoresbysund_end_of_dst': ('America/Scoresbysund', 1729990799, (2024, 10, 26, 23, 59, 59), -3600, 3600, '-01', {'fold': 0}), + 'america_scoresbysund_start_of_fold': ('America/Scoresbysund', 1729990800, (2024, 10, 26, 23, 0, 0), -7200, 0, '-02', {'fold': 1}), + 'america_scoresbysund_end_of_fold': ('America/Scoresbysund', 1729994399, (2024, 10, 26, 23, 59, 59), -7200, 0, '-02', {'fold': 1}), + 'america_scoresbysund_after_fold': ('America/Scoresbysund', 1729994400, (2024, 10, 27, 0, 0, 0), -7200, 0, '-02', {}), + 'america_scoresbysund_new_years_eve': ('America/Scoresbysund', 1735696799, (2024, 12, 31, 23, 59, 59), -7200, 0, '-02', {}), + # Pacific/Kiritimati has no DST and an extreme difference to UTC by +14 h + 'pacific_kiritimati_new_year': ('Pacific/Kiritimati', 1672480800, (2023, 1, 1, 0, 0, 0), 50400, 0, '+14', {}), + 'pacific_kiritimati_standard_time': ('Pacific/Kiritimati', 1689374096, (2023, 7, 15, 12, 34, 56), 50400, 0, '+14', {}), + 'pacific_kiritimati_new_years_eve': ('Pacific/Kiritimati', 1704016799, (2023, 12, 31, 23, 59, 59), 50400, 0, '+14', {}), + # fmt: on +} + + +@pytest.mark.parametrize( + ('key', 'ts', 'tt', 'off', 'dst', 'zone', 'kwds'), + POINTS_IN_TIME.values(), + ids=POINTS_IN_TIME.keys(), +) +def test_datetime_tz( + key: str, + ts: int, + tt: Tuple[int, ...], + off: int, + dst: int, + zone: str, + kwds: Dict[str, int], + systemtz: tzinfo, +) -> None: + dt = datetime_tz(*tt, tzinfo=ZoneInfo(key), fold=kwds.get('fold', 0)) + dt = dt.astimezone(systemtz) + datetime_tz.assert_aware_datetime(dt) + dt = dt.astimezone(ZoneInfo(key)) + datetime_tz.assert_aware_datetime(dt) + assert dt.timestamp() == ts + assert dt.utcoffset() == TimeDelta(seconds=off) + assert dt.tzname() == zone + assert dt.dst() == TimeDelta(seconds=dst) + + +@pytest.mark.parametrize( + ('key', 'ts', 'tt', 'off', 'dst', 'zone', 'kwds'), + POINTS_IN_TIME.values(), + ids=POINTS_IN_TIME.keys(), +) +def test_datetime_tz_fromtimestamp( + key: str, + ts: int, + tt: Tuple[int, ...], + off: int, + dst: int, + zone: str, + kwds: Dict[str, int], + systemtz: tzinfo, +) -> None: + dt = datetime_tz.fromtimestamp(ts, tz=ZoneInfo(key)) + dt = dt.astimezone(systemtz) + datetime_tz.assert_aware_datetime(dt) + dt = dt.astimezone(ZoneInfo(key)) + datetime_tz.assert_aware_datetime(dt) + expected_fold = kwds.get('fold', 0) + assert dt.fold == expected_fold + assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == tt + assert dt.utcoffset() == TimeDelta(seconds=off) + assert dt.tzname() == zone + assert dt.dst() == TimeDelta(seconds=dst) + + +@pytest.mark.parametrize( + ('key', 'ts', 'tt', 'off', 'dst', 'zone', 'kwds'), + POINTS_IN_TIME.values(), + ids=POINTS_IN_TIME.keys(), +) +def test_datetime_local( + key: str, + ts: int, + tt: Tuple[int, ...], + off: int, + dst: int, + zone: str, + kwds: Dict[str, int], + welldefined_timezone: Callable[[str], ContextManager[ZoneInfo]], + request: FixtureRequest, +) -> None: + with welldefined_timezone(key): + dt = datetime_local(*tt, tzinfo=tz_local, fold=kwds.get('fold', 0)) + assert isinstance(dt.tzinfo, SystemTZ) + assert dt.timestamp() == ts + assert dt.utcoffset() == TimeDelta(seconds=off) + assert dt.tzname() == zone + + if platform.libc_ver()[0] != 'glibc' or not request.node.get_closest_marker( + 'glibc_limitation' + ): + assert dt.dst() == TimeDelta(seconds=dst) + + +@pytest.mark.parametrize( + ('key', 'ts', 'tt', 'off', 'dst', 'zone', 'kwds'), + POINTS_IN_TIME.values(), + ids=POINTS_IN_TIME.keys(), +) +def test_datetime_local_fromtimestamp( + key: str, + ts: int, + tt: Tuple[int, ...], + off: int, + dst: int, + zone: str, + kwds: Dict[str, int], + welldefined_timezone: Callable[[str], ContextManager[ZoneInfo]], + request: FixtureRequest, +) -> None: + with welldefined_timezone(key): + dt = datetime_local.fromtimestamp(ts, tz=tz_local) + assert isinstance(dt.tzinfo, SystemTZ) + expected_fold = kwds.get('fold', 0) + assert dt.fold == expected_fold + assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == tt + assert dt.utcoffset() == TimeDelta(seconds=off) + assert dt.tzname() == zone + + if platform.libc_ver()[0] != 'glibc' or not request.node.get_closest_marker( + 'glibc_limitation' + ): + assert dt.dst() == TimeDelta(seconds=dst) + + +if __name__ == '__main__': + pytest.main() From 3e371dfbb294cc4f22a6978b6691ad458abbee1f Mon Sep 17 00:00:00 2001 From: Niels Boehm Date: Sat, 20 Jan 2024 19:28:41 +0100 Subject: [PATCH 7/7] Make use of a SystemTZ instance in heliclockter --- src/heliclockter/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/heliclockter/__init__.py b/src/heliclockter/__init__.py index 5f6246b..7397ee4 100644 --- a/src/heliclockter/__init__.py +++ b/src/heliclockter/__init__.py @@ -15,6 +15,8 @@ ) from zoneinfo import ZoneInfo +from .systemtz import SystemTZ + # We don't require pydantic as a dependency, but add validate logic if it exists. # `parse_datetime` doesn't exist in Pydantic v2, so `PYDANTIC_V1_AVAILABLE is False` when # pydantic v2 is installed. @@ -42,8 +44,6 @@ timedelta = _datetime.timedelta -tz_local = cast(ZoneInfo, _datetime.datetime.now().astimezone().tzinfo) - __version__ = '1.2.0' @@ -294,6 +294,9 @@ def fromtimestamp(cls, timestamp: float) -> datetime_utc: # type: ignore[overri return cls.from_datetime(_datetime.datetime.fromtimestamp(timestamp, tz=ZoneInfo('UTC'))) +tz_local = cast(ZoneInfo, SystemTZ(datetime_tz, name='tz_local')) + + class datetime_local(datetime_tz): """ A `datetime_local` is a `datetime_tz` but which is guaranteed to be in the local timezone.