Skip to content

Instantly share code, notes, and snippets.

@ynkdir
Last active July 4, 2026 15:46
Show Gist options
  • Select an option

  • Save ynkdir/f7bfd5a0088e3fb3888dffb3b01f9ec9 to your computer and use it in GitHub Desktop.

Select an option

Save ynkdir/f7bfd5a0088e3fb3888dffb3b01f9ec9 to your computer and use it in GitHub Desktop.
ZoneInfo implementation using icu.
# 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