Skip to content

Commit 17c4391

Browse files
authored
Update attrs brain to exclude ClassVar annotated attributes (#2811)
1 parent 66b716c commit 17c4391

File tree

6 files changed

+170
-11
lines changed

6 files changed

+170
-11
lines changed

ChangeLog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ What's New in astroid 4.0.0?
77
============================
88
Release date: TBA
99

10+
* Fix false positive `invalid-name` on `attrs` classes with `ClassVar` annotated variables.
11+
12+
Closes pylint-dev/pylint#10525
13+
1014
* Prevent crash when parsing deeply nested parentheses causing MemoryError in python's built-in ast.
1115

1216
Closes #2643

astroid/brain/brain_attrs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Without this hook pylint reports unsupported-assignment-operation
99
for attrs classes
1010
"""
11+
from astroid.brain.helpers import is_class_var
1112
from astroid.manager import AstroidManager
1213
from astroid.nodes.node_classes import AnnAssign, Assign, AssignName, Call, Unknown
1314
from astroid.nodes.scoped_nodes import ClassDef
@@ -78,6 +79,13 @@ def attr_attributes_transform(node: ClassDef) -> None:
7879
continue
7980
elif not use_bare_annotations:
8081
continue
82+
83+
# Skip attributes that are explicitly annotated as class variables
84+
if isinstance(cdef_body_node, AnnAssign) and is_class_var(
85+
cdef_body_node.annotation
86+
):
87+
continue
88+
8189
targets = (
8290
cdef_body_node.targets
8391
if hasattr(cdef_body_node, "targets")

astroid/brain/brain_dataclasses.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Literal
1919

2020
from astroid import bases, context, nodes
21+
from astroid.brain.helpers import is_class_var
2122
from astroid.builder import parse
2223
from astroid.const import PY313_PLUS
2324
from astroid.exceptions import AstroidSyntaxError, InferenceError, UseInferenceDefault
@@ -117,7 +118,7 @@ def _get_dataclass_attributes(
117118
continue
118119

119120
# Annotation is never None
120-
if _is_class_var(assign_node.annotation): # type: ignore[arg-type]
121+
if is_class_var(assign_node.annotation): # type: ignore[arg-type]
121122
continue
122123

123124
if _is_keyword_only_sentinel(assign_node.annotation):
@@ -550,16 +551,6 @@ def _get_field_default(field_call: nodes.Call) -> _FieldDefaultReturn:
550551
return None
551552

552553

553-
def _is_class_var(node: nodes.NodeNG) -> bool:
554-
"""Return True if node is a ClassVar, with or without subscripting."""
555-
try:
556-
inferred = next(node.infer())
557-
except (InferenceError, StopIteration):
558-
return False
559-
560-
return getattr(inferred, "name", "") == "ClassVar"
561-
562-
563554
def _is_keyword_only_sentinel(node: nodes.NodeNG) -> bool:
564555
"""Return True if node is the KW_ONLY sentinel."""
565556
inferred = safe_infer(node)

astroid/brain/helpers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
from __future__ import annotations
66

77
from collections.abc import Callable
8+
from typing import TYPE_CHECKING
89

10+
from astroid.exceptions import InferenceError
911
from astroid.manager import AstroidManager
1012
from astroid.nodes.scoped_nodes import Module
1113

14+
if TYPE_CHECKING:
15+
from astroid.nodes.node_ng import NodeNG
16+
1217

1318
def register_module_extender(
1419
manager: AstroidManager, module_name: str, get_extension_mod: Callable[[], Module]
@@ -127,3 +132,13 @@ def register_all_brains(manager: AstroidManager) -> None:
127132
brain_typing.register(manager)
128133
brain_unittest.register(manager)
129134
brain_uuid.register(manager)
135+
136+
137+
def is_class_var(node: NodeNG) -> bool:
138+
"""Return True if node is a ClassVar, with or without subscripting."""
139+
try:
140+
inferred = next(node.infer())
141+
except (InferenceError, StopIteration):
142+
return False
143+
144+
return getattr(inferred, "name", "") == "ClassVar"

tests/brain/test_attr.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,59 @@ class Foo:
229229
attr_name
230230
)[0]
231231
self.assertIsInstance(should_be_unknown, astroid.Unknown)
232+
233+
def test_attrs_with_class_var_annotation(self) -> None:
234+
cases = {
235+
"with-subscript": """
236+
import attrs
237+
from typing import ClassVar
238+
239+
@attrs.define
240+
class Foo:
241+
bar: ClassVar[int] = 1
242+
Foo()
243+
""",
244+
"no-subscript": """
245+
import attrs
246+
from typing import ClassVar
247+
248+
@attrs.define
249+
class Foo:
250+
bar: ClassVar = 1
251+
Foo()
252+
""",
253+
}
254+
255+
for name, code in cases.items():
256+
with self.subTest(case=name):
257+
instance = next(astroid.extract_node(code).infer())
258+
self.assertIsInstance(instance.getattr("bar")[0], nodes.AssignName)
259+
self.assertNotIn("bar", instance.instance_attrs)
260+
261+
def test_attrs_without_class_var_annotation(self) -> None:
262+
cases = {
263+
"wrong-name": """
264+
import attrs
265+
from typing import Final
266+
267+
@attrs.define
268+
class Foo:
269+
bar: Final[int] = 1
270+
Foo()
271+
""",
272+
"classvar-not-outermost": """
273+
import attrs
274+
from typing import ClassVar
275+
276+
@attrs.define
277+
class Foo:
278+
bar: list[ClassVar[int]] = []
279+
Foo()
280+
""",
281+
}
282+
283+
for name, code in cases.items():
284+
with self.subTest(case=name):
285+
instance = next(astroid.extract_node(code).infer())
286+
self.assertIsInstance(instance.getattr("bar")[0], nodes.Unknown)
287+
self.assertIn("bar", instance.instance_attrs)

tests/brain/test_helpers.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
2+
# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE
3+
# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt
4+
5+
import pytest
6+
7+
from astroid import extract_node, nodes
8+
from astroid.brain.helpers import is_class_var
9+
10+
11+
@pytest.mark.parametrize(
12+
"code",
13+
[
14+
pytest.param(
15+
"""
16+
from typing import ClassVar
17+
18+
foo: ClassVar[int]
19+
""",
20+
id="from-import",
21+
),
22+
pytest.param(
23+
"""
24+
from typing import ClassVar
25+
26+
foo: ClassVar
27+
""",
28+
id="bare-classvar",
29+
),
30+
pytest.param(
31+
"""
32+
import typing
33+
34+
foo: typing.ClassVar[int]
35+
""",
36+
id="module-import",
37+
),
38+
],
39+
)
40+
def test_is_class_var_returns_true(code):
41+
node = extract_node(code)
42+
assert isinstance(node, nodes.AnnAssign)
43+
assert is_class_var(node.annotation)
44+
45+
46+
@pytest.mark.parametrize(
47+
"code",
48+
[
49+
pytest.param(
50+
"""
51+
from typing import Final
52+
53+
foo: Final[int]
54+
""",
55+
id="wrong-name",
56+
),
57+
pytest.param(
58+
"""
59+
from typing import ClassVar
60+
61+
foo: list[ClassVar[int]]
62+
""",
63+
id="classvar-not-outermost",
64+
),
65+
pytest.param(
66+
"""
67+
from typing import ClassVar
68+
ClassVar = int
69+
70+
foo: ClassVar
71+
""",
72+
id="shadowed-name",
73+
),
74+
pytest.param(
75+
"""
76+
foo: ClassVar[int]
77+
""",
78+
id="missing-import",
79+
),
80+
],
81+
)
82+
def test_is_class_var_returns_false(code):
83+
node = extract_node(code)
84+
assert isinstance(node, nodes.AnnAssign)
85+
assert not is_class_var(node.annotation)

0 commit comments

Comments
 (0)