From 19d99eba41691fb95bcd84ec18307631c9c2825f Mon Sep 17 00:00:00 2001 From: Onkesh Bansal Date: Tue, 1 Sep 2026 09:13:49 -0400 Subject: [PATCH 1/3] Assume naive datetimes are UTC in the msgpack and cbor2 converters `datetime.timestamp` interprets a naive datetime as local time, so the value these two converters wrote to the wire depended on the timezone of the machine unstructuring it. The same object serialized in two timezones produced two different payloads, and neither round-tripped back to the input. The structure hooks already read timestamps back as UTC, so the unstructure hooks now pin naive datetimes to UTC before converting, which makes the two ends agree and the output machine-independent. This is what the neighbouring `date` hook in the msgpack converter already did. The preconf tests never exercised naive datetimes: the shared strategy pins `timezones=just(timezone.utc)`, and the strategy that would generate them is disabled at every call site. The regression test therefore drives the timezone explicitly, since it would otherwise pass vacuously on a UTC host. --- HISTORY.md | 2 ++ src/cattrs/preconf/__init__.py | 16 +++++++++++- src/cattrs/preconf/cbor2.py | 9 +++++-- src/cattrs/preconf/msgpack.py | 9 +++++-- tests/test_preconf.py | 47 ++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index f16afc17..9f9e2103 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)) - 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/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..5a2ab3ad 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -1,4 +1,6 @@ # ruff: noqa: PLC0415 +import os +import time from collections.abc import Callable, Set from datetime import date, datetime, timezone from enum import Enum, IntEnum, unique @@ -1036,3 +1038,48 @@ 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) + + +def _unstructure_under_tz( + converter_factory: Callable[[], Converter], value: datetime, tz: str +) -> float: + """Unstructure `value` with the process timezone temporarily set to `tz`.""" + old = os.environ.get("TZ") + os.environ["TZ"] = tz + time.tzset() + try: + return converter_factory().unstructure(value) + finally: + if old is None: + del os.environ["TZ"] + else: + os.environ["TZ"] = old + 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], +): + """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"]: + assert _unstructure_under_tz(converter_factory, naive, tz) == 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(): + """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) From 6bed1f170bd1ebfa57290e4738abdddc65548eb4 Mon Sep 17 00:00:00 2001 From: Onkesh Bansal Date: Tue, 1 Sep 2026 09:15:02 -0400 Subject: [PATCH 2/3] Reference the pull request in the HISTORY entry --- HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 9f9e2103..63db1bfe 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -14,7 +14,7 @@ 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)) + ([#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. From 739d17c8ac32e616309519cd50cebcdc948a7278 Mon Sep 17 00:00:00 2001 From: Onkesh Bansal Date: Tue, 1 Sep 2026 16:00:26 -0400 Subject: [PATCH 3/3] docs: document the naive datetime change and how to restore the old behavior Add a migrations.md entry for the msgpack and cbor2 converters, with the recipe for restoring `datetime.timestamp` on a converter, and versionchanged clauses in the preconf docs for both converters. Also replace the branchy timezone helper in the regression test with a monkeypatch fixture, so the teardown path is exercised and the test file stays at 100% coverage. --- docs/migrations.md | 18 ++++++++++++++++++ docs/preconf.md | 12 ++++++++++++ tests/test_preconf.py | 38 ++++++++++++++++++-------------------- 3 files changed, 48 insertions(+), 20 deletions(-) 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/tests/test_preconf.py b/tests/test_preconf.py index 5a2ab3ad..d258b11b 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -1,7 +1,6 @@ # ruff: noqa: PLC0415 -import os import time -from collections.abc import Callable, Set +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 @@ -1040,27 +1039,23 @@ def test_literal_dicts_tomllib(): test_literal_dicts(tomllib_make_converter) -def _unstructure_under_tz( - converter_factory: Callable[[], Converter], value: datetime, tz: str -) -> float: - """Unstructure `value` with the process timezone temporarily set to `tz`.""" - old = os.environ.get("TZ") - os.environ["TZ"] = tz - time.tzset() - try: - return converter_factory().unstructure(value) - finally: - if old is None: - del os.environ["TZ"] - else: - os.environ["TZ"] = old +@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], + converter_factory: Callable[[], Converter], set_timezone: Callable[[str], None] ): """Naive datetimes are unstructured as UTC, not as local time. @@ -1073,13 +1068,16 @@ def test_naive_datetimes_are_unstructured_as_utc( expected = naive.replace(tzinfo=timezone.utc).timestamp() for tz in ["UTC", "Asia/Tokyo", "America/Toronto"]: - assert _unstructure_under_tz(converter_factory, naive, tz) == expected + 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(): +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) + test_naive_datetimes_are_unstructured_as_utc(cbor2_make_converter, set_timezone)