Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

### 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()`.
- 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.
- Fix an `AttributeError` when a table is defined out of order under an indented header nested four or more levels deep, e.g. ` [a.b.c.d]` then `[a.b.c]`. ([#663](https://github.com/python-poetry/tomlkit/pull/663))

Expand Down
38 changes: 38 additions & 0 deletions tests/test_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,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")

Expand Down
30 changes: 27 additions & 3 deletions tomlkit/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,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):
Expand Down Expand Up @@ -276,6 +276,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.
Expand Down Expand Up @@ -1136,7 +1160,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:
(
Expand Down Expand Up @@ -1220,7 +1244,7 @@ def astimezone(self, tz: tzinfo | None = None) -> DateTime:
return self._new(result)

def _new(self, result: datetime) -> DateTime:
raw = result.isoformat()
raw = _format_datetime(result)

return DateTime(
result.year,
Expand Down
Loading