Skip to content
Open
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: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/preconf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
16 changes: 15 additions & 1 deletion src/cattrs/preconf/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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")

Expand Down
9 changes: 7 additions & 2 deletions src/cattrs/preconf/cbor2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
)
Expand Down
9 changes: 7 additions & 2 deletions src/cattrs/preconf/msgpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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)
)
Expand Down
47 changes: 46 additions & 1 deletion tests/test_preconf.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading