Repository: quora/pyanalyze (python)

Issue:
False positive: Name mangling + `@dataclass` (undefined_attribute)
This is a very specific issue. So maybe it is not needed to be fixed as I doubt somebody will ever go through this situation. Minimum reproducible example:

```python
from dataclasses import dataclass

@dataclass
class A:
    def __func(self) -> None:
        ...

    def run(self) -> None:
        self.__func()  # ERROR: Undefined attribute
```

This issue only happens with `@dataclass`. If the decorator is removed, it will work fine.

Pull request description:
Fix name mangling
Fixes #657

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 6e946c9..0543d3f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Handle mangling of private attribute names (#658)
 - Declare support for Python 3.12. Not all new features in
   Python 3.12 are supported yet. (#656)
 - Fix treatment of `@property` by the `incompatible_override`
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 0129232..8d818d2 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -1108,6 +1108,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         self.match_subject = Composite(AnyValue(AnySource.inference))
         # current class (for inferring the type of cls and self arguments)
         self.current_class = None
+        self.current_class_name: Optional[str] = None
         self.current_function_name = None
         self.current_function_info = None
 
@@ -1697,14 +1698,20 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         return isinstance(ctx, (ast.Load, ast.Del))
 
     @contextlib.contextmanager
-    def _set_current_class(self, current_class: type) -> Iterator[None]:
+    def _set_current_class(
+        self, current_class: type, node: ast.ClassDef
+    ) -> Iterator[None]:
         if should_check_for_duplicate_values(current_class, self.options):
             current_enum_members = {}
         else:
             current_enum_members = None
         with qcore.override(self, "current_class", current_class), qcore.override(
             self.asynq_checker, "current_class", current_class
-        ), qcore.override(self, "current_enum_members", current_enum_members):
+        ), qcore.override(
+            self, "current_enum_members", current_enum_members
+        ), qcore.override(
+            self, "current_class_name", node.name
+        ):
             yield
 
     def visit_ClassDef(self, node: ast.ClassDef) -> Value:
@@ -1757,7 +1764,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
 
             with self.scopes.add_scope(
                 ScopeType.class_scope, scope_node=None, scope_object=current_class
-            ), self._set_current_class(current_class):
+            ), self._set_current_class(current_class, node):
                 self._generic_visit_list(node.body)
 
             if isinstance(cls_obj, KnownValue):
@@ -4789,15 +4796,22 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         return root_composite.get_extended_varname_with_origin(index, origin)
 
     def composite_from_attribute(self, node: ast.Attribute) -> Composite:
+        mangled_attr = node.attr
+        if (
+            self.current_class_name is not None
+            and mangled_attr.startswith("__")
+            and not mangled_attr.endswith("__")
+        ):
+            mangled_attr = f"_{self.current_class_name}{mangled_attr}"
         if isinstance(node.value, ast.Name):
-            attr_str = f"{node.value.id}.{node.attr}"
+            attr_str = f"{node.value.id}.{mangled_attr}"
             if self._is_write_ctx(node.ctx):
                 self.yield_checker.record_assignment(attr_str)
             else:
                 self.yield_checker.record_usage(attr_str, node)
 
         root_composite = self.composite_from_node(node.value)
-        composite = self._extend_composite(root_composite, node.attr, node)
+        composite = self._extend_composite(root_composite, mangled_attr, node)
         if self._is_write_ctx(node.ctx):
             # TODO: We should do something here if we're in an AnnAssign, e.g.
             # note the type in the class's namespace.
@@ -4818,7 +4832,7 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 typ = root_composite.value.typ
                 if isinstance(typ, type):
                     self._record_type_attr_set(
-                        typ, node.attr, node, self.being_assigned
+                        typ, mangled_attr, node, self.being_assigned
                     )
             return Composite(self.being_assigned, composite, node)
         elif self._is_read_ctx(node.ctx):
@@ -4829,18 +4843,18 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                     and root_composite.value.val.__name__ is not None
                 ):
                     self.reexport_tracker.record_attribute_accessed(
-                        root_composite.value.val.__name__, node.attr, node, self
+                        root_composite.value.val.__name__, mangled_attr, node, self
                     )
             value = self.get_attribute(
                 root_composite,
-                node.attr,
+                mangled_attr,
                 node,
                 use_fallback=True,
                 ignore_none=self.options.get_value_for(IgnoreNoneAttributes),
             )
             self.check_deprecation(node, value)
             if self._should_use_varname_value(value):
-                varname_value = self.checker.maybe_get_variable_name_value(node.attr)
+                varname_value = self.checker.maybe_get_variable_name_value(mangled_attr)
                 if varname_value is not None:
                     return Composite(varname_value, composite, node)
             if (
@@ -4851,9 +4865,9 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
                 if local_value is not UNINITIALIZED_VALUE:
                     value = local_value
             if root_composite.value == KnownValue(sys):
-                if node.attr == "platform":
+                if mangled_attr == "platform":
                     value = annotate_value(value, [SYS_PLATFORM_EXTENSION])
-                elif node.attr == "version_info":
+                elif mangled_attr == "version_info":
                     value = annotate_value(value, [SYS_VERSION_INFO_EXTENSION])
             return Composite(value, composite, node)
         else:


Test patch:
diff --git a/pyanalyze/test_attributes.py b/pyanalyze/test_attributes.py
index 2f36607..03a1758 100644
--- a/pyanalyze/test_attributes.py
+++ b/pyanalyze/test_attributes.py
@@ -317,6 +317,38 @@ class TestAttributes(TestNameCheckVisitorBase):
             c = C()
             assert_is_value(c.f(), TypedValue(int))
 
+    @assert_passes()
+    def test_mangling(self):
+        from typing_extensions import assert_type
+
+        class A:
+            __private: int
+
+            def __func(self) -> int:
+                return 0
+
+            def run(self) -> None:
+                assert_type(self.__func(), int)
+                assert_type(self._A__func(), int)
+                assert_type(self.__private, int)
+
+    @assert_passes()
+    def test_mangling_dataclass(self):
+        from dataclasses import dataclass
+        from typing_extensions import assert_type
+
+        @dataclass
+        class A:
+            __private: int
+
+            def __func(self) -> str:
+                return ""
+
+            def run(self) -> None:
+                assert_type(self.__func(), str)
+                assert_type(self._A__func(), str)
+                assert_type(self.__private, int)
+
 
 class TestHasAttrExtension(TestNameCheckVisitorBase):
     @assert_passes()