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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYNdaGDrpaqDyT8QtrTQbM
This commit is contained in:
Ole-Morten Duesund 2026-08-25 15:29:46 +02:00
commit d44abc43ef
3 changed files with 82 additions and 15 deletions

View file

@ -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.

View file

@ -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("<h2>Dependencies (%d) and binary availability</h2>" % 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")

View file

@ -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