From 48f4a7e8930461bc9effd72f8213c334e60e4e17 Mon Sep 17 00:00:00 2001 From: uwezkhan06 Date: Mon, 11 May 2026 01:05:08 +0530 Subject: [PATCH 1/3] Validate init aliases before generated code compilation --- src/attr/_make.py | 43 ++++++++++++++++ tests/test_alias_validation.py | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/test_alias_validation.py 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..fdf367923 --- /dev/null +++ b/tests/test_alias_validation.py @@ -0,0 +1,91 @@ +import attr +import pytest +import unicodedata +from attr.exceptions import NotAnAttrsClassError + +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. + """ + @attr.s + class C: + π = attr.ib() + α = attr.ib(alias="beta") + + inst = C(π=3.14, beta=1) + assert inst.π == 3.14 + assert inst.α == 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 From 9dbc137bdf2378b0430437307c12a8f53ccaa1b2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 19:38:45 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_alias_validation.py | 41 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/test_alias_validation.py b/tests/test_alias_validation.py index fdf367923..f53b73fcf 100644 --- a/tests/test_alias_validation.py +++ b/tests/test_alias_validation.py @@ -1,14 +1,19 @@ -import attr -import pytest import unicodedata -from attr.exceptions import NotAnAttrsClassError + +import pytest + +import attr + class TestAliasValidation: def test_invalid_identifier(self): """ Invalid identifiers are rejected. """ - with pytest.raises(TypeError, match="Invalid initialization alias '1x'"): + with pytest.raises( + TypeError, match="Invalid initialization alias '1x'" + ): + @attr.s class C: x = attr.ib(alias="1x") @@ -17,7 +22,10 @@ def test_keyword_alias(self): """ Keywords are rejected. """ - with pytest.raises(TypeError, match="Invalid initialization alias 'class'"): + with pytest.raises( + TypeError, match="Invalid initialization alias 'class'" + ): + @attr.s class C: x = attr.ib(alias="class") @@ -27,6 +35,7 @@ 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") @@ -38,9 +47,14 @@ def test_unicode_normalization_collision(self): omega = "\u03a9" ohm = "\u2126" assert omega != ohm - assert unicodedata.normalize("NFKC", omega) == unicodedata.normalize("NFKC", ohm) + assert unicodedata.normalize("NFKC", omega) == unicodedata.normalize( + "NFKC", ohm + ) + + with pytest.raises( + TypeError, match="collides with another attribute's alias" + ): - with pytest.raises(TypeError, match="collides with another attribute's alias"): @attr.s class C: x = attr.ib(alias=omega) @@ -52,8 +66,10 @@ def test_make_class_normalization_collision(self): """ omega = "\u03a9" ohm = "\u2126" - - with pytest.raises(TypeError, match="collides with another attribute's alias"): + + 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): @@ -61,6 +77,7 @@ 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) @@ -69,11 +86,12 @@ def test_valid_unicode_aliases(self): """ Valid Unicode identifiers that don't collide are allowed. """ + @attr.s class C: π = attr.ib() α = attr.ib(alias="beta") - + inst = C(π=3.14, beta=1) assert inst.π == 3.14 assert inst.α == 1 @@ -82,10 +100,11 @@ 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 From 10900446b4f5f7925da875f6e11d71b07f38af39 Mon Sep 17 00:00:00 2001 From: uwezkhan06 Date: Mon, 11 May 2026 01:25:24 +0530 Subject: [PATCH 3/3] updated --- docs/init.md | 6 +++--- tests/test_alias_validation.py | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) 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/tests/test_alias_validation.py b/tests/test_alias_validation.py index f53b73fcf..0e8a97f12 100644 --- a/tests/test_alias_validation.py +++ b/tests/test_alias_validation.py @@ -86,15 +86,15 @@ def test_valid_unicode_aliases(self): """ Valid Unicode identifiers that don't collide are allowed. """ - - @attr.s - class C: - π = attr.ib() - α = attr.ib(alias="beta") - - inst = C(π=3.14, beta=1) - assert inst.π == 3.14 - assert inst.α == 1 + # 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): """