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
@@ -98,6 +112,34 @@ def package() -> None:
98112 default = False ,
99113 help = "Format output as requirement specifiers (name==version) instead of just version numbers" ,
100114)
115+ @click .option (
116+ "--details" ,
117+ is_flag = True ,
118+ default = False ,
119+ help = "Show upload timestamps, age, and cooldown status for each version." ,
120+ )
121+ @click .option (
122+ "--format" ,
123+ "output_format" ,
124+ type = click .Choice (["table" , "csv" , "json" ], case_sensitive = False ),
125+ default = "table" ,
126+ help = "Output format for detailed view (requires --details, default: table)" ,
127+ )
128+ @click .option (
129+ "-o" ,
130+ "--output" ,
131+ type = clickext .ClickPath (),
132+ help = "Output file to create (requires --details, default: stdout)" ,
133+ )
134+ @click .option (
135+ "--ignore-per-package-overrides" ,
136+ is_flag = True ,
137+ default = False ,
138+ help = (
139+ "Ignore per-package min_release_age overrides when computing cooldown "
140+ "status; uses only the global --min-release-age value."
141+ ),
142+ )
101143@click .argument ("requirement_spec" , required = True )
102144@click .pass_obj
103145def list_versions (
@@ -107,6 +149,10 @@ def list_versions(
107149 sdist_server_url : str ,
108150 ignore_no_versions : bool ,
109151 format_as_requirements : bool ,
152+ details : bool ,
153+ output_format : str ,
154+ output : pathlib .Path | None ,
155+ ignore_per_package_overrides : bool ,
110156) -> None :
111157 """List all available versions for a package requirement specifier.
112158
@@ -123,7 +169,21 @@ def list_versions(
123169 - "sdist": Only include source distributions
124170 - "wheel": Only include wheels
125171 - "both": Include both source distributions and wheels
172+
173+ With --details, shows upload timestamps, age in days, and cooldown
174+ status for each version. Using --format or --output implicitly
175+ enables --details. Use --ignore-per-package-overrides to see what
176+ the global cooldown policy would block without per-package exemptions.
126177 """
178+ if not details and (output_format != "table" or output is not None ):
179+ details = True
180+ if not details and ignore_per_package_overrides :
181+ click .echo (
182+ "Warning: --ignore-per-package-overrides is ignored when "
183+ "--details is not used" ,
184+ err = True ,
185+ )
186+
127187 try :
128188 req = Requirement (requirement_spec )
129189 except Exception as e :
@@ -154,7 +214,8 @@ def list_versions(
154214 sdist_server_url = override_sdist_server_url ,
155215 )
156216
157- # Get all available candidates from the provider
217+ # Get all available candidates from the provider (cooldown is NOT set on
218+ # the provider so we receive every version that matches the specifier).
158219 candidates = list (
159220 provider .find_matches (
160221 identifier = req .name ,
@@ -170,14 +231,200 @@ def list_versions(
170231 else :
171232 raise click .ClickException (f"No versions found for { req .name } " )
172233
173- versions : list [Version ] = sorted (set (candidate .version for candidate in candidates ))
174- logger .info (f"Found { len (versions )} version(s)" )
234+ logger .info (f"Found { len (sorted (set (c .version for c in candidates )))} version(s)" )
235+
236+ cooldown = _resolve_list_versions_cooldown (wkctx , req , ignore_per_package_overrides )
237+ version_rows = _compute_version_details (
238+ req .name ,
239+ candidates ,
240+ cooldown ,
241+ provider .supports_upload_time ,
242+ )
243+
244+ match (output_format , details ):
245+ case ("json" , _):
246+ _export_versions_json (version_rows , output )
247+ case ("csv" , _):
248+ _export_versions_csv (version_rows , output )
249+ case ("table" , True ):
250+ _export_versions_table (version_rows , req .name , cooldown , output )
251+ case ("table" , False ):
252+ _export_versions_plain (
253+ version_rows , req .name , cooldown , format_as_requirements
254+ )
255+ case _:
256+ raise ValueError (f"Invalid output format: { output_format } " )
257+
258+
259+ def _resolve_list_versions_cooldown (
260+ wkctx : context .WorkContext ,
261+ req : Requirement ,
262+ ignore_per_package_overrides : bool ,
263+ ) -> Cooldown | None :
264+ """Determine the effective cooldown for the list-versions detail view.
265+
266+ When *ignore_per_package_overrides* is ``True``, only the global
267+ ``--min-release-age`` value is used so the caller can audit what the
268+ policy would block without per-package exemptions.
269+ """
270+ if ignore_per_package_overrides :
271+ return wkctx .cooldown
272+ return resolver .resolve_package_cooldown (wkctx , req )
273+
274+
275+ def _compute_version_details (
276+ package_name : str ,
277+ candidates : list [Candidate ],
278+ cooldown : Cooldown | None ,
279+ supports_upload_time : bool ,
280+ ) -> list [dict [str , str ]]:
281+ """Group candidates by version and compute cooldown status.
282+
283+ Returns one row per version, sorted ascending. Each row is a dict with
284+ keys: ``package``, ``version``, ``upload_time``, ``age_days``,
285+ ``cooldown``.
286+ """
287+ by_version : dict [Version , list [Candidate ]] = defaultdict (list )
288+ for c in candidates :
289+ by_version [c .version ].append (c )
290+
291+ reference_time = (
292+ cooldown .bootstrap_time
293+ if cooldown is not None
294+ else datetime .datetime .now (datetime .UTC )
295+ )
296+
297+ rows : list [dict [str , str ]] = []
298+ for version in sorted (by_version ):
299+ version_candidates = by_version [version ]
175300
176- for version in versions :
301+ upload_times = [
302+ c .upload_time for c in version_candidates if c .upload_time is not None
303+ ]
304+ upload_time = max (upload_times ) if upload_times else None
305+
306+ if upload_time is not None :
307+ age_days = (reference_time - upload_time ).days
308+ else :
309+ age_days = None
310+
311+ status = _cooldown_status (upload_time , cooldown , supports_upload_time )
312+
313+ rows .append (
314+ {
315+ "package" : package_name ,
316+ "version" : str (version ),
317+ "upload_time" : upload_time .isoformat () if upload_time else "" ,
318+ "age_days" : str (age_days ) if age_days is not None else "" ,
319+ "cooldown" : status ,
320+ }
321+ )
322+ return rows
323+
324+
325+ def _cooldown_status (
326+ upload_time : datetime .datetime | None ,
327+ cooldown : Cooldown | None ,
328+ supports_upload_time : bool ,
329+ ) -> str :
330+ """Classify cooldown status for a single version.
331+
332+ Returns one of ``"blocked"``, ``"allowed"``, ``"skipped"``, or ``""``
333+ (no cooldown configured).
334+ """
335+ if cooldown is None :
336+ return ""
337+ if upload_time is None :
338+ if not supports_upload_time :
339+ return "skipped"
340+ return "blocked"
341+ cutoff = cooldown .bootstrap_time - cooldown .min_age
342+ if upload_time > cutoff :
343+ return "blocked"
344+ return "allowed"
345+
346+
347+ # -- export helpers for list-versions -------------------------------------------
348+
349+
350+ def _export_versions_plain (
351+ data : list [dict [str , str ]],
352+ package_name : str ,
353+ cooldown : Cooldown | None ,
354+ format_as_requirements : bool ,
355+ ) -> None :
356+ """Export versions as a plain list, filtering out cooldown-blocked entries."""
357+ for row in data :
358+ if cooldown is not None and row ["cooldown" ] == "blocked" :
359+ continue
177360 if format_as_requirements :
178- print (f"{ req . name } =={ version } " )
361+ print (f"{ package_name } =={ row [ ' version' ] } " )
179362 else :
180- print (version )
363+ print (row ["version" ])
364+
365+
366+ def _export_versions_json (
367+ data : list [dict [str , str ]], output : pathlib .Path | None
368+ ) -> None :
369+ """Export version details as JSON."""
370+ if output :
371+ with open (output , "w" ) as outfile :
372+ json .dump (data , outfile , indent = 2 )
373+ else :
374+ json .dump (data , sys .stdout , indent = 2 )
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