From 38afeb3168c2084c32b76f93ccab6b981547062e Mon Sep 17 00:00:00 2001 From: mmporong Date: Tue, 3 Nov 2026 09:11:02 +0900 Subject: [PATCH] Write datetimes with sub-minute UTC offsets as valid TOML isoformat() adds a seconds field for a UTC offset that is not a whole number of minutes (e.g. the LMT offsets zoneinfo gives for historical dates), and the '+00:00' -> 'Z' replacement in item() could also hit the start of such an offset ('Z:30'). TOML offsets are HH:MM, so the output could not be parsed by tomlkit or tomllib. Write such values as the same instant in UTC in item(), DateTime() and DateTime._new(). --- CHANGELOG.md | 1 + tests/test_items.py | 38 ++++++++++++++++++++++++++++++++++++++ tomlkit/items.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b213c935..ad448dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fix `item()` and `dumps()` writing a `datetime` whose UTC offset is not a whole number of minutes (e.g. the historical LMT offsets `zoneinfo` gives, such as `-00:44:30`) as invalid TOML like `1900-01-01T12:00:00Z:30`, which no parser accepts. Such a value is now written as the same instant in UTC; this also covers `DateTime.replace()` and `astimezone()`. - Arithmetic and `replace()` results for `Integer`, `Float`, `String`, `DateTime`, `Date` and `Time` no longer share the original item's `Trivia`, so changing the result's comment or indentation no longer rewrites the item still stored in the document. ## [0.15.2] - 2026-10-16 diff --git a/tests/test_items.py b/tests/test_items.py index 36f4afd2..de8b1799 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -967,6 +967,44 @@ def test_time_with_tzinfo_renders_as_local_time() -> None: assert parse(f"t = {j.as_string()}")["t"] == time(13, 32) +@pytest.mark.parametrize( + "offset", + [ + timedelta(seconds=30), + timedelta(hours=1, seconds=1), + -timedelta(minutes=44, seconds=30), + timedelta(microseconds=1), + ], +) +def test_datetime_with_sub_minute_offset_renders_valid_toml( + offset: timedelta, +) -> None: + # TOML offsets are whole minutes (``HH:MM``), but ``isoformat`` adds + # seconds for other offsets, such as the LMT offsets zoneinfo gives for + # historical dates. Such a value is written as the same instant in UTC. + value = datetime(1900, 1, 1, 12, 0, tzinfo=timezone(offset)) + + i = item(value) + assert parse(f"dt = {i.as_string()}")["dt"] == value + assert parse(api.dumps({"dt": value}))["dt"] == value + + j = item(datetime(2020, 1, 1, tzinfo=timezone.utc)).astimezone(timezone(offset)) + assert parse(f"dt = {j.as_string()}")["dt"] == j + + k = item(datetime(2020, 1, 1)).replace(tzinfo=timezone(offset)) + assert parse(f"dt = {k.as_string()}")["dt"] == k + + +def test_datetime_with_sub_minute_offset_keeps_value_fields() -> None: + value = datetime(1900, 1, 1, 12, 0, tzinfo=timezone(timedelta(seconds=30))) + i = item(value) + + assert i.as_string() == "1900-01-01T11:59:30Z" + assert i == value + assert i.hour == 12 + assert i.tzinfo == timezone(timedelta(seconds=30)) + + def test_strings_behave_like_strs() -> None: i = item("foo") diff --git a/tomlkit/items.py b/tomlkit/items.py index e84a40e4..2abbf466 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -202,7 +202,7 @@ def _sort_key(i: tuple[Any, Any]) -> Any: value.microsecond, value.tzinfo, Trivia(), - value.isoformat().replace("+00:00", "Z"), + _format_datetime(value).replace("+00:00", "Z"), fold=value.fold, ) elif isinstance(value, date): @@ -255,6 +255,30 @@ def _format_time(value: time) -> str: return s +def _format_datetime(value: datetime) -> str: + # TOML offsets are ``HH:MM``, but ``isoformat`` adds seconds for an + # offset that is not a whole number of minutes (e.g. the LMT offsets + # ``zoneinfo`` gives for historical dates), which no TOML parser accepts. + # Such a value is written as the same instant in UTC. The conversion + # uses a plain ``datetime`` so it does not go back through ``_new``. + offset = value.utcoffset() + if offset is None or not offset % timedelta(minutes=1): + return value.isoformat() + utc = ( + datetime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.microsecond, + ) + - offset + ) + return utc.isoformat() + "+00:00" + + # A carriage return that is not the CR of a CRLF line ending. Inside a multiline # string only a raw line feed or a CRLF pair is a valid line ending; a lone CR is # a control character that a basic string must escape. @@ -1107,7 +1131,7 @@ def __init__( ) -> None: super().__init__(trivia or Trivia()) - self._raw = raw or self.isoformat() + self._raw = raw or _format_datetime(self) def unwrap(self) -> datetime: ( @@ -1191,7 +1215,7 @@ def astimezone(self, tz: tzinfo) -> DateTime: # type: ignore[override] return self._new(result) def _new(self, result: datetime) -> DateTime: - raw = result.isoformat() + raw = _format_datetime(result) return DateTime( result.year,