diff --git a/HISTORY.md b/HISTORY.md index 518b5734..97997350 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -42,6 +42,8 @@ can now be used as decorators and have gained new features. ([#512](https://github.com/python-attrs/cattrs/pull/512)) - Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)). ([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491)) +- Add support for optionally un/unstructuring named tuples using dictionaries. + ([#425](https://github.com/python-attrs/cattrs/issues/425) [#549](https://github.com/python-attrs/cattrs/pull/549)) - The `include_subclasses` strategy now fetches the member hooks from the converter (making use of converter defaults) if overrides are not provided, instead of generating new hooks with no overrides. ([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472)) - The preconf `make_converter` factories are now correctly typed. diff --git a/docs/customizing.md b/docs/customizing.md index 07802b83..d14420ca 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -185,6 +185,8 @@ Available hook factories are: * {meth}`list_structure_factory ` * {meth}`namedtuple_structure_factory ` * {meth}`namedtuple_unstructure_factory ` +* {meth}`namedtuple_dict_structure_factory ` +* {meth}`namedtuple_dict_unstructure_factory ` Additional predicates and hook factories will be added as requested. @@ -225,6 +227,40 @@ ValueError: Not a list! ``` +### Customizing Named Tuples + +Named tuples can be un/structured using dictionaries using the {meth}`namedtuple_dict_structure_factory ` +and {meth}`namedtuple_dict_unstructure_factory ` +hook factories. + +To unstructure _all_ named tuples into dictionaries: + +```{doctest} namedtuples +>>> from typing import NamedTuple + +>>> from cattrs.cols import is_namedtuple, namedtuple_dict_unstructure_factory +>>> c = Converter() + +>>> c.register_unstructure_hook_factory(is_namedtuple, namedtuple_dict_unstructure_factory) + + +>>> class MyNamedTuple(NamedTuple): +... a: int + +>>> c.unstructure(MyNamedTuple(1)) +{'a': 1} +``` + +To only un/structure _some_ named tuples into dictionaries, +change the predicate function when registering the hook factory: + +```{doctest} namedtuples +>>> c.register_unstructure_hook_factory( +... lambda t: t is MyNamedTuple, +... namedtuple_dict_unstructure_factory, +... ) +``` + ## Using `cattrs.gen` Generators The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index cee50627..27997380 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -210,6 +210,10 @@ Any type parameters set to `typing.Any` will be passed through unconverted. When unstructuring, heterogeneous tuples unstructure into tuples since it's faster and virtually all serialization libraries support tuples natively. +```{seealso} +[Support for typing.NamedTuple.](#typingnamedtuple) +``` + ```{note} Structuring heterogenous tuples are not supported by the BaseConverter. ``` @@ -511,6 +515,10 @@ When unstructuring, literals are passed through. ### `typing.NamedTuple` Named tuples with type hints (created from [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)) are supported. +Named tuples are un/structured using tuples or lists by default. + +The {mod}`cattrs.cols` module contains hook factories for un/structuring named tuples using dictionaries instead, +[see here for details](customizing.md#customizing-named-tuples). ```{versionadded} 24.1.0 diff --git a/pyproject.toml b/pyproject.toml index a5e8d140..e63f49ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,10 @@ ignore = [ "DTZ006", # datetimes in tests ] +[tool.ruff.lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = true + [tool.hatch.version] source = "vcs" raw-options = { local_scheme = "no-local-version" } diff --git a/src/cattr/gen.py b/src/cattr/gen.py index a41c2d11..b1f63b59 100644 --- a/src/cattr/gen.py +++ b/src/cattr/gen.py @@ -1,8 +1,8 @@ +from cattrs.cols import iterable_unstructure_factory as make_iterable_unstructure_fn from cattrs.gen import ( make_dict_structure_fn, make_dict_unstructure_fn, make_hetero_tuple_unstructure_fn, - make_iterable_unstructure_fn, make_mapping_structure_fn, make_mapping_unstructure_fn, override, diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index c8d093ea..196c85ce 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -3,14 +3,32 @@ from __future__ import annotations from sys import version_info -from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, Tuple, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Iterable, + Literal, + NamedTuple, + Tuple, + TypeVar, + get_type_hints, +) + +from attrs import NOTHING, Attribute from ._compat import ANIES, is_bare, is_frozenset, is_sequence, is_subclass from ._compat import is_mutable_set as is_set from .dispatch import StructureHook, UnstructureHook from .errors import IterableValidationError, IterableValidationNote from .fns import identity -from .gen import make_hetero_tuple_unstructure_fn +from .gen import ( + AttributeOverride, + already_generating, + make_dict_structure_fn_from_attrs, + make_dict_unstructure_fn_from_attrs, + make_hetero_tuple_unstructure_fn, +) +from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory if TYPE_CHECKING: from .converters import BaseConverter @@ -25,6 +43,8 @@ "list_structure_factory", "namedtuple_structure_factory", "namedtuple_unstructure_factory", + "namedtuple_dict_structure_factory", + "namedtuple_dict_unstructure_factory", ] @@ -133,57 +153,134 @@ def structure_list( return structure_list -def iterable_unstructure_factory( - cl: Any, converter: BaseConverter, unstructure_to: Any = None -) -> UnstructureHook: - """A hook factory for unstructuring iterables. - - :param unstructure_to: Force unstructuring to this type, if provided. - """ - handler = converter.unstructure - - # Let's try fishing out the type args - # Unspecified tuples have `__args__` as empty tuples, so guard - # against IndexError. - if getattr(cl, "__args__", None) not in (None, ()): - type_arg = cl.__args__[0] - if isinstance(type_arg, TypeVar): - type_arg = getattr(type_arg, "__default__", Any) - handler = converter.get_unstructure_hook(type_arg, cache_result=False) - if handler == identity: - # Save ourselves the trouble of iterating over it all. - return unstructure_to or cl - - def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler): - return _seq_cl(_hook(i) for i in iterable) - - return unstructure_iterable - - def namedtuple_unstructure_factory( - type: type[tuple], converter: BaseConverter, unstructure_to: Any = None + cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None ) -> UnstructureHook: """A hook factory for unstructuring namedtuples. :param unstructure_to: Force unstructuring to this type, if provided. """ - if unstructure_to is None and _is_passthrough(type, converter): + if unstructure_to is None and _is_passthrough(cl, converter): return identity return make_hetero_tuple_unstructure_fn( - type, + cl, converter, unstructure_to=tuple if unstructure_to is None else unstructure_to, - type_args=tuple(type.__annotations__.values()), + type_args=tuple(cl.__annotations__.values()), ) def namedtuple_structure_factory( - type: type[tuple], converter: BaseConverter + cl: type[tuple], converter: BaseConverter ) -> StructureHook: - """A hook factory for structuring namedtuples.""" + """A hook factory for structuring namedtuples from iterables.""" # We delegate to the existing infrastructure for heterogenous tuples. - hetero_tuple_type = Tuple[tuple(type.__annotations__.values())] + hetero_tuple_type = Tuple[tuple(cl.__annotations__.values())] base_hook = converter.get_structure_hook(hetero_tuple_type) - return lambda v, _: type(*base_hook(v, hetero_tuple_type)) + return lambda v, _: cl(*base_hook(v, hetero_tuple_type)) + + +def _namedtuple_to_attrs(cl: type[tuple]) -> list[Attribute]: + """Generate pseudo attributes for a namedtuple.""" + return [ + Attribute( + name, + cl._field_defaults.get(name, NOTHING), + None, + False, + False, + False, + True, + False, + type=a, + alias=name, + ) + for name, a in get_type_hints(cl).items() + ] + + +def namedtuple_dict_structure_factory( + cl: type[tuple], + converter: BaseConverter, + detailed_validation: bool | Literal["from_converter"] = "from_converter", + forbid_extra_keys: bool = False, + use_linecache: bool = True, + /, + **kwargs: AttributeOverride, +) -> StructureHook: + """A hook factory for hooks structuring namedtuples from dictionaries. + + :param forbid_extra_keys: Whether the hook should raise a `ForbiddenExtraKeysError` + if unknown keys are encountered. + :param use_linecache: Whether to store the source code in the Python linecache. + + .. versionadded:: 24.1.0 + """ + try: + working_set = already_generating.working_set + except AttributeError: + working_set = set() + already_generating.working_set = working_set + else: + if cl in working_set: + raise RecursionError() + + working_set.add(cl) + + try: + return make_dict_structure_fn_from_attrs( + _namedtuple_to_attrs(cl), + cl, + converter, + _cattrs_forbid_extra_keys=forbid_extra_keys, + _cattrs_use_detailed_validation=detailed_validation, + _cattrs_use_linecache=use_linecache, + **kwargs, + ) + finally: + working_set.remove(cl) + if not working_set: + del already_generating.working_set + + +def namedtuple_dict_unstructure_factory( + cl: type[tuple], + converter: BaseConverter, + omit_if_default: bool = False, + use_linecache: bool = True, + /, + **kwargs: AttributeOverride, +) -> UnstructureHook: + """A hook factory for hooks unstructuring namedtuples to dictionaries. + + :param omit_if_default: When true, attributes equal to their default values + will be omitted in the result dictionary. + :param use_linecache: Whether to store the source code in the Python linecache. + + .. versionadded:: 24.1.0 + """ + try: + working_set = already_generating.working_set + except AttributeError: + working_set = set() + already_generating.working_set = working_set + if cl in working_set: + raise RecursionError() + + working_set.add(cl) + + try: + return make_dict_unstructure_fn_from_attrs( + _namedtuple_to_attrs(cl), + cl, + converter, + _cattrs_omit_if_default=omit_if_default, + _cattrs_use_linecache=use_linecache, + **kwargs, + ) + finally: + working_set.remove(cl) + if not working_set: + del already_generating.working_set diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 35a9ba59..8a0b2b66 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -53,6 +53,7 @@ ) from .cols import ( is_namedtuple, + iterable_unstructure_factory, list_structure_factory, namedtuple_structure_factory, namedtuple_unstructure_factory, @@ -83,7 +84,6 @@ make_dict_structure_fn, make_dict_unstructure_fn, make_hetero_tuple_unstructure_fn, - make_iterable_unstructure_fn, make_mapping_structure_fn, make_mapping_unstructure_fn, ) @@ -1248,7 +1248,7 @@ def gen_unstructure_iterable( unstructure_to = self._unstruct_collection_overrides.get( get_origin(cl) or cl, unstructure_to or list ) - h = make_iterable_unstructure_fn(cl, self, unstructure_to=unstructure_to) + h = iterable_unstructure_factory(cl, self, unstructure_to=unstructure_to) self._unstructure_func.register_cls_list([(cl, h)], direct=True) return h diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 9fc33199..4149217a 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Callable, + Final, Iterable, Literal, Mapping, @@ -12,7 +13,7 @@ TypeVar, ) -from attrs import NOTHING, Factory, resolve_types +from attrs import NOTHING, Attribute, Factory, resolve_types from .._compat import ( ANIES, @@ -26,6 +27,7 @@ is_generic, ) from .._generics import deep_copy_with +from ..dispatch import UnstructureHook from ..errors import ( AttributeValidationNote, ClassValidationError, @@ -50,6 +52,8 @@ "make_hetero_tuple_unstructure_fn", "make_mapping_unstructure_fn", "make_mapping_structure_fn", + "make_dict_unstructure_fn_from_attrs", + "make_dict_structure_fn_from_attrs", ] @@ -70,6 +74,151 @@ def override( T = TypeVar("T") +def make_dict_unstructure_fn_from_attrs( + attrs: list[Attribute], + cl: type, + converter: BaseConverter, + typevar_map: dict[str, Any] = {}, + _cattrs_omit_if_default: bool = False, + _cattrs_use_linecache: bool = True, + _cattrs_use_alias: bool = False, + _cattrs_include_init_false: bool = False, + **kwargs: AttributeOverride, +) -> Callable[[T], dict[str, Any]]: + """ + Generate a specialized dict unstructuring function for a list of attributes. + + Usually used as a building block by more specialized hook factories. + + Any provided overrides are attached to the generated function under the + `overrides` attribute. + + :param cl: The class for which the function is generated; used mostly for its name, + module name and qualname. + :param _cattrs_omit_if_default: if true, attributes equal to their default values + will be omitted in the result dictionary. + :param _cattrs_use_alias: If true, the attribute alias will be used as the + dictionary key by default. + :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` + will be included. + + .. versionadded:: 24.1.0 + """ + + fn_name = "unstructure_" + cl.__name__ + globs = {} + lines = [] + invocation_lines = [] + internal_arg_parts = {} + + for a in attrs: + attr_name = a.name + override = kwargs.get(attr_name, neutral) + if override.omit: + continue + if override.omit is None and not a.init and not _cattrs_include_init_false: + continue + if override.rename is None: + kn = attr_name if not _cattrs_use_alias else a.alias + else: + kn = override.rename + d = a.default + + # For each attribute, we try resolving the type here and now. + # If a type is manually overwritten, this function should be + # regenerated. + handler = None + if override.unstruct_hook is not None: + handler = override.unstruct_hook + else: + if a.type is not None: + t = a.type + if isinstance(t, TypeVar): + if t.__name__ in typevar_map: + t = typevar_map[t.__name__] + else: + handler = converter.unstructure + elif is_generic(t) and not is_bare(t) and not is_annotated(t): + t = deep_copy_with(t, typevar_map) + + if handler is None: + if ( + is_bare_final(t) + and a.default is not NOTHING + and not isinstance(a.default, Factory) + ): + # This is a special case where we can use the + # type of the default to dispatch on. + t = a.default.__class__ + try: + handler = converter.get_unstructure_hook(t, cache_result=False) + except RecursionError: + # There's a circular reference somewhere down the line + handler = converter.unstructure + else: + handler = converter.unstructure + + is_identity = handler == identity + + if not is_identity: + unstruct_handler_name = f"__c_unstr_{attr_name}" + globs[unstruct_handler_name] = handler + internal_arg_parts[unstruct_handler_name] = handler + invoke = f"{unstruct_handler_name}(instance.{attr_name})" + else: + invoke = f"instance.{attr_name}" + + if d is not NOTHING and ( + (_cattrs_omit_if_default and override.omit_if_default is not False) + or override.omit_if_default + ): + def_name = f"__c_def_{attr_name}" + + if isinstance(d, Factory): + globs[def_name] = d.factory + internal_arg_parts[def_name] = d.factory + if d.takes_self: + lines.append(f" if instance.{attr_name} != {def_name}(instance):") + else: + lines.append(f" if instance.{attr_name} != {def_name}():") + lines.append(f" res['{kn}'] = {invoke}") + else: + globs[def_name] = d + internal_arg_parts[def_name] = d + lines.append(f" if instance.{attr_name} != {def_name}:") + lines.append(f" res['{kn}'] = {invoke}") + + else: + # No default or no override. + invocation_lines.append(f"'{kn}': {invoke},") + + internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) + if internal_arg_line: + internal_arg_line = f", {internal_arg_line}" + for k, v in internal_arg_parts.items(): + globs[k] = v + + total_lines = ( + [f"def {fn_name}(instance{internal_arg_line}):"] + + [" res = {"] + + [f" {line}" for line in invocation_lines] + + [" }"] + + lines + + [" return res"] + ) + script = "\n".join(total_lines) + fname = generate_unique_filename( + cl, "unstructure", lines=total_lines if _cattrs_use_linecache else [] + ) + + eval(compile(script, fname, "exec"), globs) + + res = globs[fn_name] + res.overrides = kwargs + + return res + + def make_dict_unstructure_fn( cl: type[T], converter: BaseConverter, @@ -114,13 +263,6 @@ def make_dict_unstructure_fn( if origin is not None: cl = origin - cl_name = cl.__name__ - fn_name = "unstructure_" + cl_name - globs = {} - lines = [] - invocation_lines = [] - internal_arg_parts = {} - # We keep track of what we're generating to help with recursive # class graphs. try: @@ -134,128 +276,31 @@ def make_dict_unstructure_fn( working_set.add(cl) try: - for a in attrs: - attr_name = a.name - override = kwargs.get(attr_name, neutral) - if override.omit: - continue - if override.omit is None and not a.init and not _cattrs_include_init_false: - continue - if override.rename is None: - kn = attr_name if not _cattrs_use_alias else a.alias - else: - kn = override.rename - d = a.default - - # For each attribute, we try resolving the type here and now. - # If a type is manually overwritten, this function should be - # regenerated. - handler = None - if override.unstruct_hook is not None: - handler = override.unstruct_hook - else: - if a.type is not None: - t = a.type - if isinstance(t, TypeVar): - if t.__name__ in mapping: - t = mapping[t.__name__] - else: - handler = converter.unstructure - elif is_generic(t) and not is_bare(t) and not is_annotated(t): - t = deep_copy_with(t, mapping) - - if handler is None: - if ( - is_bare_final(t) - and a.default is not NOTHING - and not isinstance(a.default, Factory) - ): - # This is a special case where we can use the - # type of the default to dispatch on. - t = a.default.__class__ - try: - handler = converter.get_unstructure_hook( - t, cache_result=False - ) - except RecursionError: - # There's a circular reference somewhere down the line - handler = converter.unstructure - else: - handler = converter.unstructure - - is_identity = handler == identity - - if not is_identity: - unstruct_handler_name = f"__c_unstr_{attr_name}" - globs[unstruct_handler_name] = handler - internal_arg_parts[unstruct_handler_name] = handler - invoke = f"{unstruct_handler_name}(instance.{attr_name})" - else: - invoke = f"instance.{attr_name}" - - if d is not NOTHING and ( - (_cattrs_omit_if_default and override.omit_if_default is not False) - or override.omit_if_default - ): - def_name = f"__c_def_{attr_name}" - - if isinstance(d, Factory): - globs[def_name] = d.factory - internal_arg_parts[def_name] = d.factory - if d.takes_self: - lines.append( - f" if instance.{attr_name} != {def_name}(instance):" - ) - else: - lines.append(f" if instance.{attr_name} != {def_name}():") - lines.append(f" res['{kn}'] = {invoke}") - else: - globs[def_name] = d - internal_arg_parts[def_name] = d - lines.append(f" if instance.{attr_name} != {def_name}:") - lines.append(f" res['{kn}'] = {invoke}") - - else: - # No default or no override. - invocation_lines.append(f"'{kn}': {invoke},") - - internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) - if internal_arg_line: - internal_arg_line = f", {internal_arg_line}" - for k, v in internal_arg_parts.items(): - globs[k] = v - - total_lines = ( - [f"def {fn_name}(instance{internal_arg_line}):"] - + [" res = {"] - + [f" {line}" for line in invocation_lines] - + [" }"] - + lines - + [" return res"] - ) - script = "\n".join(total_lines) - fname = generate_unique_filename( - cl, "unstructure", lines=total_lines if _cattrs_use_linecache else [] + return make_dict_unstructure_fn_from_attrs( + attrs, + cl, + converter, + mapping, + _cattrs_omit_if_default=_cattrs_omit_if_default, + _cattrs_use_linecache=_cattrs_use_linecache, + _cattrs_use_alias=_cattrs_use_alias, + _cattrs_include_init_false=_cattrs_include_init_false, + **kwargs, ) - - eval(compile(script, fname, "exec"), globs) finally: working_set.remove(cl) if not working_set: del already_generating.working_set - res = globs[fn_name] - res.overrides = kwargs - - return res - DictStructureFn = Callable[[Mapping[str, Any], Any], T] -def make_dict_structure_fn( - cl: type[T], +def make_dict_structure_fn_from_attrs( + attrs: list[Attribute], + cl: type, converter: BaseConverter, + typevar_map: dict[str, Any] = {}, _cattrs_forbid_extra_keys: bool | Literal["from_converter"] = "from_converter", _cattrs_use_linecache: bool = True, _cattrs_prefer_attrib_converters: ( @@ -267,8 +312,9 @@ def make_dict_structure_fn( **kwargs: AttributeOverride, ) -> DictStructureFn[T]: """ - Generate a specialized dict structuring function for an attrs class or - dataclass. + Generate a specialized dict structuring function for a list of attributes. + + Usually used as a building block by more specialized hook factories. Any provided overrides are attached to the generated function under the `overrides` attribute. @@ -286,28 +332,9 @@ def make_dict_structure_fn( :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` will be included. - .. versionadded:: 23.2.0 *_cattrs_use_alias* - .. versionadded:: 23.2.0 *_cattrs_include_init_false* - .. versionchanged:: 23.2.0 - The `_cattrs_forbid_extra_keys` and `_cattrs_detailed_validation` parameters - take their values from the given converter by default. - .. versionchanged:: 24.1.0 - The `_cattrs_prefer_attrib_converters` parameter takes its value from the given - converter by default. + .. versionadded:: 24.1.0 """ - mapping = {} - if is_generic(cl): - base = get_origin(cl) - mapping = generate_mapping(cl, mapping) - if base is not None: - cl = base - - for base in getattr(cl, "__orig_bases__", ()): - if is_generic(base) and not str(base).startswith("typing.Generic"): - mapping = generate_mapping(base, mapping) - break - cl_name = cl.__name__ fn_name = "structure_" + cl_name @@ -316,7 +343,7 @@ def make_dict_structure_fn( # This is nasty, I am not sure how best to handle `typing.List[str]` or # `TClass[int, int]` as a parameter type here try: - name_base = mapping[p.__name__] + name_base = typevar_map[p.__name__] except KeyError: pn = p.__name__ raise StructureHandlerNotFoundError( @@ -337,12 +364,6 @@ def make_dict_structure_fn( pi_lines = [] # post instantiation lines invocation_lines = [] - attrs = adapted_fields(cl) - - if any(isinstance(a.type, str) for a in attrs): - # PEP 563 annotations - need to be resolved. - resolve_types(cl) - allowed_fields = set() if _cattrs_forbid_extra_keys == "from_converter": # BaseConverter doesn't have it so we're careful. @@ -356,180 +377,224 @@ def make_dict_structure_fn( globs["__c_a"] = allowed_fields globs["__c_feke"] = ForbiddenExtraKeysError - # We keep track of what we're generating to help with recursive - # class graphs. - try: - working_set = already_generating.working_set - except AttributeError: - working_set = set() - already_generating.working_set = working_set - else: - if cl in working_set: - raise RecursionError() - - working_set.add(cl) - - try: - if _cattrs_detailed_validation: - lines.append(" res = {}") - lines.append(" errors = []") - invocation_lines.append("**res,") - internal_arg_parts["__c_cve"] = ClassValidationError - internal_arg_parts["__c_avn"] = AttributeValidationNote - for a in attrs: - an = a.name - override = kwargs.get(an, neutral) - if override.omit: - continue - if ( - override.omit is None - and not a.init - and not _cattrs_include_init_false - ): - continue - t = a.type - if isinstance(t, TypeVar): - t = mapping.get(t.__name__, t) - elif is_generic(t) and not is_bare(t) and not is_annotated(t): - t = deep_copy_with(t, mapping) + if _cattrs_detailed_validation: + lines.append(" res = {}") + lines.append(" errors = []") + invocation_lines.append("**res,") + internal_arg_parts["__c_cve"] = ClassValidationError + internal_arg_parts["__c_avn"] = AttributeValidationNote + for a in attrs: + an = a.name + override = kwargs.get(an, neutral) + if override.omit: + continue + if override.omit is None and not a.init and not _cattrs_include_init_false: + continue + t = a.type + if isinstance(t, TypeVar): + t = typevar_map.get(t.__name__, t) + elif is_generic(t) and not is_bare(t) and not is_annotated(t): + t = deep_copy_with(t, typevar_map) - # For each attribute, we try resolving the type here and now. - # If a type is manually overwritten, this function should be - # regenerated. - if override.struct_hook is not None: - # If the user has requested an override, just use that. - handler = override.struct_hook - else: - handler = find_structure_handler( - a, t, converter, _cattrs_prefer_attrib_converters - ) + # For each attribute, we try resolving the type here and now. + # If a type is manually overwritten, this function should be + # regenerated. + if override.struct_hook is not None: + # If the user has requested an override, just use that. + handler = override.struct_hook + else: + handler = find_structure_handler( + a, t, converter, _cattrs_prefer_attrib_converters + ) - struct_handler_name = f"__c_structure_{an}" - if handler is not None: - internal_arg_parts[struct_handler_name] = handler + struct_handler_name = f"__c_structure_{an}" + if handler is not None: + internal_arg_parts[struct_handler_name] = handler - ian = a.alias - if override.rename is None: - kn = an if not _cattrs_use_alias else a.alias - else: - kn = override.rename + ian = a.alias + if override.rename is None: + kn = an if not _cattrs_use_alias else a.alias + else: + kn = override.rename - allowed_fields.add(kn) - i = " " + allowed_fields.add(kn) + i = " " - if not a.init: - if a.default is not NOTHING: - pi_lines.append(f"{i}if '{kn}' in o:") - i = f"{i} " - pi_lines.append(f"{i}try:") + if not a.init: + if a.default is not NOTHING: + pi_lines.append(f"{i}if '{kn}' in o:") i = f"{i} " - type_name = f"__c_type_{an}" - internal_arg_parts[type_name] = t - if handler is not None: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - pi_lines.append( - f"{i}instance.{an} = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - pi_lines.append( - f"{i}instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" - ) + pi_lines.append(f"{i}try:") + i = f"{i} " + type_name = f"__c_type_{an}" + internal_arg_parts[type_name] = t + if handler is not None: + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + pi_lines.append( + f"{i}instance.{an} = {struct_handler_name}(o['{kn}'])" + ) else: - pi_lines.append(f"{i}instance.{an} = o['{kn}']") - i = i[:-2] - pi_lines.append(f"{i}except Exception as e:") - i = f"{i} " - pi_lines.append( - f'{i}e.__notes__ = getattr(e, \'__notes__\', []) + [__c_avn("Structuring class {cl.__qualname__} @ attribute {an}", "{an}", __c_type_{an})]' - ) - pi_lines.append(f"{i}errors.append(e)") - + tn = f"__c_type_{an}" + internal_arg_parts[tn] = t + pi_lines.append( + f"{i}instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + ) else: - if a.default is not NOTHING: - lines.append(f"{i}if '{kn}' in o:") - i = f"{i} " - lines.append(f"{i}try:") + pi_lines.append(f"{i}instance.{an} = o['{kn}']") + i = i[:-2] + pi_lines.append(f"{i}except Exception as e:") + i = f"{i} " + pi_lines.append( + f'{i}e.__notes__ = getattr(e, \'__notes__\', []) + [__c_avn("Structuring class {cl.__qualname__} @ attribute {an}", "{an}", __c_type_{an})]' + ) + pi_lines.append(f"{i}errors.append(e)") + + else: + if a.default is not NOTHING: + lines.append(f"{i}if '{kn}' in o:") i = f"{i} " - type_name = f"__c_type_{an}" - internal_arg_parts[type_name] = t - if handler: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - lines.append( - f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - lines.append( - f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + lines.append(f"{i}try:") + i = f"{i} " + type_name = f"__c_type_{an}" + internal_arg_parts[type_name] = t + if handler: + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + lines.append( + f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'])" + ) else: - lines.append(f"{i}res['{ian}'] = o['{kn}']") - i = i[:-2] - lines.append(f"{i}except Exception as e:") - i = f"{i} " - lines.append( - f'{i}e.__notes__ = getattr(e, \'__notes__\', []) + [__c_avn("Structuring class {cl.__qualname__} @ attribute {an}", "{an}", __c_type_{an})]' - ) - lines.append(f"{i}errors.append(e)") + tn = f"__c_type_{an}" + internal_arg_parts[tn] = t + lines.append( + f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" + ) + else: + lines.append(f"{i}res['{ian}'] = o['{kn}']") + i = i[:-2] + lines.append(f"{i}except Exception as e:") + i = f"{i} " + lines.append( + f'{i}e.__notes__ = getattr(e, \'__notes__\', []) + [__c_avn("Structuring class {cl.__qualname__} @ attribute {an}", "{an}", __c_type_{an})]' + ) + lines.append(f"{i}errors.append(e)") - if _cattrs_forbid_extra_keys: - post_lines += [ - " unknown_fields = set(o.keys()) - __c_a", - " if unknown_fields:", - " errors.append(__c_feke('', __cl, unknown_fields))", - ] + if _cattrs_forbid_extra_keys: + post_lines += [ + " unknown_fields = set(o.keys()) - __c_a", + " if unknown_fields:", + " errors.append(__c_feke('', __cl, unknown_fields))", + ] - post_lines.append( - f" if errors: raise __c_cve('While structuring ' + {cl_name!r}, errors, __cl)" + post_lines.append( + f" if errors: raise __c_cve('While structuring ' + {cl_name!r}, errors, __cl)" + ) + if not pi_lines: + instantiation_lines = ( + [" try:"] + + [" return __cl("] + + [f" {line}" for line in invocation_lines] + + [" )"] + + [ + f" except Exception as exc: raise __c_cve('While structuring ' + {cl_name!r}, [exc], __cl)" + ] ) - if not pi_lines: - instantiation_lines = ( - [" try:"] - + [" return __cl("] - + [f" {line}" for line in invocation_lines] - + [" )"] - + [ - f" except Exception as exc: raise __c_cve('While structuring ' + {cl_name!r}, [exc], __cl)" - ] - ) + else: + instantiation_lines = ( + [" try:"] + + [" instance = __cl("] + + [f" {line}" for line in invocation_lines] + + [" )"] + + [ + f" except Exception as exc: raise __c_cve('While structuring ' + {cl_name!r}, [exc], __cl)" + ] + ) + pi_lines.append(" return instance") + else: + non_required = [] + # The first loop deals with required args. + for a in attrs: + an = a.name + override = kwargs.get(an, neutral) + if override.omit: + continue + if override.omit is None and not a.init and not _cattrs_include_init_false: + continue + if a.default is not NOTHING: + non_required.append(a) + continue + t = a.type + if isinstance(t, TypeVar): + t = typevar_map.get(t.__name__, t) + elif is_generic(t) and not is_bare(t) and not is_annotated(t): + t = deep_copy_with(t, typevar_map) + + # For each attribute, we try resolving the type here and now. + # If a type is manually overwritten, this function should be + # regenerated. + if override.struct_hook is not None: + # If the user has requested an override, just use that. + handler = override.struct_hook else: - instantiation_lines = ( - [" try:"] - + [" instance = __cl("] - + [f" {line}" for line in invocation_lines] - + [" )"] - + [ - f" except Exception as exc: raise __c_cve('While structuring ' + {cl_name!r}, [exc], __cl)" - ] + handler = find_structure_handler( + a, t, converter, _cattrs_prefer_attrib_converters ) - pi_lines.append(" return instance") - else: - non_required = [] - # The first loop deals with required args. - for a in attrs: + + if override.rename is None: + kn = an if not _cattrs_use_alias else a.alias + else: + kn = override.rename + allowed_fields.add(kn) + + if not a.init: + if handler is not None: + struct_handler_name = f"__c_structure_{an}" + internal_arg_parts[struct_handler_name] = handler + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + pi_line = f" instance.{an} = {struct_handler_name}(o['{kn}'])" + else: + tn = f"__c_type_{an}" + internal_arg_parts[tn] = t + pi_line = ( + f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + ) + else: + pi_line = f" instance.{an} = o['{kn}']" + + pi_lines.append(pi_line) + else: + if handler: + struct_handler_name = f"__c_structure_{an}" + internal_arg_parts[struct_handler_name] = handler + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + invocation_line = f"{struct_handler_name}(o['{kn}'])," + else: + tn = f"__c_type_{an}" + internal_arg_parts[tn] = t + invocation_line = f"{struct_handler_name}(o['{kn}'], {tn})," + else: + invocation_line = f"o['{kn}']," + + if a.kw_only: + invocation_line = f"{a.alias}={invocation_line}" + invocation_lines.append(invocation_line) + + # The second loop is for optional args. + if non_required: + invocation_lines.append("**res,") + lines.append(" res = {}") + + for a in non_required: an = a.name override = kwargs.get(an, neutral) - if override.omit: - continue - if ( - override.omit is None - and not a.init - and not _cattrs_include_init_false - ): - continue - if a.default is not NOTHING: - non_required.append(a) - continue t = a.type if isinstance(t, TypeVar): - t = mapping.get(t.__name__, t) + t = typevar_map.get(t.__name__, t) elif is_generic(t) and not is_bare(t) and not is_annotated(t): - t = deep_copy_with(t, mapping) + t = deep_copy_with(t, typevar_map) # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be @@ -542,136 +607,66 @@ def make_dict_structure_fn( a, t, converter, _cattrs_prefer_attrib_converters ) + struct_handler_name = f"__c_structure_{an}" + internal_arg_parts[struct_handler_name] = handler + if override.rename is None: kn = an if not _cattrs_use_alias else a.alias else: kn = override.rename allowed_fields.add(kn) - if not a.init: - if handler is not None: - struct_handler_name = f"__c_structure_{an}" - internal_arg_parts[struct_handler_name] = handler + pi_lines.append(f" if '{kn}' in o:") + if handler: if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t - pi_line = ( - f" instance.{an} = {struct_handler_name}(o['{kn}'])" + pi_lines.append( + f" instance.{an} = {struct_handler_name}(o['{kn}'])" ) else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t - pi_line = f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + pi_lines.append( + f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" + ) else: - pi_line = f" instance.{an} = o['{kn}']" - - pi_lines.append(pi_line) + pi_lines.append(f" instance.{an} = o['{kn}']") else: + post_lines.append(f" if '{kn}' in o:") if handler: - struct_handler_name = f"__c_structure_{an}" - internal_arg_parts[struct_handler_name] = handler if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t - invocation_line = f"{struct_handler_name}(o['{kn}'])," + post_lines.append( + f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'])" + ) else: tn = f"__c_type_{an}" internal_arg_parts[tn] = t - invocation_line = f"{struct_handler_name}(o['{kn}'], {tn})," - else: - invocation_line = f"o['{kn}']," - - if a.kw_only: - invocation_line = f"{a.alias}={invocation_line}" - invocation_lines.append(invocation_line) - - # The second loop is for optional args. - if non_required: - invocation_lines.append("**res,") - lines.append(" res = {}") - - for a in non_required: - an = a.name - override = kwargs.get(an, neutral) - t = a.type - if isinstance(t, TypeVar): - t = mapping.get(t.__name__, t) - elif is_generic(t) and not is_bare(t) and not is_annotated(t): - t = deep_copy_with(t, mapping) - - # For each attribute, we try resolving the type here and now. - # If a type is manually overwritten, this function should be - # regenerated. - if override.struct_hook is not None: - # If the user has requested an override, just use that. - handler = override.struct_hook - else: - handler = find_structure_handler( - a, t, converter, _cattrs_prefer_attrib_converters - ) - - struct_handler_name = f"__c_structure_{an}" - internal_arg_parts[struct_handler_name] = handler - - if override.rename is None: - kn = an if not _cattrs_use_alias else a.alias - else: - kn = override.rename - allowed_fields.add(kn) - if not a.init: - pi_lines.append(f" if '{kn}' in o:") - if handler: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - pi_lines.append( - f" instance.{an} = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - pi_lines.append( - f" instance.{an} = {struct_handler_name}(o['{kn}'], {tn})" - ) - else: - pi_lines.append(f" instance.{an} = o['{kn}']") + post_lines.append( + f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'], {tn})" + ) else: - post_lines.append(f" if '{kn}' in o:") - if handler: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - post_lines.append( - f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - post_lines.append( - f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) - else: - post_lines.append(f" res['{a.alias}'] = o['{kn}']") - if not pi_lines: - instantiation_lines = ( - [" return __cl("] - + [f" {line}" for line in invocation_lines] - + [" )"] - ) - else: - instantiation_lines = ( - [" instance = __cl("] - + [f" {line}" for line in invocation_lines] - + [" )"] - ) - pi_lines.append(" return instance") + post_lines.append(f" res['{a.alias}'] = o['{kn}']") + if not pi_lines: + instantiation_lines = ( + [" return __cl("] + + [f" {line}" for line in invocation_lines] + + [" )"] + ) + else: + instantiation_lines = ( + [" instance = __cl("] + + [f" {line}" for line in invocation_lines] + + [" )"] + ) + pi_lines.append(" return instance") - if _cattrs_forbid_extra_keys: - post_lines += [ - " unknown_fields = set(o.keys()) - __c_a", - " if unknown_fields:", - " raise __c_feke('', __cl, unknown_fields)", - ] - finally: - working_set.remove(cl) - if not working_set: - del already_generating.working_set + if _cattrs_forbid_extra_keys: + post_lines += [ + " unknown_fields = set(o.keys()) - __c_a", + " if unknown_fields:", + " raise __c_feke('', __cl, unknown_fields)", + ] # At the end, we create the function header. internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) @@ -699,31 +694,101 @@ def make_dict_structure_fn( return res -IterableUnstructureFn = Callable[[Iterable[Any]], Any] +def make_dict_structure_fn( + cl: type[T], + converter: BaseConverter, + _cattrs_forbid_extra_keys: bool | Literal["from_converter"] = "from_converter", + _cattrs_use_linecache: bool = True, + _cattrs_prefer_attrib_converters: ( + bool | Literal["from_converter"] + ) = "from_converter", + _cattrs_detailed_validation: bool | Literal["from_converter"] = "from_converter", + _cattrs_use_alias: bool = False, + _cattrs_include_init_false: bool = False, + **kwargs: AttributeOverride, +) -> DictStructureFn[T]: + """ + Generate a specialized dict structuring function for an attrs class or + dataclass. + Any provided overrides are attached to the generated function under the + `overrides` attribute. -def make_iterable_unstructure_fn( - cl: Any, converter: BaseConverter, unstructure_to: Any = None -) -> IterableUnstructureFn: - """Generate a specialized unstructure function for an iterable.""" - handler = converter.unstructure + :param _cattrs_forbid_extra_keys: Whether the structuring function should raise a + `ForbiddenExtraKeysError` if unknown keys are encountered. + :param _cattrs_use_linecache: Whether to store the source code in the Python + linecache. + :param _cattrs_prefer_attrib_converters: If an _attrs_ converter is present on a + field, use it instead of processing the field normally. + :param _cattrs_detailed_validation: Whether to use a slower mode that produces + more detailed errors. + :param _cattrs_use_alias: If true, the attribute alias will be used as the + dictionary key by default. + :param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False` + will be included. - # Let's try fishing out the type args - # Unspecified tuples have `__args__` as empty tuples, so guard - # against IndexError. - if getattr(cl, "__args__", None) not in (None, ()): - type_arg = cl.__args__[0] - if isinstance(type_arg, TypeVar): - type_arg = getattr(type_arg, "__default__", Any) - handler = converter.get_unstructure_hook(type_arg, cache_result=False) - if handler == identity: - # Save ourselves the trouble of iterating over it all. - return unstructure_to or cl + .. versionadded:: 23.2.0 *_cattrs_use_alias* + .. versionadded:: 23.2.0 *_cattrs_include_init_false* + .. versionchanged:: 23.2.0 + The `_cattrs_forbid_extra_keys` and `_cattrs_detailed_validation` parameters + take their values from the given converter by default. + .. versionchanged:: 24.1.0 + The `_cattrs_prefer_attrib_converters` parameter takes its value from the given + converter by default. + """ - def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler): - return _seq_cl(_hook(i) for i in iterable) + mapping = {} + if is_generic(cl): + base = get_origin(cl) + mapping = generate_mapping(cl, mapping) + if base is not None: + cl = base - return unstructure_iterable + for base in getattr(cl, "__orig_bases__", ()): + if is_generic(base) and not str(base).startswith("typing.Generic"): + mapping = generate_mapping(base, mapping) + break + + attrs = adapted_fields(cl) + + if any(isinstance(a.type, str) for a in attrs): + # PEP 563 annotations - need to be resolved. + resolve_types(cl) + + # We keep track of what we're generating to help with recursive + # class graphs. + try: + working_set = already_generating.working_set + except AttributeError: + working_set = set() + already_generating.working_set = working_set + else: + if cl in working_set: + raise RecursionError() + + working_set.add(cl) + + try: + return make_dict_structure_fn_from_attrs( + attrs, + cl, + converter, + mapping, + _cattrs_forbid_extra_keys=_cattrs_forbid_extra_keys, + _cattrs_use_linecache=_cattrs_use_linecache, + _cattrs_prefer_attrib_converters=_cattrs_prefer_attrib_converters, + _cattrs_detailed_validation=_cattrs_detailed_validation, + _cattrs_use_alias=_cattrs_use_alias, + _cattrs_include_init_false=_cattrs_include_init_false, + **kwargs, + ) + finally: + working_set.remove(cl) + if not working_set: + del already_generating.working_set + + +IterableUnstructureFn = Callable[[Iterable[Any]], Any] #: A type alias for heterogeneous tuple unstructure hooks. @@ -951,3 +1016,34 @@ def make_mapping_structure_fn( eval(compile(script, "", "exec"), globs) return globs[fn_name] + + +# This factory is here for backwards compatibility and circular imports. +def iterable_unstructure_factory( + cl: Any, converter: BaseConverter, unstructure_to: Any = None +) -> UnstructureHook: + """A hook factory for unstructuring iterables. + + :param unstructure_to: Force unstructuring to this type, if provided. + """ + handler = converter.unstructure + + # Let's try fishing out the type args + # Unspecified tuples have `__args__` as empty tuples, so guard + # against IndexError. + if getattr(cl, "__args__", None) not in (None, ()): + type_arg = cl.__args__[0] + if isinstance(type_arg, TypeVar): + type_arg = getattr(type_arg, "__default__", Any) + handler = converter.get_unstructure_hook(type_arg, cache_result=False) + if handler == identity: + # Save ourselves the trouble of iterating over it all. + return unstructure_to or cl + + def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler): + return _seq_cl(_hook(i) for i in iterable) + + return unstructure_iterable + + +make_iterable_unstructure_fn: Final = iterable_unstructure_factory diff --git a/src/cattrs/gen/_lc.py b/src/cattrs/gen/_lc.py index e598a393..04843cd3 100644 --- a/src/cattrs/gen/_lc.py +++ b/src/cattrs/gen/_lc.py @@ -1,10 +1,10 @@ """Line-cache functionality.""" import linecache -from typing import Any, List +from typing import List -def generate_unique_filename(cls: Any, func_name: str, lines: List[str] = []) -> str: +def generate_unique_filename(cls: type, func_name: str, lines: List[str] = []) -> str: """ Create a "filename" suitable for a function being generated. diff --git a/tests/test_gen_dict_563.py b/tests/test_gen_dict_563.py index 105ea25e..f81582b2 100644 --- a/tests/test_gen_dict_563.py +++ b/tests/test_gen_dict_563.py @@ -1,4 +1,4 @@ -"""`gen` tests under PEP 563.""" +"""`gen` tests under PEP 563 (stringified annotations).""" from __future__ import annotations diff --git a/tests/test_optionals.py b/tests/test_optionals.py index 2fca1de6..724bd15a 100644 --- a/tests/test_optionals.py +++ b/tests/test_optionals.py @@ -63,6 +63,7 @@ def _(val, _) -> Optional[int]: return int(val) assert converter.structure("", Optional[int]) is None + assert converter.structure("1", Optional[int]) == 1 @converter.register_unstructure_hook def _(val: Optional[int]) -> Any: @@ -71,3 +72,4 @@ def _(val: Optional[int]) -> Any: return val assert converter.unstructure(0, Optional[int]) is None + assert converter.unstructure(5, Optional[int]) == 5 diff --git a/tests/test_recursive.py b/tests/test_recursive.py index 857cf435..119f866b 100644 --- a/tests/test_recursive.py +++ b/tests/test_recursive.py @@ -11,7 +11,7 @@ @define class A: - inner: List[A] # noqa: UP006 + inner: List[A] def test_simple_recursive(): diff --git a/tests/test_tuples.py b/tests/test_tuples.py index 3b63af81..a6729abc 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -1,9 +1,17 @@ """Tests for tuples of all kinds.""" -from typing import NamedTuple, Tuple +from typing import List, NamedTuple, Tuple -from cattrs.cols import is_namedtuple +from attrs import Factory, define +from pytest import raises + +from cattrs.cols import ( + is_namedtuple, + namedtuple_dict_structure_factory, + namedtuple_dict_unstructure_factory, +) from cattrs.converters import Converter +from cattrs.errors import ForbiddenExtraKeysError def test_simple_hetero_tuples(genconverter: Converter): @@ -56,3 +64,74 @@ class Test(NamedTuple): assert genconverter.unstructure(Test(1)) == (2,) assert genconverter.structure([2], Test) == Test(1) + + +def test_simple_dict_nametuples(genconverter: Converter): + """Namedtuples can be un/structured to/from dicts.""" + + class Test(NamedTuple): + a: int + b: str = "test" + + genconverter.register_unstructure_hook_factory( + lambda t: t is Test, namedtuple_dict_unstructure_factory + ) + genconverter.register_structure_hook_factory( + lambda t: t is Test, namedtuple_dict_structure_factory + ) + + assert genconverter.unstructure(Test(1)) == {"a": 1, "b": "test"} + assert genconverter.structure({"a": 1, "b": "2"}, Test) == Test(1, "2") + + # Defaults work. + assert genconverter.structure({"a": 1}, Test) == Test(1, "test") + + +@define +class RecursiveAttrs: + b: "List[RecursiveNamedtuple]" = Factory(list) + + +class RecursiveNamedtuple(NamedTuple): + a: RecursiveAttrs + + +def test_recursive_dict_nametuples(genconverter: Converter): + """Recursive namedtuples can be un/structured to/from dicts.""" + + genconverter.register_unstructure_hook_factory( + lambda t: t is RecursiveNamedtuple, namedtuple_dict_unstructure_factory + ) + genconverter.register_structure_hook_factory( + lambda t: t is RecursiveNamedtuple, namedtuple_dict_structure_factory + ) + + assert genconverter.unstructure(RecursiveNamedtuple(RecursiveAttrs())) == { + "a": {"b": []} + } + assert genconverter.structure( + {"a": {}}, RecursiveNamedtuple + ) == RecursiveNamedtuple(RecursiveAttrs()) + + +def test_dict_nametuples_forbid_extra_keys(genconverter: Converter): + """Forbidding extra keys works for structuring namedtuples from dicts.""" + + class Test(NamedTuple): + a: int + + genconverter.register_structure_hook_factory( + lambda t: t is Test, + lambda t, c: namedtuple_dict_structure_factory(t, c, "from_converter", True), + ) + + with raises(Exception) as exc_info: + genconverter.structure({"a": 1, "b": "2"}, Test) + + if genconverter.detailed_validation: + exc = exc_info.value.exceptions[0] + else: + exc = exc_info.value + + assert isinstance(exc, ForbiddenExtraKeysError) + assert exc.extra_fields == {"b"} diff --git a/tests/test_tuples_563.py b/tests/test_tuples_563.py new file mode 100644 index 00000000..ef41a78f --- /dev/null +++ b/tests/test_tuples_563.py @@ -0,0 +1,36 @@ +"""Tests for tuples under PEP 563 (stringified annotations).""" + +from __future__ import annotations + +from typing import NamedTuple + +from cattrs import Converter +from cattrs.cols import ( + namedtuple_dict_structure_factory, + namedtuple_dict_unstructure_factory, +) + + +class NT(NamedTuple): + a: int + + +def test_simple_dict_nametuples(genconverter: Converter): + """Namedtuples can be un/structured to/from dicts.""" + + class Test(NamedTuple): + a: int + b: str = "test" + + genconverter.register_unstructure_hook_factory( + lambda t: t is Test, namedtuple_dict_unstructure_factory + ) + genconverter.register_structure_hook_factory( + lambda t: t is Test, namedtuple_dict_structure_factory + ) + + assert genconverter.unstructure(Test(1)) == {"a": 1, "b": "test"} + assert genconverter.structure({"a": 1, "b": "2"}, Test) == Test(1, "2") + + # Defaults work. + assert genconverter.structure({"a": 1}, Test) == Test(1, "test")