Skip to content

Commit 61ebd40

Browse files
fix(resolver): fall back to name-based graph lookup before PyPI
In repeatable build mode (using graph.json) when a dependency was encountered via a new parent or different req_type not in the previous graph, the lookup returned None and resolution fell back to PyPI, picking up newer versions instead of reusing the pinned version. Add a name-based fallback that searches all nodes in the previous graph by package name when the parent-specific lookup fails. This prevents unnecessary PyPI fallbacks while preserving the existing resolution priority. Closes: #958 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent e60f4ba commit 61ebd40

2 files changed

Lines changed: 216 additions & 1 deletion

File tree

src/fromager/requirement_resolver.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,39 @@ def _resolve_from_graph(
269269
)
270270
seen_version.add(str(edge.destination_node.version))
271271

272-
return self._resolve_from_version_source(possible_versions_from_graph, req)
272+
resolver_result = self._resolve_from_version_source(
273+
possible_versions_from_graph, req
274+
)
275+
if resolver_result:
276+
return resolver_result
277+
278+
# Fallback: search by package name across the entire previous graph,
279+
# ignoring parent and req_type. This handles cases where a dependency
280+
# is encountered via a new parent or a different req_type that did not
281+
# exist in the previous graph (#958).
282+
# NOTE: This fallback ignores both parent and req_type filters.
283+
# It may pick a version from a different parent or dependency type
284+
# than the original bootstrap order, but this is preferable to
285+
# falling through to PyPI and pulling an unpinned version.
286+
possible_versions_by_name: list[tuple[str, Version]] = []
287+
for node in self.prev_graph.get_nodes_by_name(req.name):
288+
if node.pre_built == pre_built and str(node.version) not in seen_version:
289+
possible_versions_by_name.append((node.download_url, node.version))
290+
seen_version.add(str(node.version))
291+
292+
if possible_versions_by_name:
293+
logger.debug(
294+
"%s: name-based fallback found versions in previous graph: %s",
295+
req.name,
296+
[str(v) for _, v in possible_versions_by_name],
297+
)
298+
else:
299+
logger.debug(
300+
"%s: no versions found in previous graph by name either",
301+
req.name,
302+
)
303+
304+
return self._resolve_from_version_source(possible_versions_by_name, req)
273305

274306
def _resolve_from_version_source(
275307
self,

tests/test_requirement_resolver.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,189 @@ def test_resolve_from_graph_no_previous_graph(tmp_context: WorkContext) -> None:
250250
)
251251

252252

253+
def test_resolve_from_graph_new_parent_reuses_existing_version(
254+
tmp_context: WorkContext,
255+
) -> None:
256+
"""Graph resolution finds a package even when encountered via a new parent.
257+
258+
In a repeatable build, packaging==25.0 exists in the previous graph
259+
under setuptools-scm. A new dependency (wheel) also requires packaging>=24.0.
260+
Because wheel is not in the previous graph, the parent-specific lookup fails
261+
and fromager falls back to PyPI, picking up packaging==26.0 instead of reusing 25.0.
262+
"""
263+
# Build a previous graph that mirrors the real-world scenario:
264+
# ROOT -> setuptools-scm==9.2.2 --[install]--> packaging==25.0
265+
prev_graph = DependencyGraph()
266+
prev_graph.add_dependency(
267+
parent_name=None,
268+
parent_version=None,
269+
req_type=RequirementType.TOP_LEVEL,
270+
req=Requirement("setuptools-scm"),
271+
req_version=Version("9.2.2"),
272+
)
273+
prev_graph.add_dependency(
274+
parent_name=canonicalize_name("setuptools-scm"),
275+
parent_version=Version("9.2.2"),
276+
req_type=RequirementType.INSTALL,
277+
req=Requirement("packaging>=20"),
278+
req_version=Version("25.0"),
279+
)
280+
281+
resolver = RequirementResolver(tmp_context, prev_graph)
282+
283+
# Resolve packaging>=24.0 via a NEW parent "wheel" that is NOT in prev_graph.
284+
# packaging==25.0 satisfies >=24.0 and exists in the graph, so it should
285+
# be returned instead of falling back to PyPI.
286+
result = resolver._resolve_from_graph(
287+
req=Requirement("packaging>=24.0"),
288+
req_type=RequirementType.INSTALL,
289+
pre_built=False,
290+
parent_req=Requirement("wheel"),
291+
)
292+
assert result is not None, (
293+
"Expected packaging==25.0 from prev_graph but got None (would fall back to PyPI)"
294+
)
295+
assert result == ("", Version("25.0"))
296+
297+
298+
def test_resolve_from_graph_different_req_type_reuses_existing_version(
299+
tmp_context: WorkContext,
300+
) -> None:
301+
"""Graph resolution finds a package even when req_type differs.
302+
303+
If a package appears as a build-system dependency in the previous graph
304+
but is now encountered as an install dependency (or vice-versa), the
305+
strict req_type filter causes the lookup to fail, falling back to PyPI
306+
and potentially picking up a newer version.
307+
"""
308+
# Previous graph: foo==1.0 --[build-system]--> bar==2.0
309+
prev_graph = DependencyGraph()
310+
prev_graph.add_dependency(
311+
parent_name=None,
312+
parent_version=None,
313+
req_type=RequirementType.TOP_LEVEL,
314+
req=Requirement("foo"),
315+
req_version=Version("1.0"),
316+
)
317+
prev_graph.add_dependency(
318+
parent_name=canonicalize_name("foo"),
319+
parent_version=Version("1.0"),
320+
req_type=RequirementType.BUILD_SYSTEM,
321+
req=Requirement("bar>=1.0"),
322+
req_version=Version("2.0"),
323+
)
324+
325+
resolver = RequirementResolver(tmp_context, prev_graph)
326+
327+
# Now resolve bar>=1.5 as an INSTALL dep of foo (different req_type).
328+
# bar==2.0 satisfies >=1.5 and exists in the graph under the same parent
329+
# but with a different req_type.
330+
result = resolver._resolve_from_graph(
331+
req=Requirement("bar>=1.5"),
332+
req_type=RequirementType.INSTALL,
333+
pre_built=False,
334+
parent_req=Requirement("foo"),
335+
)
336+
assert result is not None, (
337+
"Expected bar==2.0 from prev_graph but got None (would fall back to PyPI)"
338+
)
339+
assert result == ("", Version("2.0"))
340+
341+
342+
def test_resolve_from_graph_parent_specific_preferred_over_name_fallback(
343+
tmp_context: WorkContext,
344+
) -> None:
345+
"""Parent-specific lookup is preferred over the name-based fallback.
346+
347+
When the previous graph contains a package under the exact parent and
348+
req_type being requested, the parent-specific result must be returned
349+
even though the name-based fallback would also find candidates.
350+
"""
351+
# Previous graph:
352+
# ROOT -> foo==1.0 --[install]--> bar==2.0
353+
# ROOT -> baz==1.0 --[install]--> bar==3.0
354+
prev_graph = DependencyGraph()
355+
prev_graph.add_dependency(
356+
parent_name=None,
357+
parent_version=None,
358+
req_type=RequirementType.TOP_LEVEL,
359+
req=Requirement("foo"),
360+
req_version=Version("1.0"),
361+
)
362+
prev_graph.add_dependency(
363+
parent_name=canonicalize_name("foo"),
364+
parent_version=Version("1.0"),
365+
req_type=RequirementType.INSTALL,
366+
req=Requirement("bar>=1.0"),
367+
req_version=Version("2.0"),
368+
)
369+
prev_graph.add_dependency(
370+
parent_name=None,
371+
parent_version=None,
372+
req_type=RequirementType.TOP_LEVEL,
373+
req=Requirement("baz"),
374+
req_version=Version("1.0"),
375+
)
376+
prev_graph.add_dependency(
377+
parent_name=canonicalize_name("baz"),
378+
parent_version=Version("1.0"),
379+
req_type=RequirementType.INSTALL,
380+
req=Requirement("bar>=1.0"),
381+
req_version=Version("3.0"),
382+
)
383+
384+
resolver = RequirementResolver(tmp_context, prev_graph)
385+
386+
# Resolve bar>=1.0 as install dep of foo. The parent-specific lookup
387+
# should return bar==2.0 (from foo), NOT bar==3.0 (from baz via fallback).
388+
result = resolver._resolve_from_graph(
389+
req=Requirement("bar>=1.0"),
390+
req_type=RequirementType.INSTALL,
391+
pre_built=False,
392+
parent_req=Requirement("foo"),
393+
)
394+
assert result is not None
395+
assert result == ("", Version("2.0"))
396+
397+
398+
def test_resolve_from_graph_name_fallback_returns_none_for_missing_package(
399+
tmp_context: WorkContext,
400+
) -> None:
401+
"""Name-based fallback returns None when the package is not in the graph.
402+
403+
When the previous graph is populated but does not contain the requested
404+
package under any parent or req_type, both the parent-specific lookup
405+
and the name-based fallback should return None so that resolution
406+
proceeds to PyPI.
407+
"""
408+
# Previous graph has packages, but NOT "missing-pkg".
409+
prev_graph = DependencyGraph()
410+
prev_graph.add_dependency(
411+
parent_name=None,
412+
parent_version=None,
413+
req_type=RequirementType.TOP_LEVEL,
414+
req=Requirement("foo"),
415+
req_version=Version("1.0"),
416+
)
417+
prev_graph.add_dependency(
418+
parent_name=canonicalize_name("foo"),
419+
parent_version=Version("1.0"),
420+
req_type=RequirementType.INSTALL,
421+
req=Requirement("bar>=1.0"),
422+
req_version=Version("2.0"),
423+
)
424+
425+
resolver = RequirementResolver(tmp_context, prev_graph)
426+
427+
result = resolver._resolve_from_graph(
428+
req=Requirement("missing-pkg>=1.0"),
429+
req_type=RequirementType.INSTALL,
430+
pre_built=False,
431+
parent_req=Requirement("foo"),
432+
)
433+
assert result is None
434+
435+
253436
def test_resolve_rejects_git_urls_for_source(tmp_context: WorkContext) -> None:
254437
"""RequirementResolver.resolve() rejects git URLs when pre_built=False."""
255438
resolver = RequirementResolver(tmp_context)

0 commit comments

Comments
 (0)