11from __future__ import annotations
22
33import io
4+ import json
45import os
56import re
67import 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 ,
0 commit comments