diff --git a/Makefile b/Makefile index 3ac3a64..ad95241 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,15 @@ .PHONY: test-ci +TEST = "" + test-ci: - docker compose up --exit-code-from test \ No newline at end of file + docker compose up --exit-code-from test + +setup: + python test-config/prepare-test.py + +test: + pytest -k ${TEST} -s --cov-config=.coveragerc --cov=fractal -v --asyncio-mode=auto --cov-report=lcov --cov-report=term tests/ + +qtest: + pytest -k ${TEST} -s --cov-config=.coveragerc --cov=fractal --asyncio-mode=auto --cov-report=lcov tests/ diff --git a/fractal/cli/__init__.py b/fractal/cli/__init__.py index 7640bde..3c414e6 100644 --- a/fractal/cli/__init__.py +++ b/fractal/cli/__init__.py @@ -1,366 +1,3 @@ -#!/usr/bin/env python -import argparse -import functools -import inspect -import sys - import appdirs -import pkg_resources -import yaml FRACTAL_DATA_DIR = appdirs.user_data_dir("fractal") - -COLORS = { - "HEADER": "\033[95m", - "BLUE": "\033[94m", - "GREEN": "\033[92m", - "YELLOW": "\033[93m", - "RED": "\033[91m", - "ENDC": "\033[0m", - "BOLD": "\033[1m", - "UNDERLINE": "\033[4m", -} - - -class Color: - def __getattr__(self, color: str): - color = color.upper() - - def colorize(message: str): - return f"{COLORS[color]}{message}{COLORS['ENDC']}" - - return colorize - - -class CLICZ: - default_controller: str - - def __init__(self, cli_module: str = "notectl", autodiscover=True): - """ - Register any controller that has the property `enable_cli=True` - """ - self.cli_module = cli_module - self.color = Color() - self.registered_controllers = {} - self.proxy_commands = {} - # epilog = f'Run `{cli_module} --help` for more information.' - epilog = "" - # Top-level parser - self.parser = argparse.ArgumentParser(epilog=epilog) - - # the alias_parser allows us to invoke plugin methods directly without having - # to specify the plugin name itself - # it mycli install instead of mycli myplugin install - # they are registered via the clicz_alias attribute on plugin methods - self.alias_parser = self.parser.add_subparsers( - title="system commands", dest="mgmt_command", metavar="command" - ) - self.base_parser = argparse.ArgumentParser(epilog=epilog) - self.base_parser.add_argument( - "-d", "--debug", help="show debug output", action="store_true" - ) - self.base_parser._action_groups.append(self.parser._action_groups[-1]) - self.sub_parser = self.base_parser.add_subparsers( - title="plugin commands", dest="command", required=True, metavar="command" - ) - if autodiscover: - description = self._init_clicz() - self.parser.description = description - self.base_parser.description = description - - def _init_clicz(self) -> str: - """Initialize clicz application - --- - Args: None - Returns: Argparser description - """ - entrypoint = list(pkg_resources.iter_entry_points(f"{self.cli_module}.entrypoint"))[0] - clicz_module = entrypoint.load() - return clicz_module.clicz_entrypoint(self) - - def dispatch(self, argv=None): - """Dispatch a CLI invocation to a controller. - First, we fetch the controller class from the map of registered controllers (methods wrapped wit @cli_method) - then we construct an ArgParser based on the Docstring - """ - if argv: - sys.argv = argv - try: - # check for alias invocations first, trasmute to normal invocation - if sys.argv[1] in self.proxy_commands: - self.parser.parse_known_args() - alias_key = sys.argv[1] - sys.argv.insert(1, self.proxy_commands[alias_key][0]) - sys.argv.insert(2, self.proxy_commands[alias_key][1]) - sys.argv.remove(alias_key) - elif sys.argv[1].startswith("--") and "help" not in sys.argv[1]: - # if the first arg starts with --, we assume it's a flag - # and we want to run the default controller - sys.argv.insert(1, self.default_controller) - sys.argv.insert(2, "run") - self.alias_parser.add_parser( - self.default_controller, - help="Default controller", - description="Default controller", - ) - self.parser.parse_known_args() - - except IndexError: - if hasattr(self, "default_controller"): - sys.argv.insert(1, self.default_controller) - sys.argv.insert(2, "run") - self.alias_parser.add_parser( - self.default_controller, - help="Default controller", - description="Default controller", - ) - self.parser.parse_known_args() - else: - sys.argv.insert(1, "--help") - - args = self.base_parser.parse_args() - controller_name = args.command - controller_method = args.subcommand - if controller_name not in self.registered_controllers: - raise Exception(f"Subcommand {controller_name} not found") - Controller = self.registered_controllers[controller_name] - controller_instance = Controller() - controller_instance.args = args - if not hasattr(controller_instance, controller_method): - raise Exception(f"Controller {controller_name} has no CLI method {controller_method}") - method = getattr(controller_instance, controller_method) - if not hasattr(method, "cli_method"): - raise Exception( - f"Method {method.__qualname__} not registered for CLI invocation." - " Wrap method with @cli_method to expose via CLI." - ) - return method(*method.get_invocation_args(args)) - - def register_controller(self, controller): - """ - Add a parser to the controller parser for every - """ - self.registered_controllers[controller.PLUGIN_NAME] = controller - controller_docstring = inspect.getdoc(controller) - # guard for whitespace only docstrings (breaks argparse) - if isinstance(controller_docstring, str): - controller_docstring = controller_docstring.strip() - if not controller_docstring: - controller_docstring = None - controller_parser = self.sub_parser.add_parser( - controller.PLUGIN_NAME, help=controller_docstring - ) - controller_sub_parser = controller_parser.add_subparsers( - title="commands", dest="subcommand", required=True, metavar="command" - ) - for method_name, method in vars(controller).items(): - # watch out for this: https://stackoverflow.com/questions/44596009/why-is-cls-dict-meth-different-than-getattrcls-meth-for-classmethods-sta - method = getattr(controller, method_name) - if hasattr(method, "cli_method"): - self._build_method_argparser( - controller_sub_parser, controller, method_name, method - ) - - # def _prep_extra_args(self, extra_args: list): - # if not extra_args: - # return {} - # # TODO make sure this is safe - # extra_args = [ arg.replace('--', '') for arg in extra_args ] - # list_iter = iter(extra_args) - # return dict(zip(list_iter, list_iter)) - - def _register_proxy_commands(self, aliases, controller_name, method_name): - for alias in aliases: - if alias in self.proxy_commands: - raise Exception( - f"{alias} already registered. Cannot declare top-level alias with same name." - ) - self.proxy_commands[alias] = (controller_name, method_name) - - def _get_deepest_wrapped(self, method): - if hasattr(method, "__wrapped__"): - return self._get_deepest_wrapped(method.__wrapped__) - return method - - def _build_method_argparser(self, controller_parser, controller, method_name, method): - """ """ - method_description = inspect.getdoc(method) - if not method_description: - raise Exception( - f"Missing docstring for {self.color.red(method.__qualname__)}. Docstrings are required." - ) - try: - method_description = inspect.getdoc(method).split("---", 1)[0] - except KeyError: - pass - alias_parser = None - if hasattr(method, "clicz_aliases"): - self._register_proxy_commands( - method.clicz_aliases, controller.PLUGIN_NAME, method_name - ) - alias_parser = self.alias_parser.add_parser( - method.clicz_aliases[0], - help=method_description, - description=method_description, - aliases=method.clicz_aliases[1:], - ) - method_arg_parser = controller_parser.add_parser( - method_name, - help=method_description, - description=method_description, - aliases=method.clicz_aliases, - ) - else: - method_arg_parser = controller_parser.add_parser( - method_name, help=method_description, description=method_description - ) - method.parser = method_arg_parser - argspec = inspect.getfullargspec(self._get_deepest_wrapped(method.__wrapped__)) - static_method = False - if argspec.args[0] not in ["cls", "self"]: - static_method = True - start_arg_idx = 0 if static_method else 1 - docstring = inspect.getdoc(method) - if not docstring: - raise Exception("YAML based Docstring are required for clicz methods.") - else: - # Parse YAML based docstring to auto-generate ArgParser with nice help! - # if method is static and has arguments or not a static_method with more than 1 args - if (static_method and len(argspec.args)) or ( - not static_method and len(argspec.args) > 1 - ): - docstring = docstring.split("---", 1)[1] - if argspec.defaults: - num_defaults = len(argspec.defaults) - defaults = ( - dict(zip(argspec.args[-num_defaults:], argspec.defaults)) - if argspec.defaults - else [] - ) - try: - doc_yaml = yaml.safe_load(docstring) - except: - raise Exception("Unable to parse docstring; not valid YAML.") - if not isinstance(doc_yaml, dict) or "args" not in [ - key.lower() for key in [*doc_yaml.keys()] - ]: - raise Exception("Docstring YAML missing Args key.") - for arg, help in doc_yaml["Args"].items(): - if not isinstance(help, str): - raise Exception(f"Argument description for {arg} must be of type str.") - if arg in defaults: - dashed_arg = arg.replace("_", "-") - if isinstance(defaults[arg], bool): - action = "store_true" - if defaults[arg]: - action = "store_false" - method_arg_parser.add_argument( - f"--{dashed_arg}", - default=defaults[arg], - help=help, - action=action, - ) - if alias_parser: - alias_parser.add_argument( - f"--{dashed_arg}", - default=defaults[arg], - help=help, - action=action, - ) - else: - method_arg_parser.add_argument( - f"--{dashed_arg}", default=defaults[arg], help=help - ) - if alias_parser: - alias_parser.add_argument( - f"--{dashed_arg}", - default=defaults[arg], - help=help, - ) - - else: - if hasattr(method, "clicz_defaults"): - if arg in method.clicz_defaults: - default = method.clicz_defaults[arg] - method_arg_parser.add_argument( - f"{arg}", nargs="?", help=help, default=default - ) - if alias_parser: - alias_parser.add_argument( - f"{arg}", - nargs="?", - help=help, - default=default, - ) - else: - method_arg_parser.add_argument( - f"--{arg}", nargs="?", help=help, required=True - ) - if alias_parser: - alias_parser.add_argument( - f"--{arg}", - nargs="?", - help=help, - required=True, - ) - - else: - method_arg_parser.add_argument(f"{arg}", help=help) - if alias_parser: - alias_parser.add_argument(f"{arg}", help=help) - # make sure docstring YAML spe cifies all arguments defined in argspec - missing_args = list(set(argspec.args).difference(set([*doc_yaml["Args"].keys()]))) - [missing_args.remove(x) for x in ["self", "cls"] if x in missing_args] - if missing_args: - raise Exception( - f"Docstring for {self.color.red(method.__qualname__)} missing args: {', '.join(missing_args)}" - ) - - def get_invocation_args(parsed_args): - return [getattr(parsed_args, key) for key in argspec.args[start_arg_idx:]] - - method.get_invocation_args = get_invocation_args - - -def cli_method(func=None, parse_docstring=True): - """ - Decorator to mark a method as a CLI method. - - NOTE: Should be the last decorator applied to a method. - - Example: - - @some_other_decorator - @cli_method - def my_func(self, ...) - """ - if not func: - return functools.partial(cli_method, parse_docstring=parse_docstring) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - res = func(*args, **kwargs) - return res - - wrapper.parse_docstring = True if parse_docstring else False - wrapper.cli_method = True - return wrapper - - -import pkg_resources - - -class PluginManager: - @staticmethod - def load_plugins(plugin_namespace="fractal.plugins"): - plugins = pkg_resources.iter_entry_points(plugin_namespace) - loaded_plugins = {} - for entry_point in plugins: - loaded_plugins[entry_point.name] = entry_point.load() - - return loaded_plugins - - -def clicz_entrypoint(clicz: CLICZ): - for _, plugin_module in PluginManager.load_plugins().items(): - clicz.register_controller(plugin_module.Controller) diff --git a/fractal/cli/__main__.py b/fractal/cli/__main__.py index b4e6517..54f9428 100644 --- a/fractal/cli/__main__.py +++ b/fractal/cli/__main__.py @@ -1,10 +1,24 @@ +import random from sys import exit -from fractal.cli import CLICZ +from clicz import CLICZ, Color + +color = Color() def main(): - cli = CLICZ(cli_module="fractal.cli") + descriptions = [ + "Fractal Networks: Your data, your future.", + "Fractal Networks: The Future of the Web.", + "Fractal Networks: Above the Cloud and Beyond the Blockchain.", + "Fractal Networks: Edge Computing for the People.", + ] + description = random.choice(descriptions) + fn, hero = description.split(":", 1) + cli = CLICZ( + cli_module="fractal.plugins", + description=f"{color.red(fn)}: {color.green(hero.strip())}", + ) # cli.default_controller = "fractal" cli.dispatch() diff --git a/fractal/cli/controllers/authenticated.py b/fractal/cli/controllers/authenticated.py deleted file mode 100644 index dab067d..0000000 --- a/fractal/cli/controllers/authenticated.py +++ /dev/null @@ -1,61 +0,0 @@ -import functools -import os -from typing import Any, Callable, Optional, Tuple - -from fractal.cli.utils import read_user_data - - -class AuthenticatedController: - PLUGIN_NAME = "auth_check" - TOKEN_FILE = "matrix.creds.yaml" - homeserver_url: Optional[str] = None - access_token: Optional[str] = None - matrix_id: Optional[str] = None - - def __init__(self): - self.check_if_user_is_authenticated() - - @classmethod - def get_creds(cls) -> Optional[Tuple[Optional[str], Optional[str], Optional[str]]]: - """ - Returns the access token of the logged in user. - """ - try: - token_file, _ = read_user_data(cls.TOKEN_FILE) - access_token = token_file.get("access_token") - homeserver_url = token_file.get("homeserver_url") - matrix_id = token_file.get("matrix_id") - except FileNotFoundError: - return None - - return access_token, homeserver_url, matrix_id - - def check_if_user_is_authenticated(self) -> bool: - """ - Checks to see if the user is logged in. - """ - creds = self.get_creds() - if not creds: - return False - self.access_token, self.homeserver_url, self.matrix_id = creds - os.environ["MATRIX_HOMESERVER_URL"] = self.homeserver_url or "" - os.environ["MATRIX_ACCESS_TOKEN"] = self.access_token or "" - os.environ["MATRIX_USER_TOKEN"] = self.matrix_id or "" - return True - - -Controller = AuthenticatedController - - -def auth_required(func: Callable[..., Any]): - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - if not self.access_token: - print("You must be logged in to use this command.") - print("Login with fractal login.") - exit(1) - args = [self] + list(args) - res = func(*args, **kwargs) - return res - - return wrapper diff --git a/pyproject.toml b/pyproject.toml index d5fdb9f..beefb64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ python = "^3.10" pyyaml = "^6.0.1" toml = "^0.10.2" appdirs = "^1.4.4" +clicz = "^0.0.1" pytest = { version = "^7.4.3", optional = true } @@ -28,5 +29,6 @@ fractal = "fractal.cli.__main__:main" [tool.poetry.extras] dev = ["pytest", "pytest-cov", "pytest-mock", "ipython"] -[tool.poetry.plugins."fractal.cli.entrypoint"] -"fractal_cli" = "fractal.cli" +[tool.poetry.plugins."fractal.plugins"] +"auth" = "fractal.cli.controllers.auth" +"register" = "fractal.cli.controllers.registration" diff --git a/tests/cli_controller.py b/tests/cli_controller.py deleted file mode 100644 index 6b977cd..0000000 --- a/tests/cli_controller.py +++ /dev/null @@ -1,33 +0,0 @@ -from fractal.cli import cli_method - - -class TestController: - enable_cli = True - enable_grpc = True - - PLUGIN_NAME = "deploy" - - @cli_method - def say_hello(self, name: str, age: int, color: str = None) -> None: - """ - --- - Args: - name: Your name. - age: Your age. - color: Your favorite color. - Returns: - None - """ - print(f"Hello {name}, you are {age} years old.") - if color: - print(f"{name}s favorite color is {color}") - - @cli_method(parse_docstring=False) - def start(self, bottle, man): - pass - - @cli_method - def ls( - self, - ): - pass diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index f136856..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,194 +0,0 @@ -import argparse - -import pytest -from fractal.cli import CLICZ, cli_method - - -class TestController: - PLUGIN_NAME = "test" - - @cli_method - def say_hi_autogen_help(self, name: str, fav_color: str = "green"): - """This is a description of the method. - --- - Args: - name: Name to say hello to - fav_color: Your favorite color - """ - TestController.called = True - TestController.args = [name] - - -class TestControllerIncompleteDocstring(TestController): - @cli_method - def say_hi_incomplete_docstring(self, name: str, fav_color: str = "green"): - """\ - --- - Args: - badarg: yea - """ - self.called = True - self.args = [name] - - -class TestControllerNoDocstring(TestController): - @cli_method - def say_hi_no_docstring(self, name, fav_color="green"): - self.called = True - self.args = [name, fav_color] - - -class TestControllerStaticMethod(TestController): - @staticmethod - @cli_method - def say_hi_static(name): - """static metod - --- - Args: - name: name arg - """ - return name - - -class TestControllerMalformedDocstring(TestController): - @cli_method - def say_hi_malformed_docstring(self, name, fav_color="green"): - """ - --- - Args: - Broken - - Malformed docstring - """ - self.called = True - self.args = [name, fav_color] - - -class TestControllerMissingArgs(TestController): - @cli_method - def say_hi_missing_arg_key(self, name): - """ - Valid yaml but not allowed. - --- - """ - pass - - -class TestControllerUnregistered(TestController): - def say_hi_unregistered(self, name): - """unregistered method - --- - Args: - name: name arg - """ - return name - - -class TestDefaultsController(TestController): - @cli_method - def default(self, name: str, age: int, color: str = "green"): - """something cool - --- - Args: - name: string - age: int - color: str - """ - TestDefaultsController.args = [name, age, color] - - default.clicz_aliases = ["default"] - default.clicz_defaults = {"name": "mo", "age": 99} - - -def test_cli(): - """ - Test invocation calls method with args - """ - cli = CLICZ(autodiscover=False) - cli.register_controller(TestController) - - # Make sure we can call a method that has ArgParser help autogenerated - argv = ["notectl", "test", "say_hi_autogen_help", "Mo"] - cli.dispatch(argv) - assert TestController.called == True - assert "Mo" in TestController.args - - # # Make sure ArgParser help is generated properly - # argv = ['notectl', 'test', 'say_hi_autogen_help', '-h'] - # cli.dispatch(argv) - - -def test_cli_incomplete_docstring(): - cli = CLICZ(autodiscover=False) - - # Make sure a useful error is reported when docstring is YAML - # but doesn't match method's argspec - with pytest.raises(Exception) as execinfo: - cli.register_controller(TestControllerIncompleteDocstring) - exception_str = str(execinfo) - assert "missing args:" in exception_str - assert "fav_color" in exception_str - assert "name" in exception_str - - -def test_cli_no_docstring(): - cli = CLICZ(autodiscover=False) - - # Make sure calling a method without a docstring raises - with pytest.raises(Exception) as execinfo: - cli.register_controller(TestControllerNoDocstring) - assert "Docstrings are required" in str(execinfo) - - -def test_cli_static_method(): - cli = CLICZ(autodiscover=False) - cli.register_controller(TestControllerStaticMethod) - # Make sure we can call static methods - argv = ["notectl", "test", "say_hi_static", "Mo"] - result = cli.dispatch(argv) - assert result == "Mo" - - -def test_cli_unregistred_raises(): - cli = CLICZ(autodiscover=False) - cli.register_controller(TestControllerUnregistered) - # Make sure calling an unregistered method raises - with pytest.raises(SystemExit) as execinfo: - argv = ["notectl", "test", "say_hi_unregistsred", "Mo"] - cli.dispatch(argv) - - -def test_cli_malformed_docstring(): - cli = CLICZ(autodiscover=False) - with pytest.raises(Exception) as execinfo: - cli.register_controller(TestControllerMalformedDocstring) - assert "Unable to parse docstring; not valid YAML." in str(execinfo) - - -def test_cli_missing_arg_key(): - cli = CLICZ(autodiscover=False) - # Docstring is valid YAML but missing "Arg" key - with pytest.raises(Exception) as execinfo: - cli.register_controller(TestControllerMissingArgs) - assert "Docstring YAML missing Args key." in str(execinfo) - - -def test_cli_defaults(): - cli = CLICZ(autodiscover=False) - cli.register_controller(TestDefaultsController) - argv = ["notectl", "default"] - cli.dispatch(argv) - assert TestDefaultsController.args == ["mo", 99, "green"] - - -def test_cli_no_args_prints_help(capsys): - with pytest.raises(SystemExit) as pytest_exception: - cli = CLICZ(autodiscover=False) - argv = [ - "notectl", - ] - cli.dispatch(argv) - assert pytest_exception.type == SystemExit - assert pytest_exception.value.code == 0 - - captured = capsys.readouterr() - assert "usage: " in captured.out diff --git a/tests/test_login.py b/tests/test_login.py index 385d3f1..0b23286 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,4 +1,4 @@ -from fractal.matrix.controllers.auth import AuthController +from fractal.cli.controllers.auth import AuthController def test_login_with_password(mock_getpass):