From d44abc43ef3721ab6000b2f02a4bd1f3e93f6f3e Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 25 Aug 2026 15:29:46 +0200 Subject: [PATCH] conandeps: sort dependencies by level of indirection Record each package's shortest distance from the consumer while collecting the graph (1 = direct) and order the dependency table direct-first, then by increasing depth, alphabetically within a level; the kind column now reads "indirect (N)". Fixture gains a depth-3 package (libdeep under libbar) and a test pins the ordering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYNdaGDrpaqDyT8QtrTQbM --- README.md | 18 +++++++++++------- conandeps.py | 39 +++++++++++++++++++++++++++++++++++++-- tests/test_conandeps.py | 40 ++++++++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5b59675..a6501e0 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,12 @@ profile: arch=x86_64, compiler=gcc, compiler.libcxx=libstdc++11, compiler.versio Dependencies (4) and binary availability -package kind ctx Release Debug RelWithDebInfo ----------------------------------------------------------- -MyDepA/1.0 direct host knor knor knor -boost/1.8 indirect host knor knor knor -MyDepB/1.0 direct host knor knor knor -Zigma/1.0 indirect host knor knor MISSING +package kind ctx Release Debug RelWithDebInfo +-------------------------------------------------------------- +MyDepA/1.0 direct host knor knor knor +MyDepB/1.0 direct host knor knor knor +boost/1.8 indirect (2) host knor knor knor +Zigma/1.0 indirect (2) host knor knor MISSING Missing binaries (1) @@ -127,7 +127,11 @@ environment variable turns that off, and redirecting stdout to a file never produces colour codes unless you pass `--color always`. The HTML report uses the same colour coding. -Reading the table: +Reading the table: rows are sorted with direct dependencies first +(alphabetically), then indirect ones by increasing level of indirection – +`indirect (2)` is required by a direct dependency, `indirect (3)` by one of +those, and so on (shortest path counts). + * `knor` (a remote name) – the remote has a binary for the package_id your profile produces. Hover a cell in the HTML report to see the package_id. diff --git a/conandeps.py b/conandeps.py index 30119dc..e53352d 100755 --- a/conandeps.py +++ b/conandeps.py @@ -81,6 +81,9 @@ class Dep: ref: str context: str # "host" or "build" direct: bool = False + # Shortest path length from the consumer: 1 = direct, 2 = required by a + # direct dependency, ... Used to order the table by level of indirection. + depth: int = 0 build_require: bool = False requires: List[str] = field(default_factory=list) # names of its own requirements settings: Dict[str, str] = field(default_factory=dict) @@ -170,6 +173,10 @@ def _node_key(node) -> str: return "%s#%s" % (node.ref.name, node.context) +def _key_of(dep: Dep) -> str: + return "%s#%s" % (dep.name, dep.context) + + def _explicit_overrides(graph) -> set: """Set of (declaring ref or 'your conanfile', package name) for override=True requires.""" result = set() @@ -241,6 +248,22 @@ class _RemoteIndex: return found +def _depths(root) -> Dict[str, int]: + """Breadth-first shortest distance from the root for every node key.""" + depths: Dict[str, int] = {} + frontier = [(e.dst, 1) for e in root.dependencies] + while frontier: + next_frontier = [] + for node, depth in frontier: + key = _node_key(node) + if key in depths: + continue + depths[key] = depth + next_frontier.extend((e.dst, depth + 1) for e in node.dependencies) + frontier = next_frontier + return depths + + def _collect( graph, build_type: str, @@ -255,6 +278,7 @@ def _collect( """ root = graph.root direct = {_node_key(e.dst) for e in root.dependencies} + depths = _depths(root) for node in graph.nodes: if node is root: continue @@ -264,6 +288,8 @@ def _collect( dep = Dep(ref=str(node.ref), context=node.context) deps[key] = dep dep.direct = dep.direct or key in direct + depth = depths.get(key, 0) + dep.depth = min(dep.depth, depth) if dep.depth else depth dep.build_require = dep.build_require or any(e.build_require for e in node.dependants) for edge in node.dependencies: name = edge.dst.ref.name @@ -347,6 +373,10 @@ def build_report( overrides = _parse_overrides(log.getvalue(), explicit) _progress(quiet, "%d overrides detected" % len(overrides)) + # Direct dependencies first, then by increasing level of indirection, + # alphabetically within a level. Build-context nodes sort after host. + ordered = sorted(deps.values(), key=lambda d: (d.depth, d.context != "host", d.name.lower())) + deps = OrderedDict((_key_of(d), d) for d in ordered) for dep in deps.values(): # keep column order stable dep.binaries = OrderedDict( (bt, dep.binaries.get(bt, ("n/a", "", None))) for bt in build_types @@ -490,6 +520,11 @@ def _override_kind(o: Override) -> str: return "explicit (override=True)" if o.explicit else "implicit (newer requirement downstream)" +def _kind(dep: Dep) -> str: + """'direct', or 'indirect (N)' where N is the number of hops from the consumer.""" + return "direct" if dep.direct else "indirect (%d)" % dep.depth + + def render_text(report: Report, color: bool = False) -> str: pal = _Palette(color) out: List[str] = [] @@ -503,7 +538,7 @@ def render_text(report: Report, color: bool = False) -> str: w(pal.bold("Dependencies (%d) and binary availability" % len(report.deps))) w("") rows = [ - [dep.ref, "direct" if dep.direct else "indirect", dep.context] + [dep.ref, _kind(dep), dep.context] + [_cell(*dep.binaries[bt], pal) for bt in report.build_types] for dep in report.deps.values() ] @@ -606,7 +641,7 @@ def render_html(report: Report) -> str: w("

Dependencies (%d) and binary availability

" % len(report.deps)) rows = [] for dep in report.deps.values(): - row = [e(dep.ref), "direct" if dep.direct else "indirect", e(dep.context)] + row = [e(dep.ref), e(_kind(dep)), e(dep.context)] for bt in report.build_types: status, pid, remote = dep.binaries[bt] cls = {"Remote": "ok", "Missing": "missing", "CacheOnly": "cacheonly"}.get(status, "na") diff --git a/tests/test_conandeps.py b/tests/test_conandeps.py index e55fc82..5e93efc 100644 --- a/tests/test_conandeps.py +++ b/tests/test_conandeps.py @@ -6,6 +6,7 @@ the usual settings but their build() does nothing, so no compiler is needed): consumer (conanfile.py) |-- libfoo/1.0 binaries: Release, Debug, RelWithDebInfo | `-- libbar/1.0 binaries: Release only <- MISSING Debug/RWDI + | `-- libdeep/1.0 binaries: all three <- depth 3 |-- libbar/1.1 (override=True) <- explicit override |-- libbaz/2.0 binaries: all three | `-- libqux/1.0 binaries: all three @@ -17,6 +18,7 @@ remotes are never touched. """ import os +import re import shutil import socket import subprocess @@ -177,8 +179,9 @@ def conan_env(tmp_path_factory): _conan(env, "create", str(d), "-s", "build_type=%s" % bt, "--build=missing") _conan(env, "upload", "%s/%s" % (name, version), "-r", "test", "--all", "-c") - create("libbar", "1.0", build_types=("Release",)) - create("libbar", "1.1", build_types=("Release",)) + create("libdeep", "1.0") + create("libbar", "1.0", requires=("libdeep/1.0",), build_types=("Release",)) + create("libbar", "1.1", requires=("libdeep/1.0",), build_types=("Release",)) create("libfoo", "1.0", requires=("libbar/1.0",)) create("libqux", "1.0") create("libqux", "1.1") @@ -213,7 +216,14 @@ def report(conan_env): def test_dependencies_listed(report): refs = {d.ref for d in report.deps.values()} - assert refs == {"libfoo/1.0", "libbar/1.1", "libbaz/2.0", "libqux/1.1", "hdr/1.0"} + assert refs == { + "libfoo/1.0", + "libbar/1.1", + "libbaz/2.0", + "libqux/1.1", + "hdr/1.0", + "libdeep/1.0", + } direct = {d.ref for d in report.deps.values() if d.direct} assert direct == {"libfoo/1.0", "libbaz/2.0", "libqux/1.1", "hdr/1.0"} # libbar is only reachable through libfoo -> indirect, even though the @@ -221,6 +231,22 @@ def test_dependencies_listed(report): assert not report.deps["libbar#host"].direct +def test_sorted_by_level_of_indirection(report): + order = [(d.ref, d.depth) for d in report.deps.values()] + # Direct deps first (alphabetical), then depth 2, then depth 3. + assert order == [ + ("hdr/1.0", 1), + ("libbaz/2.0", 1), + ("libfoo/1.0", 1), + ("libqux/1.1", 1), # required directly, even though libbaz also pulls it in + ("libbar/1.1", 2), + ("libdeep/1.0", 3), + ] + text = conandeps.render_text(report) + assert re.search(r"libbar/1\.1\s+indirect \(2\)", text) + assert re.search(r"libdeep/1\.0\s+indirect \(3\)", text) + + def test_missing_binaries(report): missing = {d.ref: d.missing() for d in report.deps.values() if d.missing()} assert missing == {"libbar/1.1": ["Debug", "RelWithDebInfo"]} @@ -243,7 +269,9 @@ def test_cache_only_is_not_available(conan_env): env = dict(os.environ) d = Path(conan_env["home"]) / "libbar-local" d.mkdir() - (d / "conanfile.py").write_text(RECIPE.format(name="libbar", version="1.1", requires="")) + (d / "conanfile.py").write_text( + RECIPE.format(name="libbar", version="1.1", requires='requires = ("libdeep/1.0",)') + ) _conan(env, "create", str(d), "-s", "build_type=Debug") _conan(env, "install", "libbar/1.1@", "-s", "build_type=Release", "-r", "test") @@ -275,11 +303,11 @@ def test_text_and_html_render(report, tmp_path): text = conandeps.render_text(report) assert "libbar/1.1" in text and "MISSING" in text table = text.split("Missing binaries")[0] - assert table.count("test") >= 4 * 3 - 2 # every available cell names the remote + assert table.count("test") >= 5 * 3 - 2 # every available cell names the remote assert "\033[" not in text # no ANSI codes unless asked for # Missing binaries and overrides are tables with a header row. assert "package build type package_id" in text - assert "libbar/1.1 Debug 8bfd7e15c6920f4673ca8dee419ab5320ae445e4 no binary" in text + assert re.search(r"libbar/1\.1\s+Debug\s+[0-9a-f]{40}\s+no binary anywhere", text) assert "package wanted forced to by kind" in text assert "libfoo/1.0 libbar/1.0 libbar/1.1 your conanfile explicit (override=True)" in text assert "overrides libbar/1.0 (explicit" in text # tree edge annotation