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
@@ -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 ,
0 commit comments