Skip to content

Commit b9c0170

Browse files
chryslewebknjaz
authored andcommitted
Add support for JSON output format
1 parent e604dec commit b9c0170

File tree

5 files changed

+91
-27
lines changed

5 files changed

+91
-27
lines changed

piptools/scripts/compile.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def _determine_linesep(
8282
@options.color
8383
@options.verbose
8484
@options.quiet
85+
@options.json
8586
@options.dry_run
8687
@options.pre
8788
@options.rebuild
@@ -127,6 +128,7 @@ def cli(
127128
color: bool | None,
128129
verbose: int,
129130
quiet: int,
131+
json: bool,
130132
dry_run: bool,
131133
pre: bool,
132134
rebuild: bool,
@@ -514,6 +516,7 @@ def cli(
514516
cast(BinaryIO, output_file),
515517
click_ctx=ctx,
516518
dry_run=dry_run,
519+
json_output=json,
517520
emit_header=header,
518521
emit_index_url=emit_index_url,
519522
emit_trusted_host=emit_trusted_host,

piptools/scripts/options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def _get_default_option(option_name: str) -> Any:
5252
help="Give less output",
5353
)
5454

55+
json = click.option(
56+
"-j", "--json", is_flag=True, default=False, help="Emit JSON output"
57+
)
58+
5559
dry_run = click.option(
5660
"-n",
5761
"--dry-run",

piptools/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"--cache-dir",
5959
"--no-reuse-hashes",
6060
"--no-config",
61+
"--json",
6162
}
6263

6364
# Set of option that are only negative, i.e. --no-<option>
@@ -352,7 +353,7 @@ def get_compile_command(click_ctx: click.Context) -> str:
352353
- removing values that are already default
353354
- sorting the arguments
354355
- removing one-off arguments like '--upgrade'
355-
- removing arguments that don't change build behaviour like '--verbose'
356+
- removing arguments that don't change build behaviour like '--verbose' or '--json'
356357
"""
357358
from piptools.scripts.compile import cli
358359

piptools/writer.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import io
4+
import json
45
import os
56
import re
67
import sys
@@ -79,6 +80,7 @@ def __init__(
7980
dst_file: BinaryIO,
8081
click_ctx: Context,
8182
dry_run: bool,
83+
json_output: bool,
8284
emit_header: bool,
8385
emit_index_url: bool,
8486
emit_trusted_host: bool,
@@ -99,6 +101,7 @@ def __init__(
99101
self.dst_file = dst_file
100102
self.click_ctx = click_ctx
101103
self.dry_run = dry_run
104+
self.json_output = json_output
102105
self.emit_header = emit_header
103106
self.emit_index_url = emit_index_url
104107
self.emit_trusted_host = emit_trusted_host
@@ -191,14 +194,61 @@ def write_flags(self) -> Iterator[str]:
191194
if emitted:
192195
yield ""
193196

194-
def _iter_lines(
197+
def _get_json(
198+
self,
199+
ireq: InstallRequirement,
200+
line: str,
201+
hashes: dict[InstallRequirement, set[str]] | None = None,
202+
unsafe: bool = False,
203+
) -> dict[str, str]:
204+
"""Get a JSON representation for an ``InstallRequirement``."""
205+
output_hashes = []
206+
if hashes:
207+
ireq_hashes = hashes.get(ireq)
208+
if ireq_hashes:
209+
assert isinstance(ireq_hashes, set)
210+
output_hashes = list(ireq_hashes)
211+
hashable = True
212+
if ireq.link:
213+
if ireq.link.is_vcs or (ireq.link.is_file and ireq.link.is_existing_dir()):
214+
hashable = False
215+
markers = ""
216+
if ireq.markers:
217+
markers = str(ireq.markers)
218+
# Retrieve parent requirements from constructed line
219+
splitted_line = [m.strip() for m in unstyle(line).split("#")]
220+
try:
221+
via = splitted_line[splitted_line.index("via") + 1 :]
222+
except ValueError:
223+
via = [splitted_line[-1][len("via ") :]]
224+
if via[0].startswith("-r"):
225+
req_files = re.split(r"\s|,", via[0])
226+
del req_files[0]
227+
via = ["-r"]
228+
for req_file in req_files:
229+
via.append(os.path.abspath(req_file))
230+
ireq_json = {
231+
"name": ireq.name,
232+
"version": str(ireq.specifier).lstrip("=="),
233+
"requirement": str(ireq.req),
234+
"via": via,
235+
"line": unstyle(line),
236+
"hashable": hashable,
237+
"editable": ireq.editable,
238+
"hashes": output_hashes,
239+
"markers": markers,
240+
"unsafe": unsafe,
241+
}
242+
return ireq_json
243+
244+
def _iter_ireqs(
195245
self,
196246
results: set[InstallRequirement],
197247
unsafe_requirements: set[InstallRequirement],
198248
unsafe_packages: set[str],
199249
markers: dict[str, Marker],
200250
hashes: dict[InstallRequirement, set[str]] | None = None,
201-
) -> Iterator[str]:
251+
) -> Iterator[str, dict[str, str]]:
202252
# default values
203253
unsafe_packages = unsafe_packages if self.allow_unsafe else set()
204254
hashes = hashes or {}
@@ -209,12 +259,11 @@ def _iter_lines(
209259
has_hashes = hashes and any(hash for hash in hashes.values())
210260

211261
yielded = False
212-
213262
for line in self.write_header():
214-
yield line
263+
yield line, {}
215264
yielded = True
216265
for line in self.write_flags():
217-
yield line
266+
yield line, {}
218267
yielded = True
219268

220269
unsafe_requirements = unsafe_requirements or {
@@ -225,36 +274,36 @@ def _iter_lines(
225274
if packages:
226275
for ireq in sorted(packages, key=self._sort_key):
227276
if has_hashes and not hashes.get(ireq):
228-
yield MESSAGE_UNHASHED_PACKAGE
277+
yield MESSAGE_UNHASHED_PACKAGE, {}
229278
warn_uninstallable = True
230279
line = self._format_requirement(
231280
ireq, markers.get(key_from_ireq(ireq)), hashes=hashes
232281
)
233-
yield line
282+
yield line, self._get_json(ireq, line, hashes=hashes)
234283
yielded = True
235284

236285
if unsafe_requirements:
237-
yield ""
286+
yield "", {}
238287
yielded = True
239288
if has_hashes and not self.allow_unsafe:
240-
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED
289+
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED, {}
241290
warn_uninstallable = True
242291
else:
243-
yield MESSAGE_UNSAFE_PACKAGES
292+
yield MESSAGE_UNSAFE_PACKAGES, {}
244293

245294
for ireq in sorted(unsafe_requirements, key=self._sort_key):
246295
ireq_key = key_from_ireq(ireq)
247296
if not self.allow_unsafe:
248-
yield comment(f"# {ireq_key}")
297+
yield comment(f"# {ireq_key}"), {}
249298
else:
250299
line = self._format_requirement(
251300
ireq, marker=markers.get(ireq_key), hashes=hashes
252301
)
253-
yield line
302+
yield line, self._get_json(ireq, line, unsafe=True)
254303

255304
# Yield even when there's no real content, so that blank files are written
256305
if not yielded:
257-
yield ""
306+
yield "", {}
258307

259308
if warn_uninstallable:
260309
log.warning(MESSAGE_UNINSTALLABLE)
@@ -267,27 +316,33 @@ def write(
267316
markers: dict[str, Marker],
268317
hashes: dict[InstallRequirement, set[str]] | None,
269318
) -> None:
270-
if not self.dry_run:
319+
output_structure = []
320+
if not self.dry_run or self.json_output:
271321
dst_file = io.TextIOWrapper(
272322
self.dst_file,
273323
encoding="utf8",
274324
newline=self.linesep,
275325
line_buffering=True,
276326
)
277327
try:
278-
for line in self._iter_lines(
328+
for line, ireq in self._iter_ireqs(
279329
results, unsafe_requirements, unsafe_packages, markers, hashes
280330
):
281331
if self.dry_run:
282332
# Bypass the log level to always print this during a dry run
283333
log.log(line)
284334
else:
285-
log.info(line)
335+
if not self.json_output:
336+
log.info(line)
286337
dst_file.write(unstyle(line))
287338
dst_file.write("\n")
339+
if self.json_output and ireq:
340+
output_structure.append(ireq)
288341
finally:
289-
if not self.dry_run:
342+
if not self.dry_run or self.json_output:
290343
dst_file.detach()
344+
if self.json_output:
345+
print(json.dumps(output_structure, indent=4))
291346

292347
def _format_requirement(
293348
self,

tests/test_writer.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def writer(tmpdir_cwd):
3434
dst_file=ctx.params["output_file"],
3535
click_ctx=ctx,
3636
dry_run=True,
37+
json_output=False,
3738
emit_header=True,
3839
emit_index_url=True,
3940
emit_trusted_host=True,
@@ -108,11 +109,11 @@ def test_format_requirement_environment_marker(from_line, writer):
108109

109110

110111
@pytest.mark.parametrize("allow_unsafe", ((True,), (False,)))
111-
def test_iter_lines__unsafe_dependencies(writer, from_line, allow_unsafe):
112+
def test_iter_ireqs__unsafe_dependencies(writer, from_line, allow_unsafe):
112113
writer.allow_unsafe = allow_unsafe
113114
writer.emit_header = False
114115

115-
lines = writer._iter_lines(
116+
lines = writer._iter_ireqs(
116117
{from_line("test==1.2")},
117118
{from_line("setuptools==1.10.0")},
118119
unsafe_packages=set(),
@@ -128,14 +129,14 @@ def test_iter_lines__unsafe_dependencies(writer, from_line, allow_unsafe):
128129
assert tuple(lines) == expected_lines
129130

130131

131-
def test_iter_lines__unsafe_with_hashes(capsys, writer, from_line):
132+
def test_iter_ireqs__unsafe_with_hashes(capsys, writer, from_line):
132133
writer.allow_unsafe = False
133134
writer.emit_header = False
134135
ireqs = [from_line("test==1.2")]
135136
unsafe_ireqs = [from_line("setuptools==1.10.0")]
136137
hashes = {ireqs[0]: {"FAKEHASH"}, unsafe_ireqs[0]: set()}
137138

138-
lines = writer._iter_lines(
139+
lines = writer._iter_ireqs(
139140
ireqs, unsafe_ireqs, unsafe_packages=set(), markers={}, hashes=hashes
140141
)
141142

@@ -151,13 +152,13 @@ def test_iter_lines__unsafe_with_hashes(capsys, writer, from_line):
151152
assert captured.err.strip() == MESSAGE_UNINSTALLABLE
152153

153154

154-
def test_iter_lines__hash_missing(capsys, writer, from_line):
155+
def test_iter_ireqs__hash_missing(capsys, writer, from_line):
155156
writer.allow_unsafe = False
156157
writer.emit_header = False
157158
ireqs = [from_line("test==1.2"), from_line("file:///example/#egg=example")]
158159
hashes = {ireqs[0]: {"FAKEHASH"}, ireqs[1]: set()}
159160

160-
lines = writer._iter_lines(
161+
lines = writer._iter_ireqs(
161162
ireqs,
162163
hashes=hashes,
163164
unsafe_requirements=set(),
@@ -176,7 +177,7 @@ def test_iter_lines__hash_missing(capsys, writer, from_line):
176177
assert captured.err.strip() == MESSAGE_UNINSTALLABLE
177178

178179

179-
def test_iter_lines__no_warn_if_only_unhashable_packages(writer, from_line):
180+
def test_iter_ireqs__no_warn_if_only_unhashable_packages(writer, from_line):
180181
"""
181182
There shouldn't be MESSAGE_UNHASHED_PACKAGE warning if there are only unhashable
182183
packages. See GH-1101.
@@ -189,7 +190,7 @@ def test_iter_lines__no_warn_if_only_unhashable_packages(writer, from_line):
189190
]
190191
hashes = {ireq: set() for ireq in ireqs}
191192

192-
lines = writer._iter_lines(
193+
lines = writer._iter_ireqs(
193194
ireqs,
194195
hashes=hashes,
195196
unsafe_requirements=set(),
@@ -453,7 +454,7 @@ def test_write_order(writer, from_line):
453454
"package-b==2.3.4",
454455
"package2==7.8.9",
455456
]
456-
result = writer._iter_lines(
457+
result = writer._iter_ireqs(
457458
packages, unsafe_requirements=set(), unsafe_packages=set(), markers={}
458459
)
459460
assert list(result) == expected_lines

0 commit comments

Comments
 (0)