From 4f810dbc1343da04db8eb3fee5300490fd7a334b Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 27 Mar 2023 12:59:48 +0300 Subject: [PATCH] Allow running codemods without configuring in YAML (#879) * Simplify command specifier parsing * Allow running codemods without configuring in YAML This enables codemodding things by just plonking a CodemodCommand class into any old importable module and running `python -m libcst.tool codemod -x some_module.SomeClass ...` --- libcst/codemod/tests/test_codemod_cli.py | 18 ++++++ libcst/tool.py | 81 ++++++++++++++---------- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/libcst/codemod/tests/test_codemod_cli.py b/libcst/codemod/tests/test_codemod_cli.py index b8d3d79fb..0309c74a0 100644 --- a/libcst/codemod/tests/test_codemod_cli.py +++ b/libcst/codemod/tests/test_codemod_cli.py @@ -45,3 +45,21 @@ def test_codemod_formatter_error_input(self) -> None: "error: cannot format -: Cannot parse: 13:10: async with AsyncExitStack() as stack:", rlt.stderr.decode("utf-8"), ) + + def test_codemod_external(self) -> None: + # Test running the NOOP command as an "external command" + # against this very file. + output = subprocess.check_output( + [ + sys.executable, + "-m", + "libcst.tool", + "codemod", + "-x", # external module + "libcst.codemod.commands.noop.NOOPCommand", + str(Path(__file__)), + ], + encoding="utf-8", + stderr=subprocess.STDOUT, + ) + assert "Finished codemodding 1 files!" in output diff --git a/libcst/tool.py b/libcst/tool.py index a0fa5bbcd..5aa4d12f5 100644 --- a/libcst/tool.py +++ b/libcst/tool.py @@ -391,38 +391,49 @@ def _codemod_impl(proc_name: str, command_args: List[str]) -> int: # noqa: C901 # full parser below once we know the command and have added its arguments. parser = argparse.ArgumentParser(add_help=False, fromfile_prefix_chars="@") parser.add_argument("command", metavar="COMMAND", type=str, nargs="?", default=None) + ext_action = parser.add_argument( + "-x", + "--external", + action="store_true", + default=False, + help="Interpret `command` as just a module/class specifier", + ) args, _ = parser.parse_known_args(command_args) # Now, try to load the class and get its arguments for help purposes. if args.command is not None: - command_path = args.command.split(".") - if len(command_path) < 2: + command_module_name, _, command_class_name = args.command.rpartition(".") + if not (command_module_name and command_class_name): print(f"{args.command} is not a valid codemod command", file=sys.stderr) return 1 - command_module_name, command_class_name = ( - ".".join(command_path[:-1]), - command_path[-1], - ) - command_class = None - for module in config["modules"]: - try: - command_class = getattr( - importlib.import_module(f"{module}.{command_module_name}"), - command_class_name, - ) - break - # Only swallow known import errors, show the rest of the exceptions - # to the user who is trying to run the codemod. - except AttributeError: - continue - except ModuleNotFoundError: - continue - if command_class is None: - print( - f"Could not find {command_module_name} in any configured modules", - file=sys.stderr, + if args.external: + # There's no error handling here on purpose; if the user opted in for `-x`, + # they'll probably want to see the exact import error too. + command_class = getattr( + importlib.import_module(command_module_name), + command_class_name, ) - return 1 + else: + command_class = None + for module in config["modules"]: + try: + command_class = getattr( + importlib.import_module(f"{module}.{command_module_name}"), + command_class_name, + ) + break + # Only swallow known import errors, show the rest of the exceptions + # to the user who is trying to run the codemod. + except AttributeError: + continue + except ModuleNotFoundError: + continue + if command_class is None: + print( + f"Could not find {command_module_name} in any configured modules", + file=sys.stderr, + ) + return 1 else: # Dummy, specifically to allow for running --help with no arguments. command_class = CodemodCommand @@ -437,6 +448,7 @@ def _codemod_impl(proc_name: str, command_args: List[str]) -> int: # noqa: C901 prog=f"{proc_name} codemod", fromfile_prefix_chars="@", ) + parser._add_action(ext_action) parser.add_argument( "command", metavar="COMMAND", @@ -522,20 +534,21 @@ def _codemod_impl(proc_name: str, command_args: List[str]) -> int: # noqa: C901 k: v for k, v in vars(args).items() if k - not in [ + not in { "command", - "path", - "unified_diff", - "jobs", - "python_version", + "external", + "hide_blacklisted_warnings", + "hide_generated_warnings", + "hide_progress", "include_generated", "include_stubs", + "jobs", "no_format", + "path", + "python_version", "show_successes", - "hide_generated_warnings", - "hide_blacklisted_warnings", - "hide_progress", - ] + "unified_diff", + } } command_instance = command_class(CodemodContext(), **codemod_args)