diff --git a/docs/command-line-interface.rst b/docs/command-line-interface.rst index da6b5fb9e4..e13e38f701 100644 --- a/docs/command-line-interface.rst +++ b/docs/command-line-interface.rst @@ -759,8 +759,8 @@ Optional arguments: .. _cli_run: -`$ run PIPELINE_NAME [PIPELINE_NAME ...] input_location` --------------------------------------------------------- +`$ run PIPELINE_NAME [PIPELINE_NAME ...] [input_location]` +---------------------------------------------------------- A ``run`` command is available for executing pipelines and printing the results without providing any configuration. This can be useful for running a pipeline to get @@ -770,6 +770,9 @@ review the results. .. tip:: You can run multiple pipelines by providing their names, space-separated, such as `pipeline1 pipeline2`. +The ``input_location`` is optional, so pipelines that do not take any input can be +run without it. + Optional arguments: - ``--project PROJECT_NAME``: Provide a project name; otherwise, a random value is diff --git a/scanpipe/management/commands/run.py b/scanpipe/management/commands/run.py index 3a3242e57c..6637cd9af4 100644 --- a/scanpipe/management/commands/run.py +++ b/scanpipe/management/commands/run.py @@ -23,6 +23,7 @@ from collections import defaultdict from pathlib import Path +from django.apps import apps from django.core.management import call_command from django.core.management.base import BaseCommand from django.core.management.base import CommandError @@ -31,6 +32,8 @@ from scanpipe.management.commands import extract_tag_from_input_file from scanpipe.pipes.fetch import SCHEME_TO_FETCHER_MAPPING +scanpipe_app = apps.get_app_config("scanpipe") + class Command(BaseCommand): help = "Run a pipeline and print the results." @@ -50,9 +53,11 @@ def add_arguments(self, parser): ) parser.add_argument( "input_location", + nargs="?", help=( "Input location: file, directory, and URL supported." - 'Multiple values can be provided using the "input1,input2" syntax.' + 'Multiple values can be provided using the "input1,input2" syntax. ' + "Optional, as some pipelines do not require any input." ), ) parser.add_argument("--project", required=False, help="Project name.") @@ -64,8 +69,14 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): + # The ``input_location`` positional is declared for the command usage, but + # argparse collects every positional value in ``pipelines``. The trailing + # value is the input location unless it is an available pipeline name. pipelines = options["pipelines"] - input_location = options["input_location"] + input_location = None + if len(pipelines) > 1 and not self.is_pipeline_name(pipelines[-1]): + input_location = pipelines.pop() + output_format = options["format"] # Generate a random name for the project if not provided project_name = options["project"] or get_random_string(10) @@ -74,8 +85,9 @@ def handle(self, *args, **options): "pipeline": pipelines, "execute": True, "verbosity": 0, - **self.get_input_options(input_location), } + if input_location: + create_project_options.update(self.get_input_options(input_location)) # Run the database migrations in case the database is not created or outdated. call_command("migrate", verbosity=0, interactive=False) @@ -84,6 +96,13 @@ def handle(self, *args, **options): # Print the results for the specified format on stdout call_command("output", project=project_name, format=[output_format], print=True) + @staticmethod + def is_pipeline_name(value): + """Return True when the provided ``value`` is an available pipeline name.""" + pipeline_name, _ = scanpipe_app.extract_group_from_pipeline(value) + pipeline_name = scanpipe_app.get_new_pipeline_name(pipeline_name) + return pipeline_name in scanpipe_app.pipelines + @staticmethod def get_input_options(input_location): """ diff --git a/scanpipe/tests/test_commands.py b/scanpipe/tests/test_commands.py index 499c8d2100..c70a76b668 100644 --- a/scanpipe/tests/test_commands.py +++ b/scanpipe/tests/test_commands.py @@ -1007,9 +1007,7 @@ def test_scanpipe_management_command_create_user_admin_superuser(self): self.assertTrue(user.is_superuser) def test_scanpipe_management_command_run(self): - expected = ( - "Error: the following arguments are required: PIPELINE_NAME, input_location" - ) + expected = "Error: the following arguments are required: PIPELINE_NAME" with self.assertRaisesMessage(CommandError, expected): call_command("run") @@ -1047,6 +1045,29 @@ def test_scanpipe_management_command_run(self): self.assertEqual("do_nothing", runs[1]["pipeline_name"]) self.assertEqual(["Group1", "Group2"], runs[1]["selected_groups"]) + def test_scanpipe_management_command_run_without_input_location(self): + out = StringIO() + with redirect_stdout(out): + call_command("run", "do_nothing") + + json_data = json.loads(out.getvalue()) + self.assertEqual([], json_data["files"]) + runs = json_data["headers"][0]["runs"] + self.assertEqual(1, len(runs)) + self.assertEqual("do_nothing", runs[0]["pipeline_name"]) + self.assertEqual("success", runs[0]["status"]) + + # A trailing pipeline name is not mistaken for an input location + out = StringIO() + with redirect_stdout(out): + call_command("run", "do_nothing", "profile_step") + + json_data = json.loads(out.getvalue()) + runs = json_data["headers"][0]["runs"] + self.assertEqual(2, len(runs)) + self.assertEqual("do_nothing", runs[0]["pipeline_name"]) + self.assertEqual("profile_step", runs[1]["pipeline_name"]) + @mock.patch("scanpipe.pipes.fetch.is_safe_url", return_value=True) @mock.patch("scanpipe.pipes.fetch.check_url") @mock.patch("requests.sessions.Session.get")