From b89c2f4643f354f560bef5259dbfe83c79df01a6 Mon Sep 17 00:00:00 2001 From: Simon Maillard <simon@ogesta.fr> Date: Fri, 22 Nov 2024 14:27:45 +0100 Subject: [PATCH] Add informations on cli output about module If the operation or fact is not found, it will display the ones available, and if no one is avaible, Warn the user about the possibility that he have a py file or a folder with the same name as the module --- pyinfra_cli/util.py | 63 ++++++++++++++++++++++++++- tests/test_cli/test_cli_exceptions.py | 8 +++- tests/test_cli/test_cli_util.py | 6 ++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/pyinfra_cli/util.py b/pyinfra_cli/util.py index 95ea7b62e..111dc96f1 100644 --- a/pyinfra_cli/util.py +++ b/pyinfra_cli/util.py @@ -15,6 +15,7 @@ import gevent from pyinfra import logger, state +from pyinfra.api import FactBase from pyinfra.api.command import PyinfraCommand from pyinfra.api.exceptions import PyinfraError from pyinfra.api.host import HostData @@ -153,6 +154,37 @@ def parse_cli_arg(arg): return arg +def get_module_available_actions(module: ModuleType) -> dict[str, list[str]]: + """ + Lists operations and facts available in a module. + + Args: + module (ModuleType): The module to inspect. + + Returns: + dict: A dictionary with keys 'operations' and 'facts', each containing a list of names. + """ + available_actions: dict[str, list[str]] = { + "operations": [], + "facts": [], + } + + for name, obj in module.__dict__.items(): + # Check if it's a decorated operation + if callable(obj) and getattr(obj, "is_idempotent", None) is not None: + available_actions["operations"].append(name) + + # Check if it's a FactBase subclass + elif ( + isinstance(obj, type) + and issubclass(obj, FactBase) + and obj.__module__ == module.__name__ + ): + available_actions["facts"].append(name) + + return available_actions + + def try_import_module_attribute(path, prefix=None, raise_for_none=True): if ":" in path: # Allow a.module.name:function syntax @@ -189,7 +221,36 @@ def try_import_module_attribute(path, prefix=None, raise_for_none=True): attr = getattr(module, attr_name, None) if attr is None: if raise_for_none: - raise CliError(f"No such attribute in module {possible_modules[0]}: {attr_name}") + extra_info = [] + module_name = getattr(module, "__name__", str(module)) + + if prefix == "pyinfra.operations": + # List classes of type OperationMeta + available_operations = get_module_available_actions(module)["operations"] + if available_operations: + extra_info.append( + f"Available operations are: {', '.join(available_operations)}" + ) + else: + extra_info.append( + "No operations found. Maybe you have a file or folder named " + f"`{str(module_name)}` in the current folder ?" + ) + + elif prefix == "pyinfra.facts": + # List classes of type FactBase + available_facts = get_module_available_actions(module)["facts"] + if available_facts: + extra_info.append(f"Available facts are: {', '.join(available_facts)}") + else: + extra_info.append( + "No facts found. Maybe you have a file or folder named " + f"`{str(module_name)}` in the current folder ?" + ) + + message = [f"No such attribute in module {possible_modules[0]}: {attr_name}"] + + raise CliError("\n".join(message + extra_info)) return return attr diff --git a/tests/test_cli/test_cli_exceptions.py b/tests/test_cli/test_cli_exceptions.py index 7cac5f15a..e20a5797d 100644 --- a/tests/test_cli/test_cli_exceptions.py +++ b/tests/test_cli/test_cli_exceptions.py @@ -44,7 +44,13 @@ def test_no_fact_module(self): def test_no_fact_cls(self): self.assert_cli_exception( ["my-server.net", "fact", "server.NotAFact"], - "No such attribute in module server: NotAFact", + ( + "No such attribute in module server: NotAFact\n" + "Available facts in module are: User, Home, Path, TmpDir, Hostname, Kernel, " + "KernelVersion, Os, OsVersion, Arch, Command, Which, Date, MacosVersion, Mounts, " + "KernelModules, LsbRelease, OsRelease, Sysctl, Groups, Users, LinuxDistribution, " + "Selinux, LinuxGui, Locales, SecurityLimits" + ), ) diff --git a/tests/test_cli/test_cli_util.py b/tests/test_cli/test_cli_util.py index 776510f14..6596ab3b0 100644 --- a/tests/test_cli/test_cli_util.py +++ b/tests/test_cli/test_cli_util.py @@ -36,7 +36,11 @@ def test_setup_no_op(self): with self.assertRaises(CliError) as context: get_func_and_args(("server.no",)) - assert context.exception.message == "No such attribute in module server: no" + assert context.exception.message == ( + "No such attribute in module server: no\n" + "No operations found. Maybe you have a file or folder named " + "`pyinfra.operations.server` in the current folder ?" + ) def test_setup_op_and_args(self): commands = ("pyinfra.operations.server.user", "one", "two", "hello=world")