Repository: browniebroke/django-codemod

Issue:
Ability to use a specific codemodder to the CLI
We're listing codemodders in the `list` command. It would be nice to add a new CLI option accepting a codemodder name and execute just that one. 

Ideally, one could specify the option multiple time to run only the codemodders they want.


Discussion:

Resolving patch:
diff --git a/django_codemod/cli.py b/django_codemod/cli.py
--- a/django_codemod/cli.py
+++ b/django_codemod/cli.py
@@ -18,7 +18,7 @@
 from django_codemod.visitors.base import BaseDjCodemodTransformer
 
 
-def index_codemoders(version_getter: Callable) -> Dict[Tuple[int, int], List]:
+def index_codemodders(version_getter: Callable) -> Dict[Tuple[int, int], List]:
     """
     Index codemodders by version.
 
@@ -36,16 +36,22 @@ def iter_codemodders() -> Generator[BaseDjCodemodTransformer, None, None]:
     for object_name in dir(visitors):
         try:
             obj = getattr(visitors, object_name)
-            if not issubclass(obj, BaseDjCodemodTransformer) or inspect.isabstract(obj):
-                continue
-            # Looks like this one is good to go
-            yield obj
+            if issubclass(obj, BaseDjCodemodTransformer) and not inspect.isabstract(
+                obj
+            ):
+                yield obj  # Looks like this one is good to go
         except TypeError:
             continue
 
 
-DEPRECATED_IN = index_codemoders(version_getter=attrgetter("deprecated_in"))
-REMOVED_IN = index_codemoders(version_getter=attrgetter("removed_in"))
+DEPRECATED_IN = index_codemodders(version_getter=attrgetter("deprecated_in"))
+REMOVED_IN = index_codemodders(version_getter=attrgetter("removed_in"))
+BY_NAME = {cls.__name__: cls for cls in iter_codemodders()}
+
+
+class CodemodChoice(click.Choice):
+    def get_metavar(self, param):
+        return "(see `djcodemod list`)"
 
 
 class VersionParamType(click.ParamType):
@@ -117,9 +123,17 @@ def djcodemod():
     type=VersionParamType(DEPRECATED_IN),
     multiple=True,
 )
+@click.option(
+    "--codemod",
+    "codemod",
+    help="Choose a specific codemod to run. Can be repeated.",
+    type=CodemodChoice(BY_NAME.keys()),
+    multiple=True,
+)
 def run(
     removed_in: List[Tuple[int, int]],
     deprecated_in: List[Tuple[int, int]],
+    codemod: List[str],
     path: List[str],
 ) -> None:
     """
@@ -133,10 +147,12 @@ def run(
         codemodders_set |= set(REMOVED_IN[version])
     for version in deprecated_in:
         codemodders_set |= set(DEPRECATED_IN[version])
+    for name in codemod:
+        codemodders_set.add(BY_NAME[name])
     if not codemodders_set:
         raise click.UsageError(
             "No codemods were selected. "
-            "Specify '--removed-in' and/or '--deprecated-in'."
+            "Specify '--removed-in' and/or '--deprecated-in' and/or '--codemod'."
         )
     codemodders_list = sorted(codemodders_set, key=lambda m: m.__name__)
     click.echo(f"Running codemods: {', '.join(m.__name__ for m in codemodders_list)}")


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -86,12 +86,25 @@ def test_invalid_version(cli_runner):
     assert "'not.a.version' is not a valid version" in result.output
 
 
+def test_run_help(cli_runner):
+    result = cli_runner.invoke(
+        cli.djcodemod,
+        ["run", "--help"],
+    )
+
+    assert result.exit_code == 0
+    assert "--codemod" in result.output
+    assert "--deprecated-in" in result.output
+    assert "--removed-in" in result.output
+    assert "djcodemod list" in result.output
+
+
 @pytest.mark.parametrize(
     ("option", "version"),
     [
-        # Removed in option
         ("--removed-in", "3.0"),
         ("--deprecated-in", "2.0"),
+        ("--codemod", "URLResolversTransformer"),
     ],
 )
 @pytest.mark.parametrize("path", [".", "my_app"])