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
6 changes: 3 additions & 3 deletions docs/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -254,7 +254,7 @@ 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 @@ -273,7 +273,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')
>>> C(256)
Traceback (most recent call last):
...
Expand Down
43 changes: 43 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,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.
Expand Down
110 changes: 110 additions & 0 deletions tests/test_alias_validation.py
Original file line number Diff line number Diff line change
@@ -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