Last active
July 4, 2026 15:46
-
-
Save ynkdir/f7bfd5a0088e3fb3888dffb3b01f9ec9 to your computer and use it in GitHub Desktop.
ZoneInfo implementation using icu.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ZoneInfo implementation using icu. | |
| # | |
| # NOTE: The built-in ICU version on Windows 11 25H2 is 72.1.0.4 and the bundled tzdata version is 2022g. | |
| # | |
| # https://github.com/microsoft/icu | |
| # | |
| # Windows has shipped icu.dll since Windows10. | |
| # https://learn.microsoft.com/en-us/windows/win32/intl/international-components-for-unicode--icu- | |
| # | |
| # time zone data | |
| # C:\Windows\Globalization\Time Zone\timezones.xml | |
| # (tzdata which icu seems to use is stored in C:\Windows\Globalization\ICU\zoneinfo64.res) | |
| # | |
| # How can I convert between IANA time zones and Windows registry-based time zones? | |
| # https://devblogs.microsoft.com/oldnewthing/20210527-00/?p=105255 | |
| # > Bonus chatter: The Windows globalization team also strongly recommends that | |
| # > programs use IANA time zones and use the Windows registry-based time zones | |
| # > only for legacy interop purposes. | |
| # | |
| # Why is the daylight saving time cutover time 1 millisecond too soon in some time zones? | |
| # https://devblogs.microsoft.com/oldnewthing/20180309-00/?p=98195 | |
| import os | |
| import unittest | |
| from ctypes import ( | |
| CDLL, | |
| POINTER, | |
| byref, | |
| c_char_p, | |
| c_double, | |
| c_int8, | |
| c_int32, | |
| c_ssize_t, | |
| c_uint16, | |
| c_void_p, | |
| c_wchar_p, | |
| cast, | |
| create_string_buffer, | |
| create_unicode_buffer, | |
| ) | |
| from ctypes.util import find_library | |
| from datetime import datetime, timedelta, timezone, tzinfo | |
| from typing import Self | |
| c_intptr_t = c_ssize_t | |
| if os.name == "nt": | |
| icuuc = CDLL("icuuc") | |
| icuin = CDLL("icuin") | |
| uchar_p = c_wchar_p | |
| create_ustr_buffer = create_unicode_buffer | |
| else: | |
| class VersionedSymbolLoader: | |
| def __init__(self, lib: CDLL, version: str) -> None: | |
| self._lib = lib | |
| self._version = version | |
| def __getattr__(self, name: str) -> object: | |
| try: | |
| return self._lib[name] | |
| except AttributeError: | |
| return self._lib[f"{name}_{self._version}"] | |
| icuuc_name = find_library("icuuc") | |
| icuin_name = find_library("icui18n") | |
| if icuuc_name is None or icuin_name is None: | |
| raise RuntimeError("cannot load icu library") | |
| icuuc = CDLL(icuuc_name) | |
| icuin = CDLL(icuin_name) | |
| version = icuuc_name.split(".")[-1] | |
| if version.isdigit(): | |
| icuuc = VersionedSymbolLoader(icuuc, version) | |
| icuin = VersionedSymbolLoader(icuin, version) | |
| class uchar_p: | |
| @classmethod | |
| def from_param(cls, obj): | |
| if obj is None: | |
| return None | |
| elif isinstance(obj, str): | |
| return UStr.str_from_wcs(obj) | |
| elif isinstance(obj, POINTER(UChar)): | |
| return obj | |
| elif isinstance(obj, UStrBuffer): | |
| return obj._buf | |
| raise TypeError(f"cannot convert to uchar_p: {obj}") | |
| class UStrBuffer: | |
| def __init__(self, size: int) -> None: | |
| self._buf = (UChar * size)() | |
| def __len__(self) -> int: | |
| return len(self._buf) | |
| def __getitem__(self, subscript: int | slice) -> str: | |
| s = UStr.str_to_wcs(self._buf, len(self._buf)) | |
| return s[subscript] | |
| def create_ustr_buffer(size: int): | |
| return UStrBuffer(size) | |
| UChar = c_uint16 | |
| _UCalendar = c_void_p | |
| UEnumeration = c_void_p | |
| UErrorCode = c_int32 | |
| U_ZERO_ERROR = 0 | |
| U_BUFFER_OVERFLOW_ERROR = 15 | |
| U_MAX_VERSION_LENGTH = 4 | |
| U_MAX_VERSION_STRING_LENGTH = 20 | |
| UCalendarDateFields = c_int32 | |
| UCAL_YEAR = 1 | |
| UCAL_MONTH = 2 | |
| UCAL_DAY_OF_MONTH = 5 | |
| UCAL_HOUR_OF_DAY = 11 | |
| UCAL_MINUTE = 12 | |
| UCAL_SECOND = 13 | |
| UCAL_ZONE_OFFSET = 15 | |
| UCAL_DST_OFFSET = 16 | |
| UCalendarAttribute = c_int32 | |
| UCAL_REPEATED_WALL_TIME = 3 | |
| UCAL_SKIPPED_WALL_TIME = 4 | |
| UCalendarWallTimeOption = c_int32 | |
| UCAL_WALLTIME_LAST = 0 | |
| UCAL_WALLTIME_FIRST = 1 | |
| UCAL_WALLTIME_NEXT_VALID = 2 | |
| UCalendarType = c_int32 | |
| UCAL_GREGORIAN = 1 | |
| ULocDataLocaleType = c_int32 | |
| ULOC_ACTUAL_LOCALE = 0 | |
| ULOC_VALID_LOCALE = 1 | |
| u_errorName = icuuc.u_errorName | |
| u_errorName.restype = c_char_p | |
| u_errorName.argtypes = [ | |
| UErrorCode # code | |
| ] | |
| u_getVersion = icuuc.u_getVersion | |
| u_getVersion.restype = None | |
| u_getVersion.argtypes = [ | |
| POINTER(c_int8), # versionArray | |
| ] | |
| u_versionToString = icuuc.u_versionToString | |
| u_versionToString.restype = None | |
| u_versionToString.argtypes = [ | |
| POINTER(c_int8), # versionArray | |
| c_char_p, # versionString | |
| ] | |
| ucal_getTZDataVersion = icuin.ucal_getTZDataVersion | |
| ucal_getTZDataVersion.restype = c_char_p | |
| ucal_getTZDataVersion.argtypes = [ | |
| POINTER(UErrorCode), # stauts | |
| ] | |
| ucal_open = icuin.ucal_open | |
| ucal_open.restype = _UCalendar | |
| ucal_open.argtypes = [ | |
| uchar_p, # zoneID | |
| c_int32, # len | |
| c_char_p, # locale | |
| UCalendarType, # type | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_close = icuin.ucal_close | |
| ucal_close.restype = None | |
| ucal_close.argtypes = [ | |
| _UCalendar # cal | |
| ] | |
| ucal_get = icuin.ucal_get | |
| ucal_get.restype = c_int32 | |
| ucal_get.argtypes = [ | |
| _UCalendar, # cal | |
| UCalendarDateFields, # field | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_getAttribute = icuin.ucal_getAttribute | |
| ucal_getAttribute.restype = c_int32 | |
| ucal_getAttribute.argtypes = [ | |
| _UCalendar, # cal | |
| UCalendarAttribute, # attr | |
| ] | |
| ucal_setAttribute = icuin.ucal_setAttribute | |
| ucal_setAttribute.restype = None | |
| ucal_setAttribute.argtypes = [ | |
| _UCalendar, # cal | |
| UCalendarAttribute, # attr, | |
| c_int32, # newValue | |
| ] | |
| ucal_inDaylightTime = icuin.ucal_inDaylightTime | |
| ucal_inDaylightTime.restype = c_int8 | |
| ucal_inDaylightTime.argtypes = [ | |
| _UCalendar, # cal | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_setDateTime = icuin.ucal_setDateTime | |
| ucal_setDateTime.restype = None | |
| ucal_setDateTime.argtypes = [ | |
| _UCalendar, # cal | |
| c_int32, # year | |
| c_int32, # month | |
| c_int32, # date | |
| c_int32, # hour | |
| c_int32, # minute | |
| c_int32, # second | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_setMillis = icuin.ucal_setMillis | |
| ucal_setMillis.restype = None | |
| ucal_setMillis.argtypes = [ | |
| _UCalendar, # cal | |
| c_double, # dateTime | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_getTimeZoneID = icuin.ucal_getTimeZoneID | |
| ucal_getTimeZoneID.restype = c_int32 | |
| ucal_getTimeZoneID.argtypes = [ | |
| _UCalendar, # cal | |
| uchar_p, # result | |
| c_int32, # resultLength | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_setTimeZone = icuin.ucal_setTimeZone | |
| ucal_setTimeZone.restype = None | |
| ucal_setTimeZone.argtypes = [ | |
| _UCalendar, # cal | |
| uchar_p, # zoneID | |
| c_int32, # len | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_getLocaleByType = icuin.ucal_getLocaleByType | |
| ucal_getLocaleByType.restype = c_char_p | |
| ucal_getLocaleByType.argtypes = [ | |
| _UCalendar, # cal | |
| ULocDataLocaleType, # type | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_getDefaultTimeZone = icuin.ucal_getDefaultTimeZone | |
| ucal_getDefaultTimeZone.restype = c_int32 | |
| ucal_getDefaultTimeZone.argtypes = [ | |
| uchar_p, # result | |
| c_int32, # resultCapacity | |
| POINTER(UErrorCode), # ec | |
| ] | |
| ucal_getTimeZoneIDForWindowsID = icuin.ucal_getTimeZoneIDForWindowsID | |
| ucal_getTimeZoneIDForWindowsID.restype = c_int32 | |
| ucal_getTimeZoneIDForWindowsID.argtypes = [ | |
| uchar_p, # winid | |
| c_int32, # len | |
| c_char_p, # region | |
| uchar_p, # id | |
| c_int32, # idCapacity | |
| POINTER(UErrorCode), # status | |
| ] | |
| ucal_openTimeZones = icuin.ucal_openTimeZones | |
| ucal_openTimeZones.restype = UEnumeration | |
| ucal_openTimeZones.argtypes = [ | |
| POINTER(UErrorCode), # ec | |
| ] | |
| uenum_close = icuuc.uenum_close | |
| uenum_close.restype = None | |
| uenum_close.argtypes = [ | |
| UEnumeration, # en | |
| ] | |
| uenum_next = icuuc.uenum_next | |
| uenum_next.restype = c_char_p | |
| uenum_next.argtypes = [ | |
| UEnumeration, # en | |
| POINTER(c_int32), # resultLength | |
| POINTER(UErrorCode), # status | |
| ] | |
| uloc_getCountry = icuuc.uloc_getCountry | |
| uloc_getCountry.restype = c_int32 | |
| uloc_getCountry.argtypes = [ | |
| c_char_p, # localeID | |
| c_char_p, # country | |
| c_int32, # countryCapacity | |
| POINTER(UErrorCode), # err | |
| ] | |
| uloc_getDefault = icuuc.uloc_getDefault | |
| uloc_getDefault.restype = c_char_p | |
| uloc_getDefault.argtypes = [] | |
| u_strlen = icuuc.u_strlen | |
| u_strlen.restype = c_int32 | |
| u_strlen.argtypes = [ | |
| POINTER(UChar), # s | |
| ] | |
| u_strToWCS = icuuc.u_strToWCS | |
| u_strToWCS.restype = c_intptr_t # c_wchar_p | |
| u_strToWCS.argtypes = [ | |
| c_wchar_p, # dest | |
| c_int32, # destCapacity | |
| POINTER(c_int32), # pDestLength | |
| POINTER(UChar), # src | |
| c_int32, # srcLength | |
| POINTER(UErrorCode), # pErrorCode | |
| ] | |
| u_strFromWCS = icuuc.u_strFromWCS | |
| u_strFromWCS.restype = c_intptr_t # POINTER(UChar) | |
| u_strFromWCS.argtypes = [ | |
| POINTER(UChar), # dest | |
| c_int32, # destCapacity | |
| POINTER(c_int32), # pDestLength | |
| c_wchar_p, # src | |
| c_int32, # srcLength | |
| POINTER(UErrorCode), # pErrorCode | |
| ] | |
| UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) | |
| def U_FAILURE(code: UErrorCode): | |
| return code.value > U_ZERO_ERROR | |
| class IcuError(Exception): | |
| def __init__(self, status: UErrorCode) -> None: | |
| super().__init__(u_errorName(status).decode("utf-8")) | |
| class UStr: | |
| @staticmethod | |
| def strlen(s: POINTER(UChar)) -> int: | |
| return u_strlen(s) | |
| @staticmethod | |
| def str_to_wcs(src: POINTER(UChar), srclen: int = -1) -> str: | |
| if srclen == -1: | |
| srclen = UStr.strlen(src) | |
| status = UErrorCode(U_ZERO_ERROR) | |
| dstlen = c_int32() | |
| u_strToWCS(None, 0, byref(dstlen), src, srclen, byref(status)) | |
| if status.value == U_BUFFER_OVERFLOW_ERROR: | |
| pass | |
| elif U_FAILURE(status): | |
| raise IcuError(status) | |
| status = UErrorCode(U_ZERO_ERROR) | |
| dst = create_unicode_buffer(dstlen.value) | |
| u_strToWCS(dst, len(dst), None, src, srclen, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return dst[:] | |
| @staticmethod | |
| def str_from_wcs(src: str) -> POINTER(UChar): | |
| status = UErrorCode(U_ZERO_ERROR) | |
| dstlen = c_int32() | |
| u_strFromWCS(None, 0, byref(dstlen), src, len(src), byref(status)) | |
| if status.value == U_BUFFER_OVERFLOW_ERROR: | |
| pass | |
| elif U_FAILURE(status): | |
| raise IcuError(status) | |
| status = UErrorCode(U_ZERO_ERROR) | |
| dst = (UChar * (dstlen.value + 1))() # ensure NUL terminated | |
| u_strFromWCS(dst, len(dst), None, src, len(src), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return cast(dst, POINTER(UChar)) | |
| class UCal: | |
| def __init__(self, zoneid: str | None = None, locale: str | None = None) -> None: | |
| self._cal = None | |
| self.open(zoneid, locale) | |
| def __del__(self) -> None: | |
| self.close() | |
| def __enter__(self) -> Self: | |
| return self | |
| def __exit__(self, exc_type, exc_value, traceback) -> None: | |
| self.close() | |
| def open(self, zoneid: str | None = None, locale: str | None = None) -> None: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| zoneid_len = 0 if zoneid is None else len(zoneid) | |
| locale_bytes = None if locale is None else locale.encode("utf-8") | |
| cal = ucal_open(zoneid, zoneid_len, locale_bytes, UCAL_GREGORIAN, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| self._cal = cal | |
| def close(self) -> None: | |
| if self._cal is not None: | |
| ucal_close(self._cal) | |
| self._cal = None | |
| def get_attribute(self, attr: UCalendarAttribute) -> int: | |
| return ucal_getAttribute(self._cal, attr) | |
| def set_attribute(self, attr: UCalendarAttribute, new_value: int) -> None: | |
| ucal_setAttribute(self._cal, attr, new_value) | |
| def get_time_zone_id(self) -> str: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| buf = create_ustr_buffer(128) | |
| buflen = ucal_getTimeZoneID(self._cal, buf, len(buf), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return buf[:buflen] | |
| def set_time_zone(self, zoneid) -> None: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| ucal_setTimeZone(self._cal, zoneid, len(zoneid), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| def set_date_time(self, year: int, month: int, day: int, hour: int, minute: int, second: int) -> None: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| # NOTE: icu's month is 0 based. UCAL_JANUARY==0, ..., UCAL_DECEMBER==11 | |
| ucal_setDateTime(self._cal, year, month - 1, day, hour, minute, second, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| def get_date_time(self) -> tuple[int, int, int, int, int, int]: | |
| return ( | |
| self.get(UCAL_YEAR), | |
| self.get(UCAL_MONTH) + 1, | |
| self.get(UCAL_DAY_OF_MONTH), | |
| self.get(UCAL_HOUR_OF_DAY), | |
| self.get(UCAL_MINUTE), | |
| self.get(UCAL_SECOND), | |
| ) | |
| def set_millis(self, millis: float) -> None: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| ucal_setMillis(self._cal, millis, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| def in_daylight_time(self) -> bool: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| in_daylight_time = ucal_inDaylightTime(self._cal, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return bool(in_daylight_time) | |
| def get(self, field: UCalendarDateFields) -> int: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| r = ucal_get(self._cal, field, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return r | |
| def get_locale_by_type(self, type: ULocDataLocaleType) -> str: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| locale = ucal_getLocaleByType(self._cal, type, byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return locale.decode("utf-8") | |
| @staticmethod | |
| def get_tzdata_version() -> str: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| version = ucal_getTZDataVersion(byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return version.decode("utf-8") | |
| @staticmethod | |
| def get_default_time_zone() -> str: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| buf = create_ustr_buffer(128) | |
| buflen = ucal_getDefaultTimeZone(buf, len(buf), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return buf[:buflen] | |
| # winid: windows timezone id (e.g. Eastern Standard Time) | |
| # region: (e.g. US, CA) (it seems that default timezone is selected for unknown value) | |
| @staticmethod | |
| def get_time_zone_id_for_windows_id(winid: str, region: str | None = None) -> str: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| region_bytes = None if region is None else region.encode("utf-8") | |
| buf = create_ustr_buffer(128) | |
| buflen = ucal_getTimeZoneIDForWindowsID(winid, len(winid), region_bytes, buf, len(buf), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return buf[:buflen] | |
| @staticmethod | |
| def available_timezones() -> set[str]: | |
| status = UErrorCode(U_ZERO_ERROR) | |
| it = ucal_openTimeZones(byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| try: | |
| zones = set() | |
| while True: | |
| reslen = c_int32() | |
| res = uenum_next(it, byref(reslen), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| if res is None: | |
| break | |
| zones.add(res.decode("utf-8")) | |
| return zones | |
| finally: | |
| uenum_close(it) | |
| def icu_version() -> str: | |
| version_array = (c_int8 * U_MAX_VERSION_LENGTH)() | |
| u_getVersion(version_array) | |
| buf = create_string_buffer(U_MAX_VERSION_STRING_LENGTH) | |
| u_versionToString(version_array, buf) | |
| return buf.value.decode("utf-8") | |
| def tzdata_version() -> str: | |
| return UCal.get_tzdata_version() | |
| def current_locale() -> str: | |
| return uloc_getDefault().decode("utf-8") | |
| def current_region() -> str: | |
| status = UErrorCode() | |
| buf = create_string_buffer(128) | |
| buflen = uloc_getCountry(uloc_getDefault(), buf, len(buf), byref(status)) | |
| if U_FAILURE(status): | |
| raise IcuError(status) | |
| return buf[:buflen].decode("utf-8") | |
| def current_timezone() -> str: | |
| return UCal.get_default_time_zone() | |
| def available_timezones() -> set[str]: | |
| return UCal.available_timezones() | |
| class IcuZoneInfo(tzinfo): | |
| def __init__(self, key: str) -> None: | |
| if key not in available_timezones(): | |
| raise KeyError('time zone not found: "{key}"') | |
| self._key = key | |
| def utcoffset(self, dt: datetime | None) -> timedelta | None: | |
| if dt is None: | |
| return None | |
| with UCal(self._key) as cal: | |
| cal.set_attribute(UCAL_SKIPPED_WALL_TIME, [UCAL_WALLTIME_FIRST, UCAL_WALLTIME_LAST][dt.fold]) | |
| cal.set_attribute(UCAL_REPEATED_WALL_TIME, [UCAL_WALLTIME_FIRST, UCAL_WALLTIME_LAST][dt.fold]) | |
| cal.set_date_time(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) | |
| return timedelta(milliseconds=cal.get(UCAL_ZONE_OFFSET) + cal.get(UCAL_DST_OFFSET)) | |
| def dst(self, dt: datetime) -> timedelta | None: | |
| if dt is None: | |
| return None | |
| with UCal(self._key) as cal: | |
| cal.set_attribute(UCAL_SKIPPED_WALL_TIME, [UCAL_WALLTIME_FIRST, UCAL_WALLTIME_LAST][dt.fold]) | |
| cal.set_attribute(UCAL_REPEATED_WALL_TIME, [UCAL_WALLTIME_FIRST, UCAL_WALLTIME_LAST][dt.fold]) | |
| cal.set_date_time(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) | |
| return timedelta(milliseconds=cal.get(UCAL_DST_OFFSET)) | |
| def tzname(self, dt: datetime | None) -> str | None: | |
| if dt is None: | |
| return None | |
| return self._key | |
| def fromutc(self, dt: datetime) -> datetime | None: | |
| if not isinstance(dt, datetime): | |
| raise TypeError("fromutc: argument must be a datetime") | |
| if dt.tzinfo is not self: | |
| raise ValueError("fromutc: tz.tzinfo is not self") | |
| utc = dt.replace(tzinfo=timezone.utc) | |
| with UCal(self._key) as cal: | |
| cal.set_millis((utc - UNIX_EPOCH) // timedelta(milliseconds=1)) | |
| offset = timedelta(milliseconds=cal.get(UCAL_ZONE_OFFSET) + cal.get(UCAL_DST_OFFSET)) | |
| local = dt + offset | |
| cal.set_attribute(UCAL_REPEATED_WALL_TIME, UCAL_WALLTIME_FIRST) | |
| cal.set_date_time(local.year, local.month, local.day, local.hour, local.minute, local.second) | |
| first_offset = timedelta(milliseconds=cal.get(UCAL_ZONE_OFFSET) + cal.get(UCAL_DST_OFFSET)) | |
| local = local.replace(fold=0 if offset == first_offset else 1) | |
| return local | |
| def __str__(self) -> str: | |
| return f"IcuZoneInfo(key='{self._key}')" | |
| def __repr__(self) -> str: | |
| return str(self) | |
| class TestUcal(unittest.TestCase): | |
| def test_get_time_zone_id_for_windows_id(self): | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Tokyo Standard Time", ""), "Asia/Tokyo") | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Tokyo Standard Time", None), "Asia/Tokyo") | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Eastern Standard Time", ""), "America/New_York") | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Eastern Standard Time", None), "America/New_York") | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Eastern Standard Time", "US"), "America/New_York") | |
| self.assertEqual(UCal.get_time_zone_id_for_windows_id("Eastern Standard Time", "CA"), "America/Toronto") | |
| def test_indaylight_eastern(self): | |
| with UCal("America/New_York") as cal: | |
| cal.set_date_time(2006, 3, 31, 0, 0, 0) | |
| self.assertFalse(cal.in_daylight_time()) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -18000000) # UTC-5 | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 0) | |
| cal.set_date_time(2007, 3, 31, 0, 0, 0) | |
| self.assertTrue(cal.in_daylight_time()) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -18000000) # UTC-5 | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 3600000) # 1 hour | |
| def test_indaylight_turkey(self): | |
| with UCal("Europe/Istanbul") as cal: | |
| cal.set_date_time(2016, 1, 1, 0, 0, 0) | |
| self.assertFalse(cal.in_daylight_time()) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), 7200000) # UTC+2 | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 0) | |
| cal.set_date_time(2016, 4, 1, 0, 0, 0) | |
| self.assertFalse(cal.in_daylight_time()) # false? | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), 7200000) # UTC+2 | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 3600000) # 1 hour | |
| def test_zoneinfo(self): | |
| tz = IcuZoneInfo("America/New_York") | |
| self.assertEqual(datetime(2006, 3, 31, tzinfo=tz), datetime(2006, 3, 31, 5, tzinfo=timezone.utc)) | |
| self.assertEqual(datetime(2006, 3, 31, tzinfo=tz).utcoffset(), timedelta(hours=-5)) | |
| self.assertEqual(datetime(2006, 3, 31, tzinfo=tz).dst(), timedelta(0)) | |
| self.assertEqual(datetime(2007, 3, 31, tzinfo=tz), datetime(2007, 3, 31, 4, tzinfo=timezone.utc)) | |
| self.assertEqual(datetime(2007, 3, 31, tzinfo=tz).utcoffset(), timedelta(hours=-4)) | |
| self.assertEqual(datetime(2007, 3, 31, tzinfo=tz).dst(), timedelta(hours=1)) | |
| self.assertEqual( | |
| datetime(2026, 2, 2, 12, tzinfo=timezone.utc).astimezone(tz), datetime(2026, 2, 2, 7, tzinfo=tz) | |
| ) | |
| def test_default_time_zone(self): | |
| self.assertNotEqual(UCal.get_default_time_zone(), "") | |
| def test_available_timezones(self): | |
| self.assertNotEqual(set(UCal.available_timezones()), []) | |
| def test_get_time_zone_id(self): | |
| with UCal("Asia/Tokyo") as cal: | |
| self.assertEqual(cal.get_time_zone_id(), "Asia/Tokyo") | |
| with UCal() as cal: | |
| self.assertEqual(cal.get_time_zone_id(), UCal.get_default_time_zone()) | |
| def test_get_locale_by_type(self): | |
| with UCal("Asia/Tokyo", "ja_JP") as cal: | |
| self.assertEqual(cal.get_locale_by_type(ULOC_ACTUAL_LOCALE), "ja") | |
| with UCal("Asia/Tokyo", "ja") as cal: | |
| self.assertEqual(cal.get_locale_by_type(ULOC_ACTUAL_LOCALE), "ja") | |
| with UCal("Asia/Tokyo", "ja_JP") as cal: | |
| self.assertEqual(cal.get_locale_by_type(ULOC_VALID_LOCALE), "ja_JP") | |
| with UCal("Asia/Tokyo", "ja") as cal: | |
| self.assertEqual(cal.get_locale_by_type(ULOC_VALID_LOCALE), "ja_JP") | |
| def test_ambiguous(self): | |
| # When DST start, 2:00-3:00 is skipped | |
| with UCal("America/New_York") as cal: | |
| self.assertEqual(cal.get_attribute(UCAL_SKIPPED_WALL_TIME), UCAL_WALLTIME_LAST) # default | |
| cal.set_date_time(2026, 3, 8, 2, 30, 0) | |
| self.assertEqual(cal.get_date_time(), (2026, 3, 8, 3, 30, 0)) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -5 * 60 * 60 * 1000) | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 1 * 60 * 60 * 1000) | |
| cal.set_attribute(UCAL_SKIPPED_WALL_TIME, UCAL_WALLTIME_FIRST) | |
| cal.set_date_time(2026, 3, 8, 2, 30, 0) | |
| self.assertEqual(cal.get_date_time(), (2026, 3, 8, 1, 30, 0)) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -5 * 60 * 60 * 1000) | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 0) | |
| cal.set_attribute(UCAL_SKIPPED_WALL_TIME, UCAL_WALLTIME_NEXT_VALID) | |
| cal.set_date_time(2026, 3, 8, 2, 30, 0) | |
| self.assertEqual(cal.get_date_time(), (2026, 3, 8, 3, 0, 0)) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -5 * 60 * 60 * 1000) | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 1 * 60 * 60 * 1000) | |
| # When DST end, 1:00-2:00 is repeated | |
| with UCal("America/New_York") as cal: | |
| self.assertEqual(cal.get_attribute(UCAL_REPEATED_WALL_TIME), UCAL_WALLTIME_LAST) # default | |
| cal.set_date_time(2026, 11, 1, 1, 30, 0) | |
| self.assertEqual(cal.get_date_time(), (2026, 11, 1, 1, 30, 0)) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -5 * 60 * 60 * 1000) | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 0) | |
| cal.set_attribute(UCAL_REPEATED_WALL_TIME, UCAL_WALLTIME_FIRST) | |
| cal.set_date_time(2026, 11, 1, 1, 30, 0) | |
| self.assertEqual(cal.get_date_time(), (2026, 11, 1, 1, 30, 0)) | |
| self.assertEqual(cal.get(UCAL_ZONE_OFFSET), -5 * 60 * 60 * 1000) | |
| self.assertEqual(cal.get(UCAL_DST_OFFSET), 1 * 60 * 60 * 1000) | |
| def test_fold(self): | |
| tz = IcuZoneInfo("America/New_York") | |
| self.assertEqual(datetime(2026, 11, 1, 1, 30, 0, tzinfo=tz, fold=0).utcoffset(), timedelta(hours=-4)) | |
| self.assertEqual(datetime(2026, 11, 1, 1, 30, 0, tzinfo=tz, fold=1).utcoffset(), timedelta(hours=-5)) | |
| self.assertEqual( | |
| datetime(2026, 11, 1, 1, 30, 0, tzinfo=tz, fold=0), | |
| datetime(2026, 11, 1, 5, 30, 0, tzinfo=timezone.utc).astimezone(tz), | |
| ) | |
| self.assertEqual(datetime(2026, 11, 1, 5, 30, 0, tzinfo=timezone.utc).astimezone(tz).fold, 0) | |
| self.assertEqual( | |
| datetime(2026, 11, 1, 1, 30, 0, tzinfo=tz, fold=1), | |
| datetime(2026, 11, 1, 6, 30, 0, tzinfo=timezone.utc).astimezone(tz), | |
| ) | |
| self.assertEqual(datetime(2026, 11, 1, 6, 30, 0, tzinfo=timezone.utc).astimezone(tz).fold, 1) | |
| self.assertEqual(datetime(2026, 12, 1, 0, 0, 0, tzinfo=timezone.utc).astimezone(tz).fold, 0) | |
| self.assertEqual(datetime(2026, 3, 8, 2, 30, 0, tzinfo=tz, fold=0).utcoffset(), timedelta(hours=-5)) | |
| self.assertEqual(datetime(2026, 3, 8, 2, 30, 0, tzinfo=tz, fold=1).utcoffset(), timedelta(hours=-4)) | |
| def test_current_timezone(self): | |
| self.assertTrue(current_timezone() in available_timezones()) | |
| def test_current_locale(self): | |
| locale = current_locale() | |
| self.assertIsInstance(locale, str) | |
| self.assertNotEqual(locale, "") | |
| def test_current_region(self): | |
| region = current_region() | |
| self.assertIsInstance(region, str) | |
| self.assertNotEqual(region, "") | |
| def test_icuzoneinfo_raises_error_for_invalid_timezone(self): | |
| with self.assertRaises(KeyError): | |
| IcuZoneInfo("invalid name") | |
| def test_ucal_tz_data_version(self): | |
| version = tzdata_version() | |
| self.assertIsInstance(version, str) | |
| self.assertNotEqual(version, "") | |
| if __name__ == "__main__": | |
| unittest.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment