Skip to content

Commit 422b261

Browse files
committed
Add support for JSON output format
1 parent 1f00154 commit 422b261

File tree

5 files changed

+92
-27
lines changed

5 files changed

+92
-27
lines changed

piptools/scripts/compile.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def _determine_linesep(
7777
@options.color
7878
@options.verbose
7979
@options.quiet
80+
@options.json
8081
@options.dry_run
8182
@options.pre
8283
@options.rebuild
@@ -122,6 +123,7 @@ def cli(
122123
color: bool | None,
123124
verbose: int,
124125
quiet: int,
126+
json: bool,
125127
dry_run: bool,
126128
pre: bool,
127129
rebuild: bool,
@@ -506,6 +508,7 @@ def cli(
506508
cast(BinaryIO, output_file),
507509
click_ctx=ctx,
508510
dry_run=dry_run,
511+
json_output=json,
509512
emit_header=header,
510513
emit_index_url=emit_index_url,
511514
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
@@ -53,6 +53,7 @@
5353
"--cache-dir",
5454
"--no-reuse-hashes",
5555
"--no-config",
56+
"--json",
5657
}
5758

5859
# Set of option that are only negative, i.e. --no-<option>
@@ -343,7 +344,7 @@ def get_compile_command(click_ctx: click.Context) -> str:
343344
- removing values that are already default
344345
- sorting the arguments
345346
- removing one-off arguments like '--upgrade'
346-
- removing arguments that don't change build behaviour like '--verbose'
347+
- removing arguments that don't change build behaviour like '--verbose' or '--json'
347348
"""
348349
from piptools.scripts.compile import cli
349350

piptools/writer.py

Lines changed: 73 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
@@ -173,14 +176,62 @@ def write_flags(self) -> Iterator[str]:
173176
if emitted:
174177
yield ""
175178

176-
def _iter_lines(
179+
def _get_json(
180+
self,
181+
ireq: InstallRequirement,
182+
line: str,
183+
hashes: dict[InstallRequirement, set[str]] | None = None,
184+
unsafe: bool = False,
185+
) -> dict[str, str]:
186+
"""Get a JSON representation for an ``InstallRequirement``."""
187+
if hashes:
188+
ireq_hashes = hashes.get(ireq)
189+
if ireq_hashes:
190+
assert isinstance(ireq_hashes, set)
191+
output_hashes = list(ireq_hashes)
192+
else:
193+
output_hashes = []
194+
hashable = True
195+
if ireq.link:
196+
if ireq.link.is_vcs or (ireq.link.is_file and ireq.link.is_existing_dir()):
197+
hashable = False
198+
markers = ""
199+
if ireq.markers:
200+
markers = str(ireq.markers)
201+
# Retrieve parent requirements from constructed line
202+
splitted_line = [m.strip() for m in unstyle(line).split("#")]
203+
try:
204+
via = splitted_line[splitted_line.index("via") + 1 :]
205+
except ValueError:
206+
via = [splitted_line[-1][len("via ") :]]
207+
if via[0].startswith("-r"):
208+
req_files = re.split(r"\s|,", via[0])
209+
del req_files[0]
210+
via = ["-r"]
211+
for req_file in req_files:
212+
via.append(os.path.abspath(req_file))
213+
ireq_json = {
214+
"name": ireq.name,
215+
"version": str(ireq.specifier).lstrip("=="),
216+
"requirement": str(ireq.req),
217+
"via": via,
218+
"line": unstyle(line),
219+
"hashable": hashable,
220+
"editable": ireq.editable,
221+
"hashes": output_hashes,
222+
"markers": markers,
223+
"unsafe": unsafe,
224+
}
225+
return ireq_json
226+
227+
def _iter_ireqs(
177228
self,
178229
results: set[InstallRequirement],
179230
unsafe_requirements: set[InstallRequirement],
180231
unsafe_packages: set[str],
181232
markers: dict[str, Marker],
182233
hashes: dict[InstallRequirement, set[str]] | None = None,
183-
) -> Iterator[str]:
234+
) -> Iterator[str, dict[str, str]]:
184235
# default values
185236
unsafe_packages = unsafe_packages if self.allow_unsafe else set()
186237
hashes = hashes or {}
@@ -191,12 +242,11 @@ def _iter_lines(
191242
has_hashes = hashes and any(hash for hash in hashes.values())
192243

193244
yielded = False
194-
195245
for line in self.write_header():
196-
yield line
246+
yield line, {}
197247
yielded = True
198248
for line in self.write_flags():
199-
yield line
249+
yield line, {}
200250
yielded = True
201251

202252
unsafe_requirements = unsafe_requirements or {
@@ -207,36 +257,36 @@ def _iter_lines(
207257
if packages:
208258
for ireq in sorted(packages, key=self._sort_key):
209259
if has_hashes and not hashes.get(ireq):
210-
yield MESSAGE_UNHASHED_PACKAGE
260+
yield MESSAGE_UNHASHED_PACKAGE, {}
211261
warn_uninstallable = True
212262
line = self._format_requirement(
213263
ireq, markers.get(key_from_ireq(ireq)), hashes=hashes
214264
)
215-
yield line
265+
yield line, self._get_json(ireq, line, hashes=hashes)
216266
yielded = True
217267

218268
if unsafe_requirements:
219-
yield ""
269+
yield "", {}
220270
yielded = True
221271
if has_hashes and not self.allow_unsafe:
222-
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED
272+
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED, {}
223273
warn_uninstallable = True
224274
else:
225-
yield MESSAGE_UNSAFE_PACKAGES
275+
yield MESSAGE_UNSAFE_PACKAGES, {}
226276

227277
for ireq in sorted(unsafe_requirements, key=self._sort_key):
228278
ireq_key = key_from_ireq(ireq)
229279
if not self.allow_unsafe:
230-
yield comment(f"# {ireq_key}")
280+
yield comment(f"# {ireq_key}"), {}
231281
else:
232282
line = self._format_requirement(
233283
ireq, marker=markers.get(ireq_key), hashes=hashes
234284
)
235-
yield line
285+
yield line, self._get_json(ireq, line, unsafe=True)
236286

237287
# Yield even when there's no real content, so that blank files are written
238288
if not yielded:
239-
yield ""
289+
yield "", {}
240290

241291
if warn_uninstallable:
242292
log.warning(MESSAGE_UNINSTALLABLE)
@@ -249,27 +299,33 @@ def write(
249299
markers: dict[str, Marker],
250300
hashes: dict[InstallRequirement, set[str]] | None,
251301
) -> None:
252-
if not self.dry_run:
302+
output_structure = []
303+
if not self.dry_run or self.json_output:
253304
dst_file = io.TextIOWrapper(
254305
self.dst_file,
255306
encoding="utf8",
256307
newline=self.linesep,
257308
line_buffering=True,
258309
)
259310
try:
260-
for line in self._iter_lines(
311+
for line, ireq in self._iter_ireqs(
261312
results, unsafe_requirements, unsafe_packages, markers, hashes
262313
):
263314
if self.dry_run:
264315
# Bypass the log level to always print this during a dry run
265316
log.log(line)
266317
else:
267-
log.info(line)
318+
if not self.json_output:
319+
log.info(line)
268320
dst_file.write(unstyle(line))
269321
dst_file.write("\n")
322+
if self.json_output and ireq:
323+
output_structure.append(ireq)
270324
finally:
271-
if not self.dry_run:
325+
if not self.dry_run or self.json_output:
272326
dst_file.detach()
327+
if self.json_output:
328+
print(json.dumps(output_structure, indent=4))
273329

274330
def _format_requirement(
275331
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(),
@@ -418,7 +419,7 @@ def test_write_order(writer, from_line):
418419
"package-b==2.3.4",
419420
"package2==7.8.9",
420421
]
421-
result = writer._iter_lines(
422+
result = writer._iter_ireqs(
422423
packages, unsafe_requirements=set(), unsafe_packages=set(), markers={}
423424
)
424425
assert list(result) == expected_lines

0 commit comments

Comments
 (0)