Skip to content

Commit b764836

Browse files
fix(resolver): indicate resolver type in error messages
Add provider description to error messages when resolution fails. Previously, errors from custom resolvers (GitHub, GitLab, etc.) were indistinguishable from PyPI resolver errors, making debugging difficult. Closes: #858 Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 81d7024 commit b764836

2 files changed

Lines changed: 138 additions & 30 deletions

File tree

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)