Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

v35.4.1 (unreleased)
--------------------

- Add ability to download all output results formats as a zipfile for a given project.
https://github.com/aboutcode-org/scancode.io/issues/1880

v35.4.0 (2025-09-30)
--------------------

Expand Down
7 changes: 4 additions & 3 deletions docs/command-line-interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,11 @@ Displays status information about the ``PROJECT`` project.

.. _cli_output:

`$ scanpipe output --project PROJECT --format {json,csv,xlsx,spdx,cyclonedx,attribution}`
-----------------------------------------------------------------------------------------
`$ scanpipe output --project PROJECT --format {json,csv,xlsx,spdx,cyclonedx,attribution,...}`
---------------------------------------------------------------------------------------------

Outputs the ``PROJECT`` results as JSON, XLSX, CSV, SPDX, CycloneDX, and Attribution.
Outputs the ``PROJECT`` results as JSON, XLSX, CSV, SPDX, CycloneDX,
ORT package-list.yml, and Attribution.
The output files are created in the ``PROJECT`` :guilabel:`output/` directory.

Multiple formats can be provided at once::
Expand Down
2 changes: 0 additions & 2 deletions docs/output-files.rst
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ Additional sheets are included **only when relevant** (i.e., when data is availa

SPDX
^^^^

ScanCode.io can generate Software Bill of Materials (SBOM) in the **SPDX** format,
which is an open standard for communicating software component information.
SPDX is widely used for license compliance, security analysis, and software supply
Expand All @@ -309,7 +308,6 @@ The SPDX output includes:

CycloneDX
^^^^^^^^^

ScanCode.io can generate **CycloneDX** SBOMs, a lightweight standard designed for
security and dependency management. CycloneDX is optimized for vulnerability analysis
and software supply chain risk assessment.
Expand Down
8 changes: 7 additions & 1 deletion docs/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -694,10 +694,16 @@ Finally, use this action to download the project results in the provided
``output_format`` as an attachment file.

Data:
- ``output_format``: ``json``, ``xlsx``, ``spdx``, ``cyclonedx``, ``attribution``
- ``output_format``: ``json``, ``xlsx``, ``spdx``, ``cyclonedx``, ``attribution``,
``all_formats``, ``all_outputs``

``GET /api/projects/d4ed9405-5568-45ad-99f6-782a9b82d1d2/results_download/?output_format=cyclonedx``

.. note::
Use ``all_formats`` to generate a zip file containing all output formats for a
project, while ``all_outputs`` can be used to obtain a zip file of all existing
output files for that project.

.. tip::
Refer to :ref:`output_files` to learn more about the available output formats.

Expand Down
4 changes: 4 additions & 0 deletions scanpipe/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ def results_download(self, request, *args, **kwargs):
output_file = output.to_attribution(project)
elif format == "ort-package-list":
output_file = output.to_ort_package_list_yml(project)
elif format == "all_formats":
output_file = output.to_all_formats(project)
elif format == "all_outputs":
output_file = output.to_all_outputs(project)
else:
message = {"status": f"Format {format} not supported."}
return Response(message, status=status.HTTP_400_BAD_REQUEST)
Expand Down
42 changes: 42 additions & 0 deletions scanpipe/pipes/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
import json
import re
import uuid
import zipfile
from operator import attrgetter
from pathlib import Path

from django.apps import apps
from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder
from django.forms.models import model_to_dict
from django.template import Context
Expand Down Expand Up @@ -1138,3 +1140,43 @@ def to_ort_package_list_yml(project):
"attribution": to_attribution,
"ort-package-list": to_ort_package_list_yml,
}


def make_zip_from_files(files):
"""Return an in-memory zipfile given a list of (filename, file_path) pairs."""
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filename, file_path in files:
with open(file_path, "rb") as f:
zip_file.writestr(filename, f.read())
zip_buffer.seek(0)
return zip_buffer


def to_all_formats(project):
"""Generate all output formats for a project and return a Django File-like zip."""
files = []
for output_function in FORMAT_TO_FUNCTION_MAPPING.values():
output_file = output_function(project)
filename = safe_filename(f"{project.name}_{output_file.name}")
files.append((filename, output_file))

zip_buffer = make_zip_from_files(files)

# Wrap it into a Django File-like object
zip_file = ContentFile(zip_buffer.getvalue())
zip_file.name = safe_filename(f"{project.name}_outputs.zip")

return zip_file


def to_all_outputs(project):
"""Return a Django File-like zip containing all existing project's output/ files."""
files = [(path.name, path) for path in project.output_path.glob("*")]
zip_buffer = make_zip_from_files(files)

# Wrap it into a Django File-like object
zip_file = ContentFile(zip_buffer.getvalue())
zip_file.name = safe_filename(f"{project.name}_outputs.zip")

return zip_file
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<a href="{% url 'project_results' project.slug 'ort-package-list' %}" class="dropdown-item">
<strong>ORT (package-list)</strong>
</a>
<hr class="dropdown-divider" />
<a href="{% url 'project_results' project.slug 'all_formats' %}" class="dropdown-item">
<strong>All formats</strong>
</a>
</div>
</div>
</div>
24 changes: 10 additions & 14 deletions scanpipe/templates/scanpipe/includes/project_downloads.html
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
<article class="message is-success">
<div class="message-body">
Download results as:
<div class="message-body p-3">
<span class="icon"><i class="fa-solid fa-download"></i></span>
Download results:
<a class="tag is-success is-medium ml-2" href="{% url 'project_results' project.slug 'json' %}">
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>JSON
JSON
</a>
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'xlsx' %}">
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>XLSX
XLSX
</a>
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-spdx">
<span class="icon">
<i class="fa-solid fa-download" aria-hidden="true"></i>
</span>
<span>SPDX</span>
<span class="icon is-small">
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
Expand All @@ -33,9 +31,6 @@
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-cyclonedx">
<span class="icon">
<i class="fa-solid fa-download" aria-hidden="true"></i>
</span>
<span>CycloneDX</span>
<span class="icon is-small">
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
Expand All @@ -57,14 +52,11 @@
</div>
</div>
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'attribution' %}">
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>Attribution
Attribution
</a>
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-ort">
<span class="icon">
<i class="fa-solid fa-download" aria-hidden="true"></i>
</span>
<span>Tools formats</span>
<span class="icon is-small">
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
Expand All @@ -79,5 +71,9 @@
</div>
</div>
</div>
<span class="p-1">|</span>
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'all_formats' %}">
All formats
</a>
</div>
</article>
6 changes: 6 additions & 0 deletions scanpipe/templates/scanpipe/panels/project_outputs.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@
</div>
</div>
{% endfor %}
<div class="panel-block">
<a class="button is-link is-outlined is-fullwidth" href="{% url 'project_results' project.slug 'all_outputs' %}">
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>
Download all outputs
</a>
</div>
</article>
34 changes: 34 additions & 0 deletions scanpipe/tests/pipes/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import shutil
import tempfile
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path
from unittest import mock
Expand Down Expand Up @@ -633,6 +634,39 @@ def test_scanpipe_pipes_outputs_to_to_ort_package_list_yml(self):
expected_file = self.data / "asgiref" / "asgiref-3.3.0.package-list.yml"
self.assertResultsEqual(expected_file, output_file.read_text())

def test_scanpipe_pipes_outputs_to_all_formats(self):
fixtures = self.data / "asgiref" / "asgiref-3.3.0_fixtures.json"
call_command("loaddata", fixtures, **{"verbosity": 0})
project = Project.objects.get(name="asgiref")

with self.assertNumQueries(35):
output_file = output.to_all_formats(project=project)

self.assertEqual("asgiref_outputs.zip", output_file.name)

with zipfile.ZipFile(output_file, "r") as zip_ref:
zip_contents = zip_ref.namelist()
file_count = len(zip_contents)

expected_file_count = len(output.FORMAT_TO_FUNCTION_MAPPING)
self.assertEqual(expected_file_count, file_count)

def test_scanpipe_pipes_outputs_to_all_outputs(self):
fixtures = self.data / "asgiref" / "asgiref-3.3.0_fixtures.json"
call_command("loaddata", fixtures, **{"verbosity": 0})
project = Project.objects.get(name="asgiref")

with self.assertNumQueries(0):
output_file = output.to_all_outputs(project=project)

self.assertEqual("asgiref_outputs.zip", output_file.name)

with zipfile.ZipFile(output_file, "r") as zip_ref:
zip_contents = zip_ref.namelist()
file_count = len(zip_contents)

self.assertEqual(len(project.output_root), file_count)

def test_scanpipe_pipes_outputs_make_unknown_license_object(self):
licensing = get_licensing()
parsed_expression = licensing.parse("some-unknown-license")
Expand Down
10 changes: 10 additions & 0 deletions scanpipe/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,16 @@ def test_scanpipe_api_project_action_results_download_output_formats(self):
# to prevent a "ResourceWarning: unclosed file"
self.assertTrue(response.getvalue().startswith(b"PK"))

data = {"output_format": "all_formats"}
response = self.csrf_client.get(url, data=data)
expected = ["application/zip"]
self.assertIn(response["Content-Type"], expected)

data = {"output_format": "all_outputs"}
response = self.csrf_client.get(url, data=data)
expected = ["application/zip"]
self.assertIn(response["Content-Type"], expected)

def test_scanpipe_api_project_action_pipelines(self):
url = reverse("project-pipelines")
response = self.csrf_client.get(url)
Expand Down
21 changes: 11 additions & 10 deletions scanpipe/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import io
import json
import operator
import zipfile
from collections import Counter
from contextlib import suppress
from pathlib import Path
Expand Down Expand Up @@ -1444,19 +1443,17 @@ def get_project_queryset(selected_project_ids=None, action_form=None):

@staticmethod
def download_outputs_zip_response(project_qs, action_form):
"""Generate and return a zip file response for selected projects."""
output_format = action_form.cleaned_data["output_format"]
output_function = output.FORMAT_TO_FUNCTION_MAPPING.get(output_format)

# In-memory file storage for the zip archive
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for project in project_qs:
output_file = output_function(project)
filename = output.safe_filename(f"{project.name}_{output_file.name}")
with open(output_file, "rb") as f:
zip_file.writestr(filename, f.read())
files = []
for project in project_qs:
output_file = output_function(project)
filename = output.safe_filename(f"{project.name}_{output_file.name}")
files.append((filename, output_file))

zip_buffer.seek(0)
zip_buffer = output.make_zip_from_files(files)
return FileResponse(
zip_buffer,
as_attachment=True,
Expand Down Expand Up @@ -1633,6 +1630,10 @@ def get(self, request, *args, **kwargs):
output_file = output.to_attribution(project)
elif format == "ort-package-list":
output_file = output.to_ort_package_list_yml(project)
elif format == "all_formats":
output_file = output.to_all_formats(project)
elif format == "all_outputs":
output_file = output.to_all_outputs(project)
else:
raise Http404("Format not supported.")

Expand Down