diff --git a/HISTORY.md b/HISTORY.md index 4d271dc4..a4464ea6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. + ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. ([#787](https://github.com/python-attrs/cattrs/pull/787)) - Python 3.15 is now supported and part of the test matrix. diff --git a/docs/customizing.md b/docs/customizing.md index bc099791..0f28283e 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -196,6 +196,14 @@ and support `omit_if_default`, `forbid_extra_keys`, `rename` and `omit`. This override can be applied on a per-class or per-attribute basis. The generated unstructuring hook will skip unstructuring values that are equal to their default or factory values. +By default, {func}`make_dict_unstructure_fn` and {func}`make_dict_unstructure_fn_from_attrs` +inherit the converter's `omit_if_default` setting (`False` for a {class}`cattrs.BaseConverter`). +Pass `_cattrs_omit_if_default=True` or `_cattrs_omit_if_default=False` to override this setting +for a particular hook; per-attribute overrides take precedence over either value. + +If a default-valued field is needed to select a class during structuring, such as a `Literal` discriminator, preserve it with `override(omit_if_default=False)`. +This also applies when using {func}`cattrs.strategies.include_subclasses` with `overrides`, since those generated hooks now inherit the converter setting. +For example, pass `overrides={"kind": override(omit_if_default=False)}` to keep a `kind` discriminator while omitting other default-valued fields. ```{doctest} diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 43e1559f..79d318e1 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -73,7 +73,7 @@ def make_dict_unstructure_fn_from_attrs( cl: type[T], converter: BaseConverter, typevar_map: dict[str, Any] = {}, - _cattrs_omit_if_default: bool = False, + _cattrs_omit_if_default: bool | Literal["from_converter"] = "from_converter", _cattrs_use_linecache: bool = True, _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, @@ -90,7 +90,8 @@ def make_dict_unstructure_fn_from_attrs( :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. + will be omitted in the result dictionary. By default, the value is taken + from the converter, or is false for a `BaseConverter`. :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` @@ -115,6 +116,10 @@ def make_dict_unstructure_fn_from_attrs( invocation_lines = [] internal_arg_parts = {} + if _cattrs_omit_if_default == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_omit_if_default = getattr(converter, "omit_if_default", False) + if _cattrs_use_alias == "from_converter": # BaseConverter doesn't have it so we're careful. _cattrs_use_alias = getattr(converter, "use_alias", False) @@ -254,7 +259,7 @@ def make_dict_unstructure_fn_from_attrs( def make_dict_unstructure_fn( cl: type[T], converter: BaseConverter, - _cattrs_omit_if_default: bool = False, + _cattrs_omit_if_default: bool | Literal["from_converter"] = "from_converter", _cattrs_use_linecache: bool = True, _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, @@ -268,7 +273,8 @@ def make_dict_unstructure_fn( `overrides` attribute. :param _cattrs_omit_if_default: if true, attributes equal to their default values - will be omitted in the result dictionary. + will be omitted in the result dictionary. By default, the value is taken + from the converter, or is false for a `BaseConverter`. :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` diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index bce346f0..32844bdb 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -337,6 +337,40 @@ def test_overrides(with_union_strategy: bool, struct_unstruct: str): assert c.structure(unstructured, structured.__class__) == structured +@pytest.mark.parametrize("union_strategy", [None, configure_tagged_union]) +def test_omit_if_default_with_discriminator_override(union_strategy): + """Preserve the discriminator while inheriting omission for other fields.""" + + @define + class Parent: + kind: typing.Literal["parent"] = "parent" + count: int = 0 + + @define + class Child(Parent): + kind: typing.Literal["child"] = "child" + + converter = Converter(omit_if_default=True) + include_subclasses( + Parent, + converter, + overrides={ + "kind": override(omit_if_default=False), + "count": override(rename="renamed"), + }, + union_strategy=union_strategy, + ) + + for instance in (Parent(), Child(), Parent(count=3), Child(count=3)): + expected = {"kind": instance.kind} + if instance.count != 0: + expected["renamed"] = instance.count + if union_strategy is not None: + expected["_type"] = type(instance).__name__ + assert converter.unstructure(instance) == expected + assert converter.structure(expected, Parent) == instance + + def test_no_parent_classes(genconverter: Converter): """Test an edge condition when a union strategy is used. diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 5e9e8f0b..e7e1c438 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -1,5 +1,6 @@ """Tests for generated dict functions.""" +from dataclasses import dataclass from math import ceil from typing import Annotated, Dict, Literal, Type, Union @@ -11,7 +12,12 @@ from cattrs import BaseConverter, Converter from cattrs._compat import adapted_fields, fields from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError -from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override +from cattrs.gen import ( + make_dict_structure_fn, + make_dict_unstructure_fn, + make_dict_unstructure_fn_from_attrs, + override, +) from .helpers import assert_only_unstructured from .typed import nested_typed_classes, simple_typed_classes, simple_typed_dataclasses @@ -107,6 +113,76 @@ def test_nodefs_generated_unstructuring_cl( assert attr.name in res +@pytest.mark.parametrize("decorate", [define, dataclass]) +@pytest.mark.parametrize("from_attrs", [False, True]) +@pytest.mark.parametrize("converter_setting", [None, False, True, "base"]) +@pytest.mark.parametrize("explicit", [None, False, True]) +def test_omit_if_default_from_converter( + decorate, from_attrs, converter_setting, explicit +): + """Generated hooks inherit omission settings unless explicitly overridden.""" + + @decorate + class A: + a: int = 1 + b: int = 2 + + if converter_setting == "base": + converter = BaseConverter() + elif converter_setting is None: + converter = Converter() + else: + converter = Converter(omit_if_default=converter_setting) + + options = {} if explicit is None else {"_cattrs_omit_if_default": explicit} + if from_attrs: + hook = make_dict_unstructure_fn_from_attrs( + adapted_fields(A), A, converter, b=override(rename="renamed"), **options + ) + else: + hook = make_dict_unstructure_fn( + A, converter, b=override(rename="renamed"), **options + ) + converter.register_unstructure_hook(A, hook) + + omit = converter_setting is True if explicit is None else explicit + assert converter.unstructure(A()) == ({} if omit else {"a": 1, "renamed": 2}) + assert converter.unstructure(A(b=3)) == ( + {"renamed": 3} if omit else {"a": 1, "renamed": 3} + ) + + +@pytest.mark.parametrize("omit_if_default", [False, True]) +@pytest.mark.parametrize("from_attrs", [False, True]) +def test_omit_if_default_from_converter_attribute_overrides( + omit_if_default, from_attrs +): + """Attribute overrides take precedence over the inherited converter setting.""" + + @define + class A: + a: int = 1 + b: int = 2 + c: int = 3 + + converter = Converter(omit_if_default=omit_if_default) + overrides = { + "a": override(omit_if_default=False), + "b": override(omit_if_default=True), + } + if from_attrs: + hook = make_dict_unstructure_fn_from_attrs( + adapted_fields(A), A, converter, **overrides + ) + else: + hook = make_dict_unstructure_fn(A, converter, **overrides) + converter.register_unstructure_hook(A, hook) + + assert converter.unstructure(A()) == ( + {"a": 1} if omit_if_default else {"a": 1, "c": 3} + ) + + @given( one_of(just(BaseConverter), just(Converter)), nested_classes()