Repository: browniebroke/django-codemod

Issue:
Replace `assignment_tag` by `simple_tag`
https://docs.djangoproject.com/en/dev/releases/1.9/#assignment-tag

- Deprecated: 1.9
- Removed 2.0


Discussion:

Resolving patch:
diff --git a/django_codemod/visitors/__init__.py b/django_codemod/visitors/__init__.py
--- a/django_codemod/visitors/__init__.py
+++ b/django_codemod/visitors/__init__.py
@@ -24,6 +24,7 @@
     FloatRangeModelFieldTransformer,
 )
 from .shortcuts import RenderToResponseTransformer
+from .template_tags import AssignmentTagTransformer
 from .timezone import FixedOffsetTransformer
 from .translations import (
     UGetTextLazyTransformer,
@@ -36,6 +37,7 @@
 
 __all__ = (
     "AbsPathTransformer",
+    "AssignmentTagTransformer",
     "AvailableAttrsTransformer",
     "ContextDecoratorTransformer",
     "FixedOffsetTransformer",
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
@@ -23,18 +23,18 @@ class BaseDjCodemodTransformer(ContextAwareTransformer, ABC):
     removed_in: Tuple[int, int]
 
 
-def module_matcher(import_parts):
+def module_matcher(import_parts: Sequence) -> Union[m.BaseMatcherNode, m.DoNotCare]:
+    """Build matcher for a module given sequence of import parts."""
+    # If only one element, it is just a Name
+    if len(import_parts) == 1:
+        return m.Name(import_parts[0])
     *values, attr = import_parts
-    if len(values) > 1:
-        value = module_matcher(values)
-    elif len(values) == 1:
-        value = m.Name(values[0])
-    else:
-        value = m.DoNotCare()
+    value = module_matcher(values)
     return m.Attribute(value=value, attr=m.Name(attr))
 
 
-def import_from_matches(node, module_parts):
+def import_from_matches(node: ImportFrom, module_parts: Sequence):
+    """Check if an `ImportFrom` node matches sequence of module parts."""
     return m.matches(node, m.ImportFrom(module=module_matcher(module_parts)))
 
 
diff --git a/django_codemod/visitors/template_tags.py b/django_codemod/visitors/template_tags.py
new file mode 100644
--- /dev/null
+++ b/django_codemod/visitors/template_tags.py
@@ -0,0 +1,116 @@
+from typing import Optional, Union
+
+from libcst import Assign, Decorator, ImportFrom, Module, Name, RemovalSentinel
+from libcst import matchers as m
+
+from django_codemod.constants import DJANGO_1_9, DJANGO_2_0
+from django_codemod.visitors.base import BaseDjCodemodTransformer, import_from_matches
+
+
+class AssignmentTagTransformer(BaseDjCodemodTransformer):
+    """Replace `assignment_tag` by `simple_tag`."""
+
+    deprecated_in = DJANGO_1_9
+    removed_in = DJANGO_2_0
+
+    ctx_key_prefix = "AssignmentTagTransformer"
+    ctx_key_library_call_matcher = f"{ctx_key_prefix}-library_call_matcher"
+    ctx_key_decorator_matcher = f"{ctx_key_prefix}-decorator_matcher"
+
+    @property
+    def library_call_matcher(self) -> Optional[m.Call]:
+        return self.context.scratch.get(self.ctx_key_library_call_matcher, None)
+
+    @property
+    def decorators_matcher(self) -> Optional[m.BaseMatcherNode]:
+        return self.context.scratch.get(self.ctx_key_decorator_matcher, None)
+
+    def leave_Module(self, original_node: Module, updated_node: Module) -> Module:
+        """Clear context when leaving module."""
+        self.context.scratch.pop(self.ctx_key_library_call_matcher, None)
+        self.context.scratch.pop(self.ctx_key_decorator_matcher, None)
+        return super().leave_Module(original_node, updated_node)
+
+    def visit_ImportFrom(self, node: ImportFrom) -> Optional[bool]:
+        """Record whether an interesting import is detected."""
+        return self._check_template_imported(node) or self._check_libary_imported(node)
+
+    def _check_template_imported(self, node: ImportFrom) -> bool:
+        """Record matcher if django.template is imported."""
+        if import_from_matches(node, ["django"]):
+            for import_alias in node.names:
+                if m.matches(import_alias, m.ImportAlias(name=m.Name("template"))):
+                    # We're visiting the `from django import template` statement
+                    # Get the actual name it's imported as (in case of import alias)
+                    imported_name = (
+                        import_alias.asname
+                        and import_alias.asname.name
+                        or import_alias.name
+                    )
+                    # Build the `Call` matcher to look out for, eg `template.Library()`
+                    self.context.scratch[self.ctx_key_library_call_matcher] = m.Call(
+                        func=m.Attribute(
+                            attr=m.Name("Library"), value=m.Name(imported_name.value)
+                        )
+                    )
+                    return True
+        return False
+
+    def _check_libary_imported(self, node: ImportFrom) -> bool:
+        """Record matcher if django.template.Library is imported."""
+        if import_from_matches(node, ["django", "template"]):
+            for import_alias in node.names:
+                if m.matches(import_alias, m.ImportAlias(name=m.Name("Library"))):
+                    # We're visiting the `from django.template import Library` statement
+                    # Get the actual name it's imported as (in case of import alias)
+                    imported_name = (
+                        import_alias.asname
+                        and import_alias.asname.name
+                        or import_alias.name
+                    )
+                    # Build the `Call` matcher to look out for, eg `Library()`
+                    self.context.scratch[self.ctx_key_library_call_matcher] = m.Call(
+                        func=m.Name(imported_name.value)
+                    )
+                    return True
+        return False
+
+    def visit_Assign(self, node: Assign) -> Optional[bool]:
+        """Record variable name the `Library()` call is assigned to."""
+        if self.library_call_matcher and m.matches(
+            node,
+            m.Assign(value=self.library_call_matcher),
+        ):
+            # Visiting a `register = template.Library()` statement
+            # Get all names on the left side of the assignment
+            target_names = (
+                assign_target.target.value for assign_target in node.targets
+            )
+            # Build the decorator matchers to look out for
+            target_matchers = (
+                m.Decorator(
+                    decorator=m.Attribute(
+                        value=m.Name(name),
+                        attr=m.Name("assignment_tag"),
+                    )
+                )
+                for name in target_names
+            )
+            # The final matcher should match if any of the decorator matchers matches
+            self.context.scratch[self.ctx_key_decorator_matcher] = m.OneOf(
+                *target_matchers
+            )
+        return super().visit_Assign(node)
+
+    def leave_Decorator(
+        self, original_node: Decorator, updated_node: Decorator
+    ) -> Union[Decorator, RemovalSentinel]:
+        """Update decorator call if all conditions are met."""
+        if self.decorators_matcher and m.matches(updated_node, self.decorators_matcher):
+            # If we have a decorator matcher, and it matches,
+            # update the node with new name
+            updated_decorator = updated_node.decorator.with_changes(
+                attr=Name("simple_tag")
+            )
+            return updated_node.with_changes(decorator=updated_decorator)
+        return super().leave_Decorator(original_node, updated_node)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -221,6 +221,7 @@ def test_deprecated_in_mapping():
             "URLResolversTransformer",
         ],
         (1, 9): [
+            "AssignmentTagTransformer",
             "OnDeleteTransformer",
         ],
     }
@@ -265,6 +266,7 @@ def test_removed_in_mapping():
             "ModelsPermalinkTransformer",
         ],
         (2, 0): [
+            "AssignmentTagTransformer",
             "OnDeleteTransformer",
             "URLResolversTransformer",
         ],
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
@@ -13,7 +13,7 @@
 @pytest.mark.parametrize(
     ("parts", "expected_matcher"),
     [
-        (["django"], m.Attribute(attr=m.Name("django"))),
+        (["django"], m.Name("django")),
         (
             ["django", "contrib"],
             m.Attribute(value=m.Name("django"), attr=m.Name("contrib")),
diff --git a/tests/visitors/test_template_tags.py b/tests/visitors/test_template_tags.py
new file mode 100644
--- /dev/null
+++ b/tests/visitors/test_template_tags.py
@@ -0,0 +1,159 @@
+from django_codemod.visitors import AssignmentTagTransformer
+from tests.visitors.base import BaseVisitorTest
+
+
+class TestAssignmentTagTransformer(BaseVisitorTest):
+
+    transformer = AssignmentTagTransformer
+
+    def test_noop(self) -> None:
+        """Test when nothing should change."""
+        before = after = """
+            from django import template
+
+            register = template.Library()
+
+
+            @register.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_noop_not_imported(self) -> None:
+        """Test when import is missing."""
+        before = after = """
+            from django import contrib
+
+            @register.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_simple_substitution(self) -> None:
+        """Test basic substitution."""
+        before = """
+            from django import template
+
+            register = template.Library()
+
+
+            @register.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+        after = """
+            from django import template
+
+            register = template.Library()
+
+
+            @register.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_substitution_unusual_name(self) -> None:
+        """Test substitution with a different name."""
+        before = """
+            from django import template
+
+            lib = template.Library()
+
+
+            @lib.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+        after = """
+            from django import template
+
+            lib = template.Library()
+
+
+            @lib.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_template_imported_alias(self) -> None:
+        """Test when django.template is imported with alias."""
+        before = """
+            from django import template as dj_template
+
+            register = dj_template.Library()
+
+
+            @register.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+        after = """
+            from django import template as dj_template
+
+            register = dj_template.Library()
+
+
+            @register.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_library_class_imported(self) -> None:
+        """Test when Library class is imported."""
+        before = """
+            from django.template import Library
+
+            register = Library()
+
+
+            @register.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+        after = """
+            from django.template import Library
+
+            register = Library()
+
+
+            @register.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_library_class_imported_with_alias(self) -> None:
+        """Test when django.template is imported with alias."""
+        before = """
+            from django.template import Library as TmplLibrary
+
+            register = TmplLibrary()
+
+
+            @register.assignment_tag
+            def some_tag():
+                return "Hello"
+        """
+        after = """
+            from django.template import Library as TmplLibrary
+
+            register = TmplLibrary()
+
+
+            @register.simple_tag
+            def some_tag():
+                return "Hello"
+        """
+
+        self.assertCodemod(before, after)