Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/customizing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
14 changes: 10 additions & 4 deletions src/cattrs/gen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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`
Expand Down
34 changes: 34 additions & 0 deletions tests/strategies/test_include_subclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 77 additions & 1 deletion tests/test_gen_dict.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading