diff --git a/HISTORY.md b/HISTORY.md index 562d111e..2c56c85d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -11,6 +11,15 @@ The third number is for emergencies when we need to start branches for older rel Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md). +## 25.2.0 (unreleased) + +- Add a `use_alias` parameter to {class}`cattrs.Converter`. + {func}`cattrs.gen.make_dict_unstructure_fn_from_attrs`, {func}`cattrs.gen.make_dict_unstructure_fn`, + {func}`cattrs.gen.make_dict_structure_fn_from_attrs`, {func}`cattrs.gen.make_dict_structure_fn` + and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now. + If you're using these functions directly, the old behavior can be restored by passing in the desired value directly. + ([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660)) + ## 25.1.1 (2025-06-04) - Fixed `AttributeError: no attribute '__parameters__'` while structuring attrs classes that inherit from parametrized generic aliases from `collections.abc`. diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 9a54476b..aa895552 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1034,6 +1034,7 @@ class Converter(BaseConverter): "forbid_extra_keys", "omit_if_default", "type_overrides", + "use_alias", ) def __init__( @@ -1050,6 +1051,7 @@ def __init__( structure_fallback_factory: HookFactory[StructureHook] = lambda t: raise_error( None, t ), + use_alias: bool = False, ): """ :param detailed_validation: Whether to use a slightly slower mode for detailed @@ -1058,12 +1060,15 @@ def __init__( registered unstructuring hooks match. :param structure_fallback_factory: A hook factory to be called when no registered structuring hooks match. + :param use_alias: Whether to use the field alias instead of the field name as + the un/structured dictionary key by default. .. versionadded:: 23.2.0 *unstructure_fallback_factory* .. versionadded:: 23.2.0 *structure_fallback_factory* .. versionchanged:: 24.2.0 The default `structure_fallback_factory` now raises errors for missing handlers more eagerly, surfacing problems earlier. + .. versionadded:: 25.2.0 *use_alias* """ super().__init__( dict_factory=dict_factory, @@ -1076,6 +1081,7 @@ def __init__( self.omit_if_default = omit_if_default self.forbid_extra_keys = forbid_extra_keys self.type_overrides = dict(type_overrides) + self.use_alias = use_alias unstruct_collection_overrides = { get_origin(k) or k: v for k, v in unstruct_collection_overrides.items() @@ -1299,6 +1305,7 @@ def gen_structure_attrs_fromdict( _cattrs_forbid_extra_keys=self.forbid_extra_keys, _cattrs_prefer_attrib_converters=self._prefer_attrib_converters, _cattrs_detailed_validation=self.detailed_validation, + _cattrs_use_alias=self.use_alias, **attrib_overrides, ) @@ -1377,6 +1384,7 @@ def copy( unstruct_collection_overrides: Mapping[type, UnstructureHook] | None = None, prefer_attrib_converters: bool | None = None, detailed_validation: bool | None = None, + use_alias: bool | None = None, ) -> Self: """Create a copy of the converter, keeping all existing custom hooks. @@ -1416,6 +1424,7 @@ def copy( if detailed_validation is not None else self.detailed_validation ), + use_alias=(use_alias if use_alias is not None else self.use_alias), ) self._unstructure_func.copy_to( diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 3afa3b9b..62e3a37a 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -74,7 +74,7 @@ def make_dict_unstructure_fn_from_attrs( typevar_map: dict[str, Any] = {}, _cattrs_omit_if_default: bool = False, _cattrs_use_linecache: bool = True, - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> Callable[[T], dict[str, Any]]: @@ -96,6 +96,9 @@ def make_dict_unstructure_fn_from_attrs( will be included. .. versionadded:: 24.1.0 + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. """ fn_name = "unstructure_" + cl.__name__ @@ -104,6 +107,10 @@ def make_dict_unstructure_fn_from_attrs( invocation_lines = [] internal_arg_parts = {} + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) + for a in attrs: attr_name = a.name override = kwargs.get(attr_name, neutral) @@ -217,7 +224,7 @@ def make_dict_unstructure_fn( converter: BaseConverter, _cattrs_omit_if_default: bool = False, _cattrs_use_linecache: bool = True, - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> Callable[[T], dict[str, Any]]: @@ -237,11 +244,17 @@ def make_dict_unstructure_fn( .. versionadded:: 23.2.0 *_cattrs_use_alias* .. versionadded:: 23.2.0 *_cattrs_include_init_false* + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. """ origin = get_origin(cl) attrs = adapted_fields(origin or cl) # type: ignore mapping = {} + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) if is_generic(cl): mapping = generate_mapping(cl, mapping) @@ -289,7 +302,7 @@ def make_dict_structure_fn_from_attrs( bool | Literal["from_converter"] ) = "from_converter", _cattrs_detailed_validation: bool | Literal["from_converter"] = "from_converter", - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> SimpleStructureHook[Mapping[str, Any], T]: @@ -315,6 +328,9 @@ def make_dict_structure_fn_from_attrs( will be included. .. versionadded:: 24.1.0 + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. """ cl_name = cl.__name__ @@ -350,6 +366,9 @@ def make_dict_structure_fn_from_attrs( if _cattrs_forbid_extra_keys == "from_converter": # BaseConverter doesn't have it so we're careful. _cattrs_forbid_extra_keys = getattr(converter, "forbid_extra_keys", False) + if _cattrs_use_alias == "from_converter": + # BaseConverter doesn't have it so we're careful. + _cattrs_use_alias = getattr(converter, "use_alias", False) if _cattrs_detailed_validation == "from_converter": _cattrs_detailed_validation = converter.detailed_validation if _cattrs_prefer_attrib_converters == "from_converter": @@ -682,7 +701,7 @@ def make_dict_structure_fn( bool | Literal["from_converter"] ) = "from_converter", _cattrs_detailed_validation: bool | Literal["from_converter"] = "from_converter", - _cattrs_use_alias: bool = False, + _cattrs_use_alias: bool | Literal["from_converter"] = "from_converter", _cattrs_include_init_false: bool = False, **kwargs: AttributeOverride, ) -> SimpleStructureHook[Mapping[str, Any], T]: @@ -714,6 +733,9 @@ def make_dict_structure_fn( .. versionchanged:: 24.1.0 The `_cattrs_prefer_attrib_converters` parameter takes its value from the given converter by default. + .. versionchanged:: 25.2.0 + The `_cattrs_use_alias` parameter takes its value from the given converter + by default. """ mapping = {} diff --git a/tests/test_converter.py b/tests/test_converter.py index b071ae28..9f91dc53 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -146,6 +146,32 @@ def test_forbid_extra_keys(cls_and_vals): assert cve.value.exceptions[0].extra_fields == {bad_key} +@pytest.mark.parametrize("use_alias", [True, False]) +def test_use_alias(use_alias): + """ + (Un)structuring with use_alias=True generates/uses aliased keys. + """ + + @define + class C: + a: int = field(default=0, alias="_alias") + b: int = field(default=0) + + inst = C() + converter = Converter(use_alias=use_alias) + unstructured = converter.unstructure(inst) + for fld in fields(C): + if use_alias: + assert fld.alias in unstructured + if fld.name != fld.alias: + assert fld.name not in unstructured + else: + assert fld.name in unstructured + if fld.name != fld.alias: + assert fld.alias not in unstructured + converter.structure(unstructured, C) + + @given(simple_typed_attrs(defaults=True)) def test_forbid_extra_keys_defaults(attr_and_vals): """