Add conandeps: dependency, binary-availability and override report for Conan 1.66
conandeps.py lists direct and indirect dependencies of a conanfile.py, checks whether Release/Debug/RelWithDebInfo binaries exist on a remote for the exact profile, and reports explicit (override=True) and implicit requirement overrides both as a list and on the edges of the dependency tree. Text output by default, optional self-contained HTML via --html. The graph is built once per build type through Conan's own info API and each node's binary status is read back, so package_id modes and options are honoured like a real install. Overrides are parsed from Conan's WARN output since 1.x does not record them on the graph. Development runs inside a pinned podman container (Containerfile, dev.sh) with Conan 1.66 on Python 3.11. Tests start a throw-away conan_server and verify missing-binary and override detection end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYNdaGDrpaqDyT8QtrTQbM
This commit is contained in:
commit
a11e9fb34e
7 changed files with 904 additions and 0 deletions
260
tests/test_conandeps.py
Normal file
260
tests/test_conandeps.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"""Integration test for conandeps against a throw-away local conan_server.
|
||||
|
||||
The fixture builds this graph (all recipes are "fake" C++ packages: they have
|
||||
the usual settings but their build() does nothing, so no compiler is needed):
|
||||
|
||||
consumer (conanfile.py)
|
||||
|-- libfoo/1.0 binaries: Release, Debug, RelWithDebInfo
|
||||
| `-- libbar/1.0 binaries: Release only <- MISSING Debug/RWDI
|
||||
|-- libbar/1.1 (override=True) <- explicit override
|
||||
|-- libbaz/2.0 binaries: all three
|
||||
| `-- libqux/1.0 binaries: all three
|
||||
`-- libqux/1.1 binaries: all three <- implicit override
|
||||
`-- hdr/1.0 header-only (no settings), binary always exists
|
||||
|
||||
Everything runs in an isolated CONAN_USER_HOME so the developer's cache and
|
||||
remotes are never touched.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import conandeps # noqa: E402
|
||||
|
||||
RECIPE = textwrap.dedent("""\
|
||||
from conans import ConanFile
|
||||
|
||||
class Pkg(ConanFile):
|
||||
name = "{name}"
|
||||
version = "{version}"
|
||||
settings = "os", "compiler", "build_type", "arch"
|
||||
{requires}
|
||||
def package(self):
|
||||
open("marker.txt", "w").write("x")
|
||||
self.copy("marker.txt")
|
||||
""")
|
||||
|
||||
HEADER_ONLY = textwrap.dedent("""\
|
||||
from conans import ConanFile
|
||||
|
||||
class Pkg(ConanFile):
|
||||
name = "hdr"
|
||||
version = "1.0"
|
||||
def package(self):
|
||||
open("marker.txt", "w").write("x")
|
||||
self.copy("marker.txt")
|
||||
""")
|
||||
|
||||
CONSUMER = textwrap.dedent("""\
|
||||
from conans import ConanFile
|
||||
|
||||
class Consumer(ConanFile):
|
||||
settings = "os", "compiler", "build_type", "arch"
|
||||
requires = ("libfoo/1.0", "libbaz/2.0", "libqux/1.1", "hdr/1.0")
|
||||
|
||||
def requirements(self):
|
||||
self.requires("libbar/1.1", override=True)
|
||||
""")
|
||||
|
||||
PROFILE = textwrap.dedent("""\
|
||||
[settings]
|
||||
os=Linux
|
||||
arch=x86_64
|
||||
compiler=gcc
|
||||
compiler.version=12
|
||||
compiler.libcxx=libstdc++11
|
||||
build_type=Release
|
||||
""")
|
||||
|
||||
SERVER_CONF = textwrap.dedent("""\
|
||||
[server]
|
||||
jwt_secret: testsecrettestsecret
|
||||
jwt_expire_minutes: 120
|
||||
ssl_enabled: False
|
||||
port: {port}
|
||||
public_port:
|
||||
host_name: 127.0.0.1
|
||||
authorize_timeout: 1800
|
||||
disk_storage_path: ./data
|
||||
disk_authorize_timeout: 1800
|
||||
updown_secret: testsecrettestsecret
|
||||
|
||||
[write_permissions]
|
||||
*/*@*/*: demo
|
||||
|
||||
[read_permissions]
|
||||
*/*@*/*: *
|
||||
|
||||
[users]
|
||||
demo: demo
|
||||
""")
|
||||
|
||||
BUILD_TYPES = ("Release", "Debug", "RelWithDebInfo")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _conan(env, *args):
|
||||
res = subprocess.run(
|
||||
["conan", *args], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
||||
)
|
||||
if res.returncode:
|
||||
raise RuntimeError("conan %s failed:\n%s" % (" ".join(args), res.stdout))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def conan_env(tmp_path_factory):
|
||||
home = tmp_path_factory.mktemp("conan_home")
|
||||
server_home = tmp_path_factory.mktemp("server_home")
|
||||
port = _free_port()
|
||||
|
||||
# --- conan_server (ships with conan 1.x) -----------------------------
|
||||
# The port can only be set through server.conf, so write a minimal one
|
||||
# before the first start. Reads are public, writes need demo/demo.
|
||||
(server_home / "server.conf").write_text(SERVER_CONF.format(port=port))
|
||||
server = subprocess.Popen(
|
||||
["conan_server", "-d", str(server_home)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(100):
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
server.kill()
|
||||
raise RuntimeError("conan_server did not start")
|
||||
|
||||
env = dict(
|
||||
os.environ,
|
||||
CONAN_USER_HOME=str(home),
|
||||
CONAN_REVISIONS_ENABLED="0",
|
||||
CONAN_LOGIN_USERNAME="demo",
|
||||
CONAN_PASSWORD="demo",
|
||||
)
|
||||
profile_dir = home / ".conan" / "profiles"
|
||||
profile_dir.mkdir(parents=True)
|
||||
(profile_dir / "default").write_text(PROFILE)
|
||||
|
||||
_conan(env, "remote", "clean")
|
||||
_conan(env, "remote", "add", "test", "http://127.0.0.1:%d" % port)
|
||||
|
||||
# --- create + upload packages ----------------------------------------
|
||||
work = tmp_path_factory.mktemp("recipes")
|
||||
|
||||
def create(name, version, requires=(), build_types=BUILD_TYPES, header_only=False):
|
||||
d = work / ("%s-%s" % (name, version))
|
||||
d.mkdir()
|
||||
if header_only:
|
||||
(d / "conanfile.py").write_text(HEADER_ONLY)
|
||||
_conan(env, "create", str(d))
|
||||
else:
|
||||
req = "requires = (%s)" % "".join('"%s", ' % r for r in requires)
|
||||
(d / "conanfile.py").write_text(RECIPE.format(name=name, version=version, requires=req))
|
||||
for bt in build_types:
|
||||
# --build=missing lets dependencies whose binaries we deliberately
|
||||
# did not create (libbar Debug) be built locally; those local
|
||||
# binaries are never uploaded because each reference is
|
||||
# uploaded right after its own create, and the cache is wiped
|
||||
# at the end.
|
||||
_conan(env, "create", str(d), "-s", "build_type=%s" % bt, "--build=missing")
|
||||
_conan(env, "upload", "%s/%s" % (name, version), "-r", "test", "--all", "-c")
|
||||
|
||||
create("libbar", "1.0", build_types=("Release",))
|
||||
create("libbar", "1.1", build_types=("Release",))
|
||||
create("libfoo", "1.0", requires=("libbar/1.0",))
|
||||
create("libqux", "1.0")
|
||||
create("libqux", "1.1")
|
||||
create("libbaz", "2.0", requires=("libqux/1.0",))
|
||||
create("hdr", "1.0", header_only=True)
|
||||
|
||||
# Wipe the local cache so binaries can only come from the remote.
|
||||
_conan(env, "remove", "*", "-f")
|
||||
|
||||
consumer = work / "consumer"
|
||||
consumer.mkdir()
|
||||
(consumer / "conanfile.py").write_text(CONSUMER)
|
||||
|
||||
old_environ = dict(os.environ)
|
||||
os.environ.update(env)
|
||||
try:
|
||||
yield {"conanfile": str(consumer / "conanfile.py"), "home": home}
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(old_environ)
|
||||
server.terminate()
|
||||
server.wait(timeout=10)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def report(conan_env):
|
||||
return conandeps.build_report(
|
||||
conan_env["conanfile"], "test", [], [], [], list(BUILD_TYPES), update=False
|
||||
)
|
||||
|
||||
|
||||
def test_dependencies_listed(report):
|
||||
refs = {d.ref for d in report.deps.values()}
|
||||
assert refs == {"libfoo/1.0", "libbar/1.1", "libbaz/2.0", "libqux/1.1", "hdr/1.0"}
|
||||
direct = {d.ref for d in report.deps.values() if d.direct}
|
||||
assert direct == {"libfoo/1.0", "libbaz/2.0", "libqux/1.1", "hdr/1.0"}
|
||||
# libbar is only reachable through libfoo -> indirect, even though the
|
||||
# consumer mentions it with override=True.
|
||||
assert not report.deps["libbar#host"].direct
|
||||
|
||||
|
||||
def test_missing_binaries(report):
|
||||
missing = {d.ref: d.missing() for d in report.deps.values() if d.missing()}
|
||||
assert missing == {"libbar/1.1": ["Debug", "RelWithDebInfo"]}
|
||||
# The others must be found on the remote (not in cache, we wiped it).
|
||||
for dep in report.deps.values():
|
||||
for bt, (status, pid, remote) in dep.binaries.items():
|
||||
if dep.ref != "libbar/1.1":
|
||||
assert status == "Download", (dep.ref, bt, status)
|
||||
assert remote == "test"
|
||||
assert pid
|
||||
|
||||
|
||||
def test_overrides(report):
|
||||
ov = {(o.package, o.old, o.new, o.by, o.explicit) for o in report.overrides}
|
||||
assert ov == {
|
||||
("libfoo/1.0", "libbar/1.0", "libbar/1.1", "your conanfile", True),
|
||||
("libbaz/2.0", "libqux/1.0", "libqux/1.1", "your conanfile", False),
|
||||
}
|
||||
|
||||
|
||||
def test_text_and_html_render(report, tmp_path):
|
||||
text = conandeps.render_text(report)
|
||||
assert "libbar/1.1" in text and "MISSING" in text
|
||||
assert "overrides libbar/1.0 (explicit" in text and "explicit override=True" in 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 "<script" not in page # self-contained, static
|
||||
|
||||
|
||||
def test_cli_exit_code(conan_env, tmp_path, capsys):
|
||||
out = tmp_path / "r.html"
|
||||
rc = conandeps.main([conan_env["conanfile"], "-r", "test", "--html", str(out)])
|
||||
assert rc == 1 # missing binaries -> non-zero so CI can fail on it
|
||||
assert out.exists()
|
||||
captured = capsys.readouterr()
|
||||
assert "conandeps report" in captured.out
|
||||
Loading…
Add table
Add a link
Reference in a new issue