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/ 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/__init__.py b/src/heliclockter/__init__.py index a64f424..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' @@ -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, ) @@ -289,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. 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/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/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() diff --git a/tests/instantiation_test.py b/tests/instantiation_test.py index fed67b1..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]] @@ -272,3 +272,16 @@ 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_tz(fold: int) -> None: + dt = datetime_tz(2023, 10, 29, 2, 30, fold=fold, tzinfo=ZoneInfo("Europe/Berlin")) + 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" 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):