Skip to content

Commit d447f31

Browse files
andre-mottaclaude
andcommitted
feat(resolver): report cooldown-skipped versions to work_dir
Add a `cooldown-skipped-versions.json` report written to `work_dir` after resolution, giving operators visibility into which package versions were skipped due to the release-age cooldown policy. The report is produced by a module-level `CooldownReport` collector that records each blocked version during `find_matches()`, deduplicates across resolvelib backtracking, and writes a structured JSON summary grouped by package name. Also improves the cooldown log message to include the package identifier. Closes: #1190 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Andre Lustosa <alustosa@redhat.com>
1 parent b6c8fc7 commit d447f31

4 files changed

Lines changed: 228 additions & 2 deletions

File tree

src/fromager/commands/bootstrap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ def bootstrap(
137137
and optional version constraints.
138138
139139
"""
140+
resolver.cooldown_report.clear()
141+
140142
logger.info(f"cache wheel server url: {cache_wheel_server_url}")
141143

142144
to_build = _get_requirements_from_args(toplevel, requirements_files)
@@ -239,6 +241,8 @@ def bootstrap(
239241
f"Could not produce a pip compatible constraints file. Please review {constraints_filename} for more details"
240242
)
241243

244+
resolver.cooldown_report.write_to(wkctx.work_dir / "cooldown-skipped-versions.json")
245+
242246
logger.debug("match_py_req LRU cache: %r", resolver.match_py_req.cache_info())
243247

244248
metrics.summarize(wkctx, "Bootstrapping")

src/fromager/commands/build.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def build(
101101
separately.
102102
103103
"""
104+
resolver.cooldown_report.clear()
104105
wkctx.wheel_server_url = wheel_server_url
105106
server.start_wheel_server(wkctx)
106107
req = Requirement(f"{dist_name}=={dist_version}")
@@ -122,6 +123,7 @@ def build(
122123
force=True,
123124
cache_wheel_server_url=None,
124125
)
126+
resolver.cooldown_report.write_to(wkctx.work_dir / "cooldown-skipped-versions.json")
125127
print(entry.wheel_filename)
126128

127129

src/fromager/resolver.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
#
66
from __future__ import annotations
77

8+
import dataclasses
89
import datetime
910
import functools
11+
import json
1012
import logging
1113
import os
14+
import pathlib
1215
import re
16+
import threading
1317
import typing
1418
from collections.abc import Iterable
1519
from operator import attrgetter
@@ -59,6 +63,82 @@
5963
)
6064

6165

66+
@dataclasses.dataclass(frozen=True, slots=True)
67+
class CooldownBlockedEntry:
68+
"""Record of a single package version blocked by the cooldown policy."""
69+
70+
package: str
71+
version: str
72+
upload_time: str | None
73+
cooldown_min_age_days: int
74+
provider: str
75+
76+
77+
class CooldownReport:
78+
"""Accumulate cooldown-blocked versions across a resolution run."""
79+
80+
def __init__(self) -> None:
81+
self._lock = threading.Lock()
82+
self._entries: list[CooldownBlockedEntry] = []
83+
self._seen: set[tuple[str, str]] = set()
84+
85+
def record(self, entry: CooldownBlockedEntry) -> None:
86+
"""Record a blocked entry, deduplicating by (package, version)."""
87+
key = (entry.package, entry.version)
88+
with self._lock:
89+
if key not in self._seen:
90+
self._seen.add(key)
91+
self._entries.append(entry)
92+
93+
def entries(self) -> list[CooldownBlockedEntry]:
94+
"""Return a copy of all recorded blocked entries."""
95+
with self._lock:
96+
return list(self._entries)
97+
98+
def clear(self) -> None:
99+
"""Remove all recorded entries and reset the deduplication set."""
100+
with self._lock:
101+
self._entries.clear()
102+
self._seen.clear()
103+
104+
def write_to(self, path: pathlib.Path) -> None:
105+
"""Write JSON report to *path*.
106+
107+
The file is always created so downstream tooling can rely on its
108+
presence. When no versions were blocked the ``packages`` dict is
109+
empty and ``total_blocked`` is 0.
110+
"""
111+
entries = self.entries()
112+
packages: dict[str, list[dict[str, typing.Any]]] = {}
113+
for e in entries:
114+
packages.setdefault(e.package, []).append(
115+
{
116+
"version": e.version,
117+
"upload_time": e.upload_time,
118+
"cooldown_min_age_days": e.cooldown_min_age_days,
119+
"provider": e.provider,
120+
}
121+
)
122+
report = {
123+
"generated_at": datetime.datetime.now(datetime.UTC).isoformat(),
124+
"total_blocked": len(entries),
125+
"packages": packages,
126+
}
127+
with open(path, "w", encoding="utf-8") as f:
128+
json.dump(report, f, indent=2)
129+
if entries:
130+
logger.info(
131+
"cooldown skipped %d version(s) across %d package(s); "
132+
"report written to %s",
133+
len(entries),
134+
len(packages),
135+
path,
136+
)
137+
138+
139+
cooldown_report = CooldownReport()
140+
141+
62142
@functools.lru_cache(maxsize=200)
63143
def match_py_req(py_req: str, *, python_version: Version = PYTHON_VERSION) -> bool:
64144
"""Python version requirement lookup with LRU cache
@@ -824,10 +904,29 @@ def find_matches(
824904
candidates.remove(b)
825905
versions = ", ".join(str(b.version) for b in blocked)
826906
logger.info(
827-
"cooldown blocked %d version(s): %s",
907+
"%s: cooldown blocked %d version(s): %s",
908+
identifier,
828909
len(blocked),
829910
versions,
830911
)
912+
for b in blocked:
913+
cooldown_report.record(
914+
CooldownBlockedEntry(
915+
package=b.name,
916+
version=str(b.version),
917+
upload_time=(
918+
b.upload_time.isoformat()
919+
if b.upload_time is not None
920+
else None
921+
),
922+
cooldown_min_age_days=(
923+
self.cooldown.min_age.days
924+
if self.cooldown is not None
925+
else 0
926+
),
927+
provider=self.get_provider_description(),
928+
)
929+
)
831930
if not candidates:
832931
raise resolvelib.resolvers.ResolverException(
833932
self._get_no_match_error_message(identifier, requirements)

tests/test_cooldown.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import datetime
9+
import json
910
import logging
1011
import pathlib
1112
import re
@@ -84,7 +85,7 @@
8485

8586
@pytest.fixture(autouse=True)
8687
def clear_resolver_cache() -> typing.Generator[None, None, None]:
87-
"""Clear the class-level resolver cache before each test.
88+
"""Clear the class-level resolver cache and cooldown report before each test.
8889
8990
BaseProvider.resolver_cache is a ClassVar that persists across test
9091
instances. Without clearing it, candidates fetched in one test are reused
@@ -93,7 +94,9 @@ def clear_resolver_cache() -> typing.Generator[None, None, None]:
9394
"""
9495
resolver.BaseProvider.clear_cache()
9596
resolver.BaseProvider._cooldown_unsupported_warned.clear()
97+
resolver.cooldown_report.clear()
9698
yield
99+
resolver.cooldown_report.clear()
97100

98101

99102
def test_cooldown_filters_recent_version(
@@ -965,3 +968,121 @@ def test_resolve_package_cooldown_toplevel_compound_specifier_not_exempt(
965968
ctx, Requirement("test-pkg==1.0,>0.9"), req_type=RequirementType.TOP_LEVEL
966969
)
967970
assert result is _COOLDOWN
971+
972+
973+
# ---------------------------------------------------------------------------
974+
# CooldownReport tests
975+
# ---------------------------------------------------------------------------
976+
977+
978+
def test_cooldown_report_records_blocked_version() -> None:
979+
"""Blocked versions are recorded in the module-level cooldown report."""
980+
with requests_mock.Mocker() as r:
981+
r.get(
982+
"https://pypi.org/simple/test-pkg/",
983+
json=_cooldown_json_response,
984+
headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE},
985+
)
986+
provider = resolver.PyPIProvider(include_sdists=True, cooldown=_COOLDOWN)
987+
rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter())
988+
rslvr.resolve([Requirement("test-pkg")])
989+
990+
entries = resolver.cooldown_report.entries()
991+
assert len(entries) == 1
992+
entry = entries[0]
993+
assert entry.package == "test-pkg"
994+
assert entry.version == "2.0.0"
995+
assert entry.upload_time == "2026-03-24T00:00:00+00:00"
996+
assert entry.cooldown_min_age_days == 7
997+
998+
999+
def test_cooldown_report_empty_when_disabled() -> None:
1000+
"""No entries recorded when cooldown is not configured."""
1001+
with requests_mock.Mocker() as r:
1002+
r.get(
1003+
"https://pypi.org/simple/test-pkg/",
1004+
json=_cooldown_json_response,
1005+
headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE},
1006+
)
1007+
provider = resolver.PyPIProvider(include_sdists=True, cooldown=None)
1008+
rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter())
1009+
rslvr.resolve([Requirement("test-pkg")])
1010+
1011+
assert resolver.cooldown_report.entries() == []
1012+
1013+
1014+
def test_cooldown_report_write_to_json(tmp_path: pathlib.Path) -> None:
1015+
"""write_to() produces valid JSON with the expected structure."""
1016+
with requests_mock.Mocker() as r:
1017+
r.get(
1018+
"https://pypi.org/simple/test-pkg/",
1019+
json=_cooldown_json_response,
1020+
headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE},
1021+
)
1022+
provider = resolver.PyPIProvider(include_sdists=True, cooldown=_COOLDOWN)
1023+
rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter())
1024+
rslvr.resolve([Requirement("test-pkg")])
1025+
1026+
output = tmp_path / "cooldown-skipped-versions.json"
1027+
resolver.cooldown_report.write_to(output)
1028+
1029+
assert output.exists()
1030+
report = json.loads(output.read_text())
1031+
assert report["total_blocked"] == 1
1032+
assert "test-pkg" in report["packages"]
1033+
assert report["packages"]["test-pkg"][0]["version"] == "2.0.0"
1034+
assert "generated_at" in report
1035+
1036+
1037+
def test_cooldown_report_empty_file_when_no_blocked(tmp_path: pathlib.Path) -> None:
1038+
"""write_to() creates a file with zero blocked entries when report is empty."""
1039+
output = tmp_path / "cooldown-skipped-versions.json"
1040+
resolver.cooldown_report.write_to(output)
1041+
assert output.exists()
1042+
report = json.loads(output.read_text())
1043+
assert report["total_blocked"] == 0
1044+
assert report["packages"] == {}
1045+
1046+
1047+
def test_cooldown_report_deduplicates() -> None:
1048+
"""Repeated find_matches calls for the same package record only once."""
1049+
with requests_mock.Mocker() as r:
1050+
r.get(
1051+
"https://pypi.org/simple/test-pkg/",
1052+
json=_cooldown_json_response,
1053+
headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE},
1054+
)
1055+
provider = resolver.PyPIProvider(
1056+
include_sdists=True, cooldown=_COOLDOWN, use_resolver_cache=False
1057+
)
1058+
req = Requirement("test-pkg")
1059+
# Simulate resolvelib backtracking — call find_matches twice
1060+
provider.find_matches(
1061+
identifier="test-pkg",
1062+
requirements={"test-pkg": [req]},
1063+
incompatibilities={"test-pkg": []},
1064+
)
1065+
provider.find_matches(
1066+
identifier="test-pkg",
1067+
requirements={"test-pkg": [req]},
1068+
incompatibilities={"test-pkg": []},
1069+
)
1070+
1071+
entries = resolver.cooldown_report.entries()
1072+
assert len(entries) == 1
1073+
1074+
1075+
def test_cooldown_report_clear() -> None:
1076+
"""clear() removes all recorded entries."""
1077+
resolver.cooldown_report.record(
1078+
resolver.CooldownBlockedEntry(
1079+
package="pkg",
1080+
version="1.0.0",
1081+
upload_time=None,
1082+
cooldown_min_age_days=7,
1083+
provider="test",
1084+
)
1085+
)
1086+
assert len(resolver.cooldown_report.entries()) == 1
1087+
resolver.cooldown_report.clear()
1088+
assert resolver.cooldown_report.entries() == []

0 commit comments

Comments
 (0)