Skip to content

Commit ba044b3

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

File tree

5 files changed

+74
-26
lines changed

5 files changed

+74
-26
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: 56 additions & 16 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,47 @@ 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+
ireq_json = {
188+
"name": ireq.name,
189+
"version": str(ireq.specifier).lstrip("=="),
190+
"requirement": str(ireq.req),
191+
"via": _comes_from_as_string(ireq.comes_from),
192+
"line": unstyle(line),
193+
}
194+
if hashes:
195+
ireq_hashes = hashes.get(ireq)
196+
if ireq_hashes:
197+
assert isinstance(ireq_hashes, set)
198+
ireq_json["hashes"] = list(ireq_hashes)
199+
if ireq.link:
200+
if ireq.link.is_vcs or (ireq.link.is_file and ireq.link.is_existing_dir()):
201+
ireq_json["hashable"] = "false"
202+
else:
203+
ireq_json["hashable"] = "true"
204+
if unsafe:
205+
ireq_json["unsafe"] = "true"
206+
if ireq.markers:
207+
ireq_json["markers"] = str(ireq.markers)
208+
if ireq.editable:
209+
ireq_json["editable"] = "true"
210+
return ireq_json
211+
212+
def _iter_ireqs(
177213
self,
178214
results: set[InstallRequirement],
179215
unsafe_requirements: set[InstallRequirement],
180216
unsafe_packages: set[str],
181217
markers: dict[str, Marker],
182218
hashes: dict[InstallRequirement, set[str]] | None = None,
183-
) -> Iterator[str]:
219+
) -> Iterator[str, dict[str, str]]:
184220
# default values
185221
unsafe_packages = unsafe_packages if self.allow_unsafe else set()
186222
hashes = hashes or {}
@@ -191,12 +227,11 @@ def _iter_lines(
191227
has_hashes = hashes and any(hash for hash in hashes.values())
192228

193229
yielded = False
194-
195230
for line in self.write_header():
196-
yield line
231+
yield line, {}
197232
yielded = True
198233
for line in self.write_flags():
199-
yield line
234+
yield line, {}
200235
yielded = True
201236

202237
unsafe_requirements = unsafe_requirements or {
@@ -207,36 +242,36 @@ def _iter_lines(
207242
if packages:
208243
for ireq in sorted(packages, key=self._sort_key):
209244
if has_hashes and not hashes.get(ireq):
210-
yield MESSAGE_UNHASHED_PACKAGE
245+
yield MESSAGE_UNHASHED_PACKAGE, {}
211246
warn_uninstallable = True
212247
line = self._format_requirement(
213248
ireq, markers.get(key_from_ireq(ireq)), hashes=hashes
214249
)
215-
yield line
250+
yield line, self._get_json(ireq, line, hashes=hashes)
216251
yielded = True
217252

218253
if unsafe_requirements:
219-
yield ""
254+
yield "", {}
220255
yielded = True
221256
if has_hashes and not self.allow_unsafe:
222-
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED
257+
yield MESSAGE_UNSAFE_PACKAGES_UNPINNED, {}
223258
warn_uninstallable = True
224259
else:
225-
yield MESSAGE_UNSAFE_PACKAGES
260+
yield MESSAGE_UNSAFE_PACKAGES, {}
226261

227262
for ireq in sorted(unsafe_requirements, key=self._sort_key):
228263
ireq_key = key_from_ireq(ireq)
229264
if not self.allow_unsafe:
230-
yield comment(f"# {ireq_key}")
265+
yield comment(f"# {ireq_key}"), {}
231266
else:
232267
line = self._format_requirement(
233268
ireq, marker=markers.get(ireq_key), hashes=hashes
234269
)
235-
yield line
270+
yield line, self._get_json(ireq, line, unsafe=True)
236271

237272
# Yield even when there's no real content, so that blank files are written
238273
if not yielded:
239-
yield ""
274+
yield "", {}
240275

241276
if warn_uninstallable:
242277
log.warning(MESSAGE_UNINSTALLABLE)
@@ -249,15 +284,16 @@ def write(
249284
markers: dict[str, Marker],
250285
hashes: dict[InstallRequirement, set[str]] | None,
251286
) -> None:
252-
if not self.dry_run:
287+
output_structure = []
288+
if not self.dry_run or self.json_output:
253289
dst_file = io.TextIOWrapper(
254290
self.dst_file,
255291
encoding="utf8",
256292
newline=self.linesep,
257293
line_buffering=True,
258294
)
259295
try:
260-
for line in self._iter_lines(
296+
for line, ireq in self._iter_ireqs(
261297
results, unsafe_requirements, unsafe_packages, markers, hashes
262298
):
263299
if self.dry_run:
@@ -267,9 +303,13 @@ def write(
267303
log.info(line)
268304
dst_file.write(unstyle(line))
269305
dst_file.write("\n")
306+
if self.json_output and ireq:
307+
output_structure.append(ireq)
270308
finally:
271-
if not self.dry_run:
309+
if not self.dry_run or self.json_output:
272310
dst_file.detach()
311+
if self.json_output:
312+
print(json.dumps(output_structure, indent=4))
273313

274314
def _format_requirement(
275315
self,

tests/test_writer.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ def test_format_requirement_environment_marker(from_line, writer):
108108

109109

110110
@pytest.mark.parametrize("allow_unsafe", ((True,), (False,)))
111-
def test_iter_lines__unsafe_dependencies(writer, from_line, allow_unsafe):
111+
def test_iter_ireqs__unsafe_dependencies(writer, from_line, allow_unsafe):
112112
writer.allow_unsafe = allow_unsafe
113113
writer.emit_header = False
114114

115-
lines = writer._iter_lines(
115+
lines = writer._iter_ireqs(
116116
{from_line("test==1.2")},
117117
{from_line("setuptools==1.10.0")},
118118
unsafe_packages=set(),
@@ -128,14 +128,14 @@ def test_iter_lines__unsafe_dependencies(writer, from_line, allow_unsafe):
128128
assert tuple(lines) == expected_lines
129129

130130

131-
def test_iter_lines__unsafe_with_hashes(capsys, writer, from_line):
131+
def test_iter_ireqs__unsafe_with_hashes(capsys, writer, from_line):
132132
writer.allow_unsafe = False
133133
writer.emit_header = False
134134
ireqs = [from_line("test==1.2")]
135135
unsafe_ireqs = [from_line("setuptools==1.10.0")]
136136
hashes = {ireqs[0]: {"FAKEHASH"}, unsafe_ireqs[0]: set()}
137137

138-
lines = writer._iter_lines(
138+
lines = writer._iter_ireqs(
139139
ireqs, unsafe_ireqs, unsafe_packages=set(), markers={}, hashes=hashes
140140
)
141141

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

153153

154-
def test_iter_lines__hash_missing(capsys, writer, from_line):
154+
def test_iter_ireqs__hash_missing(capsys, writer, from_line):
155155
writer.allow_unsafe = False
156156
writer.emit_header = False
157157
ireqs = [from_line("test==1.2"), from_line("file:///example/#egg=example")]
158158
hashes = {ireqs[0]: {"FAKEHASH"}, ireqs[1]: set()}
159159

160-
lines = writer._iter_lines(
160+
lines = writer._iter_ireqs(
161161
ireqs,
162162
hashes=hashes,
163163
unsafe_requirements=set(),
@@ -176,7 +176,7 @@ def test_iter_lines__hash_missing(capsys, writer, from_line):
176176
assert captured.err.strip() == MESSAGE_UNINSTALLABLE
177177

178178

179-
def test_iter_lines__no_warn_if_only_unhashable_packages(writer, from_line):
179+
def test_iter_ireqs__no_warn_if_only_unhashable_packages(writer, from_line):
180180
"""
181181
There shouldn't be MESSAGE_UNHASHED_PACKAGE warning if there are only unhashable
182182
packages. See GH-1101.
@@ -189,7 +189,7 @@ def test_iter_lines__no_warn_if_only_unhashable_packages(writer, from_line):
189189
]
190190
hashes = {ireq: set() for ireq in ireqs}
191191

192-
lines = writer._iter_lines(
192+
lines = writer._iter_ireqs(
193193
ireqs,
194194
hashes=hashes,
195195
unsafe_requirements=set(),
@@ -418,7 +418,7 @@ def test_write_order(writer, from_line):
418418
"package-b==2.3.4",
419419
"package2==7.8.9",
420420
]
421-
result = writer._iter_lines(
421+
result = writer._iter_ireqs(
422422
packages, unsafe_requirements=set(), unsafe_packages=set(), markers={}
423423
)
424424
assert list(result) == expected_lines

0 commit comments

Comments
 (0)