Repository: browniebroke/django-codemod

Issue:
Attribute access to `url` incorrectly replaced
**Describe the bug**
Issue with URL Transformer, converting an attribute access of the `HttpResponseRedirectBase.url` to `HttpResponseRedirectBase.re_path` if `django.conf.urls.url` is imported

**To Reproduce**
Consider this code (from a test case):

```python
from django.conf.urls import url

# Define some URL patterns for tests
urlpatterns = [
    url(r'^something', Mock(), name='something'),
]

# Later: 
assert isinstance(response, HttpResponseRedirectBase)
assert response.url == expected_path
```


The last assertion is incorrectly converted to:

```python
assert response.re_path == expected_path
```

**Additional context**
This is potentially affecting other rename transformers.



Discussion:

Resolving patch:
diff --git a/django_codemod/visitors/base.py b/django_codemod/visitors/base.py
--- a/django_codemod/visitors/base.py
+++ b/django_codemod/visitors/base.py
@@ -4,6 +4,7 @@
 
 from libcst import (
     Arg,
+    Attribute,
     BaseExpression,
     BaseSmallStatement,
     Call,
@@ -74,14 +75,6 @@ def ctx_key_imported_as(self):
     def entity_imported_as(self):
         return self.context.scratch.get(self.ctx_key_imported_as, None)
 
-    @property
-    def ctx_key_is_name_called(self):
-        return f"{self.rename_from}-is_name_called"
-
-    @property
-    def entity_is_name_called(self):
-        return self.context.scratch.get(self.ctx_key_is_name_called, False)
-
     @property
     def is_imported_with_old_name(self):
         is_imported = self.ctx_key_imported_as in self.context.scratch
@@ -156,6 +149,14 @@ def add_new_import(self, evaluated_name: Optional[str] = None) -> None:
             asname=as_name,
         )
 
+    @property
+    def ctx_key_is_name_called(self):
+        return f"{self.rename_from}-is_name_called"
+
+    @property
+    def entity_is_name_called(self):
+        return self.context.scratch.get(self.ctx_key_is_name_called, False)
+
     def visit_Call(self, node: Call) -> Optional[bool]:
         if self.is_imported_with_old_name and m.matches(
             node, m.Call(func=m.Name(self.old_name))
@@ -167,10 +168,32 @@ def leave_Call(self, original_node: Call, updated_node: Call) -> BaseExpression:
         self.context.scratch.pop(self.ctx_key_is_name_called, None)
         return super().leave_Call(original_node, updated_node)
 
+    @property
+    def ctx_key_is_name_in_attribute(self):
+        return f"{self.rename_from}-is_in_attribute"
+
+    @property
+    def entity_is_in_attribute(self):
+        return self.context.scratch.get(self.ctx_key_is_name_in_attribute, False)
+
+    def visit_Attribute(self, node: Attribute) -> Optional[bool]:
+        if self.is_imported_with_old_name and m.matches(
+            node, m.Attribute(attr=m.Name(self.old_name))
+        ):
+            self.context.scratch[self.ctx_key_is_name_in_attribute] = True
+        return super().visit_Attribute(node)
+
+    def leave_Attribute(
+        self, original_node: Attribute, updated_node: Attribute
+    ) -> BaseExpression:
+        self.context.scratch.pop(self.ctx_key_is_name_in_attribute, None)
+        return super().leave_Attribute(original_node, updated_node)
+
     def leave_Name(self, original_node: Name, updated_node: Name) -> BaseExpression:
         if (
             self.is_imported_with_old_name
             and not self.entity_is_name_called
+            and not self.entity_is_in_attribute
             and m.matches(updated_node, m.Name(value=self.old_name))
             and self.resolve_scope(original_node) == self.import_scope
         ):


Test patch:
diff --git a/tests/visitors/test_base.py b/tests/visitors/test_base.py
--- a/tests/visitors/test_base.py
+++ b/tests/visitors/test_base.py
@@ -1,5 +1,6 @@
 import pytest
 from libcst import matchers as m
+from parameterized import parameterized
 
 from django_codemod.visitors.base import (
     BaseFuncRenameTransformer,
@@ -189,6 +190,35 @@ def something():
         """
         self.assertCodemod(before, after)
 
+    @parameterized.expand(
+        [
+            ("response.func",),
+            ("response.func.other",),
+            ("response.func.other.one",),
+        ]
+    )
+    def test_attribute_access(self, attribute_access) -> None:
+        """When accessing an attribute that looks like the imported name."""
+        before = f"""
+            from django.dummy.module import func
+
+            result = func()
+
+            def test_something():
+                response = get_response()
+                assert {attribute_access} == 1
+        """
+        after = f"""
+            from django.dummy.module import better_func
+
+            result = better_func()
+
+            def test_something():
+                response = get_response()
+                assert {attribute_access} == 1
+        """
+        self.assertCodemod(before, after)
+
 
 class OtherModuleFuncRenameTransformer(BaseFuncRenameTransformer):
     """Transformer with different module."""