Skip to content

Commit 22685c7

Browse files
feat(list-versions): show cooldown status and upload timestamps
Add --details to package list-versions to show upload timestamps, age in days, and cooldown status for each version. When --details is not used, blocked versions are filtered from the plain output. Supports --format (table/csv/json) and --output for export, and --ignore-per-package-overrides for auditing the global policy. The deprecated list-versions shim is not modified; new flags are only on fromager package list-versions. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b30aae7 commit 22685c7

2 files changed

Lines changed: 751 additions & 9 deletions

File tree

src/fromager/commands/package.py

Lines changed: 256 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1+
import csv
12
import datetime
23
import enum
4+
import json
35
import logging
6+
import pathlib
47
import sys
58
import typing
9+
from collections import defaultdict
610

711
import click
812
import pypi_simple
13+
import rich
914
from packaging.requirements import Requirement
1015
from packaging.version import Version
1116
from 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

1630
logger = 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
103145
def 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

183430
def _versions_string(versions: typing.Iterable[Version]) -> str:

0 commit comments

Comments
 (0)