Skip to content
Closed
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

- Subclasses of built-in collections (`list`, `set` and `frozenset`) are now un/structured like their built-in counterparts, both when used bare and when subscripted (for example `MyList[Item]`), instead of being passed through unchanged.
([#287](https://github.com/python-attrs/cattrs/issues/287))
- Fix the msgspec converter passing typed deques directly to the encoder, which cannot serialize them. Deques are now unstructured into lists, including when nested in attrs classes or dataclasses.
([#818](https://github.com/python-attrs/cattrs/issues/818) [#819](https://github.com/python-attrs/cattrs/pull/819))

Expand Down
26 changes: 19 additions & 7 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ def is_annotated(type) -> bool:
def is_tuple(type):
return (
type in (Tuple, tuple)
or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, Tuple))
or (
type.__class__ in (_GenericAlias, GenericAlias)
and is_subclass(type.__origin__, Tuple)
)
or (getattr(type, "__origin__", None) is tuple)
)

Expand Down Expand Up @@ -313,13 +316,14 @@ def is_mutable_sequence(type: Any) -> bool:
return (
type in (List, list, TypingMutableSequence, AbcMutableSequence, deque, Deque)
or (
type.__class__ is _GenericAlias
type.__class__ in (_GenericAlias, GenericAlias)
and (
((origin is not tuple) and is_subclass(origin, TypingMutableSequence))
or (origin is tuple and type.__args__[1] is ...)
)
)
or (origin in (list, deque, AbcMutableSequence))
or is_subclass(getattr(type, "__origin__", type), (list, deque))
)


Expand All @@ -333,7 +337,7 @@ def is_sequence(type: Any) -> bool:
return is_mutable_sequence(type) or (
type in (TypingSequence, tuple, Tuple)
or (
type.__class__ is _GenericAlias
type.__class__ in (_GenericAlias, GenericAlias)
and (
((origin is not tuple) and is_subclass(origin, TypingSequence))
or (origin is tuple and type.__args__[1] is ...)
Expand All @@ -347,7 +351,10 @@ def is_sequence(type: Any) -> bool:
def is_deque(type):
return (
type in (deque, Deque)
or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, deque))
or (
type.__class__ in (_GenericAlias, GenericAlias)
and is_subclass(type.__origin__, deque)
)
or (getattr(type, "__origin__", None) is deque)
)

Expand All @@ -360,10 +367,11 @@ def is_mutable_set(type: Any) -> bool:
return (
type in (TypingSet, TypingMutableSet, set)
or (
type.__class__ is _GenericAlias
type.__class__ in (_GenericAlias, GenericAlias)
and is_subclass(type.__origin__, TypingMutableSet)
)
or (getattr(type, "__origin__", None) in (set, AbcMutableSet, AbcSet))
or is_subclass(getattr(type, "__origin__", type), set)
)


Expand All @@ -374,8 +382,12 @@ def is_frozenset(type: Any) -> bool:
"""
return (
type in (FrozenSet, frozenset)
or (type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet))
or (
type.__class__ in (_GenericAlias, GenericAlias)
and is_subclass(type.__origin__, FrozenSet)
)
or (getattr(type, "__origin__", None) is frozenset)
or is_subclass(getattr(type, "__origin__", type), frozenset)
)


Expand All @@ -390,7 +402,7 @@ def is_mapping(type: Any) -> bool:
return (
type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping)
or (
type.__class__ is _GenericAlias
type.__class__ in (_GenericAlias, GenericAlias)
and is_subclass(type.__origin__, TypingMapping)
)
or is_subclass(
Expand Down
53 changes: 52 additions & 1 deletion tests/test_baseconverter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Test both structuring and unstructuring."""

from typing import Optional, Union
from typing import Optional, TypeVar, Union

import pytest
from attrs import define, fields, make_class
Expand Down Expand Up @@ -252,3 +252,54 @@ class C:
unstructured = converter.unstructure(inst)

assert inst == converter.structure(unstructured, C)


def test_unstructure_subclassed_list():
"""Subclasses of `list` are unstructured like lists, including their elements.

Regression test for https://github.com/python-attrs/cattrs/issues/287
"""
T = TypeVar("T")

@define
class A:
a: int

class MyList(list[T]):
pass

converter = BaseConverter()

# A bare instance, dispatched on its runtime class.
assert converter.unstructure(MyList([A(1)])) == [{"a": 1}]

@define
class Holder:
aliased: MyList[A]
bare: MyList

inst = Holder(aliased=MyList([A(1)]), bare=MyList([A(2)]))
assert converter.unstructure(inst) == {"aliased": [{"a": 1}], "bare": [{"a": 2}]}

@define
class AliasedHolder:
items: MyList[A]

assert converter.structure({"items": [{"a": 1}]}, AliasedHolder) == AliasedHolder(
items=MyList([A(1)])
)


def test_unstructure_subclassed_sets():
"""Subclasses of `set` and `frozenset` are unstructured like sets."""

class MySet(set):
pass

class MyFrozenSet(frozenset):
pass

converter = BaseConverter()

assert converter.unstructure(MySet([1, 2])) == {1, 2}
assert converter.unstructure(MyFrozenSet([1, 2])) == {1, 2}