diff --git a/HISTORY.md b/HISTORY.md index ea27e568..4389efee 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,7 +13,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## 25.2.0 (unreleased) -- Add a `use_alias` parameter to {class}`cattrs.Converter`. +- **Potentially breaking**: Sequences are now structured into tuples. + This allows hashability, better immutability and is more consistent with the [`collections.abc.Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) type. + See [Migrations](https://catt.rs/en/latest/migrations.html#sequences-structuring-into-tuples) for steps to restore legacy behavior. + ([#663](https://github.com/python-attrs/cattrs/pull/663)) +- Add a `use_alias` parameter to {class}`cattrs.Converter`. {func}`cattrs.gen.make_dict_unstructure_fn_from_attrs`, {func}`cattrs.gen.make_dict_unstructure_fn`, {func}`cattrs.gen.make_dict_structure_fn_from_attrs`, {func}`cattrs.gen.make_dict_structure_fn` and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now. diff --git a/docs/customizing.md b/docs/customizing.md index 3fb6873d..40cd0b1c 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -413,6 +413,7 @@ Available predicates are: - {meth}`is_any_set` - {meth}`is_frozenset` - {meth}`is_set` +- {meth}`is_mutable_sequence` - {meth}`is_sequence` - {meth}`is_mapping` - {meth}`is_namedtuple` @@ -432,6 +433,7 @@ Available hook factories are: - {meth}`iterable_unstructure_factory` - {meth}`list_structure_factory` +- {meth}`homogenous_tuple_structure_factory` - {meth}`namedtuple_structure_factory` - {meth}`namedtuple_unstructure_factory` - {meth}`namedtuple_dict_structure_factory` @@ -442,15 +444,15 @@ Available hook factories are: Additional predicates and hook factories will be added as requested. -For example, by default sequences are structured from any iterable into lists. +For example, by default mutable sequences are structured from any iterable into lists. This may be too lax, and additional validation may be applied by wrapping the default list structuring hook factory. ```{testcode} list-customization -from cattrs.cols import is_sequence, list_structure_factory +from cattrs.cols import is_mutable_sequence, list_structure_factory c = Converter() -@c.register_structure_hook_factory(is_sequence) +@c.register_structure_hook_factory(is_mutable_sequence) def strict_list_hook_factory(type, converter): # First, we generate the default hook... @@ -466,7 +468,7 @@ def strict_list_hook_factory(type, converter): return strict_list_hook ``` -Now, all sequence structuring will be stricter: +Now, all mutable sequence structuring will be stricter: ```{doctest} list-customization >>> c.structure({"a", "b", "c"}, list[str]) @@ -477,6 +479,9 @@ ValueError: Not a list! ```{versionadded} 24.1.0 +``` +```{versionchanged} 25.2.0 +Added the {meth}`is_mutable_sequence` predicate and {meth}`homogenous_tuple_structure_factory` hook factory. ``` ### Customizing Named Tuples diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index f5af4594..183f2b9f 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -137,8 +137,7 @@ None Lists can be structured from any iterable object. Types converting to lists are: -- `typing.Sequence[T]` -- `typing.MutableSequence[T]` +- `collections.abc.MutableSequence[T]` - `typing.List[T]` - `list[T]` @@ -154,6 +153,10 @@ A bare type, for example `Sequence` instead of `Sequence[int]`, is equivalent to When unstructuring, lists are copied and their contents are handled according to their inner type. A useful use case for unstructuring collections is to create a deep copy of a complex or recursive collection. +```{versionchanged} 25.2.0 +Sequences are no longer structured into lists by default, but tuples. +``` + ### Dictionaries Dictionaries can be produced from other mapping objects. @@ -162,8 +165,8 @@ and be able to be passed to the `dict` constructor as an argument. Types converting to dictionaries are: - `dict[K, V]` and `typing.Dict[K, V]` -- `collections.abc.MutableMapping[K, V]` and `typing.MutableMapping[K, V]` -- `collections.abc.Mapping[K, V]` and `typing.Mapping[K, V]` +- `collections.abc.MutableMapping[K, V]` +- `collections.abc.Mapping[K, V]` In all cases, a new dict will be returned, so this operation can be used to copy a mapping into a dict. Any type parameters set to `typing.Any` will be passed through unconverted. @@ -234,14 +237,15 @@ _cattrs_ will be able to structure it by default. Homogeneous and heterogeneous tuples can be structured from iterable objects. Heterogeneous tuples require an iterable with the number of elements matching the number of type parameters exactly. -Use: +Heterogenous tuples use: -- `Tuple[A, B, C, D]` +- `typing.Tuple[A, B, C, D]` - `tuple[A, B, C, D]` Homogeneous tuples use: -- `Tuple[T, ...]` +- `collections.abc.Sequence[T]` +- `typing.Tuple[T, ...]` - `tuple[T, ...]` In all cases a tuple will be produced. @@ -263,6 +267,10 @@ When unstructuring, heterogeneous tuples unstructure into tuples since it's fast Structuring heterogenous tuples are not supported by the BaseConverter. ``` +```{versionchanged} 25.2.0 +Sequences are now structured into tuples. +``` + ### Deques Deques can be structured from any iterable object. @@ -293,13 +301,12 @@ Deques are unstructured into lists, or into deques when using the {class}`BaseCo Sets and frozensets can be structured from any iterable object. Types converting to sets are: -- `typing.Set[T]` -- `typing.MutableSet[T]` +- `collections.abc.Set[T]` +- `collections.abc.MutableSet[T]` - `set[T]` Types converting to frozensets are: -- `typing.FrozenSet[T]` - `frozenset[T]` In all cases, a new set or frozenset will be returned. diff --git a/docs/migrations.md b/docs/migrations.md index 0b32ca88..c43ee72c 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -3,6 +3,20 @@ _cattrs_ sometimes changes in backwards-incompatible ways. This page contains guidance for changes and workarounds for restoring legacy behavior. +## 25.2.0 + +### Sequences structuring into tuples + +Sequences were changed to structure into tuples instead of lists. + +The old behavior can be restored by registering the `list_structure_factory` using the `is_sequence` predicate on a converter. + +```python +>>> from cattrs.cols import is_sequence, list_structure_factory + +>>> converter.register_structure_hook_factory(is_sequence, list_structure_factory) +``` + ## 24.2.0 ### The default structure hook fallback factory @@ -24,4 +38,4 @@ The old behavior can be restored by explicitly passing in the old hook fallback The internal `cattrs.gen.MappingStructureFn` and `cattrs.gen.DictStructureFn` types were replaced by a more general type, `cattrs.SimpleStructureHook[In, T]`. If you were using `MappingStructureFn`, use `SimpleStructureHook[Mapping[Any, Any], T]` instead. -If you were using `DictStructureFn`, use `SimpleStructureHook[Mapping[str, Any], T]` instead. \ No newline at end of file +If you were using `DictStructureFn`, use `SimpleStructureHook[Mapping[str, Any], T]` instead. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index da50c220..7a8c205f 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -306,6 +306,25 @@ def get_notrequired_base(type) -> Union[Any, NothingType]: return NOTHING +def is_mutable_sequence(type: Any) -> bool: + """A predicate function for mutable sequences. + + Matches lists, mutable sequences, and deques. + """ + origin = getattr(type, "__origin__", None) + return ( + type in (List, list, TypingMutableSequence, AbcMutableSequence, deque, Deque) + or ( + type.__class__ is _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)) + ) + + def is_sequence(type: Any) -> bool: """A predicate function for sequences. @@ -313,19 +332,8 @@ def is_sequence(type: Any) -> bool: tuples. """ origin = getattr(type, "__origin__", None) - return ( - type - in ( - List, - list, - TypingSequence, - TypingMutableSequence, - AbcMutableSequence, - tuple, - Tuple, - deque, - Deque, - ) + return is_mutable_sequence(type) or ( + type in (TypingSequence, tuple, Tuple) or ( type.__class__ is _GenericAlias and ( @@ -333,7 +341,7 @@ def is_sequence(type: Any) -> bool: or (origin is tuple and type.__args__[1] is ...) ) ) - or (origin in (list, deque, AbcMutableSequence, AbcSequence)) + or (origin is AbcSequence) or (origin is tuple and type.__args__[1] is ...) ) diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 0b578eb0..b72a57c3 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -24,6 +24,7 @@ is_bare, is_frozenset, is_mapping, + is_mutable_sequence, is_sequence, is_subclass, ) @@ -47,10 +48,12 @@ __all__ = [ "defaultdict_structure_factory", + "homogenous_tuple_structure_factory", "is_any_set", "is_defaultdict", "is_frozenset", "is_mapping", + "is_mutable_sequence", "is_namedtuple", "is_sequence", "is_set", @@ -151,6 +154,47 @@ def structure_list( return structure_list +def homogenous_tuple_structure_factory( + type: type, converter: BaseConverter +) -> StructureHook: + """A hook factory for homogenous (all elements the same, indeterminate length) tuples. + + Converts any given iterable into a tuple. + """ + + if is_bare(type) or type.__args__[0] in ANIES: + + def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]: + return tuple(obj) + + return structure_tuple + + elem_type = type.__args__[0] + + try: + handler = converter.get_structure_hook(elem_type) + except RecursionError: + # Break the cycle by using late binding. + handler = converter.structure + + if converter.detailed_validation: + + # We have to structure into a list first anyway. + list_structure = list_structure_factory(type, converter) + + def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]: + return tuple(list_structure(obj, _)) + + else: + + def structure_tuple( + obj: Iterable[T], _: type = type, _handler=handler, _elem_type=elem_type + ) -> tuple[T, ...]: + return tuple([_handler(e, _elem_type) for e in obj]) + + return structure_tuple + + def namedtuple_unstructure_factory( cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None ) -> UnstructureHook: diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index ee04e653..81c75996 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -43,10 +43,10 @@ is_hetero_tuple, is_literal, is_mapping, + is_mutable_sequence, is_mutable_set, is_optional, is_protocol, - is_sequence, is_tuple, is_typeddict, is_union_type, @@ -54,8 +54,10 @@ ) from .cols import ( defaultdict_structure_factory, + homogenous_tuple_structure_factory, is_defaultdict, is_namedtuple, + is_sequence, iterable_unstructure_factory, list_structure_factory, mapping_structure_factory, @@ -271,7 +273,8 @@ def __init__( ), (is_literal, self._structure_simple_literal), (is_literal_containing_enums, self._structure_enum_literal), - (is_sequence, list_structure_factory, "extended"), + (is_sequence, homogenous_tuple_structure_factory, "extended"), + (is_mutable_sequence, list_structure_factory, "extended"), (is_deque, self._structure_deque), (is_mutable_set, self._structure_set), (is_frozenset, self._structure_frozenset), diff --git a/tests/test_cols.py b/tests/test_cols.py index 92bb6a2b..199d58f8 100644 --- a/tests/test_cols.py +++ b/tests/test_cols.py @@ -1,6 +1,6 @@ """Tests for the `cattrs.cols` module.""" -from collections.abc import Set +from collections.abc import MutableSequence, Sequence, Set from typing import Dict from immutables import Map @@ -9,7 +9,9 @@ from cattrs._compat import FrozenSet from cattrs.cols import ( is_any_set, + is_sequence, iterable_unstructure_factory, + list_structure_factory, mapping_unstructure_factory, ) @@ -53,3 +55,23 @@ def test_mapping_unstructure_to(genconverter: Converter): """`unstructure_to` works.""" hook = mapping_unstructure_factory(Dict[str, str], genconverter, unstructure_to=Map) assert hook({"a": "a"}).__class__ is Map + + +def test_structure_sequences(converter: BaseConverter): + """Sequences are structured to tuples.""" + + assert converter.structure(["1", 2, 3.0], Sequence[int]) == (1, 2, 3) + + +def test_structure_sequences_override(converter: BaseConverter): + """Sequences can be overriden to structure to lists, as previously.""" + + converter.register_structure_hook_factory(is_sequence, list_structure_factory) + + assert converter.structure(["1", 2, 3.0], Sequence[int]) == [1, 2, 3] + + +def test_structure_mut_sequences(converter: BaseConverter): + """Mutable sequences are structured to lists.""" + + assert converter.structure(["1", 2, 3.0], MutableSequence[int]) == [1, 2, 3] diff --git a/tests/test_generics.py b/tests/test_generics.py index 466c4134..04621214 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -1,4 +1,5 @@ from collections import deque +from collections.abc import Sequence from typing import Deque, Dict, Generic, List, Optional, TypeVar, Union import pytest @@ -109,9 +110,16 @@ class GenericCols(Generic[T]): ( (TClass[int, int, int], str, int, TClass(TClass(1, 2), "a")), (List[TClass[int, int, int]], str, int, TClass([TClass(1, 2)], "a")), + ( + Sequence[TClass[str, str, str]], + str, + str, + TClass((TClass("a", "b", "c"),), "b", "c"), + ), ), ) def test_structure_nested_generics(converter: BaseConverter, t, t2, t3, result): + """Structuring nested generics works.""" res = converter.structure(asdict(result), TClass[t, t2, t3]) assert res == result diff --git a/tests/test_preconf.py b/tests/test_preconf.py index 0197ad0f..d8bd06e3 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -156,36 +156,38 @@ def everythings( ints = integers(min_value=min_int, max_value=max_int) return Everything( - draw(strings), - draw(binary()), - draw(ints), - draw(fs), - draw(dictionaries(key_text, ints)), - draw(dictionaries(key_text, strings)), - draw(lists(ints)), - tuple(draw(lists(ints))), - (draw(strings), draw(ints), draw(fs)), - Counter(draw(dictionaries(key_text, ints))), - draw(dictionaries(ints, fs)), - draw(dictionaries(fs, strings)), - draw(lists(fs)), - draw(lists(strings)), - draw(sets(fs)), - draw(sets(ints)), - draw(frozensets(strings)), - Everything.AnIntEnum.A, - Everything.AStringEnum.A, - Everything.ABareEnum.B, - draw(dts), - draw(dates(min_value=date(1970, 1, 1), max_value=date(2038, 1, 1))), - draw(dictionaries(just(Everything.AStringEnum.A), ints)), - draw(dictionaries(binary(min_size=min_key_length), binary())), - draw(one_of(ints, fs, strings)), - draw(one_of(ints, strings, sets(strings))), - draw(one_of(ints, strings, ints.map(A), strings.map(B))), - draw(fs.map(C)), - draw(one_of(just(1), just(Everything.AStringEnum.A))), - draw(one_of(just(1), just(Everything.ABareEnum.B))), + string=draw(strings), + bytes=draw(binary()), + an_int=draw(ints), + a_float=draw(fs), + a_dict=draw(dictionaries(key_text, ints)), + a_bare_dict=draw(dictionaries(key_text, strings)), + a_list=draw(lists(ints)), + a_homogenous_tuple=draw(lists(ints).map(tuple)), + a_hetero_tuple=(draw(strings), draw(ints), draw(fs)), + a_counter=Counter(draw(dictionaries(key_text, ints))), + a_mapping=draw(dictionaries(ints, fs)), + a_mutable_mapping=draw(dictionaries(fs, strings)), + a_sequence=draw(lists(fs).map(tuple)), + a_mutable_sequence=draw(lists(strings)), + a_set=draw(sets(fs)), + a_mutable_set=draw(sets(ints)), + a_frozenset=draw(frozensets(strings)), + an_int_enum=Everything.AnIntEnum.A, + a_str_enum=Everything.AStringEnum.A, + a_bare_enum=Everything.ABareEnum.B, + a_datetime=draw(dts), + a_date=draw(dates(min_value=date(1970, 1, 1), max_value=date(2038, 1, 1))), + a_string_enum_dict=draw(dictionaries(just(Everything.AStringEnum.A), ints)), + a_bytes_dict=draw(dictionaries(binary(min_size=min_key_length), binary())), + native_union=draw(one_of(ints, fs, strings)), + native_union_with_spillover=draw(one_of(ints, strings, sets(strings))), + native_union_with_union_spillover=draw( + one_of(ints, strings, ints.map(A), strings.map(B)) + ), + a_namedtuple=draw(fs.map(C)), + a_literal=draw(one_of(just(1), just(Everything.AStringEnum.A))), + a_literal_with_bare=draw(one_of(just(1), just(Everything.ABareEnum.B))), ) diff --git a/tests/typed.py b/tests/typed.py index 5ff4ea6f..016b710e 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -154,6 +154,7 @@ def simple_typed_attrs( | str_typed_attrs(defaults, kw_only, text_codec) | float_typed_attrs(defaults, kw_only, allow_infinity, allow_nan) | frozenset_typed_attrs(defaults, kw_only=kw_only) + | seq_typed_attrs(defaults, allow_mutable_defaults, kw_only=kw_only) | homo_tuple_typed_attrs(defaults, kw_only=kw_only) | path_typed_attrs(defaults, kw_only=kw_only) ) @@ -172,7 +173,6 @@ def simple_typed_attrs( | set_typed_attrs(defaults, allow_mutable_defaults, kw_only=kw_only) | list_typed_attrs(defaults, allow_mutable_defaults, kw_only=kw_only) | mutable_seq_typed_attrs(defaults, allow_mutable_defaults, kw_only=kw_only) - | seq_typed_attrs(defaults, allow_mutable_defaults, kw_only=kw_only) ) return res @@ -573,11 +573,14 @@ def seq_typed_attrs( kw_only=None, ): """ - Generate a tuple of an attribute and a strategy that yields lists - for that attribute. The lists contain integers. + Generate a tuple of an attribute and a strategy that yields tuples + for that attribute. The tuples contain integers. + + Args: + allow_mutable_defaults: When false, the default will always be a factory. """ default_val = NOTHING - val_strat = lists(integers()) + val_strat = lists(integers()).map(tuple) if defaults is True or (defaults is None and draw(booleans())): default_val = draw(val_strat) if not allow_mutable_defaults or draw(booleans()):