diff --git a/docs/init.md b/docs/init.md index d6ec01be8..a0657fecf 100644 --- a/docs/init.md +++ b/docs/init.md @@ -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`. @@ -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 (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>, type=None), , '42') +TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', ..., alias='x'), , '42') ``` Of course you can mix and match the two approaches at your convenience. @@ -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 (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), , '128') +TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', ..., alias='x'), , '128') >>> C(256) Traceback (most recent call last): ... @@ -290,7 +285,7 @@ C(x='128') >>> C("128") Traceback (most recent call last): ... -TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), , '128') +TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', ..., alias='x'), , '128') ``` ... or within a context manager: @@ -302,7 +297,7 @@ C(x='128') >>> C("128") Traceback (most recent call last): ... -TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), , '128') +TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', ..., alias='x'), , '128') ``` (converters)= diff --git a/src/attr/_make.py b/src/attr/_make.py index 793bfd89d..66dc3dcac 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -8,6 +8,7 @@ import enum import inspect import itertools +import keyword import linecache import sys import types @@ -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] @@ -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. diff --git a/tests/test_make.py b/tests/test_make.py index f83f85234..9135a9077 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -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