From 57a7bed0e89d43e94b54d604f6e92b2b0545d725 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Tue, 26 May 2020 09:11:43 +0200 Subject: [PATCH 1/2] Add support for process functions in `verdi plugin list` So far, only actual `Process` subclasses were supported, however, process functions can be turned into a `Process` subclass just as well. The wrapped function gets a new attribute `spec`, which when called returns the `ProcessSpec` that is dynamically built based on the function signature of the decorated function. --- aiida/cmdline/commands/cmd_plugin.py | 5 ++-- aiida/engine/processes/functions.py | 1 + tests/cmdline/commands/conftest.py | 35 ++++++++++++++++++++++ tests/cmdline/commands/test_plugin.py | 43 +++++++++++++++++++++++++++ tests/cmdline/utils/test_common.py | 12 +++++++- 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/cmdline/commands/conftest.py create mode 100644 tests/cmdline/commands/test_plugin.py diff --git a/aiida/cmdline/commands/cmd_plugin.py b/aiida/cmdline/commands/cmd_plugin.py index 3232441379..e28ae82df7 100644 --- a/aiida/cmdline/commands/cmd_plugin.py +++ b/aiida/cmdline/commands/cmd_plugin.py @@ -8,6 +8,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Command for `verdi plugins`.""" +import inspect import click @@ -48,11 +49,11 @@ def plugin_list(entry_point_group, entry_point): echo.echo_critical(str(exception)) else: try: - if issubclass(plugin, Process): + if (inspect.isclass(plugin) and issubclass(plugin, Process)) or plugin.is_process_function: print_process_info(plugin) else: echo.echo(str(plugin.get_description())) - except (AttributeError, TypeError): + except AttributeError: echo.echo_error('No description available for {}'.format(entry_point)) else: entry_points = get_entry_point_names(entry_point_group) diff --git a/aiida/engine/processes/functions.py b/aiida/engine/processes/functions.py index f23a5cbdee..8c5ea9bcca 100644 --- a/aiida/engine/processes/functions.py +++ b/aiida/engine/processes/functions.py @@ -186,6 +186,7 @@ def decorated_function(*args, **kwargs): decorated_function.run_get_node = run_get_node decorated_function.is_process_function = True decorated_function.node_class = node_class + decorated_function.spec = process_class.spec return decorated_function diff --git a/tests/cmdline/commands/conftest.py b/tests/cmdline/commands/conftest.py new file mode 100644 index 0000000000..89d6f50c11 --- /dev/null +++ b/tests/cmdline/commands/conftest.py @@ -0,0 +1,35 @@ +"""Pytest fixtures for command line interface tests.""" +import pytest + + +@pytest.fixture +def run_cli_command(): + """Run a `click` command with the given options. + + The call will raise if the command triggered an exception or the exit code returned is non-zero + """ + + def _run_cli_command(command, options=None, raises=None): + """Run the command and check the result. + + :param options: the list of command line options to pass to the command invocation + :param raises: optionally an exception class that is expected to be raised + """ + import traceback + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(command, options or []) + + if raises is not None: + assert result.exception is not None, result.output + assert result.exit_code != 0 + else: + assert result.exception is None, ''.join(traceback.format_exception(*result.exc_info)) + assert result.exit_code == 0, result.output + + result.output_lines = [line.strip() for line in result.output.split('\n') if line.strip()] + + return result + + return _run_cli_command diff --git a/tests/cmdline/commands/test_plugin.py b/tests/cmdline/commands/test_plugin.py new file mode 100644 index 0000000000..aa327595d9 --- /dev/null +++ b/tests/cmdline/commands/test_plugin.py @@ -0,0 +1,43 @@ +"""Tests for the `verdi plugin list` command.""" +import pytest + +from aiida.cmdline.commands import cmd_plugin +from aiida.plugins import CalculationFactory, WorkflowFactory + + +def test_plugin_list(run_cli_command): + """Test the `verdi plugin list` command.""" + from aiida.plugins.entry_point import ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP + + # Call base command without parameters and check that all entry point groups are listed + result = run_cli_command(cmd_plugin.plugin_list, []) + for key in ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP: + assert key in result.output + + +def test_plugin_list_group(run_cli_command): + """Test the `verdi plugin list` command for entry point group.""" + from aiida.plugins.entry_point import ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP + + # Call for each entry point group and just check it doesn't except + for key in ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP: + run_cli_command(cmd_plugin.plugin_list, [key]) + + +@pytest.mark.parametrize( + 'entry_point_string', ( + 'aiida.calculations:arithmetic.add', + 'aiida.workflows:arithmetic.multiply_add', + 'aiida.workflows:arithmetic.add_multiply', + ) +) +def test_plugin_list_detail(run_cli_command, entry_point_string): + """Test the `verdi plugin list` command for specific entry points.""" + from aiida.plugins.entry_point import parse_entry_point_string + + entry_point_group, entry_point_name = parse_entry_point_string(entry_point_string) + factory = CalculationFactory if entry_point_group == 'aiida.calculations' else WorkflowFactory + entry_point = factory(entry_point_name) + + result = run_cli_command(cmd_plugin.plugin_list, [entry_point_group, entry_point_name]) + assert entry_point.__doc__ in result.output diff --git a/tests/cmdline/utils/test_common.py b/tests/cmdline/utils/test_common.py index dea9f7ac1c..c755f34345 100644 --- a/tests/cmdline/utils/test_common.py +++ b/tests/cmdline/utils/test_common.py @@ -78,7 +78,7 @@ def test_print_process_info(self): """Test the `print_process_info` method.""" from aiida.cmdline.utils.common import print_process_info from aiida.common.utils import Capturing - from aiida.engine import Process + from aiida.engine import Process, calcfunction class TestProcessWithoutDocstring(Process): # pylint: disable=missing-docstring @@ -96,7 +96,17 @@ def define(cls, spec): super().define(spec) spec.input('some_input') + @calcfunction + def test_without_docstring(): + pass + + @calcfunction + def test_with_docstring(): + """Some docstring.""" + # We are just checking that the command does not except with Capturing(): print_process_info(TestProcessWithoutDocstring) print_process_info(TestProcessWithDocstring) + print_process_info(test_without_docstring) + print_process_info(test_with_docstring) From 6842a610ab8e4a501d0c07b8f28d2c7a0c327eb2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Wed, 3 Jun 2020 10:57:11 +0200 Subject: [PATCH 2/2] Adapt interface of `run_cli_command` and test `raises` argument --- tests/cmdline/commands/conftest.py | 16 ++++++++++------ tests/cmdline/commands/test_plugin.py | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/cmdline/commands/conftest.py b/tests/cmdline/commands/conftest.py index 89d6f50c11..77e61ea530 100644 --- a/tests/cmdline/commands/conftest.py +++ b/tests/cmdline/commands/conftest.py @@ -1,4 +1,5 @@ """Pytest fixtures for command line interface tests.""" +import click import pytest @@ -6,22 +7,25 @@ def run_cli_command(): """Run a `click` command with the given options. - The call will raise if the command triggered an exception or the exit code returned is non-zero + The call will raise if the command triggered an exception or the exit code returned is non-zero. """ + from click.testing import Result - def _run_cli_command(command, options=None, raises=None): + def _run_cli_command(command: click.Command, options: list = None, raises: bool = False) -> Result: """Run the command and check the result. + .. note:: the `output_lines` attribute is added to return value containing list of stripped output lines. + :param options: the list of command line options to pass to the command invocation - :param raises: optionally an exception class that is expected to be raised + :param raises: whether the command is expected to raise an exception + :return: test result """ import traceback - from click.testing import CliRunner - runner = CliRunner() + runner = click.testing.CliRunner() result = runner.invoke(command, options or []) - if raises is not None: + if raises: assert result.exception is not None, result.output assert result.exit_code != 0 else: diff --git a/tests/cmdline/commands/test_plugin.py b/tests/cmdline/commands/test_plugin.py index aa327595d9..ddc5e9d3df 100644 --- a/tests/cmdline/commands/test_plugin.py +++ b/tests/cmdline/commands/test_plugin.py @@ -3,27 +3,33 @@ from aiida.cmdline.commands import cmd_plugin from aiida.plugins import CalculationFactory, WorkflowFactory +from aiida.plugins.entry_point import ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP def test_plugin_list(run_cli_command): - """Test the `verdi plugin list` command.""" - from aiida.plugins.entry_point import ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP + """Test the `verdi plugin list` command. - # Call base command without parameters and check that all entry point groups are listed + Call base command without parameters and check that all entry point groups are listed. + """ result = run_cli_command(cmd_plugin.plugin_list, []) for key in ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP: assert key in result.output def test_plugin_list_group(run_cli_command): - """Test the `verdi plugin list` command for entry point group.""" - from aiida.plugins.entry_point import ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP + """Test the `verdi plugin list` command for entry point group. - # Call for each entry point group and just check it doesn't except + Call for each entry point group and just check it doesn't except. + """ for key in ENTRY_POINT_GROUP_TO_MODULE_PATH_MAP: run_cli_command(cmd_plugin.plugin_list, [key]) +def test_plugin_list_non_existing(run_cli_command): + """Test the `verdi plugin list` command for a non-existing entry point.""" + run_cli_command(cmd_plugin.plugin_list, ['aiida.calculations', 'non_existing'], raises=True) + + @pytest.mark.parametrize( 'entry_point_string', ( 'aiida.calculations:arithmetic.add',