From 3158fb8907df0e65f60a494675ffcf2be3ad587a Mon Sep 17 00:00:00 2001 From: Fares Rafat <214850337+faresrafat3@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:17:13 +0200 Subject: [PATCH] fix(tz): default to UTC when /etc/localtime is a bare zoneinfo copy _get_unix_timezone returned a Timezone with key=None when /etc/localtime was a regular file (Slurm/minimal containers). pendulum.now() then crashed in the Rust precise_diff helper, which casts the key to str. Fall through to the existing UTC default when from_file yields a keyless Timezone. Pass the file path as the key in _tz_from_env so that path never produces a keyless Timezone. Closes #989. --- src/pendulum/tz/local_timezone.py | 6 ++++-- tests/tz/test_local_timezone.py | 24 ++++++++++++++++++++++++ tests/tz/test_timezone.py | 5 ++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/pendulum/tz/local_timezone.py b/src/pendulum/tz/local_timezone.py index 9418b05d5..5549a7e32 100644 --- a/src/pendulum/tz/local_timezone.py +++ b/src/pendulum/tz/local_timezone.py @@ -215,7 +215,9 @@ def _get_unix_timezone(_root: str = "/") -> Timezone: tzpath = Path(_root) / filename if tzpath.is_file(): with tzpath.open("rb") as f: - return Timezone.from_file(f) + tz = Timezone.from_file(f) + if tz.key is not None: + return tz warnings.warn( "Unable not find any timezone configuration, defaulting to UTC.", stacklevel=1 @@ -231,7 +233,7 @@ def _tz_from_env(tzenv: str) -> Timezone: # TZ specifies a file if os.path.isfile(tzenv): with open(tzenv, "rb") as f: - return Timezone.from_file(f) + return Timezone.from_file(f, key=tzenv) # TZ specifies a zoneinfo zone. try: diff --git a/tests/tz/test_local_timezone.py b/tests/tz/test_local_timezone.py index b8eff6de6..09b22a7c4 100644 --- a/tests/tz/test_local_timezone.py +++ b/tests/tz/test_local_timezone.py @@ -50,3 +50,27 @@ def test_unix_etc_timezone_dir(): tz = _get_unix_timezone(_root=root_path) assert tz.name == "Europe/Paris" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Test only available for UNIX systems" +) +def test_unix_localtime_regular_file_defaults_to_utc(): + # /etc/localtime as a bare copy of a zoneinfo file (no IANA key), as + # found in minimal containers / Slurm jobs, used to yield a Timezone + # with key=None, which crashed the Rust helpers (#989). It must now + # fall through to the UTC default with a named key. + import shutil + import tempfile + + local_path = os.path.join(os.path.split(__file__)[0], "..") + root_path = tempfile.mkdtemp() + os.makedirs(os.path.join(root_path, "etc"), exist_ok=True) + shutil.copy( + os.path.join(local_path, "fixtures", "tz", "Paris"), + os.path.join(root_path, "etc", "localtime"), + ) + tz = _get_unix_timezone(_root=root_path) + + assert tz.key is not None + assert tz.name == "UTC" diff --git a/tests/tz/test_timezone.py b/tests/tz/test_timezone.py index 6ce475d36..a8f88b723 100644 --- a/tests/tz/test_timezone.py +++ b/tests/tz/test_timezone.py @@ -487,7 +487,10 @@ def _paris_tzif_bytes() -> bytes: def test_from_file_without_a_key_can_be_pickled(): - # get_local_timezone()'s last-resort fallback builds a Timezone this same way, with no key. + # Timezone.from_file without a key yields a keyless Timezone (key is + # None); this is still supported for pickling / utcoffset (#989). The + # local-timezone resolver no longer returns such a value, defaulting + # to UTC instead (see test_unix_localtime_regular_file_defaults_to_utc). from io import BytesIO import pendulum.tz.timezone as timezone_module