From 5eae215faba239c6bc9eca2e08b3855411595fd4 Mon Sep 17 00:00:00 2001 From: Hashem Date: Tue, 27 Aug 2024 00:28:35 -0400 Subject: [PATCH] brain_attrs: Support annotation-only members (#2515) 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 --- ChangeLog | 4 ++++ astroid/brain/brain_attrs.py | 14 ++++++++++---- script/.contributors_aliases.json | 4 ++++ tests/brain/test_attr.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/ChangeLog b/ChangeLog index d4b2ca245..fdd60ec09 100644 --- a/ChangeLog +++ b/ChangeLog @@ -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? diff --git a/astroid/brain/brain_attrs.py b/astroid/brain/brain_attrs.py index b7a7eafe1..23ec9f66a 100644 --- a/astroid/brain/brain_attrs.py +++ b/astroid/brain/brain_attrs.py @@ -24,6 +24,13 @@ "field", ) ) +NEW_ATTRS_NAMES = frozenset( + ( + "attrs.define", + "attrs.mutable", + "attrs.frozen", + ) +) ATTRS_NAMES = frozenset( ( "attr.s", @@ -33,9 +40,7 @@ "attr.define", "attr.mutable", "attr.frozen", - "attrs.define", - "attrs.mutable", - "attrs.frozen", + *NEW_ATTRS_NAMES, ) ) @@ -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 diff --git a/script/.contributors_aliases.json b/script/.contributors_aliases.json index 73e9e0db1..53d53f13b 100644 --- a/script/.contributors_aliases.json +++ b/script/.contributors_aliases.json @@ -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" diff --git a/tests/brain/test_attr.py b/tests/brain/test_attr.py index e428b0c8d..ef4887378 100644 --- a/tests/brain/test_attr.py +++ b/tests/brain/test_attr.py @@ -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 @@ -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)