conan-1.6-utilities/conandeps.py

862 lines
32 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.8"
# dependencies = ["conan>=1.66,<2"]
# ///
"""conandeps - dependency, binary-availability and override report for Conan 1.x.
Given a conanfile.py this tool:
* lists the direct and indirect dependencies (host and build context),
* checks, for every dependency, whether a binary package exists on the
chosen remote for each build_type we care about (Release, Debug and
RelWithDebInfo by default) using the *exact* profile you would build with,
* reports every requirement override - explicit ``override=True`` ones as
well as implicit ones where a downstream consumer simply asks for a newer
version than a transitive dependency declared - as a hierarchy showing who
asked for what and which version won.
How it works
------------
Instead of hand-matching ``conan search`` output against the profile we ask
Conan itself: the dependency graph is built once per build_type through the
same ``conan info`` code path that ``conan install`` uses, and every node's
``binary`` status (Cache/Download/Update/Missing/...) is read back. This
honours ``package_id()`` customisations, options and the configured
``default_package_id_mode`` exactly the way a real install would.
Conan 1.x does not record overrides on the graph; the only trace is a warning
emitted by ``Requirements.update()``. We therefore capture Conan's output
stream while the graph is built and parse those lines.
Targets Conan 1.66. Uses only the standard library plus the Conan API.
"""
from __future__ import annotations
import argparse
import html
import io
import os
import re
import sys
import time
import warnings
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
# Conan 1.x predates the stricter escape-sequence checks of recent Pythons and
# emits a screenful of SyntaxWarning/DeprecationWarning from its own modules
# (patch_ng.py, model/ref.py, ...) when they are byte-compiled. They are not
# actionable for the user of this tool, so silence them before Conan is
# imported (imports of ``conans`` are deliberately deferred to _make_api()).
warnings.filterwarnings("ignore", category=SyntaxWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
DEFAULT_BUILD_TYPES = ("Release", "Debug", "RelWithDebInfo")
# Conan 1.x: "<pkg>: requirement <old> overridden by <who> to <new> " where
# <who> is either a reference or the literal "your conanfile".
_OVERRIDE_RE = re.compile(
r"requirement (?P<old>\S+) overridden by (?P<by>your conanfile|\S+) to (?P<new>\S+)"
)
# Binary states, after our post-processing of Conan's node.binary
# (see conans/client/graph/graph.py for Conan's own set):
# Remote - the remote has the binary (Conan said Download/Update, or said
# Cache and we confirmed the remote has the same package_id)
# CacheOnly - only the local cache has it; a clean machine would fail
# Missing - nowhere
_AVAILABLE = {"Remote"}
_PROBLEM = {"Missing", "CacheOnly"}
# States that mean "does not apply", not "missing".
_NOT_APPLICABLE = {"Skip", "Editable"}
@dataclass
class Dep:
"""One package in the graph plus its binary status per build_type."""
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
# 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)
# One path per direct parent: shortest path to that parent plus the
# parent itself. Bounded by the number of parents, unlike "all paths".
paths: List[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)
# build_type -> (status, package_id, remote_name)
binaries: Dict[str, Tuple[str, str, Optional[str]]] = field(default_factory=dict)
@property
def name(self) -> str:
return self.ref.split("/", 1)[0]
def missing(self) -> List[str]:
"""Build types without a binary on the remote (cache-only counts as missing)."""
return [bt for bt, (status, _, _) in self.binaries.items() if status in _PROBLEM]
@dataclass
class Override:
"""A requirement that was changed by a downstream consumer."""
package: str # who had its requirement rewritten (or "your conanfile")
old: str
new: str
by: str # who forced it ("your conanfile" for the root)
explicit: bool # True when `by` declared it with override=True
@dataclass
class Report:
conanfile: str
remote: Optional[str]
profile: Dict[str, str]
build_types: List[str]
deps: "OrderedDict[str, Dep]" # keyed by "<name>#<context>"
overrides: List[Override]
ranges: Dict[str, str] # resolved ref -> declared range/ref
root_requires: List[str]
def problems(self) -> List[Dep]:
return [d for d in self.deps.values() if d.missing()]
# --------------------------------------------------------------------------- #
# Graph collection
# --------------------------------------------------------------------------- #
class _Tee(io.TextIOBase):
"""Capture Conan's output in memory and optionally echo it live to stderr.
The in-memory copy is parsed for override warnings afterwards; the live
echo (``--verbose``) lets the user watch recipe downloads and remote
queries while a slow graph resolution is running.
"""
def __init__(self, echo: bool):
self.buffer_ = io.StringIO()
self.echo = echo
def write(self, data: str) -> int:
self.buffer_.write(data)
if self.echo:
sys.stderr.write(data)
sys.stderr.flush()
return len(data)
def getvalue(self) -> str:
return self.buffer_.getvalue()
def _progress(quiet: bool, msg: str) -> None:
"""Status line on stderr so stdout stays a clean, parseable report."""
if not quiet:
sys.stderr.write("conandeps: %s\n" % msg)
sys.stderr.flush()
def _make_api(log: _Tee):
"""Create a Conan API that writes everything to ``log`` instead of stdout.
Colour is disabled so the override warnings can be parsed reliably.
"""
from conans.client.conan_api import ConanAPIV1
from conans.client.output import ConanOutput
return ConanAPIV1(output=ConanOutput(log, log, color=False))
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()
for node in graph.nodes:
who = (
"your conanfile"
if node.ref is None or node.recipe in ("Consumer", "Virtual")
else str(node.ref)
)
for req in node.conanfile.requires.values():
if req.override:
result.add((who, req.ref.name))
return result
def _parse_overrides(text: str, explicit: set) -> List[Override]:
seen = set()
result = []
for line in text.splitlines():
m = _OVERRIDE_RE.search(line)
if not m:
continue
# Line looks like "pkg/1.0: WARN: pkg/1.0: requirement ..."; the package
# is the token right before "requirement".
head = line[: m.start()].rstrip(": ")
package = head.split(":")[-1].strip() or "your conanfile"
key = (package, m["old"], m["new"], m["by"])
if key in seen:
continue
seen.add(key)
name = m["old"].split("/", 1)[0]
result.append(
Override(package, m["old"], m["new"], m["by"], explicit=(m["by"], name) in explicit)
)
return result
class _RemoteIndex:
"""Answers "does remote X have package_id Y of ref Z?" with one search per ref.
Conan reports ``Cache`` for a binary it already has locally without ever
asking the remote. The question this tool answers is whether the *remote*
has it, so cache hits are verified here via ``conan search`` on the remote
(or all remotes when none was given).
"""
def __init__(self, api, remote: Optional[str]):
self._api = api
self._remote = remote or "all"
self._cache: Dict[str, Dict[str, str]] = {} # ref -> {package_id: remote_name}
def remote_for(self, ref: str, package_id: str) -> Optional[str]:
if ref not in self._cache:
self._cache[ref] = self._search(ref)
return self._cache[ref].get(package_id)
def _search(self, ref: str) -> Dict[str, str]:
from conans.errors import ConanException
found: Dict[str, str] = {}
try:
info = self._api.search_packages(ref, remote_name=self._remote)
except ConanException:
return found # recipe not on the remote at all
for remote_info in info.get("results", []):
for item in remote_info.get("items", []):
for pkg in item.get("packages", []):
found.setdefault(pkg["id"], remote_info["remote"])
return found
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, path in frontier:
key = _node_key(node)
if key in paths:
continue
paths[key] = path
next_frontier.extend((e.dst, path + [node.ref.name]) for e in node.dependencies)
frontier = next_frontier
return paths
def _collect(
graph,
build_type: str,
deps: "OrderedDict[str, Dep]",
ranges: Dict[str, str],
index: _RemoteIndex,
):
"""Merge one build_type's graph into ``deps``.
The graph may differ between build types (conditional requirements), so we
union nodes rather than assume the first graph is complete.
"""
root = graph.root
direct = {_node_key(e.dst) for e in root.dependencies}
paths = _paths(root)
for node in graph.nodes:
if node is root:
continue
key = _node_key(node)
dep = deps.get(key)
if dep is None:
dep = Dep(ref=str(node.ref), context=node.context)
deps[key] = dep
dep.direct = dep.direct or key in direct
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:
if edge.src is root:
parent, via = "your conanfile", []
else:
parent = edge.src.ref.name
via = paths.get(_node_key(edge.src), []) + [parent]
if parent not in dep.parents:
dep.parents.append(parent)
dep.paths.append(via)
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
if name not in dep.requires:
dep.requires.append(name)
for req in node.conanfile.requires.values():
# range_ref also differs from ref after an override, so only
# record genuine "[...]" version ranges here.
if req.version_range:
ranges[str(req.ref)] = str(req.range_ref)
try:
dep.settings = {k: str(v) for k, v in node.conanfile.settings.values_list}
except Exception: # header-only / no settings
dep.settings = {}
status = node.binary or "Unknown"
package_id = node.package_id or ""
remote = node.binary_remote.name if node.binary_remote else None
if status in _NOT_APPLICABLE:
status = "n/a"
elif status in ("Download", "Update"):
status = "Remote"
elif status == "Cache":
remote = index.remote_for(str(node.ref), package_id)
status = "Remote" if remote else "CacheOnly"
dep.binaries[build_type] = (status, package_id, remote)
def build_report(
conanfile: str,
remote: Optional[str],
profiles: List[str],
settings: List[str],
options: List[str],
build_types: List[str],
update: bool,
verbose: bool = False,
quiet: bool = False,
) -> Report:
log = _Tee(echo=verbose)
_progress(quiet, "loading Conan API")
api = _make_api(log)
deps: "OrderedDict[str, Dep]" = OrderedDict()
ranges: Dict[str, str] = {}
overrides: List[Override] = []
root_requires: List[str] = []
profile_settings: Dict[str, str] = {}
explicit: set = set()
index = _RemoteIndex(api, remote)
total = len(build_types)
for i, bt in enumerate(build_types, 1):
_progress(
quiet,
"[%d/%d] resolving graph for build_type=%s on %s ..."
% (i, total, bt, remote or "all remotes"),
)
t0 = time.monotonic()
graph, root_conanfile = api.info(
conanfile,
remote_name=remote,
settings=list(settings) + ["build_type=%s" % bt],
options=list(options) or None,
profile_names=profiles or None,
update=update,
)
explicit |= _explicit_overrides(graph)
_collect(graph, bt, deps, ranges, index)
missing = sum(1 for d in deps.values() if d.binaries.get(bt, ("",))[0] in _PROBLEM)
_progress(
quiet,
"[%d/%d] %s: %d packages, %d missing binaries (%.1fs)"
% (i, total, bt, len(graph.nodes) - 1, missing, time.monotonic() - t0),
)
if not root_requires:
root_requires = [str(r.ref) for r in root_conanfile.requires.values()]
profile_settings = {
k: str(v) for k, v in root_conanfile.settings.values_list if k != "build_type"
}
# The same override is warned once per graph build; parse once, de-duplicated.
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, [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(
(bt, dep.binaries.get(bt, ("n/a", "", None))) for bt in build_types
)
return Report(
conanfile,
remote,
profile_settings,
list(build_types),
deps,
overrides,
ranges,
root_requires,
)
# --------------------------------------------------------------------------- #
# Text rendering
# --------------------------------------------------------------------------- #
_MARK = {
"CacheOnly": "not available",
"Missing": "MISSING",
"Build": "build",
"n/a": "-",
"Unknown": "?",
"Invalid": "invalid",
}
# A tree line is (text before tags, [(tag kind, tag text)], text after tags).
# Kinds: path, build-require, override, range, missing. Renderers colour by
# kind; the words are identical in text and HTML.
_TreeLine = Tuple[str, List[Tuple[str, str]], str]
def _tree_rows(report: Report) -> List[_TreeLine]:
"""Build the dependency hierarchy as indented rows with typed tags."""
by_name = {}
for dep in report.deps.values():
by_name.setdefault(dep.name, dep) # host first (insertion order), build ok as fallback
# (parent ref, child name) -> Override, so the tree can annotate the exact
# edge whose requirement was rewritten.
edge_overrides = {(o.package, o.old.split("/", 1)[0]): o for o in report.overrides}
rows: List[_TreeLine] = []
def walk(name: str, parent: str, prefix: str, last: bool, seen: Tuple[str, ...]):
dep = by_name.get(name)
branch = "`-- " if last else "|-- "
if dep is None:
rows.append((prefix + branch + name, [], " (not in graph)"))
return
tags: List[Tuple[str, str]] = []
if seen: # indirect: spell out this branch's path so indentation need not be counted
tags.append(("path", " -> ".join(seen + (name,))))
if dep.build_require:
tags.append(("build-require", "build-require"))
ov = edge_overrides.get((parent, name))
if ov:
tags.append(
(
"override",
"overrides %s (%s by %s)"
% (ov.old, "explicit" if ov.explicit else "implicit", ov.by),
)
)
if dep.ref in report.ranges:
tags.append(("range", "from %s" % report.ranges[dep.ref]))
missing = dep.missing()
if missing:
tags.append(("missing", "MISSING: " + ",".join(missing)))
if name in seen:
rows.append((prefix + branch + dep.ref, tags, " (cycle)"))
return
rows.append((prefix + branch + dep.ref, tags, ""))
child_prefix = prefix + (" " if last else "| ")
for i, child in enumerate(dep.requires):
walk(child, dep.ref, child_prefix, i == len(dep.requires) - 1, seen + (name,))
roots = [d.name for d in report.deps.values() if d.direct]
rows.append((report.conanfile, [], ""))
for i, name in enumerate(roots):
walk(name, "your conanfile", "", i == len(roots) - 1, ())
return rows
def _tree_lines(report: Report, fmt_tag=None) -> List[str]:
"""Flatten tree rows to lines; ``fmt_tag(kind, text)`` decorates each tag."""
fmt_tag = fmt_tag or (lambda _kind, text: text)
lines = []
for head, tags, tail in _tree_rows(report):
rendered = " [" + "; ".join(fmt_tag(k, t) for k, t in tags) + "]" if tags else ""
lines.append(head + rendered + tail)
return lines
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 (alignment uses the visible width) and
newlines: a multi-line cell spreads the row over several lines, with the
other columns left blank on the continuation lines.
"""
split_rows = [[c.split("\n") for c in row] for row in rows]
widths = [len(h) for h in headers]
for row in split_rows:
for i, cell_lines in enumerate(row):
widths[i] = max([widths[i]] + [_visible_len(line) for line in cell_lines])
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))]
for row in split_rows:
height = max(len(cell_lines) for cell_lines in row)
for n in range(height):
lines.append(fmt([cell[n] if n < len(cell) else "" for cell in row]))
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 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 _override_kind(o: Override) -> str:
return "explicit (override=True)" if o.explicit else "implicit (newer requirement downstream)"
def _via(dep: Dep, arrow: str = " -> ", newline: str = "\n") -> str:
"""How the package is reached: '-' for direct, otherwise one line per path.
``A -> B`` means consumer -> A -> B -> pkg. A package required by several
others gets one line per parent (shortest path to that parent), shortest
first.
"""
if dep.direct:
return "-"
paths = sorted(dep.paths, key=lambda p: (len(p), [x.lower() for x in p]))
return newline.join(arrow.join(p) for p in paths)
def render_text(report: Report, color: bool = False) -> str:
pal = _Palette(color)
out: List[str] = []
w = out.append
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("")
# ---- dependencies ----------------------------------------------------
w(pal.bold("Dependencies (%d) and binary availability" % len(report.deps)))
w("")
rows = [
[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", "via", "ctx"] + list(report.build_types), rows, pal))
w("")
# ---- 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:
rows = []
for dep in problems:
for bt in dep.missing():
status, pid, _ = dep.binaries[bt]
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(pal.ok("All packages have binaries on the remote for: " + ", ".join(report.build_types)))
w("")
# ---- overrides -------------------------------------------------------
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(pal.bold("Dependency hierarchy"))
w("")
tag_style = {"path": pal.dim, "override": pal.warn, "missing": pal.bad}
out.extend(_tree_lines(report, lambda kind, text: tag_style.get(kind, str)(text)))
return "\n".join(out) + "\n"
# --------------------------------------------------------------------------- #
# HTML rendering (self-contained, no external assets)
# --------------------------------------------------------------------------- #
_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;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 "}
.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}
.path{color:#1b4e8a}
.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}
.cacheonly{color:#ffc857}.new{color:#ffc857}.explicit,.path{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] = []
w = parts.append
w('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">')
w("<title>conandeps: %s</title><style>%s</style></head><body>" % (e(report.conanfile), _CSS))
w("<h1>conandeps report</h1>")
w(
"<p><b>conanfile:</b> %s<br><b>remote:</b> %s<br><b>profile:</b> %s</p>"
% (
e(report.conanfile),
e(report.remote or "(all configured remotes)"),
e(", ".join("%s=%s" % kv for kv in report.profile.items())),
)
)
# ---- dependencies ----------------------------------------------------
w("<h2>Dependencies (%d) and binary availability</h2>" % len(report.deps))
rows = []
for dep in report.deps.values():
row = [e(dep.ref), e(_via(dep, " \u2192 ")).replace("\n", "<br>"), 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('<span class="%s" title="package_id %s">%s</span>' % (cls, e(pid), text))
rows.append(row)
w(_html_table(["package", "via", "context"] + list(report.build_types), rows))
# ---- missing binaries ------------------------------------------------
problems = report.problems()
n_missing = sum(len(d.missing()) for d in problems)
w("<h2>Missing binaries (%d)</h2>" % n_missing)
if problems:
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 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:
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>")
# Tree text is escaped per fragment so the tag spans survive; the tag
# classes reuse the table's colours (path = dim, override = yellow, ...).
tag_class = {"path": "path", "override": "new", "missing": "missing", "range": "na"}
tree_lines = []
for head, tags, tail in _tree_rows(report):
spans = "; ".join(
'<span class="%s">%s</span>' % (tag_class[k], e(t)) if k in tag_class else e(t)
for k, t in tags
)
tree_lines.append(e(head) + (" [" + spans + "]" if tags else "") + e(tail))
w("<h2>Dependency hierarchy</h2><pre>%s</pre>" % "\n".join(tree_lines))
w("</body></html>")
return "".join(parts)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(
prog="conandeps",
description="List Conan dependencies, check Release/Debug/RelWithDebInfo binary "
"availability and show requirement overrides.",
)
p.add_argument("conanfile", help="path to conanfile.py (or a directory containing one)")
p.add_argument(
"-r",
"--remote",
help="remote to look for binaries in (e.g. myremote); default: all configured remotes",
)
p.add_argument(
"-pr",
"--profile",
action="append",
default=[],
help="profile to use (repeatable, same as conan -pr)",
)
p.add_argument(
"-s",
"--settings",
action="append",
default=[],
help="extra settings, key=value (repeatable)",
)
p.add_argument(
"-o",
"--options",
action="append",
default=[],
help="extra options, pkg:key=value (repeatable)",
)
p.add_argument(
"--build-types",
default=",".join(DEFAULT_BUILD_TYPES),
help="comma separated build types to check (default: %(default)s)",
)
p.add_argument(
"-u",
"--update",
action="store_true",
help="check the remote for newer recipes/binaries (conan -u)",
)
p.add_argument("--html", metavar="FILE", help="also write an HTML report to FILE")
p.add_argument(
"-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:
report = build_report(
args.conanfile,
args.remote,
args.profile,
args.settings,
args.options,
build_types,
args.update,
args.verbose,
args.quiet,
)
except Exception as exc: # ConanException and friends
sys.stderr.write("conandeps: error: %s\n" % exc)
return 2
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))
sys.stdout.write("HTML report written to %s\n" % args.html)
return 1 if report.problems() else 0
if __name__ == "__main__":
sys.exit(main())