From b5722335ee8b48a2f7c4a0053e1fb51f2ff379a0 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sun, 21 Sep 2025 22:33:34 +0200 Subject: [PATCH 1/2] Structure abstract sets to frozensets --- HISTORY.md | 4 ++++ Justfile | 5 ++++- docs/migrations.md | 17 +++++++++++++++++ src/cattrs/cols.py | 7 +++++++ src/cattrs/converters.py | 2 ++ tests/test_cols.py | 25 +++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 676f1849..01755af0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,10 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT (UNRELEASED) +- **Potentially breaking**: [Abstract sets](https://docs.python.org/3/library/collections.abc.html#collections.abc.Set) are now structured into frozensets. + This allows hashability, better immutability and is more consistent with the [`collections.abc.Set`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Set) type. + See [Migrations](https://catt.rs/en/latest/migrations.html#abstract-sets-structuring-into-frozensets) for steps to restore legacy behavior. + ([#](https://github.com/python-attrs/cattrs/pull/)) - Fix unstructuring NewTypes with the {class}`BaseConverter`. ([#684](https://github.com/python-attrs/cattrs/pull/684)) - Make some Hypothesis tests more robust. diff --git a/Justfile b/Justfile index b76bc923..33343416 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,9 @@ python := "" covcleanup := "true" +sync: + uv sync {{ if python != '' { '-p ' + python } else { '' } }} --all-groups --all-extras + lint: uv run -p python3.13 --group lint ruff check src/ tests bench uv run -p python3.13 --group lint black --check src tests docs/conf.py @@ -10,11 +13,11 @@ test *args="-x --ff -n auto tests": testall: just python=python3.9 test + just python=pypy3.9 test just python=python3.10 test just python=python3.11 test just python=python3.12 test just python=python3.13 test - just python=pypy3.9 test cov *args="-x --ff -n auto tests": uv run {{ if python != '' { '-p ' + python } else { '' } }} --all-extras --group test coverage run -m pytest {{args}} diff --git a/docs/migrations.md b/docs/migrations.md index 1cbb66f3..799c6a0b 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -1,8 +1,25 @@ # Migrations +```{currentmodule} cattrs +``` + _cattrs_ sometimes changes in backwards-incompatible ways. This page contains guidance for changes and workarounds for restoring legacy behavior. +## 25.3.0 + +### Abstract sets structuring into frozensets + +From this version on, abstract sets (`collection.abc.Set`) structure into frozensets. + +The old behavior can be restored by registering the {meth}`BaseConverter._structure_set ` method using the {meth}`is_abstract_set ` predicate on a converter. + +```python +>>> from cattrs.cols import is_abstract_set + +>>> converter.register_structure_hook_func(is_abstract_set, converter._structure_set) +``` + ## 25.2.0 ### Sequences structuring into tuples diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index b72a57c3..bd083d3b 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -19,6 +19,7 @@ from ._compat import ( ANIES, + AbcSet, get_args, get_origin, is_bare, @@ -49,6 +50,7 @@ __all__ = [ "defaultdict_structure_factory", "homogenous_tuple_structure_factory", + "is_abstract_set", "is_any_set", "is_defaultdict", "is_frozenset", @@ -73,6 +75,11 @@ def is_any_set(type) -> bool: return is_set(type) or is_frozenset(type) +def is_abstract_set(type) -> bool: + """A predicate function for abstract (collection.abc) sets.""" + return type is AbcSet or (getattr(type, "__origin__", None) is AbcSet) + + def is_namedtuple(type: Any) -> bool: """A predicate function for named tuples.""" diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 43772d60..08d7fc56 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -55,6 +55,7 @@ from .cols import ( defaultdict_structure_factory, homogenous_tuple_structure_factory, + is_abstract_set, is_defaultdict, is_namedtuple, is_sequence, @@ -281,6 +282,7 @@ def __init__( (is_mutable_sequence, list_structure_factory, "extended"), (is_deque, self._structure_deque), (is_mutable_set, self._structure_set), + (is_abstract_set, self._structure_frozenset), (is_frozenset, self._structure_frozenset), (is_tuple, self._structure_tuple), (is_namedtuple, namedtuple_structure_factory, "extended"), diff --git a/tests/test_cols.py b/tests/test_cols.py index 199d58f8..3e532367 100644 --- a/tests/test_cols.py +++ b/tests/test_cols.py @@ -8,6 +8,7 @@ from cattrs import BaseConverter, Converter from cattrs._compat import FrozenSet from cattrs.cols import ( + is_abstract_set, is_any_set, is_sequence, iterable_unstructure_factory, @@ -75,3 +76,27 @@ 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] + + +def test_abstract_set_predicate(): + """`is_abstract_set` works.""" + + assert is_abstract_set(Set) + assert is_abstract_set(Set[str]) + + assert not is_abstract_set(set) + assert not is_abstract_set(set[str]) + + +def test_structure_abstract_sets(converter: BaseConverter): + """Abstract sets structure to frozensets.""" + + assert converter.structure(["1", "2", "3"], Set[int]) == frozenset([1, 2, 3]) + assert isinstance(converter.structure([1, 2, 3], Set[int]), frozenset) + + +def test_structure_abstract_sets_override(converter: BaseConverter): + """Abstract sets can be overridden to structure to mutable sets, as before.""" + converter.register_structure_hook_func(is_abstract_set, converter._structure_set) + + assert converter.structure(["1", 2, 3.0], Set[int]) == {1, 2, 3} From 34a86c0d7952a527f9ae23e8d082fb3e4655c1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Thu, 25 Sep 2025 10:29:28 +0200 Subject: [PATCH 2/2] More docs --- docs/defaulthooks.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 183f2b9f..c3c7badb 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -301,13 +301,15 @@ 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: -- `collections.abc.Set[T]` - `collections.abc.MutableSet[T]` - `set[T]` +- `typing.Set[T]` (deprecated since Python 3.9, use `set[T]` instead) Types converting to frozensets are: +- `collections.abc.Set[T]` - `frozenset[T]` +- `typing.FrozenSet[T]` (deprecated since Python 3.9, use `frozenset[T]` instead) In all cases, a new set or frozenset will be returned. A bare type, for example `MutableSet` instead of `MutableSet[int]`, is equivalent to `MutableSet[Any]`. @@ -318,8 +320,11 @@ A bare type, for example `MutableSet` instead of `MutableSet[int]`, is equivalen {1, 2, 3, 4} ``` -Sets and frozensets are unstructured into the same class. +Sets and frozensets are unstructured into the matching class. +```{versionchanged} NEXT +Abstract sets are now structured into frozensets instead of sets. +``` ### Typed Dicts