diff --git a/HISTORY.md b/HISTORY.md index da44cbbd..61d7b192 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,7 @@ ## 23.2.0 (UNRELEASED) +- [_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. - Use [PDM](https://pdm.fming.dev/latest/) instead of Poetry. - _cattrs_ is now linted with [Ruff](https://beta.ruff.rs/docs/). - Fix TypedDicts with periods in their field names. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index e6ad59f6..c6dd1b58 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -68,7 +68,7 @@ def fields(type): raise Exception("Not an attrs or dataclass class.") from None -def _adapted_fields(cl) -> List[Attribute]: +def adapted_fields(cl) -> List[Attribute]: """Return the attrs format of `fields()` for attrs and dataclasses.""" if is_dataclass(cl): attrs = dataclass_fields(cl) @@ -95,6 +95,7 @@ def _adapted_fields(cl) -> List[Attribute]: attr.init, True, type=type_hints.get(attr.name, attr.type), + alias=attr.name, ) for attr in attrs ] diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index b5a5a815..205caa5d 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -516,7 +516,7 @@ def structure_attrs_fromdict(self, obj: Mapping[str, Any], cl: Type[T]) -> T: if name[0] == "_": name = name[1:] - conv_obj[name] = self._structure_attribute(a, val) + conv_obj[getattr(a, "alias", a.name)] = self._structure_attribute(a, val) return cl(**conv_obj) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 1d77cbb2..241b331b 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -2,14 +2,13 @@ import linecache import re -from dataclasses import is_dataclass from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Tuple, TypeVar import attr from attr import NOTHING, resolve_types from .._compat import ( - _adapted_fields, + adapted_fields, get_args, get_origin, is_annotated, @@ -53,14 +52,18 @@ def make_dict_unstructure_fn( converter: BaseConverter, _cattrs_omit_if_default: bool = False, _cattrs_use_linecache: bool = True, + _cattrs_use_alias: bool = False, **kwargs: AttributeOverride, ) -> Callable[[T], dict[str, Any]]: """ Generate a specialized dict unstructuring function for an attrs class or a dataclass. + + :param _cattrs_use_alias: If true, the attribute alias will be used as the + dictionary key by default. """ origin = get_origin(cl) - attrs = _adapted_fields(origin or cl) # type: ignore + attrs = adapted_fields(origin or cl) # type: ignore if any(isinstance(a.type, str) for a in attrs): # PEP 563 annotations - need to be resolved. @@ -102,7 +105,10 @@ def make_dict_unstructure_fn( override = kwargs.pop(attr_name, neutral) if override.omit: continue - kn = attr_name if override.rename is None else override.rename + 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. @@ -263,8 +269,7 @@ def make_dict_structure_fn( post_lines = [] invocation_lines = [] - attrs = _adapted_fields(cl) - is_dc = is_dataclass(cl) + attrs = adapted_fields(cl) if any(isinstance(a.type, str) for a in attrs): # PEP 563 annotations - need to be resolved. @@ -306,7 +311,7 @@ def make_dict_structure_fn( struct_handler_name = f"__c_structure_{an}" internal_arg_parts[struct_handler_name] = handler - ian = an if (is_dc or an[0] != "_") else an[1:] + ian = a.alias kn = an if override.rename is None else override.rename allowed_fields.add(kn) i = " " @@ -401,8 +406,7 @@ def make_dict_structure_fn( invocation_line = f"o['{kn}']," if a.kw_only: - ian = an if (is_dc or an[0] != "_") else an[1:] - invocation_line = f"{ian}={invocation_line}" + invocation_line = f"{a.alias}={invocation_line}" invocation_lines.append(invocation_line) # The second loop is for optional args. @@ -433,7 +437,6 @@ def make_dict_structure_fn( struct_handler_name = f"__c_structure_{an}" internal_arg_parts[struct_handler_name] = handler - ian = an if (is_dc or an[0] != "_") else an[1:] kn = an if override.rename is None else override.rename allowed_fields.add(kn) post_lines.append(f" if '{kn}' in o:") @@ -441,16 +444,16 @@ def make_dict_structure_fn( if handler == converter._structure_call: internal_arg_parts[struct_handler_name] = t post_lines.append( - f" res['{ian}'] = {struct_handler_name}(o['{kn}'])" + 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['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" + f" res['{a.alias}'] = {struct_handler_name}(o['{kn}'], {tn})" ) else: - post_lines.append(f" res['{ian}'] = o['{kn}']") + post_lines.append(f" res['{a.alias}'] = o['{kn}']") instantiation_lines = ( [" return __cl("] + [f" {line}" for line in invocation_lines] + [" )"] ) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 4d636931..ac202fe3 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -8,7 +8,7 @@ from hypothesis.strategies import data, just, one_of, sampled_from from cattrs import BaseConverter, Converter -from cattrs._compat import _adapted_fields, fields +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 @@ -117,7 +117,7 @@ def test_individual_overrides(converter_cls, cl_and_vals): converter = converter_cls() cl, vals, kwargs = cl_and_vals - for attr in _adapted_fields(cl): + for attr in adapted_fields(cl): if attr.default is not NOTHING: break else: @@ -141,7 +141,7 @@ def test_individual_overrides(converter_cls, cl_and_vals): assert "Hyp" not in repr(res) assert "Factory" not in repr(res) - for attr, val in zip(_adapted_fields(cl), vals): + for attr, val in zip(adapted_fields(cl), vals): if attr.name == chosen_name: assert attr.name in res elif attr.default is not NOTHING: diff --git a/tests/typed.py b/tests/typed.py index fcc0ac58..f389bf66 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -220,13 +220,27 @@ def key(t): a.counter = i vals = tuple((a[1]) for a in attrs_and_strat if not a[0].kw_only) note(f"Class fields: {attrs}") - attrs_dict = OrderedDict(zip(gen_attr_names(), attrs)) + attrs_dict = {} + + names = gen_attr_names() kwarg_strats = {} - for attr_name, attr_and_strat in zip(gen_attr_names(), attrs_and_strat): - if attr_and_strat[0].kw_only: - if attr_name.startswith("_"): - attr_name = attr_name[1:] - kwarg_strats[attr_name] = attr_and_strat[1] + + for ix, (attribute, strat) in enumerate(attrs_and_strat): + name = next(names) + attrs_dict[name] = attribute + if ix % 2 == 1: + # Every third attribute gets an alias, the next attribute name. + alias = next(names) + attribute.alias = alias + name = alias + else: + # No alias. + if name[0] == "_": + name = name[1:] + + if attribute.kw_only: + kwarg_strats[name] = strat + note(f"Attributes: {attrs_dict}") return tuples( just(make_class("HypAttrsClass", attrs_dict, frozen=frozen)),