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:
parent
1724cb2637
commit
857500f20e
4 changed files with 220 additions and 85 deletions
|
|
@ -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.
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -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
|
||||
|
|
|
|||
249
conandeps.py
249
conandeps.py
|
|
@ -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> → 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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue