diff --git a/docs/init.md b/docs/init.md index d6ec01be8..5dcdfcbe2 100644 --- a/docs/init.md +++ b/docs/init.md @@ -90,7 +90,7 @@ 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`. @@ -254,7 +254,7 @@ 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. @@ -273,7 +273,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') >>> C(256) Traceback (most recent call last): ... diff --git a/src/attr/_make.py b/src/attr/_make.py index 793bfd89d..69323230e 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,46 @@ 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 and do not collide. + """ + seen_aliases = set() + 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) + + if alias == "self": + msg = ( + f"Initialization alias {alias!r} for attribute {a.name!r} " + "shadows the 'self' parameter. This is not allowed." + ) + raise TypeError(msg) + + normalized_alias = unicodedata.normalize("NFKC", alias) + if normalized_alias in seen_aliases: + msg = ( + f"Initialization alias {alias!r} for attribute {a.name!r} " + "collides with another attribute's alias after Unicode " + "normalization." + ) + raise TypeError(msg) + + seen_aliases.add(normalized_alias) + + class Attribute: """ *Read-only* representation of an attribute. diff --git a/tests/test_alias_validation.py b/tests/test_alias_validation.py new file mode 100644 index 000000000..0e8a97f12 --- /dev/null +++ b/tests/test_alias_validation.py @@ -0,0 +1,110 @@ +import unicodedata + +import pytest + +import attr + + +class TestAliasValidation: + def test_invalid_identifier(self): + """ + Invalid identifiers are rejected. + """ + with pytest.raises( + TypeError, match="Invalid initialization alias '1x'" + ): + + @attr.s + class C: + x = attr.ib(alias="1x") + + def test_keyword_alias(self): + """ + Keywords are rejected. + """ + with pytest.raises( + TypeError, match="Invalid initialization alias 'class'" + ): + + @attr.s + class C: + x = attr.ib(alias="class") + + def test_self_shadowing(self): + """ + 'self' shadowing is rejected. + """ + with pytest.raises(TypeError, match="shadows the 'self' parameter"): + + @attr.s + class C: + x = attr.ib(alias="self") + + def test_unicode_normalization_collision(self): + """ + Aliases that collide after NFKC normalization are rejected. + """ + omega = "\u03a9" + ohm = "\u2126" + assert omega != ohm + assert unicodedata.normalize("NFKC", omega) == unicodedata.normalize( + "NFKC", ohm + ) + + with pytest.raises( + TypeError, match="collides with another attribute's alias" + ): + + @attr.s + class C: + x = attr.ib(alias=omega) + y = attr.ib(alias=ohm) + + def test_make_class_normalization_collision(self): + """ + make_class also respects alias normalization collision checks. + """ + omega = "\u03a9" + ohm = "\u2126" + + with pytest.raises( + TypeError, match="collides with another attribute's alias" + ): + attr.make_class("C", {omega: attr.ib(), ohm: attr.ib()}) + + def test_non_string_alias(self): + """ + Non-string aliases are rejected. + """ + with pytest.raises(TypeError, match="Invalid initialization alias 1"): + + @attr.s + class C: + x = attr.ib(alias=1) + + def test_valid_unicode_aliases(self): + """ + Valid Unicode identifiers that don't collide are allowed. + """ + # We use make_class to avoid non-ASCII characters in the source code, + # which satisfies linters. + pi = "\u03c0" + alpha = "\u03b1" + C = attr.make_class("C", {pi: attr.ib(), alpha: attr.ib(alias="beta")}) + + inst = C(3.14, beta=1) + assert getattr(inst, pi) == 3.14 + assert getattr(inst, alpha) == 1 + + def test_init_false_skipped(self): + """ + Validation is skipped if init=False. + """ + + @attr.s + class C: + x = attr.ib(init=False, alias="not an identifier!") + + inst = C() + inst.x = 42 + assert inst.x == 42