Skip to content
Merged
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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ What's New in astroid 4.0.0?
============================
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
Expand Down
8 changes: 8 additions & 0 deletions astroid/brain/brain_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,13 @@ def attr_attributes_transform(node: ClassDef) -> None:
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")
Expand Down
13 changes: 2 additions & 11 deletions astroid/brain/brain_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,7 +118,7 @@ def _get_dataclass_attributes(
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):
Expand Down Expand Up @@ -550,16 +551,6 @@ def _get_field_default(field_call: nodes.Call) -> _FieldDefaultReturn:
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)
Expand Down
15 changes: 15 additions & 0 deletions astroid/brain/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -127,3 +132,13 @@ def register_all_brains(manager: AstroidManager) -> None:
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"
56 changes: 56 additions & 0 deletions tests/brain/test_attr.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,59 @@ class Foo:
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)
85 changes: 85 additions & 0 deletions tests/brain/test_helpers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading