diff --git a/CLAUDE.md b/CLAUDE.md index 7024f69..c04c48d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 4a7f284..5b59675 100644 --- a/README.md +++ b/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 diff --git a/conandeps.py b/conandeps.py index 8fddd95..30119dc 100755 --- a/conandeps.py +++ b/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("%s" % html.escape(h) for h in headers) + body = "".join("%s" % "".join("%s" % c for c in row) for row in rows) + return "%s%s
" % (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("

Dependencies and binary availability

") - w( - "" - + "".join("" % e(bt) for bt in report.build_types) - + "" - ) + # ---- dependencies ---------------------------------------------------- + w("

Dependencies (%d) and binary availability

" % len(report.deps)) + rows = [] for dep in report.deps.values(): - w( - "" - % (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('' % (cls, title, e(_cell(status, pid, remote)))) - w("") - w("
packagekindcontext%s
%s%s%s%s
") + 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", "context"] + list(report.build_types), rows)) + # ---- missing binaries ------------------------------------------------ problems = report.problems() - w("

Missing binaries (%d)

" % len(problems)) + n_missing = sum(len(d.missing()) for d in problems) + w("

Missing binaries (%d)

" % n_missing) if problems: - w( - "" - ) + rows = [] + for dep in problems: + for bt in dep.missing(): + status, pid, _ = dep.binaries[bt] + reason = ( + 'not on remote, in local cache only' + if status == "CacheOnly" + else 'no binary anywhere' + ) + rows.append([e(dep.ref), e(bt), "%s" % e(pid), reason]) + w(_html_table(["package", "build type", "package_id", "reason"], rows)) else: - w("

All packages have binaries for %s.

" % e(", ".join(report.build_types))) + w( + '

All packages have binaries on the remote for %s.

' + % e(", ".join(report.build_types)) + ) + # ---- overrides ------------------------------------------------------- w("

Overrides (%d)

" % len(report.overrides)) if report.overrides: - w("") + rows = [ + [ + e(o.package), + '%s' % e(o.old), + '%s' % e(o.new), + e(o.by), + '%s' + % ("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("

None detected.

") @@ -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)) diff --git a/tests/test_conandeps.py b/tests/test_conandeps.py index 4107066..e55fc82 100644 --- a/tests/test_conandeps.py +++ b/tests/test_conandeps.py @@ -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("") and 'class="missing"' in page + assert page.count("") == 3 # dependencies, missing binaries, overrides + assert 'class="explicit"' in page and 'class="implicit"' in page assert "