From b45e3c2531d362aefea8aa12115b0702b6eb4746 Mon Sep 17 00:00:00 2001 From: Lalatendu Mohanty Date: Fri, 29 May 2026 07:42:06 -0400 Subject: [PATCH] fix(resolver): report missing sdist when PyPI has only wheels Sdists-only resolution wrongly blamed the release-age cooldown when PyPI had wheels but no sdist. Error diagnosis now follows the same order as `find_matches()`: specifier, distribution type, then cooldown. Closes: #1174 Co-Authored-By: Claude Signed-off-by: Lalatendu Mohanty --- src/fromager/resolver.py | 89 ++++++++++++++++++----- tests/test_cooldown.py | 151 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 17 deletions(-) diff --git a/src/fromager/resolver.py b/src/fromager/resolver.py index 9ec2fa17..c4591903 100644 --- a/src/fromager/resolver.py +++ b/src/fromager/resolver.py @@ -776,12 +776,17 @@ def _find_cached_candidates(self, identifier: str) -> Candidates: return candidates def _get_no_match_error_message( - self, identifier: str, requirements: RequirementsMap + self, + identifier: str, + requirements: RequirementsMap, + incompatibilities: CandidatesMap | None = None, ) -> str: """Generate an error message when no candidates are found. Subclasses should override this to provide provider-specific error details. """ + if incompatibilities is None: + incompatibilities = {} reqs = requirements.get(identifier, []) if reqs: r = next(iter(reqs)) @@ -816,7 +821,9 @@ def find_matches( ) if not candidates: raise resolvelib.resolvers.ResolverException( - self._get_no_match_error_message(identifier, requirements) + self._get_no_match_error_message( + identifier, requirements, incompatibilities + ) ) return sorted(candidates, key=attrgetter("version", "build_tag"), reverse=True) @@ -910,10 +917,21 @@ def validate_candidate( return False return True + def _accepts_distribution_type(self, candidate: Candidate) -> bool: + """Return True if the candidate's distribution type matches resolver settings.""" + if candidate.is_sdist: + return self.include_sdists + return self.include_wheels + def _get_no_match_error_message( - self, identifier: str, requirements: RequirementsMap + self, + identifier: str, + requirements: RequirementsMap, + incompatibilities: CandidatesMap | None = None, ) -> str: """Generate a PyPI-specific error message with file type and pre-release details.""" + if incompatibilities is None: + incompatibilities = {} r = next(iter(requirements[identifier])) # Determine if pre-releases are allowed @@ -931,32 +949,69 @@ def _get_no_match_error_message( else: file_type_info = "wheels" - # If a cooldown is active, check whether it's responsible for the - # failure so we can give a more actionable error message. - if self.cooldown is not None: + all_candidates = list(self._find_cached_candidates(identifier)) + specifier_matched = [ + candidate + for candidate in all_candidates + if super().validate_candidate( + identifier, requirements, incompatibilities, candidate + ) + ] + type_matched = [ + candidate + for candidate in specifier_matched + if self._accepts_distribution_type(candidate) + ] + + # Version matches exist but none are the configured distribution type. + if specifier_matched and not type_matched: + wheels = [c for c in specifier_matched if not c.is_sdist] + sdists = [c for c in specifier_matched if c.is_sdist] + if self.include_sdists and not self.include_wheels and wheels: + wheel_count = len(wheels) + wheel_label = "wheel" if wheel_count == 1 else "wheels" + return ( + f"found {wheel_count} {wheel_label} for {r} but the resolver is " + f"configured for sdists only (no sdist available on " + f"{self.sdist_server_url!r})" + ) + if self.include_wheels and not self.include_sdists and sdists: + sdist_count = len(sdists) + sdist_label = "sdist" if sdist_count == 1 else "sdists" + return ( + f"found {sdist_count} {sdist_label} for {r} but the resolver is " + f"configured for wheels only (no wheel available on " + f"{self.sdist_server_url!r})" + ) + + # If a cooldown is active, check whether it blocked all type-appropriate + # candidates so we can give a more actionable error message. + if self.cooldown is not None and type_matched: cutoff = self.cooldown.bootstrap_time - self.cooldown.min_age - all_candidates = list(self._find_cached_candidates(identifier)) - missing_time = [c for c in all_candidates if c.upload_time is None] - cooldown_blocked = [ + missing_time = [c for c in type_matched if c.upload_time is None] + age_blocked = [ c - for c in all_candidates + for c in type_matched if c.upload_time is not None and c.upload_time > cutoff ] - if missing_time and not cooldown_blocked: + if missing_time and not age_blocked: return ( f"found {len(missing_time)} candidate(s) for {r} but none have " f"upload timestamp metadata; {self.sdist_server_url!r} may not " f"support PEP 691 (JSON API), which is required to enforce the " f"{self.cooldown.min_age.days}-day release-age cooldown" ) - if cooldown_blocked: - oldest_days = min( - (self.cooldown.bootstrap_time - c.upload_time).days - for c in cooldown_blocked - if c.upload_time is not None + if age_blocked and len(age_blocked) == len(type_matched): + oldest_days = max( + 0, + min( + (self.cooldown.bootstrap_time - c.upload_time).days + for c in age_blocked + if c.upload_time is not None + ), ) return ( - f"found {len(cooldown_blocked)} candidate(s) for {r} but all " + f"found {len(age_blocked)} candidate(s) for {r} but all " f"were published within the last {self.cooldown.min_age.days} days " f"(release-age cooldown; oldest is {oldest_days} day(s) old)" ) diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 098081a3..00d774d2 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -155,6 +155,157 @@ def test_cooldown_all_blocked_raises_informative_error() -> None: assert "published within the last 7 days (release-age cooldown" in msg +# --------------------------------------------------------------------------- +# Wheel-only PyPI error messages (issue #1174) +# --------------------------------------------------------------------------- + +# 3-day cooldown anchored to 2026-04-10. All three wheels were uploaded within +# the cooldown window. Filenames must be parseable by pypi_simple (cpNNN-cpNNN +# manylinux wheels are not). Modeled on flydsl==0.1.2 from issue #1174. +_BOOTSTRAP_TIME_3DAY = datetime.datetime(2026, 4, 10, 0, 0, 0, tzinfo=datetime.UTC) +_COOLDOWN_3DAY = candidate.Cooldown( + min_age=datetime.timedelta(days=3), + bootstrap_time=_BOOTSTRAP_TIME_3DAY, +) + +_wheel_only_recent_json_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0-py3-none-any.whl", + "hashes": {"sha256": "aaa"}, + "upload-time": "2026-04-09T10:15:40+00:00", + }, + { + "filename": "test_pkg-1.0.0-cp312-abi3-manylinux_2_17_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0-cp312-abi3.whl", + "hashes": {"sha256": "bbb"}, + "upload-time": "2026-04-10T10:15:44+00:00", + }, + { + "filename": "test_pkg-1.0.0-cp312-abi3-manylinux2014_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0-cp312-manylinux.whl", + "hashes": {"sha256": "ccc"}, + "upload-time": "2026-04-10T12:00:00+00:00", + }, + ], +} + +_sdist_recent_json_response = { + "meta": {"api-version": "1.1"}, + "name": "test-pkg", + "files": [ + { + "filename": "test_pkg-1.0.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0.tar.gz", + "hashes": {"sha256": "aaa"}, + "upload-time": "2026-04-09T10:00:00+00:00", + }, + { + "filename": "test_pkg-1.0.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/test_pkg-1.0.0-py3-none-any.whl", + "hashes": {"sha256": "bbb"}, + "upload-time": "2026-04-09T10:00:00+00:00", + }, + ], +} + + +def test_cooldown_wheel_only_pypi_reports_missing_sdist() -> None: + """Wheel-only PyPI must not produce a cooldown error when sdists are required.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_wheel_only_recent_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + provider = resolver.PyPIProvider( + include_sdists=True, + include_wheels=False, + cooldown=_COOLDOWN_3DAY, + ) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + + with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info: + rslvr.resolve([Requirement("test-pkg==1.0.0")]) + + msg = str(exc_info.value) + assert "release-age cooldown" not in msg, ( + f"wheel-only resolution should not blame cooldown: {msg}" + ) + assert "published within the last" not in msg, ( + f"wheel-only resolution should not blame cooldown: {msg}" + ) + assert "3 wheels" in msg + assert "sdists only" in msg + assert "no sdist available" in msg + + +def test_cooldown_wheel_only_via_resolve_source_reports_missing_sdist( + tmp_path: pathlib.Path, +) -> None: + """sources.resolve_source() must report missing sdists for wheel-only packages.""" + ctx = context.WorkContext( + active_settings=None, + patches_dir=tmp_path / "patches", + sdists_repo=tmp_path / "sdists-repo", + wheels_repo=tmp_path / "wheels-repo", + work_dir=tmp_path / "work-dir", + cooldown=_COOLDOWN_3DAY, + ) + + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_wheel_only_recent_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + + with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info: + sources.resolve_source( + ctx=ctx, + req=Requirement("test-pkg==1.0.0"), + sdist_server_url="https://pypi.org/simple/", + ) + + msg = str(exc_info.value) + assert "Unable to resolve requirement specifier test-pkg==1.0.0" in msg + assert "release-age cooldown" not in msg, ( + f"wheel-only resolution should not blame cooldown: {msg}" + ) + assert "published within the last" not in msg, ( + f"wheel-only resolution should not blame cooldown: {msg}" + ) + assert "3 wheels" in msg + assert "sdists only" in msg + assert "no sdist available" in msg + + +def test_cooldown_sdist_within_cooldown_still_reports_cooldown() -> None: + """When an sdist exists but is blocked by cooldown, report cooldown.""" + with requests_mock.Mocker() as r: + r.get( + "https://pypi.org/simple/test-pkg/", + json=_sdist_recent_json_response, + headers={"Content-Type": _PYPI_SIMPLE_JSON_CONTENT_TYPE}, + ) + provider = resolver.PyPIProvider( + include_sdists=True, + include_wheels=False, + cooldown=_COOLDOWN_3DAY, + ) + rslvr = resolvelib.Resolver(provider, resolvelib.BaseReporter()) + + with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info: + rslvr.resolve([Requirement("test-pkg==1.0.0")]) + + msg = str(exc_info.value) + assert "release-age cooldown" in msg + assert "published within the last 3 days" in msg + + def test_cooldown_rejects_candidate_without_upload_time( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch,