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..c1c309761 100644 --- a/tests/test_cli/test_cli_exceptions.py +++ b/tests/test_cli/test_cli_exceptions.py @@ -1,3 +1,4 @@ +import re import sys from os import path from unittest import TestCase @@ -21,7 +22,24 @@ def setUpClass(cls): def assert_cli_exception(self, args, message): result = self.runner.invoke(cli, args, standalone_mode=False) self.assertIsInstance(result.exception, CliError) - assert getattr(result.exception, "message") == message + + if isinstance(message, str): + message = [message] + + for part in message: + + # Test if the string is a regex + is_regex = False + try: + re.compile(part) + is_regex = True + except re.error: + pass + + if is_regex: + assert re.search(part, result.exception.message, re.MULTILINE) + else: + assert part == result.exception.message def test_bad_deploy_file(self): self.assert_cli_exception( @@ -44,7 +62,12 @@ 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", + [ + r"^No such attribute in module server: NotAFact.*", + r".*Available facts are: .*", + r".* User,.*", + r".* Os,", + ], ) diff --git a/tests/test_cli/test_cli_util.py b/tests/test_cli/test_cli_util.py index 776510f14..ec7f6b484 100644 --- a/tests/test_cli/test_cli_util.py +++ b/tests/test_cli/test_cli_util.py @@ -1,4 +1,5 @@ import os +import re import sys from datetime import datetime from io import StringIO @@ -32,11 +33,17 @@ def test_setup_no_module(self): get_func_and_args(("no.op",)) assert context.exception.message == "No such module: no" - def test_setup_no_op(self): + def test_setup_not_exists_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" + get_func_and_args(("server.not_exists",)) + + for part in [ + r"^No such attribute in module server: not_exists$", + r"Available operations are: .*", + r".* modprobe, .*", + r".* mount, .*", + ]: + assert re.search(part, context.exception.message, re.MULTILINE) def test_setup_op_and_args(self): commands = ("pyinfra.operations.server.user", "one", "two", "hello=world")