Skip to content

Commit a8d19ce

Browse files
authored
Merge pull request #859 from LalatenduMohanty/858
fix(resolver): indicate resolver type in error messages
2 parents 26aefae + b764836 commit a8d19ce

File tree

2 files changed

+138
-30
lines changed

2 files changed

+138
-30
lines changed

src/fromager/resolver.py

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,12 @@ def resolve_from_provider(
165165
result = rslvr.resolve([req])
166166
except resolvelib.resolvers.ResolverException as err:
167167
constraint = provider.constraints.get_constraint(req.name)
168+
provider_desc = provider.get_provider_description()
169+
# Include the original error message to preserve detailed information
170+
# (e.g., file types, pre-release info from PyPIProvider)
171+
original_msg = str(err)
168172
raise resolvelib.resolvers.ResolverException(
169-
f"Unable to resolve requirement specifier {req} with constraint {constraint}"
173+
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
170174
) from err
171175
# resolvelib actually just returns one candidate per requirement.
172176
# result.mapping is map from an identifier to its resolved candidate
@@ -380,6 +384,7 @@ def get_project_from_pypi(
380384

381385
class BaseProvider(ExtrasProvider):
382386
resolver_cache: typing.ClassVar[ResolverCache] = {}
387+
provider_description: typing.ClassVar[str]
383388

384389
def __init__(
385390
self,
@@ -402,6 +407,16 @@ def cache_key(self) -> str:
402407
"""
403408
raise NotImplementedError()
404409

410+
def get_provider_description(self) -> str:
411+
"""Return a human-readable description of the provider type
412+
413+
This is used in error messages to indicate what resolver was being used.
414+
The ClassVar `provider_description` must be set by each subclass.
415+
If it contains format placeholders like {self.attr}, it will be formatted
416+
with the instance. Strings without placeholders are returned unchanged.
417+
"""
418+
return self.provider_description.format(self=self)
419+
405420
def find_candidates(self, identifier: str) -> Candidates:
406421
"""Find unfiltered candidates"""
407422
raise NotImplementedError()
@@ -512,6 +527,7 @@ def _get_cached_candidates(self, identifier: str) -> list[Candidate]:
512527

513528
def _find_cached_candidates(self, identifier: str) -> Candidates:
514529
"""Find candidates with caching"""
530+
cached_candidates: list[Candidate] = []
515531
if self.use_cache_candidates:
516532
cached_candidates = self._get_cached_candidates(identifier)
517533
if cached_candidates:
@@ -538,6 +554,16 @@ def _find_cached_candidates(self, identifier: str) -> Candidates:
538554
)
539555
return candidates
540556

557+
def _get_no_match_error_message(
558+
self, identifier: str, requirements: RequirementsMap
559+
) -> str:
560+
"""Generate an error message when no candidates are found.
561+
562+
Subclasses should override this to provide provider-specific error details.
563+
"""
564+
r = next(iter(requirements[identifier]))
565+
return f"found no match for {r} using {self.get_provider_description()}"
566+
541567
def find_matches(
542568
self,
543569
identifier: str,
@@ -553,12 +579,20 @@ def find_matches(
553579
identifier, requirements, incompatibilities, candidate
554580
)
555581
]
582+
if not candidates:
583+
raise resolvelib.resolvers.ResolverException(
584+
self._get_no_match_error_message(identifier, requirements)
585+
)
556586
return sorted(candidates, key=attrgetter("version", "build_tag"), reverse=True)
557587

558588

559589
class PyPIProvider(BaseProvider):
560590
"""Lookup package and versions from a simple Python index (PyPI)"""
561591

592+
provider_description: typing.ClassVar[str] = (
593+
"PyPI resolver (searching at {self.sdist_server_url})"
594+
)
595+
562596
def __init__(
563597
self,
564598
include_sdists: bool = True,
@@ -623,39 +657,39 @@ def validate_candidate(
623657
return False
624658
return True
625659

660+
def _get_no_match_error_message(
661+
self, identifier: str, requirements: RequirementsMap
662+
) -> str:
663+
"""Generate a PyPI-specific error message with file type and pre-release details."""
664+
r = next(iter(requirements[identifier]))
665+
666+
# Determine if pre-releases are allowed
667+
req_allows_prerelease = bool(r.specifier) and bool(r.specifier.prereleases)
668+
allow_prerelease = (
669+
self.constraints.allow_prerelease(r.name) or req_allows_prerelease
670+
)
671+
prerelease_info = "including" if allow_prerelease else "ignoring"
672+
673+
# Determine the file type that was allowed
674+
if self.include_sdists and self.include_wheels:
675+
file_type_info = "any file type"
676+
elif self.include_sdists:
677+
file_type_info = "sdists"
678+
else:
679+
file_type_info = "wheels"
680+
681+
return (
682+
f"found no match for {r} using {self.get_provider_description()}, "
683+
f"searching for {file_type_info}, {prerelease_info} pre-release versions"
684+
)
685+
626686
def find_matches(
627687
self,
628688
identifier: str,
629689
requirements: RequirementsMap,
630690
incompatibilities: CandidatesMap,
631691
) -> Candidates:
632-
candidates = super().find_matches(identifier, requirements, incompatibilities)
633-
if not candidates:
634-
# Try to construct a meaningful error message that points out the
635-
# type(s) of files the resolver has been told it can choose as a
636-
# hint in case that should be adjusted for the package that does not
637-
# resolve.
638-
r = next(iter(requirements[identifier]))
639-
640-
# Determine if pre-releases are allowed
641-
req_allows_prerelease = bool(r.specifier) and bool(r.specifier.prereleases)
642-
allow_prerelease = (
643-
self.constraints.allow_prerelease(r.name) or req_allows_prerelease
644-
)
645-
prerelease_info = "including" if allow_prerelease else "ignoring"
646-
647-
# Determine the file type that was allowed
648-
if self.include_sdists and self.include_wheels:
649-
file_type_info = "any file type"
650-
elif self.include_sdists:
651-
file_type_info = "sdists"
652-
else:
653-
file_type_info = "wheels"
654-
655-
raise resolvelib.resolvers.ResolverException(
656-
f"found no match for {r}, searching for {file_type_info}, {prerelease_info} pre-release versions, in cache or at {self.sdist_server_url}"
657-
)
658-
return sorted(candidates, key=attrgetter("version", "build_tag"), reverse=True)
692+
return super().find_matches(identifier, requirements, incompatibilities)
659693

660694

661695
class MatchFunction(typing.Protocol):
@@ -714,6 +748,8 @@ def _re_match_function(
714748
logger.debug(f"{identifier}: could not parse version from {value}: {err}")
715749
return None
716750

751+
provider_description: typing.ClassVar[str] = "custom resolver (GenericProvider)"
752+
717753
@property
718754
def cache_key(self) -> str:
719755
raise NotImplementedError("GenericProvider does not implement caching")
@@ -752,6 +788,9 @@ class GitHubTagProvider(GenericProvider):
752788
Assumes that upstream uses version tags `1.2.3` or `v1.2.3`.
753789
"""
754790

791+
provider_description: typing.ClassVar[str] = (
792+
"GitHub tag resolver (repository: {self.organization}/{self.repo})"
793+
)
755794
host = "github.com:443"
756795
api_url = "https://api.{self.host}/repos/{self.organization}/{self.repo}/tags"
757796

@@ -828,6 +867,10 @@ def _find_tags(
828867
class GitLabTagProvider(GenericProvider):
829868
"""Lookup tarball and version from GitLab git tags"""
830869

870+
provider_description: typing.ClassVar[str] = (
871+
"GitLab tag resolver (project: {self.server_url}/{self.project_path})"
872+
)
873+
831874
def __init__(
832875
self,
833876
project_path: str,

tests/test_resolver.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,7 @@ def test_github_constraint_mismatch() -> None:
734734
reporter: resolvelib.BaseReporter = resolvelib.BaseReporter()
735735
rslvr = resolvelib.Resolver(provider, reporter)
736736

737-
with pytest.raises(resolvelib.resolvers.ResolutionImpossible):
737+
with pytest.raises(resolvelib.resolvers.ResolverException):
738738
rslvr.resolve([Requirement("fromager")])
739739

740740

@@ -940,7 +940,7 @@ def test_gitlab_constraint_mismatch() -> None:
940940
reporter: resolvelib.BaseReporter = resolvelib.BaseReporter()
941941
rslvr = resolvelib.Resolver(provider, reporter)
942942

943-
with pytest.raises(resolvelib.resolvers.ResolutionImpossible):
943+
with pytest.raises(resolvelib.resolvers.ResolverException):
944944
rslvr.resolve([Requirement("submodlib")])
945945

946946

@@ -1042,3 +1042,68 @@ def test_pep592_support_constraint_mismatch() -> None:
10421042
def test_extract_filename_from_url(url, filename) -> None:
10431043
result = resolver.extract_filename_from_url(url)
10441044
assert result == filename
1045+
1046+
1047+
def test_custom_resolver_error_message_missing_tag() -> None:
1048+
"""Test that error message indicates custom resolver when tag doesn't exist.
1049+
1050+
This reproduces issue #858 where the error message mentions PyPI and sdists
1051+
even when using a custom resolver like GitHubTagProvider.
1052+
"""
1053+
with requests_mock.Mocker() as r:
1054+
# Mock GitHub API to return empty tags (simulating missing tag)
1055+
r.get(
1056+
"https://api.github.com:443/repos/test-org/test-repo/tags",
1057+
json=[], # Empty tags list - tag doesn't exist
1058+
)
1059+
1060+
provider = resolver.GitHubTagProvider(organization="test-org", repo="test-repo")
1061+
1062+
with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info:
1063+
resolver.resolve_from_provider(provider, Requirement("test-package==1.0.0"))
1064+
1065+
error_message = str(exc_info.value)
1066+
assert (
1067+
"GitHub" in error_message
1068+
or "test-org/test-repo" in error_message
1069+
or "custom resolver" in error_message.lower()
1070+
), (
1071+
f"Error message should indicate custom resolver was used (GitHub tag resolver), "
1072+
f"but got: {error_message}"
1073+
)
1074+
# Should NOT mention PyPI when using GitHub resolver
1075+
assert "pypi.org" not in error_message.lower(), (
1076+
f"Error message incorrectly mentions PyPI when using GitHub resolver: {error_message}"
1077+
)
1078+
1079+
1080+
def test_custom_resolver_error_message_via_resolve() -> None:
1081+
"""Test error message when using resolve() function with custom resolver override."""
1082+
1083+
def custom_resolver_provider(*args, **kwargs):
1084+
"""Custom resolver that returns GitHubTagProvider."""
1085+
return resolver.GitHubTagProvider(organization="test-org", repo="test-repo")
1086+
1087+
with requests_mock.Mocker() as r:
1088+
# Mock GitHub API to return empty tags
1089+
r.get(
1090+
"https://api.github.com:443/repos/test-org/test-repo/tags",
1091+
json=[],
1092+
)
1093+
1094+
provider = custom_resolver_provider()
1095+
1096+
with pytest.raises(resolvelib.resolvers.ResolverException) as exc_info:
1097+
resolver.resolve_from_provider(provider, Requirement("test-package==1.0.0"))
1098+
1099+
error_message = str(exc_info.value)
1100+
# After fix for issue #858, the error message should indicate that a GitHub resolver was used
1101+
assert (
1102+
"GitHub" in error_message
1103+
or "test-org/test-repo" in error_message
1104+
or "custom resolver" in error_message.lower()
1105+
), f"Error message should indicate GitHub resolver was used: {error_message}"
1106+
# Should NOT mention PyPI when using GitHub resolver
1107+
assert "pypi.org" not in error_message.lower(), (
1108+
f"Error message incorrectly mentions PyPI when using GitHub resolver: {error_message}"
1109+
)

0 commit comments

Comments
 (0)