Skip to content

Commit

Permalink
brain_attrs: Support annotation-only members (#2515)
Browse files Browse the repository at this point in the history
Similar to dataclasses, the following class which uses instance
variable annotations is valid:

```py
@attrs.define
class AttrsCls:
    x: int
AttrsCls(1).x
```

However, before this commit astroid failed to transform the class
attribute into an instance attribute and this led to `no-member` errors
in pylint.

Only the new `attrs` API supports this form out-of-the-box, so just
address the common case.

Closes #2514
  • Loading branch information
Hnasar authored Aug 27, 2024
1 parent 4ae4617 commit 5eae215
Show file tree
Hide file tree
Showing 4 changed files with 47 additions and 5 deletions.
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ What's New in astroid 3.3.3?
============================
Release date: TBA

* Add annotation-only instance attributes to attrs classes to fix `no-member` false positives.

Closes #2514



What's New in astroid 3.3.2?
Expand Down
14 changes: 10 additions & 4 deletions astroid/brain/brain_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
"field",
)
)
NEW_ATTRS_NAMES = frozenset(
(
"attrs.define",
"attrs.mutable",
"attrs.frozen",
)
)
ATTRS_NAMES = frozenset(
(
"attr.s",
Expand All @@ -33,9 +40,7 @@
"attr.define",
"attr.mutable",
"attr.frozen",
"attrs.define",
"attrs.mutable",
"attrs.frozen",
*NEW_ATTRS_NAMES,
)
)

Expand Down Expand Up @@ -64,13 +69,14 @@ def attr_attributes_transform(node: ClassDef) -> None:
# Prevents https://github.com/pylint-dev/pylint/issues/1884
node.locals["__attrs_attrs__"] = [Unknown(parent=node)]

use_bare_annotations = is_decorated_with_attrs(node, NEW_ATTRS_NAMES)
for cdef_body_node in node.body:
if not isinstance(cdef_body_node, (Assign, AnnAssign)):
continue
if isinstance(cdef_body_node.value, Call):
if cdef_body_node.value.func.as_string() not in ATTRIB_NAMES:
continue
else:
elif not use_bare_annotations:
continue
targets = (
cdef_body_node.targets
Expand Down
4 changes: 4 additions & 0 deletions script/.contributors_aliases.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
"name": "Hippo91",
"team": "Maintainers"
},
"Hnasar@users.noreply.github.com": {
"mails": ["Hnasar@users.noreply.github.com", "hashem@hudson-trading.com"],
"name": "Hashem Nasarat"
},
"hugovk@users.noreply.github.com": {
"mails": ["hugovk@users.noreply.github.com"],
"name": "Hugo van Kemenade"
Expand Down
30 changes: 29 additions & 1 deletion tests/brain/test_attr.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import unittest

import astroid
from astroid import nodes
from astroid import exceptions, nodes

try:
import attr # type: ignore[import] # pylint: disable=unused-import
Expand Down Expand Up @@ -201,3 +201,31 @@ class Foo:
"""
should_be_unknown = next(astroid.extract_node(code).infer()).getattr("bar")[0]
self.assertIsInstance(should_be_unknown, astroid.Unknown)

def test_attr_with_only_annotation_fails(self) -> None:
code = """
import attr
@attr.s
class Foo:
bar: int
Foo()
"""
with self.assertRaises(exceptions.AttributeInferenceError):
next(astroid.extract_node(code).infer()).getattr("bar")

def test_attrs_with_only_annotation_works(self) -> None:
code = """
import attrs
@attrs.define
class Foo:
bar: int
baz: str = "hello"
Foo(1)
"""
for attr_name in ("bar", "baz"):
should_be_unknown = next(astroid.extract_node(code).infer()).getattr(
attr_name
)[0]
self.assertIsInstance(should_be_unknown, astroid.Unknown)

0 comments on commit 5eae215

Please sign in to comment.