Skip to content
Open
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
17 changes: 6 additions & 11 deletions docs/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ Even if you're not using this feature, it's important to be aware of it because
... _1: int
Traceback (most recent call last):
...
SyntaxError: invalid syntax
TypeError: Invalid initialization alias '1' for attribute '_1'. Aliases must be valid Python identifiers.

```

In this case a valid attribute name `_1` got transformed into an invalid argument name `1`.
Expand Down Expand Up @@ -251,10 +252,7 @@ ValueError: 'x' has to be smaller than 'y'!
... x = field(validator=attrs.validators.instance_of(int))
>>> C(42)
C(x=42)
>>> C("42")
Traceback (most recent call last):
...
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
TypeError: ("'x' must be <class 'int'> (got '42' that is a <class 'str'>).", Attribute(name='x', ..., alias='x'), <class 'int'>, '42')
```

Of course you can mix and match the two approaches at your convenience.
Expand All @@ -270,10 +268,7 @@ If you use both ways to define validators for an attribute, they are both ran:
... raise ValueError("value out of bounds")
>>> C(128)
C(x=128)
>>> C("128")
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), <class 'int'>, '128')
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', ..., alias='x'), <class 'int'>, '128')
>>> C(256)
Traceback (most recent call last):
...
Expand All @@ -290,7 +285,7 @@ C(x='128')
>>> C("128")
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), <class 'int'>, '128')
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', ..., alias='x'), <class 'int'>, '128')
```

... or within a context manager:
Expand All @@ -302,7 +297,7 @@ C(x='128')
>>> C("128")
Traceback (most recent call last):
...
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), <class 'int'>, '128')
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', ..., alias='x'), <class 'int'>, '128')
```

(converters)=
Expand Down
24 changes: 24 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import enum
import inspect
import itertools
import keyword
import linecache
import sys
import types
Expand Down Expand Up @@ -496,6 +497,8 @@ def _transform_attrs(
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
_OBJ_SETATTR.__get__(a)("alias_is_default", True)

_validate_init_aliases(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 @@ -2426,6 +2429,27 @@ def _default_init_alias_for(name: str) -> str:
return name.lstrip("_")


def _validate_init_aliases(attrs: tuple[Attribute, ...]) -> None:
"""
Ensure init aliases are valid Python parameter names.
"""
for a in attrs:
if a.init is False:
continue

alias = a.alias
if (
not isinstance(alias, str)
or not alias.isidentifier()
or keyword.iskeyword(alias)
):
msg = (
f"Invalid initialization alias {alias!r} for attribute "
f"{a.name!r}. Aliases must be valid Python identifiers."
)
raise TypeError(msg)


class Attribute:
"""
*Read-only* representation of an attribute.
Expand Down
37 changes: 37 additions & 0 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -2448,6 +2448,43 @@ class EvolveCase:
dunder__=5,
) == EvolveCase(1, 4, 5)

@pytest.mark.parametrize("alias", ["x=1", "class", "not valid", 1])
def test_invalid_alias(self, alias):
"""
Invalid aliases are rejected before they can be used in generated
__init__ source code.
"""

with pytest.raises(TypeError, match="Invalid initialization alias"):

@attrs.define
class C:
x: int = attrs.field(alias=alias)

def test_invalid_alias_not_executed(self, monkeypatch):
"""
Aliases are parameter names, not Python source code.
"""

marker = "_attrs_alias_executed"
monkeypatch.delattr("builtins." + marker, raising=False)

with pytest.raises(TypeError, match="Invalid initialization alias"):
attr.make_class(
"C",
{
"x": attr.ib(
alias=(
"x=__import__('builtins').setattr("
"__import__('builtins'), "
f"{marker!r}, True)"
)
)
},
)

assert getattr(__import__("builtins"), marker, False) is False

def test_alias_is_default(self):
"""
alias_is_default is True for auto-generated aliases and False for
Expand Down
Loading