diff --git a/HISTORY.md b/HISTORY.md index 09a953f1..1e517265 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,10 @@ ## 23.2.0 (UNRELEASED) +- **Potentially breaking**: skip _attrs_ fields marked as `init=False` by default. This change is potentially breaking for unstructuring. + See [here](https://catt.rs/en/latest/customizing.html#include_init_false) for instructions on how to restore the old behavior. + ([#40](/~https://github.com/python-attrs/cattrs/issues/40) [#395](/~https://github.com/python-attrs/cattrs/pull/395)) +- The `omit` parameter of `cattrs.override()` is now of type `bool | None` (from `bool`). `None` is the new default and means to apply default `cattrs` handling to the attribute. - Fix `format_exception` parameter working for recursive calls to `transform_error` ([#389](/~https://github.com/python-attrs/cattrs/issues/389) - [_attrs_ aliases](https://www.attrs.org/en/stable/init.html#private-attributes-and-aliases) are now supported, although aliased fields still map to their attribute name instead of their alias by default when un/structuring. @@ -42,7 +46,7 @@ ([#319](/~https://github.com/python-attrs/cattrs/issues/319) [#327](/~https://github.com/python-attrs/cattrs/pull/327>)) - `pathlib.Path` is now supported by default. ([#81](/~https://github.com/python-attrs/cattrs/issues/81)) -- Add `cbor2` serialization library to the `cattr.preconf` package. +- Add `cbor2` serialization library to the `cattrs.preconf` package. - Add optional dependencies for `cattrs.preconf` third-party libraries. ([#337](/~https://github.com/python-attrs/cattrs/pull/337)) - All preconf converters now allow overriding the default `unstruct_collection_overrides` in `make_converter`. ([#350](/~https://github.com/python-attrs/cattrs/issues/350) [#353](/~https://github.com/python-attrs/cattrs/pull/353)) diff --git a/Makefile b/Makefile index ea9e0458..d553dcca 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean clean-test clean-pyc clean-build docs help bench bench-cmp +.PHONY: clean clean-test clean-pyc clean-build docs help bench bench-cmp test .DEFAULT_GOAL := help define BROWSER_PYSCRIPT import os, webbrowser, sys diff --git a/README.md b/README.md index 31634604..9c387c93 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ destructure them. - Custom converters for any type can be registered using `register_structure_hook`. _cattrs_ comes with preconfigured converters for a number of serialization libraries, including json, msgpack, cbor2, bson, yaml and toml. -For details, see the [cattr.preconf package](https://catt.rs/en/stable/preconf.html). +For details, see the [cattrs.preconf package](https://catt.rs/en/stable/preconf.html). ## Design Decisions diff --git a/docs/Makefile b/docs/Makefile index 13ae2d10..e8c0e5e6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,15 +3,10 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = pdm run sphinx-build PAPER = BUILDDIR = _build -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter @@ -177,4 +172,4 @@ pseudoxml: @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." apidoc: - sphinx-apidoc -o . ../src/cattrs/ -f + pdm run sphinx-apidoc -o . ../src/cattrs/ -f diff --git a/docs/converters.md b/docs/converters.md index a0d08e1c..85c6e31e 100644 --- a/docs/converters.md +++ b/docs/converters.md @@ -6,7 +6,7 @@ global converter. Changes done to this global converter, such as registering new structure and unstructure hooks, affect all code using the global functions. -## Global converter +## Global Converter A global converter is provided for convenience as `cattrs.global_converter`. The following functions implicitly use this global converter: @@ -21,7 +21,7 @@ Changes made to the global converter will affect the behavior of these functions Larger applications are strongly encouraged to create and customize a different, private instance of {class}`cattrs.Converter`. -## Converter objects +## Converter Objects To create a private converter, simply instantiate a {class}`cattrs.Converter`. Currently, a converter contains the following state: diff --git a/docs/customizing.md b/docs/customizing.md index 410ec170..524c8e0b 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -13,7 +13,7 @@ them for types using {meth}`Converter.register_structure_hook() `. This approach is the most flexible but also requires the most amount of boilerplate. -## Using `cattrs.gen` generators +## Using `cattrs.gen` Generators _cattrs_ includes a module, {mod}`cattrs.gen`, which allows for generating and compiling specialized functions for unstructuring _attrs_ classes. @@ -25,9 +25,8 @@ Currently, the overrides only support generating dictionary un/structuring funct ### `omit_if_default` -This override can be applied on a per-class or per-attribute basis. The generated -unstructuring function will skip unstructuring values that are equal to their -default or factory values. +This override can be applied on a per-class or per-attribute basis. +The generated unstructuring function will skip unstructuring values that are equal to their default or factory values. ```{doctest} @@ -131,9 +130,8 @@ ExampleClass(klass=1) ### `omit` -This override can only be applied to individual attributes. Using the `omit` -override will simply skip the attribute completely when generating a structuring -or unstructuring function. +This override can only be applied to individual attributes. +Using the `omit` override will simply skip the attribute completely when generating a structuring or unstructuring function. ```{doctest} @@ -198,3 +196,38 @@ AliasClass(number=2) ```{versionadded} 23.2.0 ``` + +### `include_init_false` + +By default, _attrs_ fields defined as `init=False` are skipped when un/structuring. +By generating your un/structure function with `_cattrs_include_init_false=True`, all `init=False` fields will be included for un/structuring. + +```{doctest} + +>>> from cattrs.gen import make_dict_structure_fn +>>> +>>> @define +... class ClassWithInitFalse: +... number: int = field(default=1, init=False) +>>> +>>> c = cattrs.Converter() +>>> hook = make_dict_structure_fn(ClassWithInitFalse, c, _cattrs_include_init_false=True) +>>> c.register_structure_hook(ClassWithInitFalse, hook) +>>> c.structure({"number": 2}, ClassWithInitFalse) +ClassWithInitFalse(number=2) +``` + +A single attribute can be included by overriding it with `omit=False`. + +```{doctest} + +>>> c = cattrs.Converter() +>>> hook = make_dict_structure_fn(ClassWithInitFalse, c, number=override(omit=False)) +>>> c.register_structure_hook(ClassWithInitFalse, hook) +>>> c.structure({"number": 2}, ClassWithInitFalse) +ClassWithInitFalse(number=2) +``` + +```{versionadded} 23.2.0 + +``` diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 797f0a7e..aebf3b8f 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -4,8 +4,7 @@ import re from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Tuple, TypeVar -import attr -from attr import NOTHING, resolve_types +from attrs import NOTHING, Factory, resolve_types from .._compat import ( adapted_fields, @@ -37,10 +36,14 @@ def override( omit_if_default: bool | None = None, rename: str | None = None, - omit: bool = False, + omit: bool | None = None, struct_hook: Callable[[Any, Any], Any] | None = None, unstruct_hook: Callable[[Any], Any] | None = None, -): +) -> AttributeOverride: + """Override how a particular field is handled. + + :param omit: Whether to skip the field or not. `None` means apply default handling. + """ return AttributeOverride(omit_if_default, rename, omit, struct_hook, unstruct_hook) @@ -53,16 +56,22 @@ def make_dict_unstructure_fn( _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 an attrs class or a dataclass. + :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:: 23.2.0 *_cattrs_use_alias* + .. versionadded:: 23.2.0 *_cattrs_include_init_false* """ origin = get_origin(cl) attrs = adapted_fields(origin or cl) # type: ignore @@ -107,6 +116,8 @@ def make_dict_unstructure_fn( override = kwargs.pop(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: @@ -134,7 +145,7 @@ def make_dict_unstructure_fn( if ( is_bare_final(t) and a.default is not NOTHING - and not isinstance(a.default, attr.Factory) + and not isinstance(a.default, Factory) ): # This is a special case where we can use the # type of the default to dispatch on. @@ -157,13 +168,13 @@ def make_dict_unstructure_fn( else: invoke = f"instance.{attr_name}" - if d is not attr.NOTHING and ( + 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, attr.Factory): + if isinstance(d, Factory): globs[def_name] = d.factory internal_arg_parts[def_name] = d.factory if d.takes_self: @@ -227,6 +238,7 @@ def make_dict_structure_fn( _cattrs_prefer_attrib_converters: bool = False, _cattrs_detailed_validation: bool = True, _cattrs_use_alias: bool = False, + _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> DictStructureFn[T]: """ @@ -235,8 +247,11 @@ def make_dict_structure_fn( :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:: 23.2.0 *_cattrs_use_alias* + .. versionadded:: 23.2.0 *_cattrs_include_init_false* """ mapping = {} @@ -280,6 +295,7 @@ def make_dict_structure_fn( globs = {} lines = [] post_lines = [] + pi_lines = [] # post instantiation lines invocation_lines = [] attrs = adapted_fields(cl) @@ -304,6 +320,8 @@ def make_dict_structure_fn( 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) @@ -329,34 +347,69 @@ def make_dict_structure_fn( kn = an if not _cattrs_use_alias else a.alias else: kn = override.rename + allowed_fields.add(kn) i = " " - if a.default is not NOTHING: - lines.append(f"{i}if '{kn}' in o:") + + 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:") i = f"{i} " - 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}'])") + 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 + 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})" + ) else: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - lines.append( - f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + 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: - 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 a.default is not NOTHING: + lines.append(f"{i}if '{kn}' in o:") + i = f"{i} " + 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: + 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 += [ @@ -368,15 +421,27 @@ def make_dict_structure_fn( post_lines.append( f" if errors: raise __c_cve('While structuring ' + {cl_name!r}, errors, __cl)" ) - 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. @@ -385,6 +450,8 @@ def make_dict_structure_fn( 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 @@ -411,22 +478,40 @@ def make_dict_structure_fn( kn = override.rename allowed_fields.add(kn) - 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}'])," + if not a.init: + 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 + 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: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - invocation_line = f"{struct_handler_name}(o['{kn}'], {tn})," + pi_line = f" instance.{an} = o['{kn}']" + + pi_lines.append(pi_line) else: - invocation_line = f"o['{kn}']," + 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) + 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: @@ -461,24 +546,51 @@ def make_dict_structure_fn( else: kn = override.rename allowed_fields.add(kn) - 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}'])" - ) + 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: - tn = f"__c_type_{an}" - internal_arg_parts[tn] = t - post_lines.append( - f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + pi_lines.append(f" instance.{an} = o['{kn}']") else: - post_lines.append(f" res['{a.alias}'] = o['{kn}']") - instantiation_lines = ( - [" return __cl("] + [f" {line}" for line in invocation_lines] + [" )"] - ) + 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") if _cattrs_forbid_extra_keys: post_lines += [ @@ -497,6 +609,7 @@ def make_dict_structure_fn( *lines, *post_lines, *instantiation_lines, + *pi_lines, ] fname = generate_unique_filename(cl, "structure", reserve=_cattrs_use_linecache) diff --git a/src/cattrs/gen/_consts.py b/src/cattrs/gen/_consts.py index bac7c404..a6dcd032 100644 --- a/src/cattrs/gen/_consts.py +++ b/src/cattrs/gen/_consts.py @@ -1,16 +1,18 @@ +from __future__ import annotations + from threading import local -from typing import Any, Callable, Optional +from typing import Any, Callable -from attr import frozen +from attrs import frozen @frozen class AttributeOverride: - omit_if_default: Optional[bool] = None - rename: Optional[str] = None - omit: bool = False # Omit the field completely. - struct_hook: Optional[Callable[[Any, Any], Any]] = None # Structure hook to use. - unstruct_hook: Optional[Callable[[Any], Any]] = None # Structure hook to use. + omit_if_default: bool | None = None + rename: str | None = None + omit: bool | None = None # Omit the field completely. + struct_hook: Callable[[Any, Any], Any] | None = None # Structure hook to use. + unstruct_hook: Callable[[Any], Any] | None = None # Structure hook to use. neutral = AttributeOverride() diff --git a/src/cattrs/v.py b/src/cattrs/v.py index 963f8536..6e817068 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -14,6 +14,7 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: """The default exception formatter, handling the most common exceptions. The following exceptions are handled specially: + * `KeyErrors` (`required field missing`) * `ValueErrors` (`invalid value for type, expected ` or just `invalid value`) * `TypeErrors` (`invalid value for type, expected ` and a couple special @@ -72,6 +73,7 @@ def transform_error( By default, the error messages are in the form of `{description} @ {path}`. While traversing the exception and subexceptions, the path is formed: + * by appending `.{field_name}` for fields in classes * by appending `[{int}]` for indices in iterables, like lists * by appending `[{str}]` for keys in mappings, like dictionaries diff --git a/tests/test_converter.py b/tests/test_converter.py index 1585e28c..ffbec1cd 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -12,9 +12,8 @@ Union, ) -import attr import pytest -from attr import Factory, define, fields, make_class +from attrs import Factory, define, fields, make_class from hypothesis import HealthCheck, assume, given, settings from hypothesis.strategies import booleans, just, lists, one_of, sampled_from @@ -155,14 +154,14 @@ def test_forbid_extra_keys_defaults(attr_and_vals): def test_forbid_extra_keys_nested_override(): - @attr.s + @define class C: - a = attr.ib(type=int, default=1) + a: int = 1 - @attr.s + @define class A: - c = attr.ib(type=C) - a = attr.ib(type=int, default=2) + c: C + a: int = 2 converter = Converter(forbid_extra_keys=True) unstructured = {"a": 3, "c": {"a": 4}} @@ -248,9 +247,9 @@ def test_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat): common_names = a_field_names & b_field_names assume(len(a_field_names) > len(common_names)) - @attr.s + @define class C: - a = attr.ib(type=Union[cl_a, cl_b]) + a: Union[cl_a, cl_b] inst = C(a=cl_a(*vals_a, **kwargs_a)) @@ -323,9 +322,9 @@ def test_optional_field_roundtrip(cl_and_vals): converter = Converter() cl, vals, kwargs = cl_and_vals - @attr.s + @define class C: - a = attr.ib(type=Optional[cl]) + a: Optional[cl] inst = C(a=cl(*vals, **kwargs)) assert inst == converter.structure(converter.unstructure(inst), C) @@ -366,10 +365,10 @@ def test_omit_default_roundtrip(cl_and_vals): converter = Converter(omit_if_default=True) cl, vals, kwargs = cl_and_vals - @attr.s + @define class C: - a: int = attr.ib(default=1) - b: cl = attr.ib(factory=lambda: cl(*vals, **kwargs)) + a: int = 1 + b: cl = Factory(lambda: cl(*vals, **kwargs)) inst = C() unstructured = converter.unstructure(inst) @@ -408,9 +407,9 @@ def test_calling_back(): """ converter = Converter() - @attr.define + @define class C: - a: int = attr.ib(default=1) + a: int = 1 def handler(obj): return { @@ -435,11 +434,11 @@ def test_overriding_generated_unstructure(): """Test overriding a generated unstructure hook works.""" converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Inner @@ -456,11 +455,11 @@ def test_overriding_generated_unstructure_hook_func(): """Test overriding a generated unstructure hook works.""" converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Inner @@ -477,11 +476,11 @@ def test_overriding_generated_structure(): """Test overriding a generated structure hook works.""" converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Inner @@ -499,11 +498,11 @@ def test_overriding_generated_structure_hook_func(): """Test overriding a generated structure hook works.""" converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Inner @@ -628,11 +627,11 @@ def test_annotated_attrs(): converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Annotated[Inner, "test"] j: list[Annotated[Inner, "test"]] @@ -664,11 +663,11 @@ def test_annotated_with_typing_extensions_attrs(): converter = Converter() - @attr.define + @define class Inner: a: int - @attr.define + @define class Outer: i: Annotated[Inner, "test"] j: List[Annotated[Inner, "test"]] diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 0749b5b6..e5a12f93 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -2,8 +2,7 @@ from typing import Dict, Type import pytest -from attr import Factory, define, field -from attr._make import NOTHING +from attrs import NOTHING, Factory, define, field from hypothesis import assume, given from hypothesis.strategies import data, just, one_of, sampled_from @@ -246,8 +245,8 @@ class A: assert cve.value.exceptions[1].extra_fields == {"c"} -def test_omitting(): - converter = BaseConverter() +def test_omitting(converter: BaseConverter): + """Omitting works.""" @define class A: @@ -261,6 +260,28 @@ class A: assert converter.unstructure(A(1)) == {"a": 1} +def test_omitting_none(converter: BaseConverter): + """Omitting works properly with None.""" + + @define + class A: + a: int + b: int = field(init=False) + + converter.register_unstructure_hook( + A, make_dict_unstructure_fn(A, converter, a=override(), b=override()) + ) + + assert converter.unstructure(A(1)) == {"a": 1} + + converter.register_structure_hook( + A, make_dict_structure_fn(A, converter, a=override(), b=override()) + ) + + assert converter.structure({"a": 2}, A).a == 2 + assert not hasattr(converter.structure({"a": 2}, A), "b") + + @pytest.mark.parametrize("detailed_validation", [True, False]) def test_omitting_structure(detailed_validation: bool): """Omitting fields works with generated structuring functions.""" @@ -347,8 +368,7 @@ class A: assert converter.unstructure(A(1, "")) == {"a": 2, "b": ""} -@pytest.mark.parametrize("detailed_validation", [True, False]) -def test_alias_keys(converter: BaseConverter, detailed_validation: bool) -> None: +def test_alias_keys(converter: BaseConverter) -> None: """Alias keys work.""" @define @@ -381,7 +401,7 @@ class A: A, converter, _cattrs_use_alias=True, - _cattrs_detailed_validation=detailed_validation, + _cattrs_detailed_validation=converter.detailed_validation, c=override(omit=True), d=override(rename="d_renamed"), ), @@ -390,3 +410,127 @@ class A: assert converter.structure({"a": 1, "aliased": 2, "d_renamed": 4}, A) == A( 1, 2, 3, 4 ) + + +def test_init_false(converter: BaseConverter) -> None: + """By default init=False keys are ignored.""" + + @define + class A: + a: int + b: int = field(init=False) + _c: int = field(init=False) + d: int = field(init=False, default=4) + + converter.register_unstructure_hook(A, make_dict_unstructure_fn(A, converter)) + + a = A(1) + a.b = 2 + a._c = 3 + + assert converter.unstructure(a) == {"a": 1} + + converter.register_structure_hook( + A, + make_dict_structure_fn( + A, converter, _cattrs_detailed_validation=converter.detailed_validation + ), + ) + + structured = converter.structure({"a": 1}, A) + + assert not hasattr(structured, "b") + assert not hasattr(structured, "_c") + assert structured.d == 4 + assert structured.a == 1 + + +def test_init_false_overridden(converter: BaseConverter) -> None: + """init=False handling can be overriden.""" + + @define + class A: + a: int + b: int = field(init=False) + _c: int = field(init=False) + d: int = field(init=False, default=4) + + converter.register_unstructure_hook( + A, make_dict_unstructure_fn(A, converter, _cattrs_include_init_false=True) + ) + + a = A(1) + a.b = 2 + a._c = 3 + + assert converter.unstructure(a) == {"a": 1, "b": 2, "_c": 3, "d": 4} + + converter.register_structure_hook( + A, + make_dict_structure_fn( + A, + converter, + _cattrs_include_init_false=True, + _cattrs_detailed_validation=converter.detailed_validation, + ), + ) + + structured = converter.structure({"a": 1, "b": 2, "_c": 3}, A) + assert structured.b == 2 + assert structured._c == 3 + assert structured.d == 4 + + structured = converter.structure({"a": 1, "b": 2, "_c": 3, "d": -4}, A) + assert structured.b == 2 + assert structured._c == 3 + assert structured.d == -4 + + +def test_init_false_field_override(converter: BaseConverter) -> None: + """init=False handling can be overriden on a per-field basis.""" + + @define + class A: + a: int + b: int = field(init=False) + _c: int = field(init=False) + d: int = field(init=False, default=4) + + converter.register_unstructure_hook( + A, + make_dict_unstructure_fn( + A, + converter, + b=override(omit=False), + _c=override(omit=False), + d=override(omit=False), + ), + ) + + a = A(1) + a.b = 2 + a._c = 3 + + assert converter.unstructure(a) == {"a": 1, "b": 2, "_c": 3, "d": 4} + + converter.register_structure_hook( + A, + make_dict_structure_fn( + A, + converter, + b=override(omit=False), + _c=override(omit=False), + d=override(omit=False), + _cattrs_detailed_validation=converter.detailed_validation, + ), + ) + + structured = converter.structure({"a": 1, "b": 2, "_c": 3}, A) + assert structured.b == 2 + assert structured._c == 3 + assert structured.d == 4 + + structured = converter.structure({"a": 1, "b": 2, "_c": 3, "d": -4}, A) + assert structured.b == 2 + assert structured._c == 3 + assert structured.d == -4 diff --git a/tox.ini b/tox.ini index 1020e174..4a884743 100644 --- a/tox.ini +++ b/tox.ini @@ -31,6 +31,8 @@ commands = pdm install -G :all,test coverage run -m pytest tests {posargs} passenv = CI +package = wheel +wheel_build_env = .pkg [testenv:pypy3] setenv =