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