Skip to content

Commit 3d2f059

Browse files
tdruezchinyeungli
authored andcommitted
feat: add ability to configure content of the JSON output (#2222)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 0191627 commit 3d2f059

14 files changed

Lines changed: 226 additions & 20 deletions

File tree

aboutcode/pipeline/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Define and run pipelines.
44

5-
### Install
5+
## Install
66

77
```bash
88
pip install aboutcode.pipeline
@@ -13,6 +13,7 @@ pip install aboutcode.pipeline
1313
```python
1414
from aboutcode.pipeline import BasePipeline
1515

16+
1617
class PrintMessages(BasePipeline):
1718
@classmethod
1819
def steps(cls):
@@ -21,6 +22,7 @@ class PrintMessages(BasePipeline):
2122
def step1(self):
2223
print("Message from step1")
2324

25+
2426
PrintMessages().execute()
2527
```
2628

scancodeio/static/main.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@
122122
.modal.is-medium-size .modal-card {
123123
width: 800px;
124124
}
125+
.json-section-option {
126+
cursor: pointer;
127+
border: 2px solid transparent;
128+
transition:
129+
border-color 0.15s ease,
130+
background-color 0.15s ease;
131+
}
132+
.json-section-option:hover {
133+
border-color: var(--bulma-success);
134+
}
135+
.json-section-option:has(input:checked) {
136+
border-color: var(--bulma-success);
137+
background-color: hsla(
138+
var(--bulma-success-h),
139+
var(--bulma-success-s),
140+
var(--bulma-success-l),
141+
0.08
142+
);
143+
}
125144
.modal.is-desktop-size .modal-card {
126145
width: 960px;
127146
}

scanpipe/api/views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,12 @@ def results(self, request, *args, **kwargs):
161161
Return the results compatible with ScanCode data format.
162162
The content is returned as a stream of JSON content using the
163163
JSONResultsGenerator class.
164+
Optionally restrict the packages/dependencies/files/relations arrays
165+
included using one or more `?sections=` query parameters, e.g.
166+
`?sections=packages&sections=dependencies`. Defaults to all sections.
164167
"""
165-
return project_results_json_response(self.get_object())
168+
sections = request.query_params.getlist("sections") or None
169+
return project_results_json_response(self.get_object(), sections=sections)
166170

167171
@action(detail=True, name="Results (download)")
168172
def results_download(self, request, *args, **kwargs):
@@ -176,7 +180,10 @@ def results_download(self, request, *args, **kwargs):
176180
output_kwargs["version"] = version
177181

178182
if format == "json":
179-
return project_results_json_response(project, as_attachment=True)
183+
sections = request.query_params.getlist("sections") or None
184+
return project_results_json_response(
185+
project, as_attachment=True, sections=sections
186+
)
180187
elif format == "xlsx":
181188
output_file = output.to_xlsx(project)
182189
elif format == "spdx":

scanpipe/management/commands/output.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"ort-package-list",
3636
]
3737

38+
JSON_SECTIONS = ["packages", "dependencies", "files", "relations"]
39+
3840

3941
class Command(ProjectCommand):
4042
help = "Output project results as JSON, XLSX, Attribution, SPDX, and CycloneDX."
@@ -58,11 +60,20 @@ def add_arguments(self, parser):
5860
action="store_true",
5961
help="Print the output to stdout.",
6062
)
63+
parser.add_argument(
64+
"--sections",
65+
nargs="+",
66+
choices=JSON_SECTIONS,
67+
metavar=f"{{{','.join(JSON_SECTIONS)}}}",
68+
help="Restrict the json output to the given sections. Only "
69+
"supported for the json format, and defaults to all sections.",
70+
)
6171

6272
def handle(self, *args, **options):
6373
super().handle(*args, **options)
6474
self.print_to_stdout = options["print"]
6575
formats = options["format"]
76+
sections = options["sections"]
6677

6778
if self.print_to_stdout and len(formats) > 1:
6879
raise CommandError(
@@ -72,10 +83,13 @@ def handle(self, *args, **options):
7283
if self.print_to_stdout and ("xlsx" in formats or "csv" in formats):
7384
raise CommandError("--print is not compatible with xlsx and csv formats.")
7485

86+
if sections and formats != ["json"]:
87+
raise CommandError("--sections is only supported for the json format.")
88+
7589
for output_format in formats:
76-
self.handle_output(output_format)
90+
self.handle_output(output_format, sections=sections)
7791

78-
def handle_output(self, output_format):
92+
def handle_output(self, output_format, sections=None):
7993
output_kwargs = {}
8094
if ":" in output_format:
8195
output_format, version = output_format.split(":", maxsplit=1)
@@ -85,6 +99,9 @@ def handle_output(self, output_format):
8599
)
86100
output_kwargs["version"] = version
87101

102+
if output_format == "json" and sections:
103+
output_kwargs["sections"] = sections
104+
88105
output_function = {
89106
"json": output.to_json,
90107
"csv": output.to_csv,

scanpipe/pipes/output.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,39 @@ class JSONResultsGenerator:
194194
issues.
195195
"""
196196

197-
def __init__(self, project):
197+
def __init__(self, project, sections=None):
198+
"""
199+
`sections` is an optional iterable restricting which of the
200+
packages/dependencies/files/relations arrays are included.
201+
Defaults to including all of them.
202+
"""
198203
self.project = project
204+
self.sections = sections
199205

200206
def __iter__(self):
201207
yield "{\n"
202-
yield from self.serialize(label="headers", generator=self.get_headers)
203-
yield from self.serialize(label="packages", generator=self.get_packages)
204-
yield from self.serialize(label="dependencies", generator=self.get_dependencies)
205-
yield from self.serialize(label="files", generator=self.get_files)
208+
209+
sections = [
210+
("packages", self.get_packages),
211+
("dependencies", self.get_dependencies),
212+
("files", self.get_files),
213+
("relations", self.get_relations),
214+
]
215+
if self.sections is not None:
216+
sections = [
217+
(label, generator)
218+
for label, generator in sections
219+
if label in self.sections
220+
]
221+
206222
yield from self.serialize(
207-
label="relations", generator=self.get_relations, latest=True
223+
label="headers", generator=self.get_headers, latest=not sections
208224
)
225+
for index, (label, generator) in enumerate(sections):
226+
yield from self.serialize(
227+
label=label, generator=generator, latest=index == len(sections) - 1
228+
)
229+
209230
yield "}"
210231

211232
def serialize(self, label, generator, latest=False):
@@ -257,7 +278,9 @@ def get_headers(self, project):
257278

258279
def encode_queryset(self, project, model_name, serializer):
259280
queryset = get_queryset(project, model_name)
260-
for obj in queryset.iterator(chunk_size=2000):
281+
# A larger chunk_size reduces how often prefetch_related() re-runs its
282+
# queries, since iterator() re-executes prefetching once per chunk.
283+
for obj in queryset.iterator(chunk_size=10000):
261284
yield self.encode(serializer(obj).data)
262285

263286
def get_packages(self, project):
@@ -289,13 +312,15 @@ def get_relations(self, project):
289312
)
290313

291314

292-
def to_json(project):
315+
def to_json(project, sections=None):
293316
"""
294317
Generate output for the provided `project` in JSON format.
295318
The output file is created in the `project` output/ directory.
296319
Return the path of the generated output file.
320+
`sections` is an optional iterable restricting which of the
321+
packages/dependencies/files/relations arrays are included.
297322
"""
298-
results_generator = JSONResultsGenerator(project)
323+
results_generator = JSONResultsGenerator(project, sections=sections)
299324
output_file = project.get_output_file_path("results", "json")
300325

301326
with output_file.open("w") as file:

scanpipe/templates/scanpipe/dropdowns/project_download_dropdown.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<span class="dropdown-item">
1010
Download results as:
1111
</span>
12-
<a href="{% url 'project_results' project.slug 'json' %}" class="dropdown-item">
12+
<a href="#" class="dropdown-item modal-button" data-target="modal-json-download-{{ project.slug }}">
1313
<strong>JSON</strong>
1414
</a>
1515
<a href="{% url 'project_results' project.slug 'xlsx' %}" class="dropdown-item">
@@ -46,4 +46,5 @@
4646
</a>
4747
</div>
4848
</div>
49-
</div>
49+
</div>
50+
{% include "scanpipe/modals/project_json_download_modal.html" with project=project only %}

scanpipe/templates/scanpipe/includes/project_downloads.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div class="message-body p-3">
33
<span class="icon"><i class="fa-solid fa-download"></i></span>
44
Download results:
5-
<a class="tag is-success is-medium ml-2" href="{% url 'project_results' project.slug 'json' %}">
5+
<a href="#" class="tag is-success is-medium ml-2 modal-button" data-target="modal-json-download-{{ project.slug }}" aria-haspopup="true">
66
JSON
77
</a>
88
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'xlsx' %}">
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{% load humanize %}
2+
<div class="modal" id="modal-json-download-{{ project.slug }}">
3+
<div class="modal-background"></div>
4+
<div class="modal-card">
5+
<header class="modal-card-head">
6+
<p class="modal-card-title">
7+
<span class="icon-text">
8+
<span class="icon has-text-success"><i class="fa-solid fa-download"></i></span>
9+
<span>Download JSON results</span>
10+
</span>
11+
</p>
12+
<button class="delete" aria-label="close"></button>
13+
</header>
14+
<form action="{% url 'project_results' project.slug 'json' %}" method="get">
15+
<section class="modal-card-body">
16+
<p class="has-text-grey mb-4">Select the sections to include in the JSON output.</p>
17+
<label class="json-section-option box has-border-radius p-3 mb-2 is-flex is-align-items-center">
18+
<span class="icon has-text-success is-size-4 mr-3"><i class="fa-solid fa-box"></i></span>
19+
<span class="has-text-weight-semibold is-flex-grow-1">Packages</span>
20+
<span class="tag is-light has-text-weight-bold mr-2">{{ project.package_count|intcomma }}</span>
21+
<input type="checkbox" class="width-1 height-1" name="sections" value="packages" checked>
22+
</label>
23+
<label class="json-section-option box has-border-radius p-3 mb-2 is-flex is-align-items-center">
24+
<span class="icon has-text-success is-size-4 mr-3"><i class="fa-solid fa-layer-group"></i></span>
25+
<span class="has-text-weight-semibold is-flex-grow-1">Dependencies</span>
26+
<span class="tag is-light has-text-weight-bold mr-2">{{ project.dependency_count|intcomma }}</span>
27+
<input type="checkbox" class="width-1 height-1" name="sections" value="dependencies" checked>
28+
</label>
29+
<label class="json-section-option box has-border-radius p-3 mb-2 is-flex is-align-items-center">
30+
<span class="icon has-text-success is-size-4 mr-3"><i class="fa-solid fa-folder-open"></i></span>
31+
<span class="has-text-weight-semibold is-flex-grow-1">Resources</span>
32+
<span class="tag is-light has-text-weight-bold mr-2">{{ project.resource_count|intcomma }}</span>
33+
<input type="checkbox" class="width-1 height-1" name="sections" value="files" checked>
34+
</label>
35+
{% if project.relation_count %}
36+
<label class="json-section-option box has-border-radius p-3 mb-0 is-flex is-align-items-center">
37+
<span class="icon has-text-success is-size-4 mr-3"><i class="fa-solid fa-link"></i></span>
38+
<span class="has-text-weight-semibold is-flex-grow-1">Relations</span>
39+
<span class="tag is-light has-text-weight-bold mr-2">{{ project.relation_count|intcomma }}</span>
40+
<input type="checkbox" class="width-1 height-1" name="sections" value="relations" checked>
41+
</label>
42+
{% endif %}
43+
</section>
44+
<footer class="modal-card-foot is-justify-content-flex-end">
45+
<div class="buttons">
46+
<button class="button has-text-weight-semibold" type="reset">Cancel</button>
47+
<button class="button is-success" type="submit">
48+
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>
49+
Download
50+
</button>
51+
</div>
52+
</footer>
53+
</form>
54+
</div>
55+
</div>

scanpipe/templates/scanpipe/project_detail.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
{% include 'scanpipe/modals/clone_modal.html' %}
206206
{% include "scanpipe/modals/add_labels_modal.html" %}
207207
{% include "scanpipe/modals/edit_input_tag_modal.html" %}
208+
{% include "scanpipe/modals/project_json_download_modal.html" %}
208209
{% endblock %}
209210

210211
{% block scripts %}

scanpipe/tests/pipes/test_output.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,22 @@ def test_scanpipe_pipes_outputs_to_json(self):
208208
output_file = output.to_json(project=project)
209209
self.assertIn(output_file.name, project.output_root)
210210

211+
def test_scanpipe_pipes_outputs_to_json_with_sections(self):
212+
fixtures = self.data / "asgiref" / "asgiref-3.3.0_fixtures.json"
213+
call_command("loaddata", fixtures, **{"verbosity": 0})
214+
project = Project.objects.get(name="asgiref")
215+
216+
output_file = output.to_json(project=project, sections=["packages"])
217+
with output_file.open() as f:
218+
results = json.loads(f.read())
219+
self.assertEqual(["headers", "packages"], sorted(results.keys()))
220+
self.assertEqual(2, len(results["packages"]))
221+
222+
output_file = output.to_json(project=project, sections=[])
223+
with output_file.open() as f:
224+
results = json.loads(f.read())
225+
self.assertEqual(["headers"], sorted(results.keys()))
226+
211227
def test_scanpipe_pipes_outputs_to_xlsx(self):
212228
fixtures = self.data / "asgiref" / "asgiref-3.3.0_fixtures.json"
213229
call_command("loaddata", fixtures, **{"verbosity": 0})

0 commit comments

Comments
 (0)