conandeps: render missing binaries and overrides as colour-coded tables

All three sections (dependencies, missing binaries, overrides) now use the
same column-aligned table layout in both the text and the HTML report. The
text report gets ANSI colour on a terminal (green remote, red missing,
yellow cache-only/overridden) via --color auto|always|never, honouring
NO_COLOR; the table helper aligns on visible width so colour codes never
break columns. Every state is still spelled out in words so nothing is
conveyed by colour alone.

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:19:22 +02:00
commit 857500f20e
4 changed files with 220 additions and 85 deletions

View file

@ -60,6 +60,11 @@ for what it does and how it is used.
header makes the file usable via `uv run conandeps.py` without a checkout.
- **Exit codes:** `0` fine, `1` missing binaries, `2` Conan error. CI relies
on this.
- **Tables and colour, consistently.** Dependencies, missing binaries and
overrides are all rendered through the same `_table()` helper (text) and
`_html_table()` (HTML). Colour is an aid only every state is also spelled
out in words and `_table()` aligns on *visible* width so ANSI codes never
break columns. `--color auto` is the default (terminal + no `NO_COLOR`).
- **stdout is the report, stderr is progress.** Graph resolution against a
remote takes a while; progress lines (`_progress`) and `--verbose` live
Conan output go to stderr only, so stdout can be redirected to a file.

View file

@ -72,6 +72,7 @@ conandeps CONANFILE [-r REMOTE] [-pr PROFILE] [-s KEY=VALUE] [-o PKG:KEY=VALUE]
| `--html FILE` | Also write a self-contained HTML report (no scripts, no external assets). |
| `-v` | Stream Conan's own output to stderr live. |
| `-q` | Suppress the progress lines on stderr. |
| `--color auto\|always\|never` | Colour the text report. Default `auto`: only on a terminal, honours `NO_COLOR`. |
Progress (`[1/3] resolving graph for build_type=Release on knor ...`) goes to
stderr; the report itself goes to stdout, so redirecting stdout to a file
@ -91,22 +92,27 @@ 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 indir. host knor knor knor
MyDepB/1.0 direct host knor knor knor
Zigma/1.0 indir. host knor knor MISSING
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
MISSING BINARIES (1 packages)
Zigma/1.0: missing RelWithDebInfo
RelWithDebInfo package_id 3f9c...e21a
Missing binaries (1)
package build type package_id reason
---------------------------------------------------------------------------------------
Zigma/1.0 RelWithDebInfo 3f9c2b7d0e6a4f18c5d9a0b3e7f1c2d4a5b6e21a no binary anywhere
Overrides (1)
Zigma/1.0 wanted boost/1.7
-> forced to boost/1.8 by your conanfile [implicit (newer direct requirement)]
package wanted forced to by kind
-------------------------------------------------------------------------------------
Zigma/1.0 boost/1.7 boost/1.8 your conanfile implicit (newer requirement downstream)
Dependency hierarchy
MyProject/conanfile.py
|-- MyDepA/1.0
| `-- boost/1.8
@ -115,6 +121,12 @@ MyProject/conanfile.py
`-- boost/1.8 [overrides boost/1.7 (implicit by your conanfile)]
```
On a terminal the tables are coloured (green = on the remote, red = missing,
yellow = cache-only / overridden version); `--color never` or the `NO_COLOR`
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:
* `knor` (a remote name) the remote has a binary for the package_id your

View file

@ -37,6 +37,7 @@ from __future__ import annotations
import argparse
import html
import io
import os
import re
import sys
import time
@ -421,65 +422,140 @@ def _tree_lines(report: Report) -> List[str]:
return lines
def _cell(status: str, _package_id: str, remote: Optional[str]) -> str:
class _Palette:
"""ANSI colours for the terminal report; a no-op when disabled.
Colour is a readability aid only: every state is also spelled out in
words, so a colour-blind reader or a log file loses nothing.
"""
def __init__(self, enabled: bool):
self.enabled = enabled
def _wrap(self, code: str, text: str) -> str:
return "\033[%sm%s\033[0m" % (code, text) if self.enabled else text
def ok(self, text: str) -> str:
return self._wrap("32", text) # green
def bad(self, text: str) -> str:
return self._wrap("1;31", text) # bold red
def warn(self, text: str) -> str:
return self._wrap("33", text) # yellow
def dim(self, text: str) -> str:
return self._wrap("2", text)
def bold(self, text: str) -> str:
return self._wrap("1", text)
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
def _visible_len(text: str) -> int:
return len(_ANSI_RE.sub("", text))
def _table(headers: List[str], rows: List[List[str]], pal: _Palette) -> List[str]:
"""Render a column-aligned text table; cells may contain ANSI colour codes."""
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], _visible_len(cell))
def fmt(cells: List[str]) -> str:
padded = [c + " " * (widths[i] - _visible_len(c)) for i, c in enumerate(cells)]
return " ".join(padded).rstrip()
lines = [pal.bold(fmt(headers)), "-" * (sum(widths) + 2 * (len(widths) - 1))]
lines.extend(fmt(r) for r in rows)
return lines
def _cell(status: str, _package_id: str, remote: Optional[str], pal: _Palette) -> str:
"""Cell text: the remote that has the binary, or why it is not available."""
if status == "Remote":
return remote or "remote"
return _MARK.get(status, status)
return pal.ok(remote or "remote")
text = _MARK.get(status, status)
if status == "Missing":
return pal.bad(text)
if status == "CacheOnly":
return pal.warn(text)
return pal.dim(text)
def render_text(report: Report) -> str:
def _override_kind(o: Override) -> str:
return "explicit (override=True)" if o.explicit else "implicit (newer requirement downstream)"
def render_text(report: Report, color: bool = False) -> str:
pal = _Palette(color)
out: List[str] = []
w = out.append
w("conandeps report for %s" % report.conanfile)
w(pal.bold("conandeps report for %s" % report.conanfile))
w("remote : %s" % (report.remote or "(all configured remotes)"))
w("profile: " + ", ".join("%s=%s" % kv for kv in report.profile.items()))
w("")
# ---- table -----------------------------------------------------------
w("Dependencies (%d) and binary availability" % len(report.deps))
# ---- dependencies ----------------------------------------------------
w(pal.bold("Dependencies (%d) and binary availability" % len(report.deps)))
w("")
refw = max([len(d.ref) for d in report.deps.values()] + [len("package")])
header = "%-*s %-6s %-4s " % (refw, "package", "kind", "ctx") + " ".join(
"%-14s" % bt for bt in report.build_types
)
w(header)
w("-" * len(header))
for dep in report.deps.values():
kind = "direct" if dep.direct else "indir."
cells = " ".join("%-14s" % _cell(*dep.binaries[bt]) for bt in report.build_types)
w("%-*s %-6s %-4s %s" % (refw, dep.ref, kind, dep.context, cells))
rows = [
[dep.ref, "direct" if dep.direct else "indirect", dep.context]
+ [_cell(*dep.binaries[bt], pal) for bt in report.build_types]
for dep in report.deps.values()
]
out.extend(_table(["package", "kind", "ctx"] + list(report.build_types), rows, pal))
w("")
# ---- problems --------------------------------------------------------
# ---- missing binaries ------------------------------------------------
problems = report.problems()
n_missing = sum(len(d.missing()) for d in problems)
w(pal.bold("Missing binaries (%d)" % n_missing))
w("")
if problems:
w("MISSING BINARIES (%d packages)" % len(problems))
rows = []
for dep in problems:
w(" %s: missing %s" % (dep.ref, ", ".join(dep.missing())))
for bt in dep.missing():
status, pid, _ = dep.binaries[bt]
note = " (in local cache only, not on the remote)" if status == "CacheOnly" else ""
w(" %s package_id %s%s" % (bt, pid, note))
reason = (
pal.warn("not on remote, in local cache only")
if status == "CacheOnly"
else pal.bad("no binary anywhere")
)
rows.append([dep.ref, bt, pid, reason])
out.extend(_table(["package", "build type", "package_id", "reason"], rows, pal))
else:
w("All packages have binaries on the remote for: " + ", ".join(report.build_types))
w(pal.ok("All packages have binaries on the remote for: " + ", ".join(report.build_types)))
w("")
# ---- overrides -------------------------------------------------------
w("Overrides (%d)" % len(report.overrides))
if not report.overrides:
w(" none detected")
for o in report.overrides:
kind = "explicit override=True" if o.explicit else "implicit (newer direct requirement)"
w(" %s wanted %s" % (o.package, o.old))
w(" -> forced to %s by %s [%s]" % (o.new, o.by, kind))
if o.new in report.ranges:
w(" (%s resolved from %s)" % (o.new, report.ranges[o.new]))
w(pal.bold("Overrides (%d)" % len(report.overrides)))
w("")
if report.overrides:
rows = [
[
o.package,
pal.dim(o.old),
pal.warn(o.new),
o.by,
pal.bold(_override_kind(o)) if o.explicit else _override_kind(o),
]
for o in report.overrides
]
out.extend(_table(["package", "wanted", "forced to", "by", "kind"], rows, pal))
else:
w("none detected")
w("")
# ---- hierarchy -------------------------------------------------------
w("Dependency hierarchy")
out.extend(_tree_lines(report))
w(pal.bold("Dependency hierarchy"))
w("")
for line in _tree_lines(report):
line = line.replace("MISSING:", pal.bad("MISSING:")) if color else line
w(line)
return "\n".join(out) + "\n"
@ -489,16 +565,27 @@ def render_text(report: Report) -> str:
_CSS = """
body{font:14px/1.4 system-ui,sans-serif;margin:2rem;color:#1b1b1b;background:#fff}
h1,h2{font-weight:600}table{border-collapse:collapse;margin:1rem 0}
th,td{border:1px solid #bbb;padding:.3rem .6rem;text-align:left}
th{background:#eee}.ok{color:#1b6e1b}.ok::before{content:"\\2713 "}
th,td{border:1px solid #bbb;padding:.3rem .6rem;text-align:left;vertical-align:top}
th{background:#eee}code{font-size:.95em}
.ok{color:#1b6e1b}.ok::before{content:"\\2713 "}
.missing{color:#a40000;font-weight:700}.missing::before{content:"\\2717 "}
.na{color:#777}pre{background:#f5f5f5;padding:1rem;overflow-x:auto}
.tag{font-size:.85em;color:#555}
.cacheonly{color:#8a5a00;font-weight:700}.cacheonly::before{content:"\\26A0 "}
.na{color:#777}.old{color:#777;text-decoration:line-through}.new{color:#8a5a00;font-weight:700}
.explicit{color:#1b4e8a;font-weight:700}.implicit{color:#555}
pre{background:#f5f5f5;padding:1rem;overflow-x:auto}
@media (prefers-color-scheme:dark){body{background:#111;color:#eee}th{background:#222}
th,td{border-color:#444}pre{background:#1c1c1c}.ok{color:#6fd66f}.missing{color:#ff7b7b}}
th,td{border-color:#444}pre{background:#1c1c1c}.ok{color:#6fd66f}.missing{color:#ff7b7b}
.cacheonly{color:#ffc857}.new{color:#ffc857}.explicit{color:#8ab8f0}.na,.old,.implicit{color:#999}}
"""
def _html_table(headers: List[str], rows: List[List[str]]) -> str:
"""Rows are lists of already-escaped/markup cell strings."""
head = "".join("<th>%s</th>" % html.escape(h) for h in headers)
body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % c for c in row) for row in rows)
return "<table><tr>%s</tr>%s</table>" % (head, body)
def render_html(report: Report) -> str:
e = html.escape
parts: List[str] = []
@ -515,49 +602,56 @@ def render_html(report: Report) -> str:
)
)
w("<h2>Dependencies and binary availability</h2><table>")
w(
"<tr><th>package</th><th>kind</th><th>context</th>"
+ "".join("<th>%s</th>" % e(bt) for bt in report.build_types)
+ "</tr>"
)
# ---- dependencies ----------------------------------------------------
w("<h2>Dependencies (%d) and binary availability</h2>" % len(report.deps))
rows = []
for dep in report.deps.values():
w(
"<tr><td>%s</td><td>%s</td><td>%s</td>"
% (e(dep.ref), "direct" if dep.direct else "indirect", e(dep.context))
)
row = [e(dep.ref), "direct" if dep.direct else "indirect", e(dep.context)]
for bt in report.build_types:
status, pid, remote = dep.binaries[bt]
cls = "ok" if status in _AVAILABLE else "missing" if status in _PROBLEM else "na"
title = ' title="package_id %s"' % e(pid)
w('<td class="%s"%s>%s</td>' % (cls, title, e(_cell(status, pid, remote))))
w("</tr>")
w("</table>")
cls = {"Remote": "ok", "Missing": "missing", "CacheOnly": "cacheonly"}.get(status, "na")
text = e(_cell(status, pid, remote, _Palette(False)))
row.append('<span class="%s" title="package_id %s">%s</span>' % (cls, e(pid), text))
rows.append(row)
w(_html_table(["package", "kind", "context"] + list(report.build_types), rows))
# ---- missing binaries ------------------------------------------------
problems = report.problems()
w("<h2>Missing binaries (%d)</h2>" % len(problems))
n_missing = sum(len(d.missing()) for d in problems)
w("<h2>Missing binaries (%d)</h2>" % n_missing)
if problems:
w(
"<ul>"
+ "".join(
"<li>%s: missing %s</li>" % (e(d.ref), e(", ".join(d.missing()))) for d in problems
)
+ "</ul>"
)
rows = []
for dep in problems:
for bt in dep.missing():
status, pid, _ = dep.binaries[bt]
reason = (
'<span class="cacheonly">not on remote, in local cache only</span>'
if status == "CacheOnly"
else '<span class="missing">no binary anywhere</span>'
)
rows.append([e(dep.ref), e(bt), "<code>%s</code>" % e(pid), reason])
w(_html_table(["package", "build type", "package_id", "reason"], rows))
else:
w("<p>All packages have binaries for %s.</p>" % e(", ".join(report.build_types)))
w(
'<p class="ok">All packages have binaries on the remote for %s.</p>'
% e(", ".join(report.build_types))
)
# ---- overrides -------------------------------------------------------
w("<h2>Overrides (%d)</h2>" % len(report.overrides))
if report.overrides:
w("<ul>")
for o in report.overrides:
kind = "explicit override=True" if o.explicit else "implicit"
w(
"<li>%s wanted <code>%s</code> &rarr; forced to <code>%s</code> by %s "
'<span class="tag">[%s]</span></li>'
% (e(o.package), e(o.old), e(o.new), e(o.by), kind)
)
w("</ul>")
rows = [
[
e(o.package),
'<span class="old">%s</span>' % e(o.old),
'<span class="new">%s</span>' % e(o.new),
e(o.by),
'<span class="%s">%s</span>'
% ("explicit" if o.explicit else "implicit", e(_override_kind(o))),
]
for o in report.overrides
]
w(_html_table(["package", "wanted", "forced to", "by", "kind"], rows))
else:
w("<p>None detected.</p>")
@ -618,7 +712,16 @@ def main(argv: Optional[List[str]] = None) -> int:
"-v", "--verbose", action="store_true", help="stream Conan's own output to stderr live"
)
p.add_argument("-q", "--quiet", action="store_true", help="no progress lines on stderr")
p.add_argument(
"--color",
choices=("auto", "always", "never"),
default="auto",
help="colour the text report (default: auto = only on a terminal, honours NO_COLOR)",
)
args = p.parse_args(argv)
color = args.color == "always" or (
args.color == "auto" and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
)
build_types = [b.strip() for b in args.build_types.split(",") if b.strip()]
try:
@ -637,7 +740,7 @@ def main(argv: Optional[List[str]] = None) -> int:
sys.stderr.write("conandeps: error: %s\n" % exc)
return 2
sys.stdout.write(render_text(report))
sys.stdout.write(render_text(report, color))
if args.html:
with open(args.html, "w", encoding="utf-8") as fh:
fh.write(render_html(report))

View file

@ -259,6 +259,7 @@ def test_cache_only_is_not_available(conan_env):
text = conandeps.render_text(report)
assert "not available" in text and "in local cache only" in text
assert 'class="cacheonly"' in conandeps.render_html(report)
_conan(env, "remove", "libbar/1.1@", "-f") # leave the cache as other tests expect
@ -273,12 +274,26 @@ def test_overrides(report):
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]
table = text.split("Missing binaries")[0]
assert table.count("test") >= 4 * 3 - 2 # every available cell names the remote
assert "overrides libbar/1.0 (explicit" in text and "explicit override=True" in text
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 "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
coloured = conandeps.render_text(report, color=True)
assert "\033[1;31mMISSING\033[0m" in coloured and "\033[32mtest\033[0m" in coloured
# Colour codes must not break column alignment: same visible layout.
strip = conandeps._ANSI_RE.sub
assert strip("", coloured) == text
assert "`-- " in text or "|-- " in text # tree drawn
page = conandeps.render_html(report)
assert page.startswith("<!DOCTYPE html>") and 'class="missing"' in page
assert page.count("<table>") == 3 # dependencies, missing binaries, overrides
assert 'class="explicit"' in page and 'class="implicit"' in page
assert "<script" not in page # self-contained, static