diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bf49b0..cbb46a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Preserve valid serialization for dates and times created through standard-library factory methods. + - 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()`. - Allow `DateTime.astimezone()` to use the local timezone when no timezone argument is supplied, matching `datetime.datetime`. - 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. diff --git a/tests/test_temporal_factories.py b/tests/test_temporal_factories.py new file mode 100644 index 00000000..edf31a6d --- /dev/null +++ b/tests/test_temporal_factories.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys + +from datetime import date +from datetime import datetime +from datetime import time +from typing import Any + +import pytest + +from tomlkit import dumps +from tomlkit import parse +from tomlkit.items import Date +from tomlkit.items import DateTime +from tomlkit.items import Time + + +def assert_temporal_round_trip(value: date | time, expected: date | time) -> None: + rendered = dumps({"value": value}) + if sys.version_info >= (3, 11): + import tomllib + + assert tomllib.loads(rendered) == {"value": expected} + assert parse(rendered).unwrap() == {"value": expected} + + +@pytest.mark.parametrize( + "method, args", + [ + ("fromisoformat", ("2026-11-09",)), + ("fromordinal", (date(2026, 11, 9).toordinal(),)), + ("fromtimestamp", (1720000000,)), + ("fromisocalendar", (2026, 46, 1)), + ], +) +def test_date_factories_serialize(method: str, args: tuple[Any, ...]) -> None: + value = getattr(Date, method)(*args) + expected = getattr(date, method)(*args) + + assert isinstance(value, Date) + assert_temporal_round_trip(value, expected) + + +def test_date_today_serializes() -> None: + before = date.today() + value = Date.today() + after = date.today() + + assert isinstance(value, Date) + assert value.unwrap() in (before, after) + assert_temporal_round_trip(value, value.unwrap()) + + +@pytest.mark.parametrize( + "text", + [ + "00:00:00", + "12:34", + "12:34:56", + "12:34:56.123456", + "12:34:56.000001", + "12:34:56+02:00", + ], +) +def test_time_fromisoformat_serializes(text: str) -> None: + value = Time.fromisoformat(text) + expected = time.fromisoformat(text) + + assert isinstance(value, Time) + assert value.tzinfo == expected.tzinfo + assert_temporal_round_trip(value, expected.replace(tzinfo=None)) + + +@pytest.mark.parametrize( + "source", + [ + "value = 2026-11-09 # date\n", + "value = 12:34:56.120000 # time\n", + "value = 12:34:56.000000 # whole second\n", + ], +) +def test_parsed_temporal_items_keep_original_format(source: str) -> None: + assert dumps(parse(source)) == source + + +def test_datetime_factory_serialization_is_unchanged() -> None: + text = "2026-11-09T12:34:56.123456" + value = DateTime.fromisoformat(text) + + assert isinstance(value, DateTime) + assert_temporal_round_trip(value, datetime.fromisoformat(text)) diff --git a/tomlkit/items.py b/tomlkit/items.py index 51eba39e..8c42c54d 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -1302,7 +1302,7 @@ def __init__( ) -> None: super().__init__(trivia or Trivia()) - self._raw = raw + self._raw = raw or date.isoformat(self) def unwrap(self) -> date: (year, month, day, _, _) = self._getstate() @@ -1387,7 +1387,7 @@ def __init__( ) -> None: super().__init__(trivia or Trivia()) - self._raw = raw + self._raw = raw or _format_time(self) def unwrap(self) -> time: (hour, minute, second, microsecond, tzinfo, _, _) = self._getstate()