Repository: browniebroke/django-codemod

Issue:
Removed `weak` argument to `django.dispatch.signals.Signal.disconnect()`
- Deprecated in Django 1.9
- Removed in Django 2.0

> The `weak` argument to `django.dispatch.signals.Signal.disconnect()` will be removed.

It [has no effect since Django 1.9](https://docs.djangoproject.com/en/dev/releases/1.9/#id1).


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 .signals import SignalDisconnectWeakTransformer
 from .template_tags import AssignmentTagTransformer
 from .timezone import FixedOffsetTransformer
 from .translations import (
@@ -56,6 +57,7 @@
     "OnDeleteTransformer",
     "QuerySetPaginatorTransformer",
     "RenderToResponseTransformer",
+    "SignalDisconnectWeakTransformer",
     "SmartTextTransformer",
     "UGetTextLazyTransformer",
     "UGetTextNoopTransformer",
diff --git a/django_codemod/visitors/signals.py b/django_codemod/visitors/signals.py
new file mode 100644
--- /dev/null
+++ b/django_codemod/visitors/signals.py
@@ -0,0 +1,98 @@
+from typing import List, Optional
+
+from libcst import BaseExpression, Call, ImportFrom, MaybeSentinel, Module
+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 SignalDisconnectWeakTransformer(BaseDjCodemodTransformer):
+    """Remove the `weak` argument to `Signal.disconnect()`."""
+
+    deprecated_in = DJANGO_1_9
+    removed_in = DJANGO_2_0
+
+    ctx_key_prefix = "SignalDisconnectWeakTransformer"
+    ctx_key_call_matchers = f"{ctx_key_prefix}-call_matchers"
+    builtin_signals = [
+        "pre_init",
+        "post_init",
+        "pre_save",
+        "post_save",
+        "pre_delete",
+        "post_delete",
+        "m2m_changed",
+        "pre_migrate",
+        "post_migrate",
+    ]
+
+    @property
+    def disconnect_call_matchers(self) -> List[m.Call]:
+        return self.context.scratch.get(self.ctx_key_call_matchers, [])
+
+    def add_disconnect_call_matcher(self, call_matcher: m.Call) -> None:
+        self.context.scratch[
+            self.ctx_key_call_matchers
+        ] = self.disconnect_call_matchers + [call_matcher]
+
+    def leave_Module(self, original_node: Module, updated_node: Module) -> Module:
+        """Clear context when leaving module."""
+        self.context.scratch.pop(self.ctx_key_call_matchers, None)
+        return super().leave_Module(original_node, updated_node)
+
+    def visit_ImportFrom(self, node: ImportFrom) -> Optional[bool]:
+        """Set the `Call` matcher depending on which signals are imported.."""
+        if import_from_matches(node, ["django", "db", "models", "signals"]):
+            import_alias_matcher = m.OneOf(
+                *(
+                    m.ImportAlias(name=m.Name(signal_name))
+                    for signal_name in self.builtin_signals
+                )
+            )
+            for import_alias in node.names:
+                if m.matches(import_alias, import_alias_matcher):
+                    # We're visiting an import statement for a built-in signal
+                    # 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
+                    )
+                    # Add the call matcher for the current signal to the list
+                    self.add_disconnect_call_matcher(
+                        m.Call(
+                            func=m.Attribute(
+                                value=m.Name(imported_name.value),
+                                attr=m.Name("disconnect"),
+                            ),
+                        )
+                    )
+        return super().visit_ImportFrom(node)
+
+    def leave_Call(self, original_node: Call, updated_node: Call) -> BaseExpression:
+        """
+        Remove the `weak` argument if present in the call.
+
+        This is only changing calls with keyword arguments.
+        """
+        if self.disconnect_call_matchers and m.matches(
+            updated_node, m.OneOf(*self.disconnect_call_matchers)
+        ):
+            updated_args = []
+            should_change = False
+            last_comma = MaybeSentinel.DEFAULT
+            # Keep all arguments except the one with the keyword `weak` (if present)
+            for index, arg in enumerate(updated_node.args):
+                if m.matches(arg, m.Arg(keyword=m.Name("weak"))):
+                    # An argument with the keyword `weak` was found
+                    # -> we need to rewrite the statement
+                    should_change = True
+                else:
+                    updated_args.append(arg)
+                last_comma = arg.comma
+            if should_change:
+                # Make sure the end of line is formatted as initially
+                updated_args[-1] = updated_args[-1].with_changes(comma=last_comma)
+                return updated_node.with_changes(args=updated_args)
+        return super().leave_Call(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
@@ -223,6 +223,7 @@ def test_deprecated_in_mapping():
         (1, 9): [
             "AssignmentTagTransformer",
             "OnDeleteTransformer",
+            "SignalDisconnectWeakTransformer",
         ],
     }
 
@@ -268,6 +269,7 @@ def test_removed_in_mapping():
         (2, 0): [
             "AssignmentTagTransformer",
             "OnDeleteTransformer",
+            "SignalDisconnectWeakTransformer",
             "URLResolversTransformer",
         ],
     }
diff --git a/tests/visitors/test_signals.py b/tests/visitors/test_signals.py
new file mode 100644
--- /dev/null
+++ b/tests/visitors/test_signals.py
@@ -0,0 +1,127 @@
+from parameterized import parameterized
+
+from django_codemod.visitors import SignalDisconnectWeakTransformer
+from tests.visitors.base import BaseVisitorTest
+
+
+class TestSignalDisconnectWeakTransformer(BaseVisitorTest):
+
+    transformer = SignalDisconnectWeakTransformer
+    DJANGO_SIGNAL_NAMES = [
+        "pre_init",
+        "post_init",
+        "pre_save",
+        "post_save",
+        "pre_delete",
+        "post_delete",
+        "m2m_changed",
+        "pre_migrate",
+        "post_migrate",
+    ]
+
+    @parameterized.expand(DJANGO_SIGNAL_NAMES)
+    def test_noop(self, signal_name):
+        before = after = f"""
+            from django.db.models.signals import {signal_name}
+
+            {signal_name}.disconnect(
+                receiver=some_handler,
+                sender=MyModel,
+                dispatch_uid='something-unique',
+            )
+        """
+
+        self.assertCodemod(before, after)
+
+    @parameterized.expand(DJANGO_SIGNAL_NAMES)
+    def test_with_kwargs(self, signal_name):
+        before = f"""
+            from django.db.models.signals import {signal_name}
+
+            {signal_name}.disconnect(receiver=some_handler, sender=MyModel, weak=True)
+        """
+
+        after = f"""
+            from django.db.models.signals import {signal_name}
+
+            {signal_name}.disconnect(receiver=some_handler, sender=MyModel)
+        """
+
+        self.assertCodemod(before, after)
+
+    @parameterized.expand(DJANGO_SIGNAL_NAMES)
+    def test_with_kwargs_dispatch_uid(self, signal_name):
+        before = f"""
+            from django.db.models.signals import {signal_name}
+
+            {signal_name}.disconnect(
+                receiver=some_handler,
+                sender=MyModel,
+                weak=True,
+                dispatch_uid='my-unique-id',
+            )
+        """
+
+        after = f"""
+            from django.db.models.signals import {signal_name}
+
+            {signal_name}.disconnect(
+                receiver=some_handler,
+                sender=MyModel,
+                dispatch_uid='my-unique-id',
+            )
+        """
+
+        self.assertCodemod(before, after)
+
+    @parameterized.expand(DJANGO_SIGNAL_NAMES)
+    def test_imported_with_alias(self, signal_name):
+        before = f"""
+            from django.db.models.signals import {signal_name} as dj_{signal_name}
+
+            dj_{signal_name}.disconnect(receiver=some_handler, weak=True)
+        """
+
+        after = f"""
+            from django.db.models.signals import {signal_name} as dj_{signal_name}
+
+            dj_{signal_name}.disconnect(receiver=some_handler)
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_multiple_signal_disconnected_single_import(self):
+        before = """
+            from django.db.models.signals import pre_save, post_save
+
+            pre_save.disconnect(receiver=some_handler, weak=True)
+            post_save.disconnect(receiver=some_handler, weak=True)
+        """
+
+        after = """
+            from django.db.models.signals import pre_save, post_save
+
+            pre_save.disconnect(receiver=some_handler)
+            post_save.disconnect(receiver=some_handler)
+        """
+
+        self.assertCodemod(before, after)
+
+    def test_multiple_signal_disconnected_separate_imports(self):
+        before = """
+            from django.db.models.signals import pre_save
+            from django.db.models.signals import post_save
+
+            pre_save.disconnect(receiver=some_handler, weak=True)
+            post_save.disconnect(receiver=some_handler, weak=True)
+        """
+
+        after = """
+            from django.db.models.signals import pre_save
+            from django.db.models.signals import post_save
+
+            pre_save.disconnect(receiver=some_handler)
+            post_save.disconnect(receiver=some_handler)
+        """
+
+        self.assertCodemod(before, after)