From c2b8e4b2d42d7ff9528ccc053c57fb9034a19da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 5 Oct 2024 04:06:51 +0200 Subject: [PATCH 1/8] Expose `mapping_unstructure_factory` --- HISTORY.md | 3 ++- docs/customizing.md | 1 + src/cattrs/cols.py | 2 ++ src/cattrs/gen/__init__.py | 5 ++++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index f618a92e..2d5e3a34 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#577](https://github.com/python-attrs/cattrs/pull/577)) - Add a [Migrations](https://catt.rs/latest/migrations.html) page, with instructions on migrating changed behavior for each version. ([#577](https://github.com/python-attrs/cattrs/pull/577)) +- Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`. - Python 3.13 is now supported. ([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547)) @@ -39,7 +40,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#473](https://github.com/python-attrs/cattrs/pull/473)) - **Minor change**: Heterogeneous tuples are now unstructured into tuples instead of lists by default; this is significantly faster and widely supported by serialization libraries. ([#486](https://github.com/python-attrs/cattrs/pull/486)) -- **Minor change**: {py:func}`cattrs.gen.make_dict_structure_fn` will use the value for the `prefer_attrib_converters` parameter from the given converter by default now. +- **Minor change**: {func}`cattrs.gen.make_dict_structure_fn` will use the value for the `prefer_attrib_converters` parameter from the given converter by default now. If you're using this function directly, the old behavior can be restored by passing in the desired values explicitly. ([#527](https://github.com/python-attrs/cattrs/issues/527) [#528](https://github.com/python-attrs/cattrs/pull/528)) - Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods. diff --git a/docs/customizing.md b/docs/customizing.md index 8ceef7fe..c8860e04 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -189,6 +189,7 @@ Available hook factories are: * {meth}`namedtuple_dict_structure_factory ` * {meth}`namedtuple_dict_unstructure_factory ` * {meth}`mapping_structure_factory ` +* {meth}`mapping_unstructure_factory ` Additional predicates and hook factories will be added as requested. diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 8ff5c0f0..40a79f17 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -28,6 +28,7 @@ make_dict_unstructure_fn_from_attrs, make_hetero_tuple_unstructure_fn, mapping_structure_factory, + mapping_unstructure_factory, ) from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory @@ -48,6 +49,7 @@ "namedtuple_dict_structure_factory", "namedtuple_dict_unstructure_factory", "mapping_structure_factory", + "mapping_unstructure_factory", ] diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index bb312368..85a550ca 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -844,7 +844,8 @@ def make_hetero_tuple_unstructure_fn( MappingUnstructureFn = Callable[[Mapping[Any, Any]], Any] -def make_mapping_unstructure_fn( +# This factory is here for backwards compatibility and circular imports. +def mapping_unstructure_factory( cl: Any, converter: BaseConverter, unstructure_to: Any = None, @@ -896,6 +897,8 @@ def make_mapping_unstructure_fn( return globs[fn_name] +make_mapping_unstructure_fn: Final = mapping_unstructure_factory + MappingStructureFn = Callable[[Mapping[Any, Any], Any], T] From c5dfb806522ddeb7538f99d9bfce8574da6d03cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 5 Oct 2024 04:16:55 +0200 Subject: [PATCH 2/8] Optimize a little --- src/cattrs/gen/__init__.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 85a550ca..387b3cd9 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -851,7 +851,11 @@ def mapping_unstructure_factory( unstructure_to: Any = None, key_handler: Callable[[Any, Any | None], Any] | None = None, ) -> MappingUnstructureFn: - """Generate a specialized unstructure function for a mapping.""" + """Generate a specialized unstructure function for a mapping. + + :param unstructure_to: The class to unstructure to; defaults to the + same class as the mapping being unstructured. + """ kh = key_handler or converter.unstructure val_handler = converter.unstructure @@ -886,9 +890,15 @@ def mapping_unstructure_factory( lines = [] lines.append(f"def {fn_name}(mapping):") - lines.append( - f" res = __cattr_mapping_cl(({k_u}, {v_u}) for k, v in mapping.items())" - ) + + if unstructure_to is dict: + lines.append( + f" res = {{{k_u}: {v_u}}} for k, v in mapping.items())" + ) + else: + lines.append( + f" res = __cattr_mapping_cl(({k_u}, {v_u}) for k, v in mapping.items())" + ) total_lines = [*lines, " return res"] From 1c8810032e51e3fbd5925365593b082c0a16a302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 5 Oct 2024 04:17:08 +0200 Subject: [PATCH 3/8] Use new names --- src/cattrs/converters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 4f291e7a..3e67bd7f 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -57,6 +57,8 @@ is_namedtuple, iterable_unstructure_factory, list_structure_factory, + mapping_structure_factory, + mapping_unstructure_factory, namedtuple_structure_factory, namedtuple_unstructure_factory, ) @@ -86,8 +88,6 @@ make_dict_structure_fn, make_dict_unstructure_fn, make_hetero_tuple_unstructure_fn, - make_mapping_structure_fn, - make_mapping_unstructure_fn, ) from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn @@ -1335,14 +1335,14 @@ def gen_unstructure_mapping( unstructure_to = self._unstruct_collection_overrides.get( get_origin(cl) or cl, unstructure_to or dict ) - h = make_mapping_unstructure_fn( + h = mapping_unstructure_factory( cl, self, unstructure_to=unstructure_to, key_handler=key_handler ) self._unstructure_func.register_cls_list([(cl, h)], direct=True) return h def gen_structure_counter(self, cl: Any) -> MappingStructureFn[T]: - h = make_mapping_structure_fn( + h = mapping_structure_factory( cl, self, structure_to=Counter, @@ -1361,7 +1361,7 @@ def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]: AbcMapping, ): # These default to dicts structure_to = dict - h = make_mapping_structure_fn( + h = mapping_structure_factory( cl, self, structure_to, detailed_validation=self.detailed_validation ) self._structure_func.register_cls_list([(cl, h)], direct=True) From de490e9011a7dba2cab730ece9a0289e692c049e Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 5 Oct 2024 07:50:28 +0200 Subject: [PATCH 4/8] Fix syntax --- src/cattrs/gen/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 387b3cd9..b0c0e99b 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -893,7 +893,7 @@ def mapping_unstructure_factory( if unstructure_to is dict: lines.append( - f" res = {{{k_u}: {v_u}}} for k, v in mapping.items())" + f" res = {{{k_u}: {v_u} for k, v in mapping.items()}}" ) else: lines.append( From 70cf225ea6f40213456881781fd03b729a50b5d7 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 5 Oct 2024 08:21:46 +0200 Subject: [PATCH 5/8] Reformat --- src/cattrs/gen/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index b0c0e99b..ad6cb47a 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -892,9 +892,7 @@ def mapping_unstructure_factory( lines.append(f"def {fn_name}(mapping):") if unstructure_to is dict: - lines.append( - f" res = {{{k_u}: {v_u} for k, v in mapping.items()}}" - ) + lines.append(f" res = {{{k_u}: {v_u} for k, v in mapping.items()}}") else: lines.append( f" res = __cattr_mapping_cl(({k_u}, {v_u}) for k, v in mapping.items())" From 8aacc5baae0a57c4987f79da128a1ee90ba05273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 6 Oct 2024 03:16:02 +0200 Subject: [PATCH 6/8] Optimize and tests --- src/cattrs/gen/__init__.py | 16 ++++++++++------ tests/test_cols.py | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index ad6cb47a..57450856 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -860,6 +860,7 @@ def mapping_unstructure_factory( val_handler = converter.unstructure fn_name = "unstructure_mapping" + origin = cl # Let's try fishing out the type args. if getattr(cl, "__args__", None) is not None: @@ -878,11 +879,9 @@ def mapping_unstructure_factory( if val_handler == identity: val_handler = None - globs = { - "__cattr_mapping_cl": unstructure_to or cl, - "__cattr_k_u": kh, - "__cattr_v_u": val_handler, - } + origin = get_origin(cl) + + globs = {"__cattr_k_u": kh, "__cattr_v_u": val_handler} k_u = "__cattr_k_u(k)" if kh is not None else "k" v_u = "__cattr_v_u(v)" if val_handler is not None else "v" @@ -891,9 +890,14 @@ def mapping_unstructure_factory( lines.append(f"def {fn_name}(mapping):") - if unstructure_to is dict: + if unstructure_to is dict or origin is dict: + if kh is None and val_handler is None: + # Simplest path. + return dict + lines.append(f" res = {{{k_u}: {v_u} for k, v in mapping.items()}}") else: + globs["__cattr_mapping_cl"] = unstructure_to or cl lines.append( f" res = __cattr_mapping_cl(({k_u}, {v_u}) for k, v in mapping.items())" ) diff --git a/tests/test_cols.py b/tests/test_cols.py index ea00bbac..b1fda7dd 100644 --- a/tests/test_cols.py +++ b/tests/test_cols.py @@ -1,10 +1,18 @@ """Tests for the `cattrs.cols` module.""" +from typing import Dict + from immutables import Map from cattrs import BaseConverter, Converter from cattrs._compat import AbstractSet, FrozenSet -from cattrs.cols import is_any_set, iterable_unstructure_factory +from cattrs.cols import ( + is_any_set, + iterable_unstructure_factory, + mapping_unstructure_factory, +) + +from ._compat import is_py310_plus def test_set_overriding(converter: BaseConverter): @@ -26,3 +34,15 @@ def test_set_overriding(converter: BaseConverter): def test_structuring_immutables_map(genconverter: Converter): """This should work due to our new is_mapping predicate.""" assert genconverter.structure({"a": 1}, Map[str, int]) == Map(a=1) + + +def test_mapping_unstructure_direct(genconverter: Converter): + """Some cases reduce to just `dict`.""" + assert genconverter.get_unstructure_hook(Dict[str, int]) is dict + + # `dict` is equivalent to `dict[Any, Any]`, which should not reduce to + # just `dict`. + assert genconverter.get_unstructure_hook(dict) is not dict + + if is_py310_plus: + assert genconverter.get_unstructure_hook(dict[str, int]) is dict From 85850c7f2c4f6c1c8e9f66d707391656df999c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 6 Oct 2024 03:18:36 +0200 Subject: [PATCH 7/8] Cleanup --- src/cattrs/gen/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 57450856..caf71e66 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -886,25 +886,23 @@ def mapping_unstructure_factory( k_u = "__cattr_k_u(k)" if kh is not None else "k" v_u = "__cattr_v_u(v)" if val_handler is not None else "v" - lines = [] - - lines.append(f"def {fn_name}(mapping):") + lines = [f"def {fn_name}(mapping):"] if unstructure_to is dict or origin is dict: if kh is None and val_handler is None: # Simplest path. return dict - lines.append(f" res = {{{k_u}: {v_u} for k, v in mapping.items()}}") + lines.append(f" return {{{k_u}: {v_u} for k, v in mapping.items()}}") else: globs["__cattr_mapping_cl"] = unstructure_to or cl lines.append( f" res = __cattr_mapping_cl(({k_u}, {v_u}) for k, v in mapping.items())" ) - total_lines = [*lines, " return res"] + lines = [*lines, " return res"] - eval(compile("\n".join(total_lines), "", "exec"), globs) + eval(compile("\n".join(lines), "", "exec"), globs) return globs[fn_name] From a95d5ff3175897ae7dbfc99d68cac57654f002cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 6 Oct 2024 13:06:14 +0200 Subject: [PATCH 8/8] Fix `unstructure_to` --- src/cattrs/gen/__init__.py | 2 +- tests/test_cols.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index caf71e66..f89f63fe 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -888,7 +888,7 @@ def mapping_unstructure_factory( lines = [f"def {fn_name}(mapping):"] - if unstructure_to is dict or origin is dict: + if unstructure_to is dict or unstructure_to is None and origin is dict: if kh is None and val_handler is None: # Simplest path. return dict diff --git a/tests/test_cols.py b/tests/test_cols.py index b1fda7dd..61353dd3 100644 --- a/tests/test_cols.py +++ b/tests/test_cols.py @@ -46,3 +46,9 @@ def test_mapping_unstructure_direct(genconverter: Converter): if is_py310_plus: assert genconverter.get_unstructure_hook(dict[str, int]) is dict + + +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