diff --git a/HISTORY.md b/HISTORY.md index f16afc17..63db1bfe 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- Fix the `msgpack` and `cbor2` converters unstructuring naive datetimes as local time, which made the serialized value depend on the timezone of the machine doing the unstructuring; naive datetimes are now assumed to be UTC, matching what the structure hooks already read back. + ([#774](https://github.com/python-attrs/cattrs/issues/774) [#775](https://github.com/python-attrs/cattrs/pull/775)) - Fix `override(rename=...)` targets containing a quote (or other characters not safe in a bare string literal) crashing code generation with `SyntaxError`; the rename key is now embedded with `repr`. ([#771](https://github.com/python-attrs/cattrs/pull/771)) - Fix `Counter` keys not being unstructured with the key type's own hook; the single-type-arg branch passed the whole type-args tuple to the key hook lookup instead of the key type. diff --git a/docs/migrations.md b/docs/migrations.md index 799c6a0b..0e084aab 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -6,6 +6,24 @@ _cattrs_ sometimes changes in backwards-incompatible ways. This page contains guidance for changes and workarounds for restoring legacy behavior. +## NEXT + +### Naive datetimes unstructuring as UTC in the _msgpack_ and _cbor2_ converters + +The _msgpack_ and _cbor2_ converters unstructure `datetime` s into UNIX timestamps using `datetime.timestamp`, which reads a naive `datetime` as local time. +The serialized value therefore depended on the timezone of the machine doing the unstructuring, while the structure hooks read timestamps back as UTC. +From this version on, naive `datetime` s are assumed to be UTC when unstructuring, so both ends of the round-trip agree and the payload no longer depends on the host. + +Aware `datetime` s are unaffected. + +The old behavior can be restored by registering `datetime.timestamp` directly on a converter. + +```python +>>> from datetime import datetime + +>>> converter.register_unstructure_hook(datetime, datetime.timestamp) +``` + ## 25.3.0 ### Abstract sets structuring into frozensets diff --git a/docs/preconf.md b/docs/preconf.md index 41370386..86f5e3d7 100644 --- a/docs/preconf.md +++ b/docs/preconf.md @@ -164,6 +164,12 @@ _ujson_ doesn't support integers less than -9223372036854775808, and greater tha Found at {mod}`cattrs.preconf.msgpack`. Sets are serialized as lists, and deserialized back into sets. `datetime` s are serialized as UNIX timestamp float values. `date` s are serialized as midnight-aligned UNIX timestamp float values. +Naive `datetime` s are assumed to be UTC, which is also how timestamps are interpreted when structuring. + +```{versionchanged} NEXT +Naive `datetime` s are assumed to be UTC when unstructuring. +They were previously interpreted as local time, making the serialized value depend on the timezone of the machine doing the unstructuring. +``` _msgpack_ doesn't support integers less than -9223372036854775808, and greater than 18446744073709551615. @@ -182,6 +188,12 @@ Tuples are serialized as lists. `datetime` s are serialized as a text string by default (CBOR Tag 0). Use keyword argument `datetime_as_timestamp=True` to encode as UNIX timestamp integer/float (CBOR Tag 1) **note:** this replaces timezone information as UTC. +Naive `datetime` s are assumed to be UTC, which is also how timestamps are interpreted when structuring. + +```{versionchanged} NEXT +Naive `datetime` s are assumed to be UTC when unstructuring. +They were previously interpreted as local time, making the serialized value depend on the timezone of the machine doing the unstructuring. +``` `date` s are serialized as ISO 8601 strings. diff --git a/src/cattrs/preconf/__init__.py b/src/cattrs/preconf/__init__.py index a15d0ffc..493a8f5f 100644 --- a/src/cattrs/preconf/__init__.py +++ b/src/cattrs/preconf/__init__.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, ParamSpec, TypeVar, get_args @@ -15,6 +15,20 @@ def validate_datetime(v, _): return v +def unstructure_datetime_as_timestamp(v: datetime) -> float: + """Unstructure a datetime into a UNIX timestamp. + + `datetime.timestamp` interprets naive datetimes as local time, which would + make the result depend on the timezone of the machine unstructuring them. + The matching structure hooks read timestamps back as UTC, so naive + datetimes are treated as UTC here too, keeping both ends of the round-trip + in agreement. + """ + if v.tzinfo is None: + v = v.replace(tzinfo=timezone.utc) + return v.timestamp() + + T = TypeVar("T") P = ParamSpec("P") diff --git a/src/cattrs/preconf/cbor2.py b/src/cattrs/preconf/cbor2.py index ad011c86..0eed8e70 100644 --- a/src/cattrs/preconf/cbor2.py +++ b/src/cattrs/preconf/cbor2.py @@ -10,7 +10,12 @@ from ..fns import identity from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough -from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +from . import ( + is_primitive_enum, + literals_with_enums_unstructure_factory, + unstructure_datetime_as_timestamp, + wrap, +) T = TypeVar("T") @@ -31,7 +36,7 @@ def configure_converter(converter: BaseConverter): * sets are serialized as lists * string and int enums are passed through when unstructuring """ - converter.register_unstructure_hook(datetime, lambda v: v.timestamp()) + converter.register_unstructure_hook(datetime, unstructure_datetime_as_timestamp) converter.register_structure_hook( datetime, lambda v, _: datetime.fromtimestamp(v, timezone.utc) ) diff --git a/src/cattrs/preconf/msgpack.py b/src/cattrs/preconf/msgpack.py index b0726da5..c23129d8 100644 --- a/src/cattrs/preconf/msgpack.py +++ b/src/cattrs/preconf/msgpack.py @@ -10,7 +10,12 @@ from ..fns import identity from ..literals import is_literal_containing_enums from ..strategies import configure_union_passthrough -from . import is_primitive_enum, literals_with_enums_unstructure_factory, wrap +from . import ( + is_primitive_enum, + literals_with_enums_unstructure_factory, + unstructure_datetime_as_timestamp, + wrap, +) __all__ = ["MsgpackConverter", "configure_converter", "make_converter"] @@ -36,7 +41,7 @@ def configure_converter(converter: BaseConverter) -> None: .. versionchanged:: 24.2.0 Enums are left to the library to unstructure, speeding them up. """ - converter.register_unstructure_hook(datetime, lambda v: v.timestamp()) + converter.register_unstructure_hook(datetime, unstructure_datetime_as_timestamp) converter.register_structure_hook( datetime, lambda v, _: datetime.fromtimestamp(v, timezone.utc) ) diff --git a/tests/test_preconf.py b/tests/test_preconf.py index 654a5c4e..d258b11b 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -1,5 +1,6 @@ # ruff: noqa: PLC0415 -from collections.abc import Callable, Set +import time +from collections.abc import Callable, Iterator, Set from datetime import date, datetime, timezone from enum import Enum, IntEnum, unique from json import dumps as json_dumps @@ -1036,3 +1037,47 @@ def test_literal_dicts_msgspec(): def test_literal_dicts_tomllib(): """Dicts with keys that aren't subclasses of `type` work.""" test_literal_dicts(tomllib_make_converter) + + +@pytest.fixture +def set_timezone(monkeypatch: pytest.MonkeyPatch) -> Iterator[Callable[[str], None]]: + """Set the process timezone for the duration of a test.""" + + def setter(tz: str) -> None: + monkeypatch.setenv("TZ", tz) + time.tzset() + + yield setter + monkeypatch.undo() + time.tzset() + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset not available") +@pytest.mark.parametrize("converter_factory", [msgpack_make_converter]) +def test_naive_datetimes_are_unstructured_as_utc( + converter_factory: Callable[[], Converter], set_timezone: Callable[[str], None] +): + """Naive datetimes are unstructured as UTC, not as local time. + + `datetime.timestamp` reads a naive datetime as local time, so without an + explicit assumption the value on the wire would depend on the timezone of + the machine doing the unstructuring. The structure hook reads timestamps + back as UTC, so UTC is what the unstructure hook has to assume. + """ + naive = datetime(2026, 8, 25, 12, 30) + expected = naive.replace(tzinfo=timezone.utc).timestamp() + + for tz in ["UTC", "Asia/Tokyo", "America/Toronto"]: + set_timezone(tz) + assert converter_factory().unstructure(naive) == expected + + +@pytest.mark.skipif(NO_CBOR2, reason="cbor2 not available") +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset not available") +def test_naive_datetimes_are_unstructured_as_utc_cbor2( + set_timezone: Callable[[str], None], +): + """Naive datetimes are unstructured as UTC, not as local time.""" + from cattrs.preconf.cbor2 import make_converter as cbor2_make_converter + + test_naive_datetimes_are_unstructured_as_utc(cbor2_make_converter, set_timezone)