Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/test_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
25 changes: 25 additions & 0 deletions tomlkit/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1291,6 +1315,7 @@ def __init__(
) -> None:
super().__init__(trivia or Trivia())

_check_time_offset(self)
self._raw = raw

def unwrap(self) -> time:
Expand Down