diff --git a/src/fromager/bootstrapper.py b/src/fromager/bootstrapper.py index e538b012..ae0c17da 100644 --- a/src/fromager/bootstrapper.py +++ b/src/fromager/bootstrapper.py @@ -939,6 +939,9 @@ def _add_to_graph( _, parent_req, parent_version = self.why[-1] if self.why else (None, None, None) pbi = self.ctx.package_build_info(req) + # Get the constraint rule if any + constraint_req = self.ctx.constraints.get_constraint(req.name) + constraint = str(constraint_req) if constraint_req else "" # Update the dependency graph after we determine that this requirement is # useful but before we determine if it is redundant so that we capture all # edges to use for building a valid constraints file. @@ -950,6 +953,7 @@ def _add_to_graph( req_version=req_version, download_url=download_url, pre_built=pbi.pre_built, + constraint=constraint, ) self.ctx.write_to_graph_to_file() diff --git a/src/fromager/commands/bootstrap.py b/src/fromager/commands/bootstrap.py index f643966f..07be56ce 100644 --- a/src/fromager/commands/bootstrap.py +++ b/src/fromager/commands/bootstrap.py @@ -169,6 +169,9 @@ def bootstrap( req_type=RequirementType.TOP_LEVEL, ) logger.info("%s resolves to %s", req, version) + # Get the constraint rule if any + constraint_req = wkctx.constraints.get_constraint(req.name) + constraint = str(constraint_req) if constraint_req else "" wkctx.dependency_graph.add_dependency( parent_name=None, parent_version=None, @@ -177,6 +180,7 @@ def bootstrap( req_version=version, download_url=source_url, pre_built=pbi.pre_built, + constraint=constraint, ) requirement_ctxvar.reset(token) diff --git a/src/fromager/commands/graph.py b/src/fromager/commands/graph.py index bee94b41..9e4ccd61 100644 --- a/src/fromager/commands/graph.py +++ b/src/fromager/commands/graph.py @@ -237,7 +237,11 @@ def show_explain_duplicates(graph: DependencyGraph) -> None: usable_versions: dict[str, list[str]] = {} user_counter: int = 0 - print(f"\n{dep_name}") + # Get the constraint from the first node (all versions have the same constraint) + constraint_info = ( + f" (constraint: {nodes[0].constraint})" if nodes[0].constraint else "" + ) + print(f"\n{dep_name}{constraint_info}") for node in sorted(nodes, key=lambda x: x.version): print(f" {node.version}") @@ -327,7 +331,8 @@ def find_why( # we might be invoked for multiple packages and we want the format to be # consistent. if depth == 0: - print(f"\n{node.key}") + constraint_info = f" (constraint: {node.constraint})" if node.constraint else "" + print(f"\n{node.key}{constraint_info}") seen = set([node.key]).union(seen) all_skipped = True @@ -338,16 +343,25 @@ def find_why( # dependencies. if parent.destination_node.key == ROOT: is_toplevel = True + # Show constraint for top-level dependencies + constraint_info = ( + f" (constraint: {node.constraint})" if node.constraint else "" + ) print( - f"{' ' * depth} * {node.key} is a toplevel dependency with req {parent.req}" + f"{' ' * depth} * {node.key}{constraint_info} is a toplevel dependency with req {parent.req}" ) continue # Skip dependencies that don't match the req_type. if req_type and parent.req_type not in req_type: continue all_skipped = False + parent_constraint = ( + f" (constraint: {parent.destination_node.constraint})" + if parent.destination_node.constraint + else "" + ) print( - f"{' ' * depth} * {node.key} is an {parent.req_type} dependency of {parent.destination_node.key} with req {parent.req}" + f"{' ' * depth} * {node.key} is an {parent.req_type} dependency of {parent.destination_node.key}{parent_constraint} with req {parent.req}" ) if max_depth and (max_depth == -1 or depth <= max_depth): find_why( diff --git a/src/fromager/dependency_graph.py b/src/fromager/dependency_graph.py index efde31cf..fcc23ddc 100644 --- a/src/fromager/dependency_graph.py +++ b/src/fromager/dependency_graph.py @@ -29,6 +29,7 @@ class DependencyNodeDict(typing.TypedDict): canonicalized_name: str version: str pre_built: bool + constraint: str edges: list[DependencyEdgeDict] @@ -38,6 +39,7 @@ class DependencyNode: version: Version download_url: str = dataclasses.field(default="", compare=False) pre_built: bool = dataclasses.field(default=False, compare=False) + constraint: str = dataclasses.field(default="", compare=False) # additional fields key: str = dataclasses.field(init=False, compare=False, repr=False) parents: list[DependencyEdge] = dataclasses.field( @@ -85,6 +87,7 @@ def to_dict(self) -> DependencyNodeDict: "pre_built": self.pre_built, "version": str(self.version), "canonicalized_name": str(self.canonicalized_name), + "constraint": self.constraint, "edges": [edge.to_dict() for edge in self.children], } @@ -175,6 +178,7 @@ def from_dict( req_version=Version(destination_node_dict["version"]), download_url=destination_node_dict["download_url"], pre_built=destination_node_dict["pre_built"], + constraint=destination_node_dict.get("constraint", ""), ) stack.append(edge_dict["key"]) visited.add(curr_key) @@ -207,12 +211,14 @@ def _add_node( version: Version, download_url: str, pre_built: bool, + constraint: str, ): new_node = DependencyNode( canonicalized_name=req_name, version=version, download_url=download_url, pre_built=pre_built, + constraint=constraint, ) # check if a node with that key already exists. if it does then use that node = self.nodes.get(new_node.key, new_node) @@ -229,6 +235,7 @@ def add_dependency( req_version: Version, download_url: str = "", pre_built: bool = False, + constraint: str = "", ) -> None: logger.debug( "recording %s dependency %s%s -> %s==%s", @@ -244,6 +251,7 @@ def add_dependency( version=req_version, download_url=download_url, pre_built=pre_built, + constraint=constraint, ) parent_key = ROOT if parent_name is None else f"{parent_name}=={parent_version}" diff --git a/tests/test_dependency_graph.py b/tests/test_dependency_graph.py index 5ce835ea..9201ddaf 100644 --- a/tests/test_dependency_graph.py +++ b/tests/test_dependency_graph.py @@ -48,7 +48,7 @@ def test_dependencynode_dataclass(): assert a.key == "a==1.0" assert ( repr(a) - == "DependencyNode(canonicalized_name='a', version=, download_url='', pre_built=False)" + == "DependencyNode(canonicalized_name='a', version=, download_url='', pre_built=False, constraint='')" ) with pytest.raises(dataclasses.FrozenInstanceError): a.version = Version("2.0") diff --git a/tests/test_graph.py b/tests/test_graph.py index 96889cd4..5a463ad7 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -11,6 +11,7 @@ "pre_built": False, "version": "0", "canonicalized_name": "", + "constraint": "", "edges": [{"key": "a==2.0", "req_type": "install", "req": "a==2.0"}], }, "a==2.0": { @@ -18,6 +19,7 @@ "pre_built": False, "version": "2.0", "canonicalized_name": "a", + "constraint": "", "edges": [ {"key": "b==3.0", "req_type": "build-system", "req": "b==3.0"}, {"key": "c==4.0", "req_type": "build-backend", "req": "c==4.0"}, @@ -28,6 +30,7 @@ "pre_built": False, "version": "3.0", "canonicalized_name": "b", + "constraint": "", "edges": [ {"key": "c==4.0", "req_type": "build-sdist", "req": "c<=4.0"}, ], @@ -37,6 +40,7 @@ "pre_built": False, "version": "4.0", "canonicalized_name": "c", + "constraint": "", "edges": [], }, } diff --git a/tests/test_graph_commands.py b/tests/test_graph_commands.py new file mode 100644 index 00000000..f875d2bd --- /dev/null +++ b/tests/test_graph_commands.py @@ -0,0 +1,182 @@ +"""Test graph command functions that display constraint information.""" + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + +from fromager import dependency_graph +from fromager.commands.graph import find_why, show_explain_duplicates +from fromager.requirements_file import RequirementType + + +def test_show_explain_duplicates_with_constraints(capsys): + """Test that explain_duplicates shows constraint information.""" + # Create a graph with duplicate dependencies that have constraints + graph = dependency_graph.DependencyGraph() + + # Add top-level package + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("package-a"), + req_version=Version("1.0.0"), + download_url="https://example.com/package-a-1.0.0.tar.gz", + ) + + # Add package-b version 1.0.0 as dependency of package-a with constraint + graph.add_dependency( + parent_name=canonicalize_name("package-a"), + parent_version=Version("1.0.0"), + req_type=RequirementType.INSTALL, + req=Requirement("package-b>=1.0"), + req_version=Version("1.0.0"), + download_url="https://example.com/package-b-1.0.0.tar.gz", + constraint="package-b>=1.0,<2.0", + ) + + # Add another top-level package + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("package-c"), + req_version=Version("1.0.0"), + download_url="https://example.com/package-c-1.0.0.tar.gz", + ) + + # Add package-b version 2.0.0 as dependency of package-c without constraint + graph.add_dependency( + parent_name=canonicalize_name("package-c"), + parent_version=Version("1.0.0"), + req_type=RequirementType.INSTALL, + req=Requirement("package-b>=2.0"), + req_version=Version("2.0.0"), + download_url="https://example.com/package-b-2.0.0.tar.gz", + constraint="", + ) + + # Run the command + show_explain_duplicates(graph) + + # Capture output + captured = capsys.readouterr() + + # Verify constraint is shown at the package name level, not per-version + assert "package-b (constraint: package-b>=1.0,<2.0)" in captured.out + # Versions should be shown without constraint info + assert " 1.0.0\n" in captured.out + assert " 2.0.0\n" in captured.out + # Version lines should not have constraint info + assert "1.0.0 (constraint:" not in captured.out + assert "2.0.0 (constraint:" not in captured.out + + +def test_find_why_with_constraints(capsys): + """Test that why command shows constraint information.""" + # Create a graph with constraints + graph = dependency_graph.DependencyGraph() + + # Add top-level package with constraint + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("parent-pkg"), + req_version=Version("1.0.0"), + download_url="https://example.com/parent-pkg-1.0.0.tar.gz", + constraint="parent-pkg==1.0.0", + ) + + # Add child dependency with its own constraint + graph.add_dependency( + parent_name=canonicalize_name("parent-pkg"), + parent_version=Version("1.0.0"), + req_type=RequirementType.INSTALL, + req=Requirement("child-pkg>=1.0"), + req_version=Version("1.5.0"), + download_url="https://example.com/child-pkg-1.5.0.tar.gz", + constraint="child-pkg>=1.0,<2.0", + ) + + # Find why child-pkg is included + child_node = graph.nodes["child-pkg==1.5.0"] + find_why(graph, child_node, 1, 0, []) + + # Capture output + captured = capsys.readouterr() + + # Verify constraint is shown for the child package at depth 0 + assert "child-pkg==1.5.0 (constraint: child-pkg>=1.0,<2.0)" in captured.out + # Verify constraint is shown for the parent when showing the dependency relationship + assert "(constraint: parent-pkg==1.0.0)" in captured.out + + +def test_find_why_toplevel_with_constraint(capsys): + """Test that why command shows constraint for top-level dependencies.""" + # Create a graph with a top-level package that has a constraint + graph = dependency_graph.DependencyGraph() + + # Add top-level package with constraint + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("toplevel-pkg"), + req_version=Version("2.0.0"), + download_url="https://example.com/toplevel-pkg-2.0.0.tar.gz", + constraint="toplevel-pkg>=2.0,<3.0", + ) + + # Find why toplevel-pkg is included + node = graph.nodes["toplevel-pkg==2.0.0"] + find_why(graph, node, 0, 0, []) + + # Capture output + captured = capsys.readouterr() + + # Verify constraint is shown at depth 0 + assert "toplevel-pkg==2.0.0 (constraint: toplevel-pkg>=2.0,<3.0)" in captured.out + # Verify constraint is shown when identifying it as a top-level dependency + assert ( + "toplevel-pkg==2.0.0 (constraint: toplevel-pkg>=2.0,<3.0) is a toplevel dependency" + in captured.out + ) + + +def test_find_why_without_constraints(capsys): + """Test that why command works when no constraints are present.""" + # Create a graph without constraints + graph = dependency_graph.DependencyGraph() + + # Add top-level package without constraint + graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("simple-pkg"), + req_version=Version("1.0.0"), + download_url="https://example.com/simple-pkg-1.0.0.tar.gz", + ) + + # Add child dependency without constraint + graph.add_dependency( + parent_name=canonicalize_name("simple-pkg"), + parent_version=Version("1.0.0"), + req_type=RequirementType.INSTALL, + req=Requirement("simple-child"), + req_version=Version("2.0.0"), + download_url="https://example.com/simple-child-2.0.0.tar.gz", + ) + + # Find why simple-child is included + child_node = graph.nodes["simple-child==2.0.0"] + find_why(graph, child_node, 1, 0, []) + + # Capture output + captured = capsys.readouterr() + + # Verify no constraint info is shown + assert "(constraint:" not in captured.out + assert "simple-child==2.0.0" in captured.out + assert "simple-pkg==1.0.0" in captured.out