Skip to content

Commit dce7abd

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 a7ee0f8 commit dce7abd

File tree

2 files changed

+136
-30
lines changed

2 files changed

+136
-30
lines changed

src/fromager/resolver.py

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

374378
class BaseProvider(ExtrasProvider):
375379
resolver_cache: typing.ClassVar[ResolverCache] = {}
380+
provider_description: typing.ClassVar[str]
376381

377382
def __init__(
378383
self,
@@ -395,6 +400,20 @@ def cache_key(self) -> str:
395400
"""
396401
raise NotImplementedError()
397402

403+
def get_provider_description(self) -> str:
404+
"""Return a human-readable description of the provider type
405+
406+
This is used in error messages to indicate what resolver was being used.
407+
The ClassVar `provider_description` must be set by each subclass.
408+
If it contains format placeholders like {self.attr}, it will be formatted
409+
with the instance.
410+
"""
411+
try:
412+
return self.provider_description.format(self=self)
413+
except (KeyError, AttributeError):
414+
# No format placeholders or invalid format, return as-is
415+
return self.provider_description
416+
398417
def find_candidates(self, identifier: str) -> Candidates:
399418
"""Find unfiltered candidates"""
400419
raise NotImplementedError()
@@ -505,6 +524,7 @@ def _get_cached_candidates(self, identifier: str) -> list[Candidate]:
505524

506525
def _find_cached_candidates(self, identifier: str) -> Candidates:
507526
"""Find candidates with caching"""
527+
cached_candidates: list[Candidate] = []
508528
if self.use_cache_candidates:
509529
cached_candidates = self._get_cached_candidates(identifier)
510530
if cached_candidates:
@@ -531,6 +551,16 @@ def _find_cached_candidates(self, identifier: str) -> Candidates:
531551
)
532552
return candidates
533553

554+
def _get_no_match_error_message(
555+
self, identifier: str, requirements: RequirementsMap
556+
) -> str:
557+
"""Generate an error message when no candidates are found.
558+
559+
Subclasses should override this to provide provider-specific error details.
560+
"""
561+
r = next(iter(requirements[identifier]))
562+
return f"found no match for {r} using {self.get_provider_description()}"
563+
534564
def find_matches(
535565
self,
536566
identifier: str,
@@ -546,12 +576,18 @@ def find_matches(
546576
identifier, requirements, incompatibilities, candidate
547577
)
548578
]
579+
if not candidates:
580+
raise resolvelib.resolvers.ResolverException(
581+
self._get_no_match_error_message(identifier, requirements)
582+
)
549583
return sorted(candidates, key=attrgetter("version", "build_tag"), reverse=True)
550584

551585

552586
class PyPIProvider(BaseProvider):
553587
"""Lookup package and versions from a simple Python index (PyPI)"""
554588

589+
provider_description: typing.ClassVar[str] = "PyPI resolver (searching at {self.sdist_server_url})"
590+
555591
def __init__(
556592
self,
557593
include_sdists: bool = True,
@@ -616,39 +652,39 @@ def validate_candidate(
616652
return False
617653
return True
618654

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

653689

654690
class MatchFunction(typing.Protocol):
@@ -707,6 +743,8 @@ def _re_match_function(
707743
logger.debug(f"{identifier}: could not parse version from {value}: {err}")
708744
return None
709745

746+
provider_description: typing.ClassVar[str] = "custom resolver (GenericProvider)"
747+
710748
@property
711749
def cache_key(self) -> str:
712750
raise NotImplementedError("GenericProvider does not implement caching")
@@ -734,6 +772,7 @@ class GitHubTagProvider(GenericProvider):
734772
Assumes that upstream uses version tags `1.2.3` or `v1.2.3`.
735773
"""
736774

775+
provider_description: typing.ClassVar[str] = "GitHub tag resolver (repository: {self.organization}/{self.repo})"
737776
host = "github.com:443"
738777
api_url = "https://api.{self.host}/repos/{self.organization}/{self.repo}/tags"
739778

@@ -799,6 +838,8 @@ def _find_tags(
799838
class GitLabTagProvider(GenericProvider):
800839
"""Lookup tarball and version from GitLab git tags"""
801840

841+
provider_description: typing.ClassVar[str] = "GitLab tag resolver (project: {self.server_url}/{self.project_path})"
842+
802843
def __init__(
803844
self,
804845
project_path: str,

tests/test_resolver.py

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

733-
with pytest.raises(resolvelib.resolvers.ResolutionImpossible):
733+
with pytest.raises(resolvelib.resolvers.ResolverException):
734734
rslvr.resolve([Requirement("fromager")])
735735

736736

@@ -931,7 +931,7 @@ def test_gitlab_constraint_mismatch() -> None:
931931
reporter: resolvelib.BaseReporter = resolvelib.BaseReporter()
932932
rslvr = resolvelib.Resolver(provider, reporter)
933933

934-
with pytest.raises(resolvelib.resolvers.ResolutionImpossible):
934+
with pytest.raises(resolvelib.resolvers.ResolverException):
935935
rslvr.resolve([Requirement("submodlib")])
936936

937937

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

0 commit comments

Comments
 (0)