@@ -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+
253436def 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