Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add informations on cli output about module when fact or operation was not found #1244

Draft
wants to merge 2 commits into
base: 3.x
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion pyinfra_cli/util.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 25 additions & 2 deletions tests/test_cli/test_cli_exceptions.py
Original file line number Diff line number Diff line change
@@ -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,",
],
)


15 changes: 11 additions & 4 deletions tests/test_cli/test_cli_util.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Oops, something went wrong.