Update `attrs` brain to exclude `ClassVar` annotated attributes (#2811)
diff --git a/ChangeLog b/ChangeLog index 379a2a7..f207eb7 100644 --- a/ChangeLog +++ b/ChangeLog
@@ -7,6 +7,10 @@ ============================ Release date: TBA +* Fix false positive `invalid-name` on `attrs` classes with `ClassVar` annotated variables. + + Closes pylint-dev/pylint#10525 + * Prevent crash when parsing deeply nested parentheses causing MemoryError in python's built-in ast. Closes #2643
diff --git a/astroid/brain/brain_attrs.py b/astroid/brain/brain_attrs.py index 23ec9f6..727fb90 100644 --- a/astroid/brain/brain_attrs.py +++ b/astroid/brain/brain_attrs.py
@@ -8,6 +8,7 @@ Without this hook pylint reports unsupported-assignment-operation for attrs classes """ +from astroid.brain.helpers import is_class_var from astroid.manager import AstroidManager from astroid.nodes.node_classes import AnnAssign, Assign, AssignName, Call, Unknown from astroid.nodes.scoped_nodes import ClassDef @@ -78,6 +79,13 @@ continue elif not use_bare_annotations: continue + + # Skip attributes that are explicitly annotated as class variables + if isinstance(cdef_body_node, AnnAssign) and is_class_var( + cdef_body_node.annotation + ): + continue + targets = ( cdef_body_node.targets if hasattr(cdef_body_node, "targets")
diff --git a/astroid/brain/brain_dataclasses.py b/astroid/brain/brain_dataclasses.py index 16e992b..e6794be 100644 --- a/astroid/brain/brain_dataclasses.py +++ b/astroid/brain/brain_dataclasses.py
@@ -18,6 +18,7 @@ from typing import Literal from astroid import bases, context, nodes +from astroid.brain.helpers import is_class_var from astroid.builder import parse from astroid.const import PY313_PLUS from astroid.exceptions import AstroidSyntaxError, InferenceError, UseInferenceDefault @@ -117,7 +118,7 @@ continue # Annotation is never None - if _is_class_var(assign_node.annotation): # type: ignore[arg-type] + if is_class_var(assign_node.annotation): # type: ignore[arg-type] continue if _is_keyword_only_sentinel(assign_node.annotation): @@ -550,16 +551,6 @@ return None -def _is_class_var(node: nodes.NodeNG) -> bool: - """Return True if node is a ClassVar, with or without subscripting.""" - try: - inferred = next(node.infer()) - except (InferenceError, StopIteration): - return False - - return getattr(inferred, "name", "") == "ClassVar" - - def _is_keyword_only_sentinel(node: nodes.NodeNG) -> bool: """Return True if node is the KW_ONLY sentinel.""" inferred = safe_infer(node)
diff --git a/astroid/brain/helpers.py b/astroid/brain/helpers.py index 250d438..ccf6727 100644 --- a/astroid/brain/helpers.py +++ b/astroid/brain/helpers.py
@@ -5,10 +5,15 @@ from __future__ import annotations from collections.abc import Callable +from typing import TYPE_CHECKING +from astroid.exceptions import InferenceError from astroid.manager import AstroidManager from astroid.nodes.scoped_nodes import Module +if TYPE_CHECKING: + from astroid.nodes.node_ng import NodeNG + def register_module_extender( manager: AstroidManager, module_name: str, get_extension_mod: Callable[[], Module] @@ -127,3 +132,13 @@ brain_typing.register(manager) brain_unittest.register(manager) brain_uuid.register(manager) + + +def is_class_var(node: NodeNG) -> bool: + """Return True if node is a ClassVar, with or without subscripting.""" + try: + inferred = next(node.infer()) + except (InferenceError, StopIteration): + return False + + return getattr(inferred, "name", "") == "ClassVar"
diff --git a/tests/brain/test_attr.py b/tests/brain/test_attr.py index ef48873..667561b 100644 --- a/tests/brain/test_attr.py +++ b/tests/brain/test_attr.py
@@ -229,3 +229,59 @@ attr_name )[0] self.assertIsInstance(should_be_unknown, astroid.Unknown) + + def test_attrs_with_class_var_annotation(self) -> None: + cases = { + "with-subscript": """ + import attrs + from typing import ClassVar + + @attrs.define + class Foo: + bar: ClassVar[int] = 1 + Foo() + """, + "no-subscript": """ + import attrs + from typing import ClassVar + + @attrs.define + class Foo: + bar: ClassVar = 1 + Foo() + """, + } + + for name, code in cases.items(): + with self.subTest(case=name): + instance = next(astroid.extract_node(code).infer()) + self.assertIsInstance(instance.getattr("bar")[0], nodes.AssignName) + self.assertNotIn("bar", instance.instance_attrs) + + def test_attrs_without_class_var_annotation(self) -> None: + cases = { + "wrong-name": """ + import attrs + from typing import Final + + @attrs.define + class Foo: + bar: Final[int] = 1 + Foo() + """, + "classvar-not-outermost": """ + import attrs + from typing import ClassVar + + @attrs.define + class Foo: + bar: list[ClassVar[int]] = [] + Foo() + """, + } + + for name, code in cases.items(): + with self.subTest(case=name): + instance = next(astroid.extract_node(code).infer()) + self.assertIsInstance(instance.getattr("bar")[0], nodes.Unknown) + self.assertIn("bar", instance.instance_attrs)
diff --git a/tests/brain/test_helpers.py b/tests/brain/test_helpers.py new file mode 100644 index 0000000..d30fd1d --- /dev/null +++ b/tests/brain/test_helpers.py
@@ -0,0 +1,85 @@ +# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html +# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt + +import pytest + +from astroid import extract_node, nodes +from astroid.brain.helpers import is_class_var + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + from typing import ClassVar + + foo: ClassVar[int] + """, + id="from-import", + ), + pytest.param( + """ + from typing import ClassVar + + foo: ClassVar + """, + id="bare-classvar", + ), + pytest.param( + """ + import typing + + foo: typing.ClassVar[int] + """, + id="module-import", + ), + ], +) +def test_is_class_var_returns_true(code): + node = extract_node(code) + assert isinstance(node, nodes.AnnAssign) + assert is_class_var(node.annotation) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + from typing import Final + + foo: Final[int] + """, + id="wrong-name", + ), + pytest.param( + """ + from typing import ClassVar + + foo: list[ClassVar[int]] + """, + id="classvar-not-outermost", + ), + pytest.param( + """ + from typing import ClassVar + ClassVar = int + + foo: ClassVar + """, + id="shadowed-name", + ), + pytest.param( + """ + foo: ClassVar[int] + """, + id="missing-import", + ), + ], +) +def test_is_class_var_returns_false(code): + node = extract_node(code) + assert isinstance(node, nodes.AnnAssign) + assert not is_class_var(node.annotation)