diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 184725f7f1..71f1bd88be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,13 +4,18 @@ Changelog v33.2.0 (unreleased) -------------------- +- Add ability to "group" pipeline steps to control their inclusion in a pipeline run. + The groups can be selected in the UI, or provided using the + "pipeline_name:group1,group2" syntax in CLI and REST API. + https://github.com/nexB/scancode.io/issues/1045 + - Refine pipeline choices in the "Add pipeline" modal based on the project context. * When there is at least one existing pipeline in the project, the modal now includes all addon pipelines along with the existing pipeline for selection. * In cases where no pipelines are assigned to the project, the modal displays all base (non-addon) pipelines for user selection. - https://github.com/nexB/scancode.io/issues/ + https://github.com/nexB/scancode.io/issues/1071 v33.1.0 (2024-02-02) -------------------- diff --git a/docs/command-line-interface.rst b/docs/command-line-interface.rst index e370172054..466a255a59 100644 --- a/docs/command-line-interface.rst +++ b/docs/command-line-interface.rst @@ -84,6 +84,11 @@ Optional arguments: - ``--pipeline PIPELINES`` Pipelines names to add on the project. +.. tip:: + Use the "pipeline_name:group1,group2" syntax to select steps groups: + + ``--pipeline map_deploy_to_develop:Java,JavaScript`` + - ``--input-file INPUTS_FILES`` Input file locations to copy in the :guilabel:`input/` work directory. @@ -190,6 +195,11 @@ add the docker pipeline to your project:: $ scanpipe add-pipeline --project foo analyze_docker_image +.. tip:: + Use the "pipeline_name:group1,group2" syntax to select steps groups: + + ``--pipeline map_deploy_to_develop:Java,JavaScript`` + `$ scanpipe execute --project PROJECT` -------------------------------------- @@ -201,6 +211,7 @@ Optional arguments: - ``--async`` Add the pipeline run to the tasks queue for execution by a worker instead of running in the current thread. + `$ scanpipe show-pipeline --project PROJECT` -------------------------------------------- diff --git a/docs/rest-api.rst b/docs/rest-api.rst index b9e65f3c52..bf544c2b05 100644 --- a/docs/rest-api.rst +++ b/docs/rest-api.rst @@ -278,6 +278,11 @@ Data: - ``pipeline``: The pipeline name - ``execute_now``: ``true`` or ``false`` +.. tip:: + Use the "pipeline_name:group1,group2" syntax to select steps groups: + + ``"pipeline": "map_deploy_to_develop:Java,JavaScript"`` + Using cURL: .. code-block:: console diff --git a/scanpipe/api/views.py b/scanpipe/api/views.py index 1add425497..f27c5ceb99 100644 --- a/scanpipe/api/views.py +++ b/scanpipe/api/views.py @@ -247,10 +247,11 @@ def add_pipeline(self, request, *args, **kwargs): pipeline = request.data.get("pipeline") if pipeline: - pipeline = scanpipe_app.get_new_pipeline_name(pipeline) - if pipeline in scanpipe_app.pipelines: + pipeline_name, groups = scanpipe_app.extract_group_from_pipeline(pipeline) + pipeline_name = scanpipe_app.get_new_pipeline_name(pipeline_name) + if pipeline_name in scanpipe_app.pipelines: execute_now = request.data.get("execute_now") - project.add_pipeline(pipeline, execute_now) + project.add_pipeline(pipeline_name, execute_now, selected_groups=groups) return Response({"status": "Pipeline added."}) message = {"status": f"{pipeline} is not a valid pipeline."} diff --git a/scanpipe/apps.py b/scanpipe/apps.py index a74314a309..8f46096a96 100644 --- a/scanpipe/apps.py +++ b/scanpipe/apps.py @@ -189,6 +189,17 @@ def get_new_pipeline_name(pipeline_name): return new_name return pipeline_name + @staticmethod + def extract_group_from_pipeline(pipeline): + pipeline_name = pipeline + groups = None + + if ":" in pipeline: + pipeline_name, value = pipeline.split(":", maxsplit=1) + groups = value.split(",") if value else [] + + return pipeline_name, groups + def get_scancode_licenses(self): """ Load licenses-related information from the ScanCode-toolkit ``licensedcode`` diff --git a/scanpipe/forms.py b/scanpipe/forms.py index 8580e95783..f0c65c991d 100644 --- a/scanpipe/forms.py +++ b/scanpipe/forms.py @@ -100,6 +100,14 @@ def handle_inputs(self, project): project.add_input_source(download_url=url) +class GroupChoiceField(forms.MultipleChoiceField): + widget = forms.CheckboxSelectMultiple + + def valid_value(self, value): + """Accept all values.""" + return True + + class PipelineBaseForm(forms.Form): pipeline = forms.ChoiceField( choices=scanpipe_app.get_pipeline_choices(), @@ -110,12 +118,14 @@ class PipelineBaseForm(forms.Form): initial=True, required=False, ) + selected_groups = GroupChoiceField(required=False) def handle_pipeline(self, project): pipeline = self.cleaned_data["pipeline"] execute_now = self.cleaned_data["execute_now"] + selected_groups = self.cleaned_data.get("selected_groups", []) if pipeline: - project.add_pipeline(pipeline, execute_now) + project.add_pipeline(pipeline, execute_now, selected_groups) class ProjectForm(InputsBaseForm, PipelineBaseForm, forms.ModelForm): @@ -127,6 +137,7 @@ class Meta: "input_urls", "pipeline", "execute_now", + "selected_groups", ] def __init__(self, *args, **kwargs): @@ -158,32 +169,11 @@ def save(self, project): class AddPipelineForm(PipelineBaseForm): pipeline = forms.ChoiceField( + choices=scanpipe_app.get_pipeline_choices(), widget=forms.RadioSelect(), required=True, ) - def __init__(self, project_runs=None, *args, **kwargs): - super().__init__(*args, **kwargs) - - # The pipeline choices are determined based on the project context: - # 1. If no pipelines are assigned to the project: - # Include all base (non-addon) pipelines. - # 2. If at least one pipeline already exists on the project: - # Include all addon pipelines and the existing pipeline (useful for - # potential re-runs in debug mode). - project_run_names = {run.pipeline_name for run in project_runs or []} - - pipeline_choices = [ - (name, pipeline_class.get_summary()) - for name, pipeline_class in scanpipe_app.pipelines.items() - # no pipelines are assigned to the project - if (not project_runs and not pipeline_class.is_addon) - # at least one pipeline already exists on the project - or (project_runs and (name in project_run_names or pipeline_class.is_addon)) - ] - - self.fields["pipeline"].choices = pipeline_choices - def save(self, project): self.handle_pipeline(project) return project diff --git a/scanpipe/management/commands/__init__.py b/scanpipe/management/commands/__init__.py index 3cbc6b529f..6c3c1051e9 100644 --- a/scanpipe/management/commands/__init__.py +++ b/scanpipe/management/commands/__init__.py @@ -152,14 +152,22 @@ def add_arguments(self, parser): action="append", dest="input_files", default=list(), - help="Input file locations to copy in the input/ work directory.", + help=( + "Input file locations to copy in the input/ work directory. " + 'Use the "filename:tag" syntax to tag input files such as ' + '"path/filename:tag"' + ), ) parser.add_argument( "--input-url", action="append", dest="input_urls", default=list(), - help="Input URLs to download in the input/ work directory.", + help=( + "Input URLs to download in the input/ work directory. " + 'Use the "url#tag" syntax to tag downloaded files such as ' + '"https://url.com/filename#tag"' + ), ) parser.add_argument( "--copy-codebase", @@ -251,19 +259,32 @@ def validate_copy_from(copy_from): raise CommandError(f"{copy_from} is not a directory") -def validate_pipelines(pipeline_names): +def extract_group_from_pipelines(pipelines): + """ + Add support for the ":group1,group2" suffix in pipeline data. + + For example: "map_deploy_to_develop:Java,JavaScript" + """ + pipelines_data = {} + for pipeline in pipelines: + pipeline_name, groups = scanpipe_app.extract_group_from_pipeline(pipeline) + pipelines_data[pipeline_name] = groups + return pipelines_data + + +def validate_pipelines(pipelines_data): """Raise an error if one of the `pipeline_names` is not available.""" # Backward compatibility with old pipeline names. - pipeline_names = [ - scanpipe_app.get_new_pipeline_name(pipeline_name) - for pipeline_name in pipeline_names - ] + pipelines_data = { + scanpipe_app.get_new_pipeline_name(pipeline_name): groups + for pipeline_name, groups in pipelines_data.items() + } - for pipeline_name in pipeline_names: + for pipeline_name in pipelines_data.keys(): if pipeline_name not in scanpipe_app.pipelines: raise CommandError( f"{pipeline_name} is not a valid pipeline. \n" f"Available: {', '.join(scanpipe_app.pipelines.keys())}" ) - return pipeline_names + return pipelines_data diff --git a/scanpipe/management/commands/add-pipeline.py b/scanpipe/management/commands/add-pipeline.py index 417d849226..8d4bcd9401 100644 --- a/scanpipe/management/commands/add-pipeline.py +++ b/scanpipe/management/commands/add-pipeline.py @@ -23,6 +23,7 @@ from django.template.defaultfilters import pluralize from scanpipe.management.commands import ProjectCommand +from scanpipe.management.commands import extract_group_from_pipelines from scanpipe.management.commands import validate_pipelines @@ -38,13 +39,16 @@ def add_arguments(self, parser): help="One or more pipeline names.", ) - def handle(self, *pipeline_names, **options): - super().handle(*pipeline_names, **options) + def handle(self, *pipelines, **options): + super().handle(*pipelines, **options) - pipeline_names = validate_pipelines(pipeline_names) - for pipeline_name in pipeline_names: - self.project.add_pipeline(pipeline_name) + pipelines_data = extract_group_from_pipelines(pipelines) + pipelines_data = validate_pipelines(pipelines_data) + for pipeline_name, selected_groups in pipelines_data.items(): + self.project.add_pipeline(pipeline_name, selected_groups=selected_groups) + + pipeline_names = pipelines_data.keys() msg = ( f"Pipeline{pluralize(pipeline_names)} {', '.join(pipeline_names)} " f"added to the project" diff --git a/scanpipe/management/commands/create-project.py b/scanpipe/management/commands/create-project.py index af3464b05f..e1816aa9e6 100644 --- a/scanpipe/management/commands/create-project.py +++ b/scanpipe/management/commands/create-project.py @@ -26,6 +26,7 @@ from django.core.management.base import BaseCommand from scanpipe.management.commands import AddInputCommandMixin +from scanpipe.management.commands import extract_group_from_pipelines from scanpipe.management.commands import validate_copy_from from scanpipe.management.commands import validate_pipelines from scanpipe.models import Project @@ -43,8 +44,9 @@ def add_arguments(self, parser): dest="pipelines", default=list(), help=( - "Pipelines names to add to the project." - "The pipelines are added and executed based on their given order." + "Pipelines names to add to the project. " + "The pipelines are added and executed based on their given order. " + 'Groups can be provided using the "pipeline_name:group1,group2" syntax.' ), ) parser.add_argument( @@ -68,7 +70,7 @@ def add_arguments(self, parser): def handle(self, *args, **options): name = options["name"] - pipeline_names = options["pipelines"] + pipelines = options["pipelines"] input_files = options["input_files"] input_urls = options["input_urls"] copy_from = options["copy_codebase"] @@ -84,22 +86,24 @@ def handle(self, *args, **options): raise CommandError("\n".join(e.messages)) # Run validation before creating the project in the database - pipeline_names = validate_pipelines(pipeline_names) + pipelines_data = extract_group_from_pipelines(pipelines) + pipelines_data = validate_pipelines(pipelines_data) + input_files_data = self.extract_tag_from_input_files(input_files) self.validate_input_files(input_files=input_files_data.keys()) validate_copy_from(copy_from) - if execute and not pipeline_names: + if execute and not pipelines: raise CommandError("The --execute option requires one or more pipelines.") project.save() + self.project = project msg = f"Project {name} created with work directory {project.work_directory}" self.stdout.write(msg, self.style.SUCCESS) - for pipeline_name in pipeline_names: - project.add_pipeline(pipeline_name) + for pipeline_name, selected_groups in pipelines_data.items(): + self.project.add_pipeline(pipeline_name, selected_groups=selected_groups) - self.project = project if input_files: self.handle_input_files(input_files_data) diff --git a/scanpipe/management/commands/show-pipeline.py b/scanpipe/management/commands/show-pipeline.py index e3e8fa4ecd..d180286db9 100644 --- a/scanpipe/management/commands/show-pipeline.py +++ b/scanpipe/management/commands/show-pipeline.py @@ -32,4 +32,7 @@ def handle(self, *args, **options): for run in self.project.runs.all(): status_code = self.get_run_status_code(run) - self.stdout.write(f" [{status_code}] {run.pipeline_name}") + output = f" [{status_code}] {run.pipeline_name}" + if run.selected_groups: + output += f" ({','.join(run.selected_groups)})" + self.stdout.write(output) diff --git a/scanpipe/migrations/0052_run_selected_groups.py b/scanpipe/migrations/0052_run_selected_groups.py new file mode 100644 index 0000000000..db5951b486 --- /dev/null +++ b/scanpipe/migrations/0052_run_selected_groups.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.1 on 2024-02-02 11:42 + +import scanpipe.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("scanpipe", "0051_rename_pipelines_data"), + ] + + operations = [ + migrations.AddField( + model_name="run", + name="selected_groups", + field=models.JSONField( + blank=True, + null=True, + validators=[scanpipe.models.validate_none_or_list], + ), + ), + ] diff --git a/scanpipe/models.py b/scanpipe/models.py index d7efabdf71..bcf7eadd29 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -691,7 +691,9 @@ def clone( if copy_pipelines: for run in self.runs.all(): - cloned_project.add_pipeline(run.pipeline_name, execute_now) + cloned_project.add_pipeline( + run.pipeline_name, execute_now, selected_groups=run.selected_groups + ) if copy_subscriptions: for subscription in self.webhooksubscriptions.all(): @@ -1025,7 +1027,7 @@ def add_uploads(self, uploads): for uploaded_file in uploads: self.add_upload(uploaded_file) - def add_pipeline(self, pipeline_name, execute_now=False): + def add_pipeline(self, pipeline_name, execute_now=False, selected_groups=None): """ Create a new Run instance with the provided `pipeline` on the current project. @@ -1039,10 +1041,13 @@ def add_pipeline(self, pipeline_name, execute_now=False): if not pipeline_class: raise ValueError(f"Unknown pipeline: {pipeline_name}") + validate_none_or_list(selected_groups) + run = Run.objects.create( project=self, pipeline_name=pipeline_name, description=pipeline_class.get_summary(), + selected_groups=selected_groups, ) # Do not start the pipeline execution, even if explicitly requested, @@ -1585,6 +1590,11 @@ def queued_or_running(self): return self.filter(task_id__isnull=False, task_end_date__isnull=True) +def validate_none_or_list(value): + if value is not None and not isinstance(value, list): + raise ValidationError("Value must be a list.") + + class Run(UUIDPKModel, ProjectRelatedModel, AbstractTaskFieldsModel): """The Database representation of a pipeline execution.""" @@ -1596,6 +1606,9 @@ class Run(UUIDPKModel, ProjectRelatedModel, AbstractTaskFieldsModel): scancodeio_version = models.CharField(max_length=30, blank=True) description = models.TextField(blank=True) current_step = models.CharField(max_length=256, blank=True) + selected_groups = models.JSONField( + null=True, blank=True, validators=[validate_none_or_list] + ) objects = RunQuerySet.as_manager() diff --git a/scanpipe/pipelines/__init__.py b/scanpipe/pipelines/__init__.py index 874148cc4a..11ad697aec 100644 --- a/scanpipe/pipelines/__init__.py +++ b/scanpipe/pipelines/__init__.py @@ -23,7 +23,6 @@ import inspect import logging import traceback -import warnings from contextlib import contextmanager from functools import wraps from pydoc import getdoc @@ -43,6 +42,19 @@ class InputFileError(Exception): """InputFile is missing or cannot be downloaded.""" +def group(*groups): + """Mark a function as part of a particular group.""" + + def decorator(obj): + if hasattr(obj, "groups"): + obj.groups = obj.groups.union(groups) + else: + setattr(obj, "groups", set(groups)) + return obj + + return decorator + + class BasePipeline: """Base class for all pipelines.""" @@ -63,19 +75,28 @@ def steps(cls): raise NotImplementedError @classmethod - def get_steps(cls): + def get_steps(cls, groups=None): """ - Raise a deprecation warning when the steps are defined as a tuple instead of - a classmethod. + Return the list of steps defined in the ``steps`` class method. + + If the optional ``groups`` parameter is provided, only include steps labeled + with groups that intersect with the provided list. If a step has no groups or + if ``groups`` is not specified, include the step in the result. """ - if callable(cls.steps): - return cls.steps() + if not callable(cls.steps): + raise TypeError("Use a ``steps(cls)`` classmethod to declare the steps.") - warnings.warn( - f"Defining ``steps`` as a tuple is deprecated in {cls} " - f"Use a ``steps(cls)`` classmethod instead." - ) - return cls.steps + steps = cls.steps() + + if groups is not None: + steps = tuple( + step + for step in steps + if not getattr(step, "groups", []) + or set(getattr(step, "groups")).intersection(groups) + ) + + return steps @classmethod def get_doc(cls): @@ -86,7 +107,12 @@ def get_doc(cls): def get_graph(cls): """Return a graph of steps.""" return [ - {"name": step.__name__, "doc": getdoc(step)} for step in cls.get_steps() + { + "name": step.__name__, + "doc": getdoc(step), + "groups": getattr(step, "groups", []), + } + for step in cls.get_steps() ] @classmethod @@ -97,6 +123,7 @@ def get_info(cls): "summary": summary, "description": description, "steps": cls.get_graph(), + "available_groups": cls.get_available_groups(), } @classmethod @@ -104,6 +131,16 @@ def get_summary(cls): """Get the doc string summary.""" return cls.get_info()["summary"] + @classmethod + def get_available_groups(cls): + return sorted( + set( + group_name + for step in cls.get_steps() + for group_name in getattr(step, "groups", []) + ) + ) + def log(self, message): """Log the given `message` to the current module logger and Run instance.""" now_as_localtime = timezone.localtime(timezone.now()) @@ -115,7 +152,8 @@ def log(self, message): def execute(self): """Execute each steps in the order defined on this pipeline class.""" self.log(f"Pipeline [{self.pipeline_name}] starting") - steps = self.get_steps() + + steps = self.get_steps(groups=self.run.selected_groups) if self.download_inputs: steps = (self.__class__.download_missing_inputs,) + steps diff --git a/scanpipe/pipelines/deploy_to_develop.py b/scanpipe/pipelines/deploy_to_develop.py index 54ad7a5be3..1351dcfa43 100644 --- a/scanpipe/pipelines/deploy_to_develop.py +++ b/scanpipe/pipelines/deploy_to_develop.py @@ -22,6 +22,7 @@ from scanpipe import pipes from scanpipe.pipelines import Pipeline +from scanpipe.pipelines import group from scanpipe.pipes import d2d from scanpipe.pipes import flag from scanpipe.pipes import matchcode @@ -151,10 +152,7 @@ def fingerprint_codebase_directories(self): matchcode.fingerprint_codebase_directories(self.project, to_codebase_only=True) def flag_whitespace_files(self): - """ - Flag whitespace files with size less than or equal - to 100 byte as ignored. - """ + """Flag whitespace files with size less than or equal to 100 byte as ignored.""" d2d.flag_whitespace_files(project=self.project) def map_about_files(self): @@ -178,18 +176,22 @@ def match_archives_to_purldb(self): logger=self.log, ) + @group("Java") def find_java_packages(self): """Find the java package of the .java source files.""" d2d.find_java_packages(self.project, logger=self.log) + @group("Java") def map_java_to_class(self): """Map a .class compiled file to its .java source.""" d2d.map_java_to_class(project=self.project, logger=self.log) + @group("Java") def map_jar_to_source(self): """Map .jar files to their related source directory.""" d2d.map_jar_to_source(project=self.project, logger=self.log) + @group("JavaScript") def map_javascript(self): """ Map a packed or minified JavaScript, TypeScript, CSS and SCSS @@ -221,18 +223,22 @@ def match_resources_to_purldb(self): logger=self.log, ) + @group("JavaScript") def map_javascript_post_purldb_match(self): """Map minified javascript file based on existing PurlDB match.""" d2d.map_javascript_post_purldb_match(project=self.project, logger=self.log) + @group("JavaScript") def map_javascript_path(self): """Map javascript file based on path.""" d2d.map_javascript_path(project=self.project, logger=self.log) + @group("JavaScript") def map_javascript_colocation(self): """Map JavaScript files based on neighborhood file mapping.""" d2d.map_javascript_colocation(project=self.project, logger=self.log) + @group("JavaScript") def map_thirdparty_npm_packages(self): """Map thirdparty package using package.json metadata.""" d2d.map_thirdparty_npm_packages(project=self.project, logger=self.log) diff --git a/scanpipe/templates/scanpipe/modals/add_pipeline_modal.html b/scanpipe/templates/scanpipe/modals/add_pipeline_modal.html index 5a2cf3e68d..d5e7e65f23 100644 --- a/scanpipe/templates/scanpipe/modals/add_pipeline_modal.html +++ b/scanpipe/templates/scanpipe/modals/add_pipeline_modal.html @@ -23,13 +23,25 @@ {% endif %}
- {% for label, summary in add_pipeline_form.pipeline.field.choices %} + {% for pipeline_name, pipeline_info in pipeline_choices %}
-

{{ summary }}

+

{{ pipeline_info.summary }}

+ {% if pipeline_info.available_groups %} +
+ {% for group in pipeline_info.available_groups %} + + {% endfor %} +
+ {% endif %}
{% endfor %}
diff --git a/scanpipe/templates/scanpipe/modals/run_modal_content.html b/scanpipe/templates/scanpipe/modals/run_modal_content.html index 646fb9823c..5820779312 100644 --- a/scanpipe/templates/scanpipe/modals/run_modal_content.html +++ b/scanpipe/templates/scanpipe/modals/run_modal_content.html @@ -15,6 +15,19 @@ {% include "scanpipe/includes/run_status_tag.html" with run=run only %}
+ {% if run.selected_groups %} +
+
+ Selected groups + + {% for group in run.selected_groups %} + {% if not forloop.first %},{% endif %} + {{ group }} + {% endfor %} + +
+
+ {% endif %} {% if run.status == run.Status.NOT_STARTED or run.status == run.Status.QUEUED %} diff --git a/scanpipe/templates/scanpipe/project_form.html b/scanpipe/templates/scanpipe/project_form.html index 2759f4b415..6f4aa1c888 100644 --- a/scanpipe/templates/scanpipe/project_form.html +++ b/scanpipe/templates/scanpipe/project_form.html @@ -53,7 +53,15 @@

- +
+ + +
{{ form.pipeline }} @@ -62,10 +70,7 @@

- + {{ form.selected_groups }}
@@ -98,15 +103,26 @@

Pipelines:

-
+

{{ pipeline_info.summary }}

{% if pipeline_info.description %} -

{{ pipeline_info.description|linebreaks }}

+

+ {{ pipeline_info.description|linebreaksbr }} +

{% endif %}