diff --git a/CHANGELOG.md b/CHANGELOG.md index 3737b80e..d7330bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [Unreleased] + +### Fixed + +- Fix `dumps()` and `item()` rendering a `datetime` whose UTC offset is not a whole number of minutes (e.g. the historical LMT offsets `zoneinfo` yields, such as `+00:19:32`) or a tz-aware `time` as invalid TOML that no parser, including tomlkit's own, accepts; both now raise `ValueError` instead. The same check covers `DateTime.astimezone()`/`replace()` and `Time.replace()`, which build a new item from the result. + ## [0.15.1] - 2026-07-17 ### Changed diff --git a/tests/test_items.py b/tests/test_items.py index e331caeb..45009acf 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -20,6 +20,7 @@ from tests.util import assert_is_ppo from tests.util import elementary_test from tomlkit import api +from tomlkit import dumps from tomlkit import parse from tomlkit.container import OutOfOrderTableProxy from tomlkit.exceptions import NonExistentKey @@ -726,6 +727,58 @@ def test_datetimes_behave_like_datetimes(tz_utc: tzinfo, tz_pst: tzinfo) -> None assert doc.as_string() == "dt = 2018-07-23T12:34:56-05:00" +def test_datetime_with_sub_minute_utc_offset_is_rejected() -> None: + # TOML offset date-times are RFC 3339: the offset is always HH:MM, so an + # offset with seconds (e.g. the LMT offsets zoneinfo yields for historical + # dates, Europe/Amsterdam before 1937 is +00:19:32) cannot be written. + # It used to be rendered verbatim, producing a document that no TOML + # parser, including tomlkit's own, would accept. + amsterdam_lmt = timezone(timedelta(minutes=19, seconds=32)) + dt = datetime(1930, 1, 1, 12, 0, tzinfo=amsterdam_lmt) + + with pytest.raises(ValueError, match="whole number of minutes"): + item(dt) + + with pytest.raises(ValueError, match="whole number of minutes"): + dumps({"dt": dt}) + + with pytest.raises(ValueError, match="whole number of minutes"): + item(datetime(2020, 1, 1, tzinfo=timezone(timedelta(microseconds=1)))) + + # The same offsets can be reached from a valid item through the datetime + # API, so those paths must refuse as well instead of rendering garbage. + i = item(datetime(2020, 1, 1, 12, 0, tzinfo=timezone.utc)) + with pytest.raises(ValueError, match="whole number of minutes"): + i.astimezone(amsterdam_lmt) + with pytest.raises(ValueError, match="whole number of minutes"): + i.replace(tzinfo=amsterdam_lmt) + + # Whole-minute offsets keep working exactly as before. + i = item( + datetime(2020, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) + ) + assert i.as_string() == "2020-01-01T12:00:00+05:30" + assert i.astimezone(timezone.utc).as_string() == "2020-01-01T06:30:00+00:00" + + +def test_time_with_utc_offset_is_rejected() -> None: + # TOML local times carry no offset at all; a tz-aware time used to be + # rendered as e.g. `01:02:03+00:00`, which no TOML parser accepts. + t = time(1, 2, 3, tzinfo=timezone.utc) + + with pytest.raises(ValueError, match="cannot have a UTC offset"): + item(t) + + with pytest.raises(ValueError, match="cannot have a UTC offset"): + dumps({"t": t}) + + i = item(time(1, 2, 3)) + with pytest.raises(ValueError, match="cannot have a UTC offset"): + i.replace(tzinfo=timezone.utc) + + assert i.replace(hour=4).as_string() == "04:02:03" + + def test_dates_behave_like_dates() -> None: i = item(date(2018, 7, 22)) diff --git a/tomlkit/items.py b/tomlkit/items.py index 31369b03..36739cef 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -49,6 +49,29 @@ def __call__(self, __value: Any, /) -> Item: ... AT = TypeVar("AT", bound="AbstractTable") +def _check_datetime_offset(value: datetime) -> None: + """Refuse a UTC offset that TOML cannot write. + + TOML offset date-times follow RFC 3339, whose offset is always ``HH:MM``. + Python allows offsets with seconds and microseconds (``zoneinfo`` yields + them for historical dates, e.g. Europe/Amsterdam before 1937 is + ``+00:19:32``), and ``isoformat()`` renders them, producing a document + that no TOML parser accepts. Raise instead of writing an invalid file. + """ + offset = value.utcoffset() + if offset is not None and (offset.seconds % 60 or offset.microseconds): + raise ValueError( + f"TOML cannot represent a UTC offset of {offset}: " + "it must be a whole number of minutes" + ) + + +def _check_time_offset(value: time) -> None: + """Refuse a tz-aware time: TOML local times carry no offset at all.""" + if value.utcoffset() is not None: + raise ValueError("TOML local times cannot have a UTC offset") + + @overload def item(value: bool, _parent: Item | None = ..., _sort_keys: bool = ...) -> Bool: ... # type: ignore[overload-overlap] @@ -1070,6 +1093,7 @@ def __init__( ) -> None: super().__init__(trivia or Trivia()) + _check_datetime_offset(self) self._raw = raw or self.isoformat() def unwrap(self) -> datetime: @@ -1291,6 +1315,7 @@ def __init__( ) -> None: super().__init__(trivia or Trivia()) + _check_time_offset(self) self._raw = raw def unwrap(self) -> time: