Skip to content

Commit 6648ae6

Browse files
authored
Merge pull request #855 from tiran/integrate-tracking-topo
feat: implement DependencyGraph.get_build_topology
2 parents 58e963f + 93bc18d commit 6648ae6

5 files changed

Lines changed: 185 additions & 1 deletion

File tree

src/fromager/commands/graph.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,3 +565,43 @@ def migrate_graph(
565565
graph.serialize(f)
566566
else:
567567
graph.serialize(sys.stdout)
568+
569+
570+
@graph.command()
571+
@click.argument(
572+
"graph-file",
573+
type=clickext.ClickPath(),
574+
)
575+
@click.pass_obj
576+
def build_graph(
577+
wkctx: context.WorkContext,
578+
graph_file: pathlib.Path,
579+
):
580+
"""Print build graph steps for parallel-build
581+
582+
The build-graph command takes a graph.json file and analyzes in which
583+
order parallel build is going to build the wheels. It also shows which
584+
wheels are recognized as build dependencies or exclusive builds.
585+
"""
586+
graph = DependencyGraph.from_file(graph_file)
587+
topo = graph.get_build_topology(context=wkctx)
588+
topo.prepare()
589+
590+
def n2s(nodes: typing.Iterable[DependencyNode]) -> str:
591+
return ", ".join(sorted(node.key for node in nodes))
592+
593+
print(f"Build dependencies ({len(topo.dependency_nodes)}):")
594+
print(n2s(topo.dependency_nodes), "\n")
595+
if topo.exclusive_nodes:
596+
print(f"Exclusive builds ({len(topo.exclusive_nodes)}):")
597+
print(n2s(topo.exclusive_nodes), "\n")
598+
599+
print("Build rounds:")
600+
rounds: int = 0
601+
while topo.is_active():
602+
rounds += 1
603+
nodes_to_build = topo.get_available()
604+
print(f"{rounds}.", n2s(nodes_to_build))
605+
topo.done(*nodes_to_build)
606+
607+
print(f"\nBuilding {len(graph)} packages in {rounds} rounds.")

src/fromager/dependency_graph.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
from .read import open_file_or_url
1616
from .requirements_file import RequirementType
1717

18+
if typing.TYPE_CHECKING:
19+
from .context import WorkContext
20+
1821
logger = logging.getLogger(__name__)
1922

2023
ROOT = ""
@@ -67,6 +70,12 @@ def __post_init__(self) -> None:
6770
self, "key", f"{self.canonicalized_name}=={self.version}"
6871
)
6972

73+
@property
74+
def requirement(self) -> Requirement:
75+
if self.canonicalized_name == ROOT:
76+
raise RuntimeError("root node is not a requirement")
77+
return Requirement(self.key)
78+
7079
def add_child(
7180
self,
7281
child: DependencyNode,
@@ -249,6 +258,10 @@ def clear(self) -> None:
249258
self.nodes.clear()
250259
self.nodes[ROOT] = DependencyNode.construct_root_node()
251260

261+
def __len__(self) -> int:
262+
# exclude ROOT
263+
return len(self.nodes) - 1
264+
252265
def _to_dict(self):
253266
raw_graph = {}
254267
stack = [self.nodes[ROOT]]
@@ -382,6 +395,22 @@ def _depth_first_traversal(
382395
edge.destination_node.children, visited, match_dep_types
383396
)
384397

398+
def get_build_topology(self, context: WorkContext) -> TrackingTopologicalSorter:
399+
"""Create build topology graph
400+
401+
The build topology contains all nodes of the graph (except ROOT).
402+
Each node depends on its build dependencies, but not on its
403+
installation dependencies.
404+
"""
405+
topo = TrackingTopologicalSorter()
406+
for node in self.get_all_nodes():
407+
if node.key == ROOT:
408+
continue
409+
pbi = context.package_build_info(node.canonicalized_name)
410+
build_req = tuple(node.iter_build_requirements())
411+
topo.add(node, *build_req, exclusive=pbi.exclusive_build)
412+
return topo
413+
385414

386415
class TrackingTopologicalSorter:
387416
"""A thread-safe topological sorter that tracks nodes in progress

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
from fromager import context, packagesettings
88

99
TESTDATA_PATH = pathlib.Path(__file__).parent.absolute() / "testdata"
10+
E2E_PATH = pathlib.Path(__file__).parent.parent.absolute() / "e2e"
1011

1112

1213
@pytest.fixture
1314
def testdata_path() -> typing.Generator[pathlib.Path, None, None]:
1415
yield TESTDATA_PATH
1516

1617

18+
@pytest.fixture
19+
def e2e_path() -> typing.Generator[pathlib.Path, None, None]:
20+
yield E2E_PATH
21+
22+
1723
@pytest.fixture
1824
def tmp_context(tmp_path: pathlib.Path) -> context.WorkContext:
1925
patches_dir = tmp_path / "overrides/patches"

tests/test_commands_graph.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import pathlib
2+
3+
from click.testing import CliRunner
4+
5+
from fromager.__main__ import main as fromager
6+
7+
8+
def test_fromager_version(cli_runner: CliRunner, e2e_path: pathlib.Path) -> None:
9+
graph_json = e2e_path / "build-parallel" / "graph.json"
10+
result = cli_runner.invoke(fromager, ["graph", "build-graph", str(graph_json)])
11+
assert result.exit_code == 0
12+
assert "1. flit-core==3.12.0, setuptools==80.8.0" in result.stdout
13+
assert "Building 16 packages in 4 rounds" in result.stdout

tests/test_dependency_graph.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import dataclasses
22
import graphlib
3+
import pathlib
34
import typing
45

56
import pytest
67
from packaging.requirements import Requirement
78
from packaging.utils import canonicalize_name
89
from packaging.version import Version
910

10-
from fromager.dependency_graph import DependencyNode, TrackingTopologicalSorter
11+
from fromager import context
12+
from fromager.dependency_graph import (
13+
DependencyGraph,
14+
DependencyNode,
15+
TrackingTopologicalSorter,
16+
)
1117
from fromager.requirements_file import RequirementType
1218

1319

@@ -54,6 +60,8 @@ def test_dependencynode_dataclass() -> None:
5460
repr(a)
5561
== "DependencyNode(canonicalized_name='a', version=<Version('1.0')>, download_url='', pre_built=False, constraint=None)"
5662
)
63+
assert a.requirement == Requirement("a==1.0")
64+
5765
with pytest.raises(dataclasses.FrozenInstanceError):
5866
a.version = Version("2.0") # type: ignore[misc]
5967
with pytest.raises((TypeError, AttributeError)):
@@ -63,6 +71,8 @@ def test_dependencynode_dataclass() -> None:
6371
assert root.canonicalized_name == ""
6472
assert root.version == Version("0.0")
6573
assert root.key == ""
74+
with pytest.raises(RuntimeError):
75+
assert root.requirement
6676

6777

6878
def test_iter_requirements() -> None:
@@ -295,3 +305,89 @@ def test_tracking_topology_sorter_not_active_error() -> None:
295305
with pytest.raises(ValueError) as excinfo:
296306
topo.get_available()
297307
assert "topology is not active" in str(excinfo.value)
308+
309+
310+
def node2str(nodes: set[DependencyNode]) -> set[str]:
311+
return {node.key for node in nodes}
312+
313+
314+
def test_e2e_parallel_graph(
315+
tmp_context: context.WorkContext, e2e_path: pathlib.Path
316+
) -> None:
317+
graph = DependencyGraph.from_file(e2e_path / "build-parallel" / "graph.json")
318+
assert len(graph) == 16
319+
320+
topo = graph.get_build_topology(tmp_context)
321+
assert node2str(topo.dependency_nodes) == {
322+
"cython==3.1.1",
323+
"flit-core==3.12.0",
324+
"packaging==25.0",
325+
"setuptools-scm==8.3.1",
326+
"setuptools==80.8.0",
327+
"wheel==0.46.1",
328+
}
329+
330+
steps = [node2str(batch) for batch in topo.static_batches()]
331+
assert steps == [
332+
{
333+
"flit-core==3.12.0",
334+
"setuptools==80.8.0",
335+
},
336+
{
337+
"cython==3.1.1",
338+
"imapclient==3.0.1",
339+
"jinja2==3.1.6",
340+
"markupsafe==3.0.2",
341+
"more-itertools==10.7.0",
342+
"packaging==25.0",
343+
},
344+
{
345+
"setuptools-scm==8.3.1",
346+
"wheel==0.46.1",
347+
},
348+
{
349+
"imapautofiler==1.14.0",
350+
"jaraco-classes==3.4.0",
351+
"jaraco-context==6.0.1",
352+
"jaraco-functools==4.1.0",
353+
"keyring==25.6.0",
354+
"pyyaml==6.0.2",
355+
},
356+
]
357+
358+
# same graph, but mark cython as exclusive
359+
topo = graph.get_build_topology(tmp_context)
360+
node = graph.nodes["cython==3.1.1"]
361+
topo.add(node, exclusive=True)
362+
363+
steps = [node2str(batch) for batch in topo.static_batches()]
364+
assert steps == [
365+
{
366+
"flit-core==3.12.0",
367+
"setuptools==80.8.0",
368+
},
369+
{
370+
"imapclient==3.0.1",
371+
"jinja2==3.1.6",
372+
"markupsafe==3.0.2",
373+
"more-itertools==10.7.0",
374+
"packaging==25.0",
375+
},
376+
{
377+
"setuptools-scm==8.3.1",
378+
"wheel==0.46.1",
379+
},
380+
{
381+
"imapautofiler==1.14.0",
382+
"jaraco-classes==3.4.0",
383+
"jaraco-context==6.0.1",
384+
"jaraco-functools==4.1.0",
385+
"keyring==25.6.0",
386+
},
387+
{
388+
"cython==3.1.1",
389+
},
390+
{
391+
"pyyaml==6.0.2",
392+
},
393+
]

0 commit comments

Comments
 (0)