Skip to content

Commit

Permalink
Add Attribute.alias (#950)
Browse files Browse the repository at this point in the history
* Spike `alias` implementation.

* Move default alias init to after field_transformer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fixup docs.

* Update docs/extending.rst

* Pre-commit fixes

* Partially fix doctest

* Add test docstrings.

* Add typing_example tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tidy typing_example

* Add note in init.rst on private aliases

* Add alias example to examples.rst

* Assert to comment

* Add changelog entry

* Fixup doc error

* Tidy dataclass_transform docs

* Lil' spice for the changelog.

* Fix doctest

* Update extending.rst

* Make alias introspection more explicit

* Update src/attr/_make.py

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Hynek Schlawack <hs@ox.cx>
  • Loading branch information
3 people committed Nov 30, 2022
1 parent 69aca6c commit ee3ecb1
Show file tree
Hide file tree
Showing 14 changed files with 254 additions and 12 deletions.
5 changes: 5 additions & 0 deletions 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 <https://peps.python.org/pep-0681/#field-specifier-parameters>`_ ``alias`` option can be use to override private attribute name mangling,
or add other arbitrary field argument name overrides.
10 changes: 5 additions & 5 deletions docs/api.rst
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/examples.rst
Expand Up @@ -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?):

Expand Down
24 changes: 23 additions & 1 deletion docs/extending.rst
Expand Up @@ -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=<class 'int'>, 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=<class 'int'>, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a'),)


.. warning::
Expand Down Expand Up @@ -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()``
---------------------------------------------
Expand Down
19 changes: 17 additions & 2 deletions docs/init.rst
Expand Up @@ -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 <https://cattrs.readthedocs.io/>`_ or our `third-party extensions <https://github.com/python-attrs/attrs/wiki/Extensions-to-attrs>`_.

.. _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:
Expand Down Expand Up @@ -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__)
<Signature (self, _x: int, distasteful_y: int) -> None>


Defaults
--------
Expand Down
10 changes: 10 additions & 0 deletions src/attr/__init__.pyi
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -234,6 +239,7 @@ def attrib(
eq: Optional[_EqOrderType] = ...,
order: Optional[_EqOrderType] = ...,
on_setattr: Optional[_OnSetAttrArgType] = ...,
alias: Optional[str] = ...,
) -> Any: ...
@overload
def field(
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion src/attr/_funcs.py
Expand Up @@ -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)

Expand Down
45 changes: 44 additions & 1 deletion src/attr/_make.py
Expand Up @@ -101,6 +101,7 @@ def attrib(
eq=None,
order=None,
on_setattr=None,
alias=None,
):
"""
Create a new attribute on a class.
Expand Down Expand Up @@ -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*
Expand All @@ -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
Expand Down Expand Up @@ -279,6 +284,7 @@ def attrib(
order=order,
order_key=order_key,
on_setattr=on_setattr,
alias=alias,
)


Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -2382,12 +2411,16 @@ class Attribute:
- Validators get them passed as the first argument.
- The :ref:`field transformer <transform-fields>` 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`.
"""
Expand All @@ -2409,6 +2442,7 @@ class Attribute:
"kw_only",
"inherited",
"on_setattr",
"alias",
)

def __init__(
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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__
]
Expand Down Expand Up @@ -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,
Expand All @@ -2623,10 +2662,12 @@ class _CountingAttr:
"hash",
"init",
"on_setattr",
"alias",
)
) + (
Attribute(
name="metadata",
alias="metadata",
default=None,
validator=None,
repr=True,
Expand Down Expand Up @@ -2661,6 +2702,7 @@ def __init__(
order,
order_key,
on_setattr,
alias,
):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
Expand All @@ -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):
"""
Expand Down

0 comments on commit ee3ecb1

Please sign in to comment.