1+ import csv
12import datetime
23import enum
4+ import json
35import logging
6+ import pathlib
47import sys
58import typing
9+ from collections import defaultdict
610
711import click
812import pypi_simple
13+ import rich
914from packaging .requirements import Requirement
1015from packaging .version import Version
1116from resolvelib .resolvers import ResolverException
12-
13- from .. import context , log , overrides , packagesettings , request_session , resolver
14- from ..candidate import Candidate
17+ from rich .table import Table
18+
19+ from .. import (
20+ clickext ,
21+ context ,
22+ log ,
23+ overrides ,
24+ packagesettings ,
25+ request_session ,
26+ resolver ,
27+ )
28+ from ..candidate import Candidate , Cooldown
1529
1630logger = logging .getLogger (__name__ )
1731
@@ -94,9 +108,29 @@ def package() -> None:
94108 help = "Do not treat missing versions as an error" ,
95109)
96110@click .option (
97- "--format-as-requirements/--no-format-as-requirements" ,
111+ "--format" ,
112+ "output_format" ,
113+ type = click .Choice (
114+ ["versions" , "requirements" , "table" , "csv" , "json" ],
115+ case_sensitive = False ,
116+ ),
117+ default = "versions" ,
118+ help = "Output format (default: versions)" ,
119+ )
120+ @click .option (
121+ "-o" ,
122+ "--output" ,
123+ type = clickext .ClickPath (),
124+ help = "Output file (default: stdout)" ,
125+ )
126+ @click .option (
127+ "--ignore-per-package-overrides" ,
128+ is_flag = True ,
98129 default = False ,
99- help = "Format output as requirement specifiers (name==version) instead of just version numbers" ,
130+ help = (
131+ "Ignore per-package min_release_age overrides when computing cooldown "
132+ "status; uses only the global --min-release-age value."
133+ ),
100134)
101135@click .argument ("requirement_spec" , required = True )
102136@click .pass_obj
@@ -106,7 +140,9 @@ def list_versions(
106140 distribution_type : str ,
107141 sdist_server_url : str ,
108142 ignore_no_versions : bool ,
109- format_as_requirements : bool ,
143+ output_format : str ,
144+ output : pathlib .Path | None ,
145+ ignore_per_package_overrides : bool ,
110146) -> None :
111147 """List all available versions for a package requirement specifier.
112148
@@ -123,7 +159,18 @@ def list_versions(
123159 - "sdist": Only include source distributions
124160 - "wheel": Only include wheels
125161 - "both": Include both source distributions and wheels
162+
163+ Output formats:
164+ - "versions": one version per line (default)
165+ - "requirements": name==version per line (pip-installable pins)
166+ - "table": Rich table with upload timestamps, age, and cooldown status
167+ - "csv": CSV with the same detail columns
168+ - "json": JSON array with the same detail columns
169+
170+ Use --ignore-per-package-overrides to see what the global cooldown
171+ policy would block without per-package exemptions.
126172 """
173+
127174 try :
128175 req = Requirement (requirement_spec )
129176 except Exception as e :
@@ -154,7 +201,8 @@ def list_versions(
154201 sdist_server_url = override_sdist_server_url ,
155202 )
156203
157- # Get all available candidates from the provider
204+ # Get all available candidates from the provider (cooldown is NOT set on
205+ # the provider so we receive every version that matches the specifier).
158206 candidates = list (
159207 provider .find_matches (
160208 identifier = req .name ,
@@ -170,14 +218,213 @@ def list_versions(
170218 else :
171219 raise click .ClickException (f"No versions found for { req .name } " )
172220
173- versions : list [Version ] = sorted (set (candidate .version for candidate in candidates ))
174- logger .info (f"Found { len (versions )} version(s)" )
221+ logger .info (f"Found { len (set (c .version for c in candidates ))} version(s)" )
222+
223+ cooldown = _resolve_list_versions_cooldown (wkctx , req , ignore_per_package_overrides )
224+ version_rows = _compute_version_details (
225+ req .name ,
226+ candidates ,
227+ cooldown ,
228+ provider .supports_upload_time ,
229+ )
230+
231+ if output is not None and output_format in ("versions" , "requirements" ):
232+ click .echo (
233+ "Warning: --output option is ignored for 'versions' and 'requirements' formats" ,
234+ err = True ,
235+ )
236+
237+ match output_format :
238+ case "versions" :
239+ _export_versions_plain (version_rows , req .name , cooldown )
240+ case "requirements" :
241+ _export_versions_plain (
242+ version_rows , req .name , cooldown , as_requirements = True
243+ )
244+ case "table" :
245+ _export_versions_table (version_rows , req .name , cooldown , output )
246+ case "csv" :
247+ _export_versions_csv (version_rows , output )
248+ case "json" :
249+ _export_versions_json (version_rows , output )
250+ case _:
251+ raise ValueError (f"Invalid output format: { output_format } " )
252+
253+
254+ def _resolve_list_versions_cooldown (
255+ wkctx : context .WorkContext ,
256+ req : Requirement ,
257+ ignore_per_package_overrides : bool ,
258+ ) -> Cooldown | None :
259+ """Determine the effective cooldown for the list-versions detail view.
260+
261+ When *ignore_per_package_overrides* is ``True``, only the global
262+ ``--min-release-age`` value is used so the caller can audit what the
263+ policy would block without per-package exemptions.
264+ """
265+ if ignore_per_package_overrides :
266+ return wkctx .cooldown
267+ return resolver .resolve_package_cooldown (wkctx , req )
268+
269+
270+ def _compute_version_details (
271+ package_name : str ,
272+ candidates : list [Candidate ],
273+ cooldown : Cooldown | None ,
274+ supports_upload_time : bool ,
275+ ) -> list [dict [str , str ]]:
276+ """Group candidates by version and compute cooldown status.
277+
278+ Returns one row per version, sorted ascending. Each row is a dict with
279+ keys: ``package``, ``version``, ``upload_time``, ``age_days``,
280+ ``cooldown``.
281+ """
282+ by_version : dict [Version , list [Candidate ]] = defaultdict (list )
283+ for c in candidates :
284+ by_version [c .version ].append (c )
285+
286+ reference_time = (
287+ cooldown .bootstrap_time
288+ if cooldown is not None
289+ else datetime .datetime .now (datetime .UTC )
290+ )
291+
292+ rows : list [dict [str , str ]] = []
293+ for version in sorted (by_version ):
294+ version_candidates = by_version [version ]
175295
176- for version in versions :
177- if format_as_requirements :
178- print (f"{ req .name } =={ version } " )
296+ upload_times = [
297+ c .upload_time for c in version_candidates if c .upload_time is not None
298+ ]
299+ upload_time = max (upload_times ) if upload_times else None
300+
301+ if upload_time is not None :
302+ age_days = (reference_time - upload_time ).days
179303 else :
180- print (version )
304+ age_days = None
305+
306+ status = _cooldown_status (upload_time , cooldown , supports_upload_time )
307+
308+ rows .append (
309+ {
310+ "package" : package_name ,
311+ "version" : str (version ),
312+ "upload_time" : upload_time .strftime ("%Y-%m-%d %H:%M" )
313+ if upload_time
314+ else "" ,
315+ "age_days" : str (age_days ) if age_days is not None else "" ,
316+ "cooldown" : status ,
317+ }
318+ )
319+ return rows
320+
321+
322+ def _cooldown_status (
323+ upload_time : datetime .datetime | None ,
324+ cooldown : Cooldown | None ,
325+ supports_upload_time : bool ,
326+ ) -> str :
327+ """Classify cooldown status for a single version.
328+
329+ Returns one of ``"blocked"``, ``"available"``, ``"skipped"``, or ``""``
330+ (no cooldown configured).
331+ """
332+ if cooldown is None :
333+ return ""
334+ if upload_time is None :
335+ if not supports_upload_time :
336+ return "skipped"
337+ return "blocked"
338+ cutoff = cooldown .bootstrap_time - cooldown .min_age
339+ if upload_time > cutoff :
340+ return "blocked"
341+ return "available"
342+
343+
344+ # -- export helpers for list-versions -------------------------------------------
345+
346+
347+ def _export_versions_plain (
348+ data : list [dict [str , str ]],
349+ package_name : str ,
350+ cooldown : Cooldown | None ,
351+ * ,
352+ as_requirements : bool = False ,
353+ ) -> None :
354+ """Export versions as a plain list, filtering out cooldown-blocked entries."""
355+ for row in data :
356+ if cooldown is not None and row ["cooldown" ] == "blocked" :
357+ continue
358+ if as_requirements :
359+ print (f"{ package_name } =={ row ['version' ]} " )
360+ else :
361+ print (row ["version" ])
362+
363+
364+ def _export_versions_json (
365+ data : list [dict [str , str ]], output : pathlib .Path | None
366+ ) -> None :
367+ """Export version details as JSON."""
368+ if output :
369+ with open (output , "w" ) as outfile :
370+ json .dump (data , outfile , indent = 2 )
371+ print (file = outfile )
372+ else :
373+ json .dump (data , sys .stdout , indent = 2 )
374+ print ()
375+
376+
377+ _VERSIONS_CSV_FIELDS = ["package" , "version" , "upload_time" , "age_days" , "cooldown" ]
378+
379+
380+ def _export_versions_csv (
381+ data : list [dict [str , str ]], output : pathlib .Path | None
382+ ) -> None :
383+ """Export version details as CSV."""
384+ if output :
385+ with open (output , "w" , newline = "" ) as outfile :
386+ writer = csv .DictWriter (
387+ outfile ,
388+ fieldnames = _VERSIONS_CSV_FIELDS ,
389+ quoting = csv .QUOTE_NONNUMERIC ,
390+ )
391+ writer .writeheader ()
392+ writer .writerows (data )
393+ else :
394+ writer = csv .DictWriter (
395+ sys .stdout ,
396+ fieldnames = _VERSIONS_CSV_FIELDS ,
397+ quoting = csv .QUOTE_NONNUMERIC ,
398+ )
399+ writer .writeheader ()
400+ writer .writerows (data )
401+
402+
403+ def _export_versions_table (
404+ data : list [dict [str , str ]],
405+ package_name : str ,
406+ cooldown : Cooldown | None ,
407+ output : pathlib .Path | None = None ,
408+ ) -> None :
409+ """Export version details as a Rich table."""
410+ table = Table (title = f"Versions for { package_name } " )
411+ table .add_column ("Version" , justify = "left" , no_wrap = True )
412+ table .add_column ("Upload Time" , justify = "left" , no_wrap = True )
413+ table .add_column ("Age (days)" , justify = "right" , no_wrap = True )
414+ if cooldown is not None :
415+ table .add_column ("Cooldown" , justify = "left" , no_wrap = True )
416+
417+ for row in data :
418+ cells = [row ["version" ], row ["upload_time" ], row ["age_days" ]]
419+ if cooldown is not None :
420+ cells .append (row ["cooldown" ])
421+ table .add_row (* cells )
422+
423+ if output :
424+ with open (output , "w" ) as fh :
425+ rich .console .Console (file = fh , width = 120 ).print (table )
426+ else :
427+ rich .get_console ().print (table )
181428
182429
183430def _versions_string (versions : typing .Iterable [Version ]) -> str :
0 commit comments