diff --git a/changelog.d/950.change.rst b/changelog.d/950.change.rst new file mode 100644 index 000000000..714623fc4 --- /dev/null +++ b/changelog.d/950.change.rst @@ -0,0 +1,5 @@ +``attrs.field`` now supports an ``alias`` option for explicit ``__init__`` argument names. + +Get ``__init__`` signatures matching any taste, peculiar or plain! +The `PEP 681 compatible `_ ``alias`` option can be use to override private attribute name mangling, +or add other arbitrary field argument name overrides. diff --git a/docs/api.rst b/docs/api.rst index 9dff7a54a..59d006f33 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -74,7 +74,7 @@ Core ... class C: ... x = attr.ib() >>> attr.fields(C).x - Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) + Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='x') .. autofunction:: attrs.make_class @@ -246,9 +246,9 @@ Helpers ... x = attr.ib() ... y = attr.ib() >>> attrs.fields(C) - (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)) + (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='x'), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='y')) >>> attrs.fields(C)[1] - Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) + Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='y') >>> attrs.fields(C).y is attrs.fields(C)[1] True @@ -267,9 +267,9 @@ Helpers ... x = attr.ib() ... y = attr.ib() >>> attrs.fields_dict(C) - {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)} + {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='x'), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='y')} >>> attr.fields_dict(C)['y'] - Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) + Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='y') >>> attrs.fields_dict(C)['y'] is attrs.fields(C).y True diff --git a/docs/examples.rst b/docs/examples.rst index ab9fe2d03..558a9fa19 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -70,6 +70,16 @@ If you want to initialize your private attributes yourself, you can do that too: ... TypeError: __init__() takes exactly 1 argument (2 given) +If you prefer to expose your privates, you can use keyword argument aliases: + +.. doctest:: + + >>> @define + ... class C: + ... _x: int = field(alias="_x") + >>> C(_x=1) + C(_x=1) + An additional way of defining attributes is supported too. This is useful in times when you want to enhance classes that are not yours (nice ``__repr__`` for Django models anyone?): diff --git a/docs/extending.rst b/docs/extending.rst index 13d66edf3..7fcebec24 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -16,7 +16,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``: ... @define ... class C: ... a: int - (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=, converter=None, kw_only=False, inherited=False, on_setattr=None),) + (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a'),) .. warning:: @@ -260,6 +260,28 @@ A more realistic example would be to automatically convert data that you, e.g., >>> Data(**from_json) # **** Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37)) +Or, perhaps you would prefer to generate dataclass-compatible ``__init__`` signatures via a default field ``alias``. +Note, ``field_transformer`` operates on `attrs.Attribute` instances before the default private-attribute handling is applied so explicit user-provided aliases can be detected. + +.. doctest:: + + >>> def dataclass_names(cls, fields): + ... return [ + ... field.evolve(alias=field.name) + ... if not field.alias + ... else field + ... for field in fields + ... ] + ... + >>> @frozen(field_transformer=dataclass_names) + ... class Data: + ... public: int + ... _private: str + ... explicit: str = field(alias="aliased_name") + ... + >>> Data(public=42, _private="spam", aliased_name="yes") + Data(public=42, _private='spam', explicit='yes') + Customize Value Serialization in ``asdict()`` --------------------------------------------- diff --git a/docs/init.rst b/docs/init.rst index 33cfde3d9..3ba49f0ba 100644 --- a/docs/init.rst +++ b/docs/init.rst @@ -47,9 +47,10 @@ Embrace functions and classmethods as a filter between reality and what's best f If you look for powerful-yet-unintrusive serialization and validation for your ``attrs`` classes, have a look at our sibling project `cattrs `_ or our `third-party extensions `_. +.. _private_attributes: -Private Attributes ------------------- +Private Attributes and Aliases +------------------------------ One thing people tend to find confusing is the treatment of private attributes that start with an underscore. ``attrs`` follows the doctrine that `there is no such thing as a private argument`_ and strips the underscores from the name when writing the ``__init__`` method signature: @@ -78,6 +79,20 @@ But it's important to be aware of it because it can lead to surprising syntax er In this case a valid attribute name ``_1`` got transformed into an invalid argument name ``1``. +If your taste differs, you can use the ``alias`` argument to `attrs.field` to explicitly set the argument name. +This can be used to override private attribute handling, or make other arbitrary changes to ``__init__`` argument names. + +.. doctest:: + + >>> import inspect, attr, attrs + >>> from attr import define + >>> @define + ... class C: + ... _x: int = field(alias="_x") + ... y: int = field(alias="distasteful_y") + >>> inspect.signature(C.__init__) + None> + Defaults -------- diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index dfa69578b..7aff65c3c 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -134,6 +134,8 @@ class Attribute(Generic[_T]): type: Optional[Type[_T]] kw_only: bool on_setattr: _OnSetAttrType + alias: Optional[str] + def evolve(self, **changes: Any) -> "Attribute[Any]": ... # NOTE: We had several choices for the annotation to use for type arg: @@ -176,6 +178,7 @@ def attrib( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> Any: ... # This form catches an explicit None or no default and infers the type from the @@ -196,6 +199,7 @@ def attrib( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> _T: ... # This form catches an explicit default argument. @@ -215,6 +219,7 @@ def attrib( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> _T: ... # This form covers type=non-Type: e.g. forward references (str), Any @@ -234,6 +239,7 @@ def attrib( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> Any: ... @overload def field( @@ -250,6 +256,7 @@ def field( eq: Optional[bool] = ..., order: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> Any: ... # This form catches an explicit None or no default and infers the type from the @@ -269,6 +276,7 @@ def field( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> _T: ... # This form catches an explicit default argument. @@ -287,6 +295,7 @@ def field( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> _T: ... # This form covers type=non-Type: e.g. forward references (str), Any @@ -305,6 +314,7 @@ def field( eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., + alias: Optional[str] = ..., ) -> Any: ... @overload @__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 49f241d02..1f573c110 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -359,7 +359,7 @@ def evolve(inst, **changes): if not a.init: continue attr_name = a.name # To deal with private attributes. - init_name = attr_name if attr_name[0] != "_" else attr_name[1:] + init_name = a.alias if init_name not in changes: changes[init_name] = getattr(inst, attr_name) diff --git a/src/attr/_make.py b/src/attr/_make.py index 680738151..4e6846aaa 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -101,6 +101,7 @@ def attrib( eq=None, order=None, on_setattr=None, + alias=None, ): """ Create a new attribute on a class. @@ -208,6 +209,9 @@ def attrib( attribute -- regardless of the setting in `attr.s`. :type on_setattr: `callable`, or a list of callables, or `None`, or `attrs.setters.NO_OP` + :param Optional[str] alias: Override this attribute's parameter name in the + generated ``__init__`` method. If left `None`, default to ``name`` + stripped of leading underscores. See `private_attributes`. .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* @@ -230,6 +234,7 @@ def attrib( .. versionchanged:: 21.1.0 *eq*, *order*, and *cmp* also accept a custom callable .. versionchanged:: 21.1.0 *cmp* undeprecated + .. versionadded:: 22.2.0 *alias* """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True @@ -279,6 +284,7 @@ def attrib( order=order, order_key=order_key, on_setattr=on_setattr, + alias=alias, ) @@ -563,6 +569,14 @@ def _transform_attrs( if field_transformer is not None: attrs = field_transformer(cls, attrs) + # Resolve default field alias after executing field_transformer. + # This allows field_transformer to differentiate between explicit vs + # default aliases and supply their own defaults. + attrs = [ + a.evolve(alias=_default_init_alias_for(a.name)) if not a.alias else a + for a in attrs + ] + # Create AttrsClass *after* applying the field_transformer since it may # add or remove attributes! attr_names = [a.name for a in attrs] @@ -2165,7 +2179,9 @@ def fmt_setter_with_converter( has_on_setattr = a.on_setattr is not None or ( a.on_setattr is not setters.NO_OP and has_cls_on_setattr ) - arg_name = a.name.lstrip("_") + # a.alias is set to maybe-mangled attr_name in _ClassBuilder if not + # explicitly provided + arg_name = a.alias has_factory = isinstance(a.default, Factory) if has_factory and a.default.takes_self: @@ -2358,6 +2374,17 @@ def fmt_setter_with_converter( ) +def _default_init_alias_for(name: str) -> str: + """ + The default __init__ parameter name for a field. + + This performs private-name adjustment via leading-unscore stripping, + and is the default value of Attribute.alias if not provided. + """ + + return name.lstrip("_") + + class Attribute: """ *Read-only* representation of an attribute. @@ -2367,6 +2394,8 @@ class Attribute: following: - ``name`` (`str`): The name of the attribute. + - ``alias`` (`str`): The __init__ parameter name of the attribute, after + any explicit overrides and default private-attribute-name handling. - ``inherited`` (`bool`): Whether or not that attribute has been inherited from a base class. - ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The callables @@ -2382,12 +2411,16 @@ class Attribute: - Validators get them passed as the first argument. - The :ref:`field transformer ` hook receives a list of them. + - The ``alias`` property exposes the __init__ parameter name of the field, + with any overrides and default private-attribute handling applied. + .. versionadded:: 20.1.0 *inherited* .. versionadded:: 20.1.0 *on_setattr* .. versionchanged:: 20.2.0 *inherited* is not taken into account for equality checks and hashing anymore. .. versionadded:: 21.1.0 *eq_key* and *order_key* + .. versionadded:: 22.2.0 *alias* For the full version history of the fields, see `attr.ib`. """ @@ -2409,6 +2442,7 @@ class Attribute: "kw_only", "inherited", "on_setattr", + "alias", ) def __init__( @@ -2430,6 +2464,7 @@ def __init__( order=None, order_key=None, on_setattr=None, + alias=None, ): eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq_key or eq, order_key or order, True @@ -2463,6 +2498,7 @@ def __init__( bound_setattr("kw_only", kw_only) bound_setattr("inherited", inherited) bound_setattr("on_setattr", on_setattr) + bound_setattr("alias", alias) def __setattr__(self, name, value): raise FrozenInstanceError() @@ -2558,6 +2594,7 @@ def _setattrs(self, name_values_pairs): hash=(name != "metadata"), init=True, inherited=False, + alias=_default_init_alias_for(name), ) for name in Attribute.__slots__ ] @@ -2596,10 +2633,12 @@ class _CountingAttr: "type", "kw_only", "on_setattr", + "alias", ) __attrs_attrs__ = tuple( Attribute( name=name, + alias=_default_init_alias_for(name), default=NOTHING, validator=None, repr=True, @@ -2623,10 +2662,12 @@ class _CountingAttr: "hash", "init", "on_setattr", + "alias", ) ) + ( Attribute( name="metadata", + alias="metadata", default=None, validator=None, repr=True, @@ -2661,6 +2702,7 @@ def __init__( order, order_key, on_setattr, + alias, ): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter @@ -2678,6 +2720,7 @@ def __init__( self.type = type self.kw_only = kw_only self.on_setattr = on_setattr + self.alias = alias def validator(self, meth): """ diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 260519f1c..79e8a44dc 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -169,6 +169,7 @@ def field( eq=None, order=None, on_setattr=None, + alias=None, ): """ Identical to `attr.ib`, except keyword-only and with some arguments @@ -189,6 +190,7 @@ def field( eq=eq, order=order, on_setattr=on_setattr, + alias=alias, ) diff --git a/tests/test_functional.py b/tests/test_functional.py index dbb834777..617266c1e 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -119,6 +119,7 @@ def test_fields(self, cls): assert ( Attribute( name="x", + alias="x", default=foo, validator=None, repr=True, @@ -131,6 +132,7 @@ def test_fields(self, cls): ), Attribute( name="y", + alias="y", default=attr.Factory(list), validator=None, repr=True, @@ -188,6 +190,7 @@ def test_programmatic(self, slots, frozen): assert ( Attribute( name="a", + alias="a", default=NOTHING, validator=None, repr=True, @@ -200,6 +203,7 @@ def test_programmatic(self, slots, frozen): ), Attribute( name="b", + alias="b", default=NOTHING, validator=None, repr=True, diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 92fc2dcaa..54a27fb1a 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -99,6 +99,24 @@ class C: assert attr.asdict(C(1, 2)) == {"x": 1, "new": 2} + def test_hook_override_alias(self): + """ + It is possible to set field alias via hook + """ + + def use_dataclass_names(cls, attribs): + return [a.evolve(alias=a.name) for a in attribs] + + @attr.s(auto_attribs=True, field_transformer=use_dataclass_names) + class NameCase: + public: int + _private: int + __dunder__: int + + assert NameCase(public=1, _private=2, __dunder__=3) == NameCase( + 1, 2, 3 + ) + def test_hook_with_inheritance(self): """ The hook receives all fields from base classes. diff --git a/tests/test_make.py b/tests/test_make.py index 119bbe8a8..0fae62d7d 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -223,7 +223,7 @@ class C: "eq=True, eq_key=None, order=True, order_key=None, " "hash=None, init=True, " "metadata=mappingproxy({}), type=None, converter=None, " - "kw_only=False, inherited=False, on_setattr=None)", + "kw_only=False, inherited=False, on_setattr=None, alias=None)", ) == e.value.args def test_kw_only(self): @@ -1730,6 +1730,107 @@ def __setstate__(self, state): assert actual == expected +class TestInitAlias: + """ + Tests for Attribute alias handling. + """ + + def test_default_and_specify(self): + """ + alias is present on the Attributes returned from attr.fields. + + If left unspecified, it defaults to standard private-attribute + handling. If specified, it passes through the explicit alias. + """ + + # alias is None by default on _CountingAttr + default_counting = attr.ib() + assert default_counting.alias is None + + override_counting = attr.ib(alias="specified") + assert override_counting.alias == "specified" + + @attr.s + class Cases: + public_default = attr.ib() + _private_default = attr.ib() + __dunder_default__ = attr.ib() + + public_override = attr.ib(alias="public") + _private_override = attr.ib(alias="_private") + __dunder_override__ = attr.ib(alias="__dunder__") + + cases = attr.fields_dict(Cases) + + # Default applies private-name mangling logic + assert cases["public_default"].name == "public_default" + assert cases["public_default"].alias == "public_default" + + assert cases["_private_default"].name == "_private_default" + assert cases["_private_default"].alias == "private_default" + + assert cases["__dunder_default__"].name == "__dunder_default__" + assert cases["__dunder_default__"].alias == "dunder_default__" + + # Override is passed through + assert cases["public_override"].name == "public_override" + assert cases["public_override"].alias == "public" + + assert cases["_private_override"].name == "_private_override" + assert cases["_private_override"].alias == "_private" + + assert cases["__dunder_override__"].name == "__dunder_override__" + assert cases["__dunder_override__"].alias == "__dunder__" + + # And aliases are applied to the __init__ signature + example = Cases( + public_default=1, + private_default=2, + dunder_default__=3, + public=4, + _private=5, + __dunder__=6, + ) + + assert example.public_default == 1 + assert example._private_default == 2 + assert example.__dunder_default__ == 3 + assert example.public_override == 4 + assert example._private_override == 5 + assert example.__dunder_override__ == 6 + + def test_evolve(self): + """ + attr.evolve uses Attribute.alias to determine parameter names. + """ + + @attr.s + class EvolveCase: + _override = attr.ib(alias="_override") + __mangled = attr.ib() + __dunder__ = attr.ib() + + org = EvolveCase(1, 2, 3) + + # Previous behavior of evolve as broken for double-underscore + # passthrough, and would raise here due to mis-mapping the __dunder__ + # alias + assert attr.evolve(org) == org + + # evolve uses the alias to match __init__ signature + assert attr.evolve( + org, + _override=0, + ) == EvolveCase(0, 2, 3) + + # and properly passes through dunders and mangles + assert attr.evolve( + org, + EvolveCase__mangled=4, + dunder__=5, + ) == EvolveCase(1, 4, 5) + + class TestMakeOrder: """ Tests for _make_order(). diff --git a/tests/typing_example.py b/tests/typing_example.py index 012a190cc..b4b617e0e 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -119,6 +119,17 @@ class Error2(Exception): e.args str(e) +# Field aliases + + +@attrs.define +class AliasExample: + without_alias: int + _with_alias: int = attr.ib(alias="_with_alias") + + +attr.fields(AliasExample).without_alias.alias +attr.fields(AliasExample)._with_alias.alias # Converters # XXX: Currently converters can only be functions so none of this works diff --git a/tests/utils.py b/tests/utils.py index 261d15b65..9e678f05f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,7 +6,7 @@ from attr import Attribute -from attr._make import NOTHING, make_class +from attr._make import NOTHING, _default_init_alias_for, make_class def simple_class( @@ -64,4 +64,5 @@ def simple_attr( converter=converter, kw_only=kw_only, inherited=inherited, + alias=_default_init_alias_for(name), )