From f71185995aee043e2e9acfd31028501f23992aea Mon Sep 17 00:00:00 2001 From: NoiceHax Date: Sat, 15 Aug 2026 15:12:52 +0530 Subject: [PATCH] fix: make the input location optional in the run command The run command always required an input location, so pipelines that do not take any input could only be started by passing an empty string. The input location is now optional. The trailing positional value is only treated as an input when it is not an available pipeline name, and the input options are only built when a location was provided. Signed-off-by: NoiceHax --- docs/command-line-interface.rst | 7 +++++-- scanpipe/management/commands/run.py | 25 ++++++++++++++++++++++--- scanpipe/tests/test_commands.py | 27 ++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 8 deletions(-) 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")