Skip to content

Commit 6791179

Browse files
committed
Merge branch 'main' into 1727-sca-integration-ort-spdx
Signed-off-by: tdruez <[email protected]> # Conflicts: # scanpipe/tests/pipes/test_output.py
2 parents c401144 + 45ef995 commit 6791179

File tree

13 files changed

+796
-8
lines changed

13 files changed

+796
-8
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ v35.4.0 (unreleased)
2929
* Add support when the "components" entry is missing.
3030
https://github.com/aboutcode-org/scancode.io/issues/1727
3131

32+
- Split the functionality of
33+
``scanpipe.pipes.federatedcode.commit_and_push_changes`` into
34+
``scanpipe.pipes.federatedcode.commit_changes`` and
35+
``scanpipe.pipes.federatedcode.push_changes``. Add
36+
``scanpipe.pipes.federatedcode.write_data_as_yaml``.
37+
38+
- Add ORT ``package-list.yml`` as new downloadable output format.
39+
https://github.com/aboutcode-org/scancode.io/pull/1852
40+
3241
v35.3.0 (2025-08-20)
3342
--------------------
3443

scanpipe/api/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ def results_download(self, request, *args, **kwargs):
169169
output_file = output.to_cyclonedx(project, **output_kwargs)
170170
elif format == "attribution":
171171
output_file = output.to_attribution(project)
172+
elif format == "ort-package-list":
173+
output_file = output.to_ort_package_list_yml(project)
172174
else:
173175
message = {"status": f"Format {format} not supported."}
174176
return Response(message, status=status.HTTP_400_BAD_REQUEST)

scanpipe/management/commands/output.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@
2525
from scanpipe.management.commands import ProjectCommand
2626
from scanpipe.pipes import output
2727

28-
SUPPORTED_FORMATS = ["json", "csv", "xlsx", "attribution", "spdx", "cyclonedx"]
28+
SUPPORTED_FORMATS = [
29+
"json",
30+
"csv",
31+
"xlsx",
32+
"attribution",
33+
"spdx",
34+
"cyclonedx",
35+
"ort-package-list",
36+
]
2937

3038

3139
class Command(ProjectCommand):
@@ -84,6 +92,7 @@ def handle_output(self, output_format):
8492
"spdx": output.to_spdx,
8593
"cyclonedx": output.to_cyclonedx,
8694
"attribution": output.to_attribution,
95+
"ort-package-list": output.to_ort_package_list_yml,
8796
}.get(output_format)
8897

8998
if not output_function:

scanpipe/pipes/federatedcode.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from django.conf import settings
3232

3333
import requests
34+
import saneyaml
3435
from git import Repo
3536
from packageurl import PackageURL
3637

@@ -161,6 +162,19 @@ def add_scan_result(project, repo, package_scan_file, logger=None):
161162
return relative_scan_file_path
162163

163164

165+
def commit_changes(repo, files_to_commit, commit_message):
166+
"""Commit changes to remote repository."""
167+
repo.index.add(files_to_commit)
168+
repo.index.commit(textwrap.dedent(commit_message))
169+
170+
171+
def push_changes(repo, remote_name="origin", branch_name=""):
172+
"""Push changes to remote repository."""
173+
if not branch_name:
174+
branch_name = repo.active_branch.name
175+
repo.git.push(remote_name, branch_name, "--no-verify")
176+
177+
164178
def commit_and_push_changes(
165179
repo, file_to_commit, purl, remote_name="origin", logger=None
166180
):
@@ -177,14 +191,27 @@ def commit_and_push_changes(
177191
178192
Signed-off-by: {author_name} <{author_email}>
179193
"""
180-
181-
default_branch = repo.active_branch.name
182-
183-
repo.index.add([file_to_commit])
184-
repo.index.commit(textwrap.dedent(commit_message))
185-
repo.git.push(remote_name, default_branch, "--no-verify")
194+
files_to_commit = [file_to_commit]
195+
commit_changes(
196+
repo=repo, files_to_commit=files_to_commit, commit_message=commit_message
197+
)
198+
push_changes(
199+
repo=repo,
200+
remote_name=remote_name,
201+
)
186202

187203

188204
def delete_local_clone(repo):
189205
"""Remove local clone."""
190206
shutil.rmtree(repo.working_dir)
207+
208+
209+
def write_data_as_yaml(base_path, file_path, data):
210+
"""
211+
Write the ``data`` as YAML to the ``file_path`` in the ``base_path`` root directory.
212+
Create directories in the path as needed.
213+
"""
214+
write_to = Path(base_path) / Path(file_path)
215+
write_to.parent.mkdir(parents=True, exist_ok=True)
216+
with open(write_to, encoding="utf-8", mode="w") as f:
217+
f.write(saneyaml.dump(data))

scanpipe/pipes/ort.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
#
3+
# http://nexb.com and https://github.com/aboutcode-org/scancode.io
4+
# The ScanCode.io software is licensed under the Apache License version 2.0.
5+
# Data generated with ScanCode.io is provided as-is without warranties.
6+
# ScanCode is a trademark of nexB Inc.
7+
#
8+
# You may not use this software except in compliance with the License.
9+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
10+
# Unless required by applicable law or agreed to in writing, software distributed
11+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
12+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES
16+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
17+
# ScanCode.io should be considered or used as legal advice. Consult an Attorney
18+
# for any legal advice.
19+
#
20+
# ScanCode.io is a free software code scanning tool from nexB Inc. and others.
21+
# Visit https://github.com/aboutcode-org/scancode.io for support and download.
22+
23+
24+
from dataclasses import asdict
25+
from dataclasses import dataclass
26+
from dataclasses import field
27+
from pathlib import Path
28+
29+
import saneyaml
30+
31+
"""
32+
This module provides Python dataclass models for representing a package list
33+
in a format compatible with the OSS Review Toolkit (ORT)
34+
`CreateAnalyzerResultFromPackageListCommand`.
35+
36+
The models are simplified adaptations of the Kotlin classes from:
37+
https://github.com/oss-review-toolkit/ort/blob/main/cli-helper/src/main/kotlin/commands/CreateAnalyzerResultFromPackageListCommand.kt
38+
39+
This module is intended for generating ORT-compatible YAML package lists from Python
40+
objects, allowing integration with ORT's analyzer workflows or manual creation of
41+
package metadata.
42+
"""
43+
44+
45+
# private data class SourceArtifact(
46+
# val url: String,
47+
# val hash: Hash? = null
48+
# )
49+
@dataclass
50+
class SourceArtifact:
51+
url: str
52+
# Cannot coerce empty String ("") to `org.ossreviewtoolkit.model.Hash` value
53+
# hash: str = None
54+
55+
56+
# private data class Vcs(
57+
# val type: String? = null,
58+
# val url: String? = null,
59+
# val revision: String? = null,
60+
# val path: String? = null
61+
# )
62+
@dataclass
63+
class Vcs:
64+
type: str = None
65+
url: str = None
66+
revision: str = None
67+
path: str = None
68+
69+
70+
# private data class Dependency(
71+
# val id: Identifier,
72+
# val purl: String? = null,
73+
# val vcs: Vcs? = null,
74+
# val sourceArtifact: SourceArtifact? = null,
75+
# val declaredLicenses: Set<String> = emptySet(),
76+
# val concludedLicense: SpdxExpression? = null,
77+
# val description: String? = null,
78+
# val homepageUrl: String? = null,
79+
# val isExcluded: Boolean = false,
80+
# val isDynamicallyLinked: Boolean = false,
81+
# val labels: Map<String, String> = emptyMap()
82+
# )
83+
@dataclass
84+
class Dependency:
85+
id: str
86+
purl: str = None
87+
vcs: Vcs = None
88+
sourceArtifact: SourceArtifact = None
89+
declaredLicenses: list = field(default_factory=set)
90+
# concludedLicense: str = None
91+
description: str = None
92+
homepageUrl: str = None
93+
# isExcluded: bool = False
94+
# isDynamicallyLinked: bool = False
95+
# labels: dict = field(default_factory=dict)
96+
97+
98+
# private data class PackageList(
99+
# val projectName: String? = null,
100+
# val projectVcs: Vcs? = null,
101+
# val dependencies: List<Dependency> = emptyList()
102+
# )
103+
@dataclass
104+
class PackageList:
105+
projectName: str
106+
projectVcs: Vcs = field(default_factory=Vcs)
107+
dependencies: list = field(default_factory=list)
108+
109+
def to_yaml(self):
110+
"""Dump the Project object back to a YAML string."""
111+
return saneyaml.dump(asdict(self))
112+
113+
def to_file(self, filepath):
114+
"""Write the Project object to a YAML file."""
115+
Path(filepath).write_text(self.to_yaml(), encoding="utf-8")
116+
117+
118+
def get_ort_project_type(project):
119+
"""
120+
Determine the ORT project type based on the project's input sources.
121+
122+
Currently, this function checks whether any of the project's
123+
input download URLs start with "docker://".
124+
If at least one Docker URL is found, it returns "docker".
125+
"""
126+
inputs_url = project.inputsources.values_list("download_url", flat=True)
127+
if any(url.startswith("docker://") for url in inputs_url):
128+
return "docker"
129+
130+
131+
def to_ort_package_list_yml(project):
132+
"""Convert a project object into a YAML string in the ORT package list format."""
133+
project_type = get_ort_project_type(project)
134+
135+
dependencies = []
136+
for package in project.discoveredpackages.all():
137+
dependency = Dependency(
138+
id=f"{project_type or package.type}::{package.name}:{package.version}",
139+
purl=package.purl,
140+
sourceArtifact=SourceArtifact(url=package.download_url),
141+
declaredLicenses=[package.get_declared_license_expression_spdx()],
142+
vcs=Vcs(url=package.vcs_url),
143+
description=package.description,
144+
homepageUrl=package.homepage_url,
145+
)
146+
dependencies.append(dependency)
147+
148+
package_list = PackageList(
149+
projectName=project.name,
150+
dependencies=dependencies,
151+
)
152+
153+
return package_list.to_yaml()

scanpipe/pipes/output.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from scanpipe.models import ProjectMessage
6262
from scanpipe.pipes import docker
6363
from scanpipe.pipes import flag
64+
from scanpipe.pipes import ort
6465
from scanpipe.pipes import spdx
6566

6667
scanpipe_app = apps.get_app_config("scanpipe")
@@ -1117,10 +1118,23 @@ def to_attribution(project):
11171118
return output_file
11181119

11191120

1121+
def to_ort_package_list_yml(project):
1122+
"""
1123+
Generate an ORT compatible "package-list.yml" output.
1124+
The output file is created in the ``project`` "output/" directory.
1125+
Return the path of the generated output file.
1126+
"""
1127+
output_file = project.get_output_file_path("results", "package-list.yml")
1128+
ort_yml = ort.to_ort_package_list_yml(project)
1129+
output_file.write_text(ort_yml)
1130+
return output_file
1131+
1132+
11201133
FORMAT_TO_FUNCTION_MAPPING = {
11211134
"json": to_json,
11221135
"xlsx": to_xlsx,
11231136
"spdx": to_spdx,
11241137
"cyclonedx": to_cyclonedx,
11251138
"attribution": to_attribution,
1139+
"ort-package-list": to_ort_package_list_yml,
11261140
}

scanpipe/templates/scanpipe/dropdowns/project_download_dropdown.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
<a href="{% url 'project_results' project.slug 'attribution' %}" class="dropdown-item">
3838
<strong>Attribution</strong>
3939
</a>
40+
<a href="{% url 'project_results' project.slug 'ort-package-list' %}" class="dropdown-item">
41+
<strong>ORT (package-list)</strong>
42+
</a>
4043
</div>
4144
</div>
4245
</div>

scanpipe/templates/scanpipe/includes/project_downloads.html

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@
2929
</div>
3030
<div class="dropdown is-hoverable">
3131
<div class="dropdown-trigger">
32-
<button class="button tag is-success is-medium" aria-haspopup="true" aria-controls="dropdown-menu-cyclonedx">
32+
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-cyclonedx">
3333
<span class="icon">
3434
<i class="fa-solid fa-download" aria-hidden="true"></i>
3535
</span>
3636
<span>CycloneDX</span>
37+
<span class="icon is-small">
38+
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
39+
</span>
3740
</button>
3841
</div>
3942
<div class="dropdown-menu" id="dropdown-menu-cyclonedx" role="menu">
@@ -53,5 +56,25 @@
5356
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'attribution' %}">
5457
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>Attribution
5558
</a>
59+
<div class="dropdown is-hoverable">
60+
<div class="dropdown-trigger">
61+
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-ort">
62+
<span class="icon">
63+
<i class="fa-solid fa-download" aria-hidden="true"></i>
64+
</span>
65+
<span>Tools formats</span>
66+
<span class="icon is-small">
67+
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
68+
</span>
69+
</button>
70+
</div>
71+
<div class="dropdown-menu" id="dropdown-menu-ort" role="menu">
72+
<div class="dropdown-content">
73+
<a href="{% url 'project_results' project.slug 'ort-package-list' %}" class="dropdown-item has-text-weight-semibold">
74+
ORT package-list.yml
75+
</a>
76+
</div>
77+
</div>
78+
</div>
5679
</div>
5780
</article>

0 commit comments

Comments
 (0)