Skip to content

Commit 0847559

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

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
@@ -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: 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
@@ -173,14 +176,61 @@ 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+
output_hashes = []
188+
if hashes:
189+
ireq_hashes = hashes.get(ireq)
190+
if ireq_hashes:
191+
assert isinstance(ireq_hashes, set)
192+
output_hashes = list(ireq_hashes)
193+
hashable = True
194+
if ireq.link:
195+
if ireq.link.is_vcs or (ireq.link.is_file and ireq.link.is_existing_dir()):
196+
hashable = False
197+
markers = ""
198+
if ireq.markers:
199+
markers = str(ireq.markers)
200+
# Retrieve parent requirements from constructed line
201+
splitted_line = [m.strip() for m in unstyle(line).split("#")]
202+
try:
203+
via = splitted_line[splitted_line.index("via") + 1 :]
204+
except ValueError:
205+
via = [splitted_line[-1][len("via ") :]]
206+
if via[0].startswith("-r"):
207+
req_files = re.split(r"\s|,", via[0])
208+
del req_files[0]
209+
via = ["-r"]
210+
for req_file in req_files:
211+
via.append(os.path.abspath(req_file))
212+
ireq_json = {
213+
"name": ireq.name,
214+
"version": str(ireq.specifier).lstrip("=="),
215+
"requirement": str(ireq.req),
216+
"via": via,
217+
"line": unstyle(line),
218+
"hashable": hashable,
219+
"editable": ireq.editable,
220+
"hashes": output_hashes,
221+
"markers": markers,
222+
"unsafe": unsafe,
223+
}
224+
return ireq_json
225+
226+
def _iter_ireqs(
177227
self,
178228
results: set[InstallRequirement],
179229
unsafe_requirements: set[InstallRequirement],
180230
unsafe_packages: set[str],
181231
markers: dict[str, Marker],
182232
hashes: dict[InstallRequirement, set[str]] | None = None,
183-
) -> Iterator[str]:
233+
) -> Iterator[str, dict[str, str]]:
184234
# default values
185235
unsafe_packages = unsafe_packages if self.allow_unsafe else set()
186236
hashes = hashes or {}
@@ -191,12 +241,11 @@ def _iter_lines(
191241
has_hashes = hashes and any(hash for hash in hashes.values())
192242

193243
yielded = False
194-
195244
for line in self.write_header():
196-
yield line
245+
yield line, {}
197246
yielded = True
198247
for line in self.write_flags():
199-
yield line
248+
yield line, {}
200249
yielded = True
201250

202251
unsafe_requirements = unsafe_requirements or {
@@ -207,36 +256,36 @@ def _iter_lines(
207256
if packages:
208257
for ireq in sorted(packages, key=self._sort_key):
209258
if has_hashes and not hashes.get(ireq):
210-
yield MESSAGE_UNHASHED_PACKAGE
259+
yield MESSAGE_UNHASHED_PACKAGE, {}
211260
warn_uninstallable = True
212261
line = self._format_requirement(
213262
ireq, markers.get(key_from_ireq(ireq)), hashes=hashes
214263
)
215-
yield line
264+
yield line, self._get_json(ireq, line, hashes=hashes)
216265
yielded = True
217266

218267
if unsafe_requirements:
219-
yield ""
268+
yield "", {}
220269
yielded = True
221270
if has_hashes and not self.allow_unsafe:
222-
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED
271+
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED, {}
223272
warn_uninstallable = True
224273
else:
225-
yield MESSAGE_UNSAFE_PACKAGES
274+
yield MESSAGE_UNSAFE_PACKAGES, {}
226275

227276
for ireq in sorted(unsafe_requirements, key=self._sort_key):
228277
ireq_key = key_from_ireq(ireq)
229278
if not self.allow_unsafe:
230-
yield comment(f"# {ireq_key}")
279+
yield comment(f"# {ireq_key}"), {}
231280
else:
232281
line = self._format_requirement(
233282
ireq, marker=markers.get(ireq_key), hashes=hashes
234283
)
235-
yield line
284+
yield line, self._get_json(ireq, line, unsafe=True)
236285

237286
# Yield even when there's no real content, so that blank files are written
238287
if not yielded:
239-
yield ""
288+
yield "", {}
240289

241290
if warn_uninstallable:
242291
log.warning(MESSAGE_UNINSTALLABLE)
@@ -249,27 +298,33 @@ def write(
249298
markers: dict[str, Marker],
250299
hashes: dict[InstallRequirement, set[str]] | None,
251300
) -> None:
252-
if not self.dry_run:
301+
output_structure = []
302+
if not self.dry_run or self.json_output:
253303
dst_file = io.TextIOWrapper(
254304
self.dst_file,
255305
encoding="utf8",
256306
newline=self.linesep,
257307
line_buffering=True,
258308
)
259309
try:
260-
for line in self._iter_lines(
310+
for line, ireq in self._iter_ireqs(
261311
results, unsafe_requirements, unsafe_packages, markers, hashes
262312
):
263313
if self.dry_run:
264314
# Bypass the log level to always print this during a dry run
265315
log.log(line)
266316
else:
267-
log.info(line)
317+
if not self.json_output:
318+
log.info(line)
268319
dst_file.write(unstyle(line))
269320
dst_file.write("\n")
321+
if self.json_output and ireq:
322+
output_structure.append(ireq)
270323
finally:
271-
if not self.dry_run:
324+
if not self.dry_run or self.json_output:
272325
dst_file.detach()
326+
if self.json_output:
327+
print(json.dumps(output_structure, indent=4))
273328

274329
def _format_requirement(
275330
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)