Skip to content

Commit ed6da67

Browse files
authored
Merge pull request #1127 from shifa-khan/list-versions-1078
feat(list-versions): show cooldown status and upload timestamps
2 parents 960728b + 5203f38 commit ed6da67

3 files changed

Lines changed: 764 additions & 14 deletions

File tree

src/fromager/commands/list_versions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ def list_versions(
3838
) -> None:
3939
"""List all available versions for a package requirement specifier."""
4040
click.secho("use 'fromager package list-versions'", bold=True)
41+
output_format = "requirements" if format_as_requirements else "versions"
4142
ctx.invoke(
4243
package.list_versions,
4344
requirement_spec=requirement_spec,
4445
distribution_type=distribution_type,
4546
sdist_server_url=sdist_server_url,
4647
ignore_no_versions=ignore_no_versions,
47-
format_as_requirements=format_as_requirements,
48+
output_format=output_format,
4849
)

src/fromager/commands/package.py

Lines changed: 260 additions & 13 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

@@ -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

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

0 commit comments

Comments
 (0)