Repository: nf-core/tools

Issue:
add `nf-core modules test` command

<!--
# nf-core/tools feature request

Hi there!

Thanks for suggesting a new feature for the nf-core/tools package!
Please delete this text and anything that's not relevant from the template below:
-->

## Is your feature request related to a problem? Please describe

Running module tests locally requires a fairly complex command that's hard to remember, for instance
```
NF_CORE_MODULES_TEST=1 TMPDIR=~ PROFILE=singularity pytest --tag fastqc --symlink --keep-workflow-wd
```

## Describe the solution you'd like

A subcommand for the nf-core tools CLI, for instance

```
nf-core modules test --profile singularity --tag fastqc
```

A Rich-based CLI asks the user what profile they would like to run with and which software to test when `nf-core modules test` 
is executed without arguments

## Additional context

Support to emit multiple versions per module required adding the `NF_CORE_MODULES_TEST=1` variable, which made the 
command even more complex. See https://github.com/nf-core/nf-co.re/pull/877/files



Discussion:
![](https://github.trello.services/images/mini-trello-icon.png) [nf-core modules test NEW](https://trello.com/c/FvloTeOj/11-nf-core-modules-test-new)

Resolving patch:
diff --git a/nf_core/__main__.py b/nf_core/__main__.py
--- a/nf_core/__main__.py
+++ b/nf_core/__main__.py
@@ -48,7 +48,7 @@
         },
         {
             "name": "Developing new modules",
-            "commands": ["create", "create-test-yml", "lint", "bump-versions", "mulled"],
+            "commands": ["create", "create-test-yml", "lint", "bump-versions", "mulled", "test"],
         },
     ],
 }
@@ -709,6 +709,26 @@ def mulled(specifications, build_number):
         sys.exit(1)
 
 
+# nf-core modules test
+@modules.command("test")
+@click.pass_context
+@click.argument("tool", type=str, required=False, metavar="<tool> or <tool/subtool>")
+@click.option("-p", "--no-prompts", is_flag=True, default=False, help="Use defaults without prompting")
+@click.option("-a", "--pytest_args", type=str, required=False, multiple=True, help="Additional pytest arguments")
+def test_module(ctx, tool, no_prompts, pytest_args):
+    """
+    Run module tests locally.
+
+    Given the name of a module, runs the Nextflow test command.
+    """
+    try:
+        meta_builder = nf_core.modules.ModulesTest(tool, no_prompts, pytest_args)
+        meta_builder.run()
+    except UserWarning as e:
+        log.critical(e)
+        sys.exit(1)
+
+
 # nf-core schema subcommands
 @nf_core_cli.group()
 def schema():
diff --git a/nf_core/modules/__init__.py b/nf_core/modules/__init__.py
--- a/nf_core/modules/__init__.py
+++ b/nf_core/modules/__init__.py
@@ -10,3 +10,4 @@
 from .remove import ModuleRemove
 from .info import ModuleInfo
 from .mulled import MulledImageNameGenerator
+from .module_test import ModulesTest


Test patch:
diff --git a/nf_core/modules/module_test.py b/nf_core/modules/module_test.py
new file mode 100644
--- /dev/null
+++ b/nf_core/modules/module_test.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+"""
+The ModulesTest class runs the tests locally
+"""
+
+import logging
+import questionary
+import os
+import pytest
+import sys
+import rich
+from pathlib import Path
+from shutil import which
+
+import nf_core.utils
+from .modules_repo import ModulesRepo
+
+log = logging.getLogger(__name__)
+
+
+class ModulesTest(object):
+    """
+    Class to run module pytests.
+
+    ...
+
+    Attributes
+    ----------
+    module_name : str
+        name of the tool to run tests for
+    no_prompts : bool
+        flat indicating if prompts are used
+    pytest_args : tuple
+        additional arguments passed to pytest command
+
+    Methods
+    -------
+    run():
+        Run test steps
+    _check_inputs():
+        Check inputs. Ask for module_name if not provided and check that the directory exists
+    _set_profile():
+        Set software profile
+    _run_pytests(self):
+        Run pytest
+    """
+
+    def __init__(
+        self,
+        module_name=None,
+        no_prompts=False,
+        pytest_args="",
+    ):
+        self.module_name = module_name
+        self.no_prompts = no_prompts
+        self.pytest_args = pytest_args
+
+    def run(self):
+        """Run test steps"""
+        if not self.no_prompts:
+            log.info(
+                "[yellow]Press enter to use default values [cyan bold](shown in brackets) [yellow]or type your own responses"
+            )
+        self._check_inputs()
+        self._set_profile()
+        self._check_profile()
+        self._run_pytests()
+
+    def _check_inputs(self):
+        """Do more complex checks about supplied flags."""
+
+        # Get the tool name if not specified
+        if self.module_name is None:
+            if self.no_prompts:
+                raise UserWarning(
+                    "Tool name not provided and prompts deactivated. Please provide the tool name as TOOL/SUBTOOL or TOOL."
+                )
+            modules_repo = ModulesRepo()
+            modules_repo.get_modules_file_tree()
+            self.module_name = questionary.autocomplete(
+                "Tool name:",
+                choices=modules_repo.modules_avail_module_names,
+                style=nf_core.utils.nfcore_question_style,
+            ).ask()
+        module_dir = Path("modules") / self.module_name
+
+        # First, sanity check that the module directory exists
+        if not module_dir.is_dir():
+            raise UserWarning(
+                f"Cannot find directory '{module_dir}'. Should be TOOL/SUBTOOL or TOOL. Are you running the tests inside the nf-core/modules main directory?"
+            )
+
+    def _set_profile(self):
+        """Set $PROFILE env variable.
+        The config expects $PROFILE and Nextflow fails if it's not set.
+        """
+        if os.environ.get("PROFILE") is None:
+            os.environ["PROFILE"] = ""
+            if self.no_prompts:
+                log.info(
+                    "Setting environment variable '$PROFILE' to an empty string as not set.\n"
+                    "Tests will run with Docker by default. "
+                    "To use Singularity set 'export PROFILE=singularity' in your shell before running this command."
+                )
+            else:
+                question = {
+                    "type": "list",
+                    "name": "profile",
+                    "message": "Choose software profile",
+                    "choices": ["Docker", "Singularity", "Conda"],
+                }
+                answer = questionary.unsafe_prompt([question], style=nf_core.utils.nfcore_question_style)
+                profile = answer["profile"].lower()
+                os.environ["PROFILE"] = profile
+                log.info(f"Setting environment variable '$PROFILE' to '{profile}'")
+
+    def _check_profile(self):
+        """Check if profile is available"""
+        profile = os.environ.get("PROFILE")
+        # Make sure the profile read from the environment is a valid Nextflow profile.
+        valid_nextflow_profiles = ["docker", "singularity", "conda"]
+        if profile in valid_nextflow_profiles:
+            if not which(profile):
+                raise UserWarning(f"Command '{profile}' not found - is it installed?")
+        else:
+            raise UserWarning(
+                f"The PROFILE '{profile}' set in the shell environment is not valid.\n"
+                f"Valid Nextflow profiles are '{', '.join(valid_nextflow_profiles)}'."
+            )
+
+    def _run_pytests(self):
+        """Given a module name, run tests."""
+        # Print nice divider line
+        console = rich.console.Console()
+        console.rule(self.module_name, style="black")
+
+        # Set pytest arguments
+        command_args = ["--tag", f"{self.module_name}", "--symlink", "--keep-workflow-wd", "--git-aware"]
+        command_args += self.pytest_args
+
+        # Run pytest
+        log.info(f"Running pytest for module '{self.module_name}'")
+        sys.exit(pytest.main(command_args))
diff --git a/tests/modules/module_test.py b/tests/modules/module_test.py
new file mode 100644
--- /dev/null
+++ b/tests/modules/module_test.py
@@ -0,0 +1,27 @@
+"""Test the 'modules test' command which runs module pytests."""
+import os
+import pytest
+
+import nf_core.modules
+
+
+def test_modules_test_check_inputs(self):
+    """Test the check_inputs() function - raise UserWarning because module doesn't exist"""
+    cwd = os.getcwd()
+    os.chdir(self.nfcore_modules)
+    meta_builder = nf_core.modules.ModulesTest("none", True, "")
+    with pytest.raises(UserWarning) as excinfo:
+        meta_builder._check_inputs()
+    os.chdir(cwd)
+    assert "Cannot find directory" in str(excinfo.value)
+
+
+def test_modules_test_no_name_no_prompts(self):
+    """Test the check_inputs() function - raise UserWarning prompts are deactivated and module name is not provided."""
+    cwd = os.getcwd()
+    os.chdir(self.nfcore_modules)
+    meta_builder = nf_core.modules.ModulesTest(None, True, "")
+    with pytest.raises(UserWarning) as excinfo:
+        meta_builder._check_inputs()
+    os.chdir(cwd)
+    assert "Tool name not provided and prompts deactivated." in str(excinfo.value)
diff --git a/tests/test_modules.py b/tests/test_modules.py
--- a/tests/test_modules.py
+++ b/tests/test_modules.py
@@ -113,3 +113,8 @@ def test_modulesrepo_class(self):
         test_modules_bump_versions_fail,
         test_modules_bump_versions_fail_unknown_version,
     )
+
+    from .modules.module_test import (
+        test_modules_test_check_inputs,
+        test_modules_test_no_name_no_prompts,
+    )