From 5d5d9a194ebae70e3c6c224d3674f3fd07bc7221 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Mon, 20 Jul 2026 19:45:32 +0200 Subject: [PATCH] fix: normalize non-standard fractional-second precision before parsing timestamps (#2221) datetime.fromisoformat() only reliably accepts fractional-second components of exactly 0, 3, or 6 digits on Python versions before 3.11 (the widths datetime.isoformat() itself produces); any other width, such as the nanosecond-precision (9-digit) timestamps some environments emit, raises ValueError there but parses fine on 3.11+. Since elementary supports Python >=3.10, this made convert_partial_iso_format_to_full_iso_format() silently fall back to returning the raw, unnormalized timestamp string whenever the interpreter running elementary happened to be on the strict side of that version split, which downstream code (e.g. report generation) can't handle consistently. Add _normalize_fractional_seconds() to elementary/utils/time.py, coercing the fractional-seconds component to exactly 6 digits before calling datetime.fromisoformat(), so parsing no longer depends on which Python version is running. Co-Authored-By: Claude Sonnet 5 --- elementary/utils/time.py | 18 +++++++++ tests/unit/utils/test_time.py | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/elementary/utils/time.py b/elementary/utils/time.py index 7b861aa1d..f2f1b7007 100644 --- a/elementary/utils/time.py +++ b/elementary/utils/time.py @@ -91,15 +91,33 @@ def datetime_strftime(datetime: datetime, include_timezone: bool = False) -> str _ABBREVIATED_TZ_OFFSET_PATTERN = re.compile(r"(:\d{2}(?:\.\d+)?)([+-])(\d{2})$") +_FRACTIONAL_SECONDS_PATTERN = re.compile(r"(\d{2}\.)(\d+)") def _normalize_timezone_offset(time_string: str) -> str: return _ABBREVIATED_TZ_OFFSET_PATTERN.sub(r"\1\2\3:00", time_string) +def _normalize_fractional_seconds(time_string: str) -> str: + # datetime.fromisoformat() only guarantees support for the fractional-second + # widths that datetime.isoformat() itself produces: none, 3 digits (ms), or + # 6 digits (us). On Python versions before 3.11, any other width (e.g. the + # nanosecond precision some environments emit) raises ValueError instead of + # being parsed. Rather than depending on that platform/version-specific + # leniency, always coerce the fractional part to exactly 6 digits + # (microseconds) ourselves before parsing, truncating extra precision or + # right-padding a shorter one. + def _pad_or_truncate(match: "re.Match[str]") -> str: + prefix, fractional = match.group(1), match.group(2) + return prefix + (fractional + "000000")[:6] + + return _FRACTIONAL_SECONDS_PATTERN.sub(_pad_or_truncate, time_string, count=1) + + def convert_partial_iso_format_to_full_iso_format(partial_iso_format_time: str) -> str: try: normalized = _normalize_timezone_offset(partial_iso_format_time) + normalized = _normalize_fractional_seconds(normalized) date = datetime.fromisoformat(normalized) time_zone_name = date.strftime("%Z") time_zone = tz.gettz(time_zone_name) if time_zone_name else tz.UTC diff --git a/tests/unit/utils/test_time.py b/tests/unit/utils/test_time.py index 37499b357..edd61482c 100644 --- a/tests/unit/utils/test_time.py +++ b/tests/unit/utils/test_time.py @@ -1,4 +1,6 @@ +import re from datetime import datetime +from unittest.mock import patch import pytest from dateutil import tz @@ -115,3 +117,73 @@ def test_convert_partial_iso_format_to_full_iso_format( input_time: str, expected_output: str ) -> None: assert convert_partial_iso_format_to_full_iso_format(input_time) == expected_output + + +class _StrictLegacyDatetime(datetime): + """ + A ``datetime`` subclass whose ``fromisoformat`` reproduces CPython's + pre-3.11 parser: it only accepts a fractional-seconds component of + exactly 0, 3 or 6 digits (the only widths ``datetime.isoformat()`` + itself ever produces) and raises ``ValueError`` for anything else, such + as the 9-digit/nanosecond-precision timestamps reported in + https://github.com/elementary-data/elementary/issues/2221. + + Python 3.11 relaxed ``fromisoformat`` to tolerate (and truncate) any + number of fractional digits, so the very same input that fails on 3.9/3.10 + parses silently on 3.11+. That made the bug look "platform dependent" + (it showed up in a Docker image pinned to an older Python while the + reporter's local machine happened to run a newer one) when it is really a + Python-version-dependent parsing difference. Subclassing here lets the + test force that legacy behavior deterministically, regardless of which + Python actually runs the test suite. + """ + + @classmethod + def fromisoformat(cls, date_string): + match = re.search(r"\.(\d+)", date_string) + if match and len(match.group(1)) not in (3, 6): + raise ValueError(f"Invalid isoformat string: {date_string!r}") + return datetime.fromisoformat(date_string) + + +@pytest.mark.parametrize( + "input_time, expected_output", + [ + pytest.param( + "2026-04-27T14:34:33.609083964Z", + "2026-04-27T14:34:33+00:00", + id="nanosecond_precision_utc_z_suffix", + ), + pytest.param( + "2026-04-27T14:34:33.609083964+00:00", + "2026-04-27T14:34:33+00:00", + id="nanosecond_precision_full_offset", + ), + pytest.param( + "2026-04-27T14:34:33.6+00:00", + "2026-04-27T14:34:33+00:00", + id="single_digit_fraction", + ), + pytest.param( + "2026-04-27T14:34:33.60908+00:00", + "2026-04-27T14:34:33+00:00", + id="five_digit_fraction", + ), + ], +) +def test_convert_partial_iso_format_to_full_iso_format_handles_any_fractional_precision( + input_time: str, expected_output: str +) -> None: + """ + Reproduces the bug from issue #2221: a timestamp whose fractional-seconds + component isn't exactly 3 or 6 digits (e.g. the nanosecond-precision + values some environments emit) must still normalize correctly, instead of + depending on the interpreter's ``datetime.fromisoformat`` leniency. We + patch the module's ``datetime`` with a subclass that enforces the strict, + pre-3.11 CPython behavior so the test's outcome does not depend on which + Python version happens to run it. + """ + with patch("elementary.utils.time.datetime", _StrictLegacyDatetime): + assert ( + convert_partial_iso_format_to_full_iso_format(input_time) == expected_output + )