diff --git a/CLAUDE.md b/CLAUDE.md index 1e6b76b..ba19697 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,13 @@ Users install/upgrade on their own machine with replace this with settings-dict matching against `conan search` output. - **"Missing" means missing for the exact profile.** Not "no binary with that build_type at all". -- **Dependency table order is by level of indirection.** `_depths()` does a - breadth-first walk from the root; `Dep.depth` is the *shortest* path (1 = - direct, so a package that is both direct and transitive is direct). Rows - sort on (depth, host before build, name) and the `level` column shows the - number. The tree section keeps graph order on purpose. +- **Dependency table shows the shortest path, not a level number.** + `_paths()` does a breadth-first walk from the root; `Dep.path` is the list + of package names on the *shortest* path (empty = direct, so a package that + is both direct and transitive is direct). The `via` column renders it as + `A -> B` (`→` in HTML) with `(+N)` for further direct parents from + `Dep.parents`. Rows sort on (depth, path, host before build, name). The + tree section keeps graph order and shows every path. - **Only the remote counts, and the source is always shown.** Conan says `Cache` for a locally cached binary without asking the remote, so `_RemoteIndex` verifies cache hits with `search_packages` on the remote. diff --git a/README.md b/README.md index f221bf4..edd2711 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 level ctx Release Debug RelWithDebInfo ------------------------------------------------------------------ -MyDepA/1.0 direct 1 host knor knor knor -MyDepB/1.0 direct 1 host knor knor knor -boost/1.8 indirect 2 host knor knor knor -Zigma/1.0 indirect 2 host knor knor MISSING +package via ctx Release Debug RelWithDebInfo +---------------------------------------------------------------------- +MyDepA/1.0 - host knor knor knor +MyDepB/1.0 - host knor knor knor +boost/1.8 MyDepA (+1) host knor knor knor +Zigma/1.0 MyDepB host knor knor MISSING Missing binaries (1) @@ -127,11 +127,13 @@ 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: rows are sorted with direct dependencies first -(alphabetically), then indirect ones by increasing level of indirection. The -`level` column is the number of hops from your conanfile: 1 is direct, 2 is -required by a direct dependency, 3 by one of those, and so on (shortest path -counts). The HTML report has the same column. +Reading the table: the `via` column is the shortest path from your conanfile +to the package – `-` for a direct dependency, `MyDepB` for something MyDepB +requires, `MyDepB -> Zigma` for something Zigma requires, and so on. `(+N)` +means N other packages also require it directly (boost above is pulled in by +both MyDepA and Zigma); the dependency hierarchy section shows every path. +Rows are sorted direct-first, then by path, so a branch's transitive +dependencies stay together. * `knor` (a remote name) – the remote has a binary for the package_id your diff --git a/conandeps.py b/conandeps.py index 3de775d..4f58c2e 100755 --- a/conandeps.py +++ b/conandeps.py @@ -84,6 +84,11 @@ class Dep: # 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 + # Package names on the shortest path from the consumer to this package, + # excluding the package itself: [] for direct, ["A"] for A -> pkg, ... + path: List[str] = field(default_factory=list) + # Names of every package that directly requires this one (all paths). + parents: List[str] = field(default_factory=list) build_require: bool = False requires: List[str] = field(default_factory=list) # names of its own requirements settings: Dict[str, str] = field(default_factory=dict) @@ -248,20 +253,25 @@ 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] +def _paths(root) -> Dict[str, List[str]]: + """Breadth-first shortest path (list of package names) from the root to every node. + + The path excludes the node itself, so direct dependencies get []. Among + equally short paths the first one found in graph order wins; the other + parents are still reported through ``Dep.parents``. + """ + paths: Dict[str, List[str]] = {} + frontier = [(e.dst, []) for e in root.dependencies] while frontier: next_frontier = [] - for node, depth in frontier: + for node, path in frontier: key = _node_key(node) - if key in depths: + if key in paths: continue - depths[key] = depth - next_frontier.extend((e.dst, depth + 1) for e in node.dependencies) + paths[key] = path + next_frontier.extend((e.dst, path + [node.ref.name]) for e in node.dependencies) frontier = next_frontier - return depths + return paths def _collect( @@ -278,7 +288,7 @@ def _collect( """ root = graph.root direct = {_node_key(e.dst) for e in root.dependencies} - depths = _depths(root) + paths = _paths(root) for node in graph.nodes: if node is root: continue @@ -288,8 +298,14 @@ 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 + path = paths.get(key, []) + depth = len(path) + 1 + if not dep.depth or depth < dep.depth: + dep.depth, dep.path = depth, path + for edge in node.dependants: + parent = "your conanfile" if edge.src is root else edge.src.ref.name + if parent not in dep.parents: + dep.parents.append(parent) 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 @@ -375,7 +391,10 @@ def build_report( # 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())) + ordered = sorted( + deps.values(), + key=lambda d: (d.depth, [p.lower() for p in d.path], 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( @@ -520,8 +539,17 @@ def _override_kind(o: Override) -> str: return "explicit (override=True)" if o.explicit else "implicit (newer requirement downstream)" -def _kind(dep: Dep) -> str: - return "direct" if dep.direct else "indirect" +def _via(dep: Dep, arrow: str = " -> ") -> str: + """Shortest path to the package: '-' for direct, 'A -> B' for consumer -> A -> B -> pkg. + + ``(+N)`` marks N further packages that also require it directly; the + dependency hierarchy section shows every path in full. + """ + if dep.direct: + return "-" + via = arrow.join(dep.path) + extra = len(dep.parents) - 1 + return via + (" (+%d)" % extra if extra > 0 else "") def render_text(report: Report, color: bool = False) -> str: @@ -537,11 +565,11 @@ def render_text(report: Report, color: bool = False) -> str: w(pal.bold("Dependencies (%d) and binary availability" % len(report.deps))) w("") rows = [ - [dep.ref, _kind(dep), str(dep.depth), dep.context] + [dep.ref, _via(dep), dep.context] + [_cell(*dep.binaries[bt], pal) for bt in report.build_types] for dep in report.deps.values() ] - out.extend(_table(["package", "kind", "level", "ctx"] + list(report.build_types), rows, pal)) + out.extend(_table(["package", "via", "ctx"] + list(report.build_types), rows, pal)) w("") # ---- missing binaries ------------------------------------------------ @@ -640,14 +668,14 @@ 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), e(_kind(dep)), str(dep.depth), e(dep.context)] + row = [e(dep.ref), e(_via(dep, " \u2192 ")), 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") text = e(_cell(status, pid, remote, _Palette(False))) row.append('%s' % (cls, e(pid), text)) rows.append(row) - w(_html_table(["package", "kind", "level", "context"] + list(report.build_types), rows)) + w(_html_table(["package", "via", "context"] + list(report.build_types), rows)) # ---- missing binaries ------------------------------------------------ problems = report.problems() diff --git a/tests/test_conandeps.py b/tests/test_conandeps.py index 36d846b..a65be48 100644 --- a/tests/test_conandeps.py +++ b/tests/test_conandeps.py @@ -231,23 +231,26 @@ 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. +def test_sorted_by_path(report): + order = [(d.ref, d.path) for d in report.deps.values()] + # Direct deps first (alphabetical), then by shortest path. 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), + ("hdr/1.0", []), + ("libbaz/2.0", []), + ("libfoo/1.0", []), + ("libqux/1.1", []), # required directly, even though libbaz also pulls it in + ("libbar/1.1", ["libfoo"]), + ("libdeep/1.0", ["libfoo", "libbar"]), ] + # libqux is required by both the consumer and libbaz -> two parents. + assert sorted(report.deps["libqux#host"].parents) == ["libbaz", "your conanfile"] text = conandeps.render_text(report) - assert re.search(r"libbar/1\.1\s+indirect\s+2\s+host", text) - assert re.search(r"libdeep/1\.0\s+indirect\s+3\s+host", text) + assert re.search(r"hdr/1\.0\s+-\s+host", text) + assert re.search(r"libbar/1\.1\s+libfoo\s+host", text) + assert re.search(r"libdeep/1\.0\s+libfoo -> libbar\s+host", text) page = conandeps.render_html(report) - assert "level" in page - assert "libdeep/1.0indirect3host" in page + assert "via" in page + assert "libdeep/1.0libfoo \u2192 libbarhost" in page def test_missing_binaries(report):