Skip to content

Commit 6ff2394

Browse files
committed
Fix existing bug and add test coverage
This commit attempts to fix the existing bug #956 and add test coverage for the refactor as sugegsted in the MR Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
1 parent 3eed051 commit 6ff2394

3 files changed

Lines changed: 204 additions & 22 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,18 @@ def resolve_version(
189189
)
190190

191191
# Check cache first to avoid re-resolving
192-
cached_result = self._resolver.get_cached_resolution(req)
192+
# Git URLs are always source (not prebuilt)
193+
cached_result = self._resolver.get_cached_resolution(req, pre_built=False)
193194
if cached_result is not None:
194195
logger.debug(f"resolved {req} from cache")
195196
return cached_result
196197

197198
logger.info("resolving source via URL, ignoring any plugins")
198199
source_url, resolved_version = self._resolve_version_from_git_url(req=req)
199-
# Cache the git URL resolution
200-
self._resolver.cache_resolution(req, (source_url, resolved_version))
200+
# Cache the git URL resolution (always source, not prebuilt)
201+
self._resolver.cache_resolution(
202+
req, pre_built=False, result=(source_url, resolved_version)
203+
)
201204
return source_url, resolved_version
202205

203206
# Delegate to RequirementResolver

src/fromager/requirement_resolver.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def __init__(
5050
self.ctx = ctx
5151
self.prev_graph = prev_graph
5252
# Session-level resolution cache to avoid re-resolving same requirements
53-
self._resolved_requirements: dict[str, tuple[str, Version]] = {}
53+
# Key: (requirement_string, pre_built) to distinguish source vs prebuilt
54+
self._resolved_requirements: dict[tuple[str, bool], tuple[str, Version]] = {}
5455

5556
def resolve(
5657
self,
@@ -67,7 +68,7 @@ def resolve(
6768
3. PyPI resolution (source or prebuilt based on package build info)
6869
6970
Args:
70-
req: Package requirement (must NOT have URL)
71+
req: Package requirement
7172
req_type: Type of requirement
7273
parent_req: Parent requirement from dependency chain
7374
pre_built: Optional override to force prebuilt (True) or source (False).
@@ -77,15 +78,24 @@ def resolve(
7778
Tuple of (url, resolved_version)
7879
7980
Raises:
80-
ValueError: If req contains a URL (must use Bootstrapper for git URLs)
81+
ValueError: If req contains a git URL and pre_built is False
82+
(git URL source resolution must be handled by Bootstrapper)
8183
"""
82-
if req.url:
84+
# Determine pre_built if not specified (needed for cache key and URL guard)
85+
if pre_built is None:
86+
pbi = self.ctx.package_build_info(req)
87+
pre_built = pbi.pre_built
88+
89+
# Git URL source resolution must be handled by Bootstrapper.
90+
# But git URL prebuilt resolution is allowed - we look for wheels on PyPI
91+
# (test mode fallback uses this path).
92+
if req.url and not pre_built:
8393
raise ValueError(
8494
f"Git URL requirements must be handled by Bootstrapper: {req}"
8595
)
8696

87-
# Check session cache first
88-
cached_result = self.get_cached_resolution(req)
97+
# Check session cache (keyed by requirement + pre_built)
98+
cached_result = self.get_cached_resolution(req, pre_built)
8999
if cached_result is not None:
90100
logger.debug(f"resolved {req} from cache")
91101
return cached_result
@@ -95,15 +105,15 @@ def resolve(
95105

96106
# Cache the result
97107
result = (url, resolved_version)
98-
self.cache_resolution(req, result)
108+
self.cache_resolution(req, pre_built, result)
99109
return url, resolved_version
100110

101111
def _resolve(
102112
self,
103113
req: Requirement,
104114
req_type: RequirementType,
105115
parent_req: Requirement | None,
106-
pre_built: bool | None,
116+
pre_built: bool,
107117
) -> tuple[str, Version]:
108118
"""Internal resolution logic without caching.
109119
@@ -115,17 +125,11 @@ def _resolve(
115125
req: Package requirement
116126
req_type: Type of requirement
117127
parent_req: Parent requirement from dependency chain
118-
pre_built: Optional override to force prebuilt (True) or source (False).
119-
If None, uses package build info to determine.
128+
pre_built: Whether to resolve prebuilt (True) or source (False)
120129
121130
Returns:
122131
Tuple of (url, resolved_version)
123132
"""
124-
# Determine whether to resolve prebuilt or source
125-
if pre_built is None:
126-
pbi = self.ctx.package_build_info(req)
127-
pre_built = pbi.pre_built
128-
129133
# Try graph
130134
cached_resolution = self._resolve_from_graph(
131135
req=req,
@@ -162,20 +166,24 @@ def _resolve(
162166
def get_cached_resolution(
163167
self,
164168
req: Requirement,
169+
pre_built: bool,
165170
) -> tuple[str, Version] | None:
166171
"""Get a cached resolution result if it exists.
167172
168173
Args:
169174
req: Package requirement to look up in cache
175+
pre_built: Whether looking for prebuilt or source resolution
170176
171177
Returns:
172178
Tuple of (source_url, resolved_version) if cached, None otherwise
173179
"""
174-
return self._resolved_requirements.get(str(req))
180+
cache_key = (str(req), pre_built)
181+
return self._resolved_requirements.get(cache_key)
175182

176183
def cache_resolution(
177184
self,
178185
req: Requirement,
186+
pre_built: bool,
179187
result: tuple[str, Version],
180188
) -> None:
181189
"""Cache a resolution result.
@@ -185,9 +193,11 @@ def cache_resolution(
185193
186194
Args:
187195
req: Package requirement to cache
196+
pre_built: Whether this is a prebuilt or source resolution
188197
result: Tuple of (source_url, resolved_version)
189198
"""
190-
self._resolved_requirements[str(req)] = result
199+
cache_key = (str(req), pre_built)
200+
self._resolved_requirements[cache_key] = result
191201

192202
def _resolve_from_graph(
193203
self,

tests/test_requirement_resolver.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for requirement_resolver module."""
22

3+
from unittest.mock import MagicMock, patch
4+
35
import pytest
46
from packaging.requirements import Requirement
57
from packaging.utils import canonicalize_name
@@ -248,8 +250,8 @@ def test_resolve_from_graph_no_previous_graph(tmp_context: WorkContext) -> None:
248250
)
249251

250252

251-
def test_resolve_rejects_git_urls(tmp_context: WorkContext) -> None:
252-
"""RequirementResolver.resolve() rejects git URLs."""
253+
def test_resolve_rejects_git_urls_for_source(tmp_context: WorkContext) -> None:
254+
"""RequirementResolver.resolve() rejects git URLs when pre_built=False."""
253255
resolver = RequirementResolver(tmp_context)
254256

255257
with pytest.raises(
@@ -261,3 +263,170 @@ def test_resolve_rejects_git_urls(tmp_context: WorkContext) -> None:
261263
pre_built=False,
262264
parent_req=None,
263265
)
266+
267+
268+
@patch("fromager.requirement_resolver.wheels.resolve_prebuilt_wheel")
269+
@patch("fromager.requirement_resolver.wheels.get_wheel_server_urls")
270+
def test_resolve_allows_git_urls_for_prebuilt(
271+
mock_get_servers: MagicMock,
272+
mock_resolve_wheel: MagicMock,
273+
tmp_context: WorkContext,
274+
) -> None:
275+
"""RequirementResolver.resolve() allows git URLs when pre_built=True (test mode fallback)."""
276+
resolver = RequirementResolver(tmp_context)
277+
req = Requirement("mypkg @ git+https://github.com/example/repo.git")
278+
279+
# Mock wheel resolution to return expected result
280+
mock_get_servers.return_value = ["https://pypi.org/simple"]
281+
mock_resolve_wheel.return_value = (
282+
"https://files.pythonhosted.org/mypkg-1.0-py3-none-any.whl",
283+
Version("1.0"),
284+
)
285+
286+
# Should NOT raise - git URLs are allowed when explicitly requesting prebuilt
287+
url, version = resolver.resolve(
288+
req=req,
289+
req_type=RequirementType.INSTALL,
290+
pre_built=True,
291+
parent_req=None,
292+
)
293+
294+
# Verify it routed to wheel resolution
295+
mock_resolve_wheel.assert_called_once()
296+
assert url == "https://files.pythonhosted.org/mypkg-1.0-py3-none-any.whl"
297+
assert version == Version("1.0")
298+
299+
300+
@patch("fromager.requirement_resolver.wheels.resolve_prebuilt_wheel")
301+
@patch("fromager.requirement_resolver.wheels.get_wheel_server_urls")
302+
def test_resolve_auto_routes_to_prebuilt(
303+
mock_get_servers: MagicMock,
304+
mock_resolve_wheel: MagicMock,
305+
tmp_context: WorkContext,
306+
) -> None:
307+
"""resolve(pre_built=None) with pbi.pre_built=True routes to wheels.resolve_prebuilt_wheel."""
308+
req = Requirement("setuptools>=40")
309+
310+
# Mock package build info to return pre_built=True
311+
mock_pbi = MagicMock()
312+
mock_pbi.pre_built = True
313+
314+
with patch.object(tmp_context, "package_build_info", return_value=mock_pbi):
315+
resolver = RequirementResolver(tmp_context)
316+
317+
# Mock wheel resolution to return expected result
318+
mock_get_servers.return_value = ["https://pypi.org/simple"]
319+
mock_resolve_wheel.return_value = (
320+
"https://files.pythonhosted.org/setuptools-1.0-py3-none-any.whl",
321+
Version("1.0"),
322+
)
323+
324+
# Call resolve with pre_built=None (should auto-detect)
325+
url, version = resolver.resolve(
326+
req=req,
327+
req_type=RequirementType.INSTALL,
328+
parent_req=None,
329+
pre_built=None,
330+
)
331+
332+
# Verify it routed to wheel resolution
333+
mock_resolve_wheel.assert_called_once()
334+
assert url == "https://files.pythonhosted.org/setuptools-1.0-py3-none-any.whl"
335+
assert version == Version("1.0")
336+
337+
338+
@patch("fromager.requirement_resolver.sources.resolve_source")
339+
def test_resolve_auto_routes_to_source(
340+
mock_resolve_source: MagicMock,
341+
tmp_context: WorkContext,
342+
) -> None:
343+
"""resolve(pre_built=None) with pbi.pre_built=False routes to sources.resolve_source."""
344+
req = Requirement("mypackage>=1.0")
345+
346+
# Mock package build info to return pre_built=False
347+
mock_pbi = MagicMock()
348+
mock_pbi.pre_built = False
349+
350+
with patch.object(tmp_context, "package_build_info", return_value=mock_pbi):
351+
resolver = RequirementResolver(tmp_context)
352+
353+
# Mock source resolution to return expected result
354+
mock_resolve_source.return_value = (
355+
"https://files.pythonhosted.org/mypackage-2.0.tar.gz",
356+
Version("2.0"),
357+
)
358+
359+
# Call resolve with pre_built=None (should auto-detect)
360+
url, version = resolver.resolve(
361+
req=req,
362+
req_type=RequirementType.INSTALL,
363+
parent_req=None,
364+
pre_built=None,
365+
)
366+
367+
# Verify it routed to source resolution
368+
mock_resolve_source.assert_called_once()
369+
assert url == "https://files.pythonhosted.org/mypackage-2.0.tar.gz"
370+
assert version == Version("2.0")
371+
372+
373+
@patch("fromager.requirement_resolver.wheels.resolve_prebuilt_wheel")
374+
@patch("fromager.requirement_resolver.wheels.get_wheel_server_urls")
375+
@patch("fromager.requirement_resolver.sources.resolve_source")
376+
def test_resolve_prebuilt_after_source_uses_separate_cache(
377+
mock_resolve_source: MagicMock,
378+
mock_get_servers: MagicMock,
379+
mock_resolve_wheel: MagicMock,
380+
tmp_context: WorkContext,
381+
) -> None:
382+
"""resolve(pre_built=True) after same req resolved as source uses separate cache."""
383+
req = Requirement("testpkg==1.5")
384+
385+
# Mock package build info to return pre_built=False initially
386+
mock_pbi = MagicMock()
387+
mock_pbi.pre_built = False
388+
389+
with patch.object(tmp_context, "package_build_info", return_value=mock_pbi):
390+
resolver = RequirementResolver(tmp_context)
391+
392+
# Mock source resolution
393+
mock_resolve_source.return_value = (
394+
"https://files.pythonhosted.org/testpkg-1.5.tar.gz",
395+
Version("1.5"),
396+
)
397+
398+
# First call: resolve as source (pre_built=None, auto-detects to False)
399+
url1, version1 = resolver.resolve(
400+
req=req,
401+
req_type=RequirementType.INSTALL,
402+
parent_req=None,
403+
pre_built=None,
404+
)
405+
406+
assert url1 == "https://files.pythonhosted.org/testpkg-1.5.tar.gz"
407+
assert version1 == Version("1.5")
408+
assert mock_resolve_source.call_count == 1
409+
410+
# Mock wheel resolution for second call
411+
mock_get_servers.return_value = ["https://pypi.org/simple"]
412+
mock_resolve_wheel.return_value = (
413+
"https://files.pythonhosted.org/testpkg-1.5-py3-none-any.whl",
414+
Version("1.5"),
415+
)
416+
417+
# Second call: resolve same req as prebuilt (explicit pre_built=True)
418+
# This should NOT return the cached source result
419+
url2, version2 = resolver.resolve(
420+
req=req,
421+
req_type=RequirementType.INSTALL,
422+
parent_req=None,
423+
pre_built=True,
424+
)
425+
426+
# Verify it called wheel resolution (not cached)
427+
assert mock_resolve_wheel.call_count == 1
428+
assert url2 == "https://files.pythonhosted.org/testpkg-1.5-py3-none-any.whl"
429+
assert version2 == Version("1.5")
430+
431+
# Verify source was only called once (first time, not second)
432+
assert mock_resolve_source.call_count == 1

0 commit comments

Comments
 (0)