Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions vinca/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,8 +797,17 @@ def get_selected_packages(distro, vinca_conf):
selected_packages = set()
skipped_packages = set()

# Provenance tracking: record *why* each package ended up being selected.
# requested_by_config: package was explicitly listed in the config
# (packages_select_by_deps / build_all / additional recipes)
# required_by: map of package -> set of config-requested packages whose
# (transitive) dependency closure pulled this package in
requested_by_config = set()
required_by = {}

if vinca_conf.get("build_all", False):
selected_packages = set(distro._distro.release_packages.keys())
requested_by_config |= selected_packages
# Add packages from rosdistro_additional_recipes.yaml when build_all is True
if (
"_additional_packages_snapshot" in vinca_conf
Expand All @@ -808,6 +817,7 @@ def get_selected_packages(distro, vinca_conf):
vinca_conf["_additional_packages_snapshot"].keys()
)
selected_packages = selected_packages.union(additional_packages)
requested_by_config |= additional_packages
elif vinca_conf["packages_select_by_deps"]:
if (
"packages_skip_by_deps" in vinca_conf
Expand All @@ -820,6 +830,7 @@ def get_selected_packages(distro, vinca_conf):
for i in vinca_conf["packages_select_by_deps"]:
i = i.replace("-", "_")
selected_packages = selected_packages.union([i])
requested_by_config.add(i)
if i in skipped_packages:
continue
try:
Expand All @@ -830,7 +841,13 @@ def get_selected_packages(distro, vinca_conf):
pkgs = distro.get_depends(i.replace("_", "-"))
selected_packages.remove(i)
selected_packages.add(i.replace("_", "-"))
requested_by_config.discard(i)
i = i.replace("_", "-")
requested_by_config.add(i)
selected_packages = selected_packages.union(pkgs)
# record that the (config-requested) package `i` depends on each `dep`
for dep in pkgs:
required_by.setdefault(dep, set()).add(i)

# Automatically include ros_workspace and ros_environment for ROS2 distributions
# if any ROS2 packages are selected (these are added as dependencies automatically)
Expand All @@ -849,8 +866,20 @@ def get_selected_packages(distro, vinca_conf):
if has_ros_packages:
if distro.check_package("ros_workspace"):
selected_packages.add("ros_workspace")
required_by.setdefault("ros_workspace", set()).add(
"(automatic ROS2 dependency)"
)
if distro.check_package("ros_environment"):
selected_packages.add("ros_environment")
required_by.setdefault("ros_environment", set()).add(
"(automatic ROS2 dependency)"
)

# expose provenance so a summary of *why* each recipe was generated can be printed
vinca_conf["_pkg_provenance"] = {
"requested_by_config": requested_by_config,
"required_by": required_by,
}

result = sorted(list(selected_packages))
return result
Expand Down Expand Up @@ -1089,6 +1118,68 @@ def parse_package(pkg, distro, vinca_conf, path):
return recipe


def print_generation_summary(distro, vinca_conf, outputs):
"""Print a summary of the generated recipes and, for each, *why* it was
generated: whether it was requested directly by the config and which
already-selected packages depend on it."""
from rich.console import Console
from rich.table import Table

provenance = vinca_conf.get("_pkg_provenance", {})
requested = provenance.get("requested_by_config", set())
required_by = provenance.get("required_by", {})

# names that actually produced a recipe in this run
generated_names = {o["package"]["name"] for o in outputs}
matched_names = set()

rows = []
for shortname in vinca_conf.get("_selected_pkgs", []):
try:
pkg_names = resolve_pkgname(shortname, vinca_conf, distro)
except Exception:
pkg_names = []
if not pkg_names or pkg_names[0] not in generated_names:
continue
name = pkg_names[0]
matched_names.add(name)

is_requested = shortname in requested
deps = sorted(required_by.get(shortname, set()))
if deps:
depended_on = ", ".join(deps[:8])
if len(deps) > 8:
depended_on += f", ... (+{len(deps) - 8} more)"
else:
depended_on = ""
rows.append((name, is_requested, depended_on))

# auxiliary recipes that don't map back to a selected package (e.g. mutex)
leftovers = sorted(generated_names - matched_names)

table = Table(
title="Generated recipes and why they were selected",
title_style="bold",
header_style="bold",
)
table.add_column("Recipe", style="cyan", no_wrap=True)
table.add_column("Requested by config", justify="center")
table.add_column("Depended on by")

for name, is_requested, depended_on in sorted(rows):
table.add_row(
name,
"[green]yes[/green]" if is_requested else "[dim]no[/dim]",
depended_on or "[dim]-[/dim]",
)
for name in leftovers:
table.add_row(name, "[dim]no[/dim]", "[dim]auxiliary (e.g. mutex)[/dim]")

console = Console()
console.print(table)
console.print(f"Total generated recipes: [bold]{len(rows) + len(leftovers)}[/bold]")


def main():
global distro, unsatisfied_deps

Expand Down Expand Up @@ -1232,6 +1323,8 @@ def main():
else:
write_recipe(source, outputs, vinca_conf)

print_generation_summary(distro, vinca_conf, outputs)

if unsatisfied_deps:
print("Unsatisfied dependencies:", unsatisfied_deps)

Expand Down
117 changes: 117 additions & 0 deletions vinca/test_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Tests for package-selection provenance tracking and the generation summary.

These cover *why* a recipe gets generated: whether a package was requested
directly by the config and which already-selected packages depend on it.
"""

import vinca.main as m


class FakeDistro:
"""Minimal Distro stand-in for selection/provenance tests."""

def __init__(self, depends, ros1=False):
self._depends = depends
self._ros1 = ros1

def check_ros1(self):
return self._ros1

def check_package(self, pkg):
return True

def get_depends(self, pkg, ignore_pkgs=None):
deps = set(self._depends.get(pkg, set()))
if ignore_pkgs:
deps -= set(ignore_pkgs)
return deps


def test_provenance_requested_and_required_by():
distro = FakeDistro(
{"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}},
ros1=True, # keep ROS2 auto-injection out of this assertion
)
conf = {"packages_select_by_deps": ["app", "libA"], "packages_skip_by_deps": None}

selected = m.get_selected_packages(distro, conf)

assert set(selected) == {"app", "libA", "libB", "libcommon"}

prov = conf["_pkg_provenance"]
# both seeds are recorded as requested by config
assert prov["requested_by_config"] == {"app", "libA"}
# libA is requested *and* depended on by app
assert prov["required_by"]["libA"] == {"app"}
# transitive dep pulled in by the requested seeds
assert prov["required_by"]["libcommon"] == {"libA"}
assert prov["required_by"]["libB"] == {"app"}
# purely-requested package has no reverse-dep entry
assert "app" not in prov["required_by"]


def test_provenance_skip_by_deps_excludes_package():
distro = FakeDistro({"app": {"libA", "skipme"}}, ros1=True)
conf = {
"packages_select_by_deps": ["app"],
"packages_skip_by_deps": ["skipme"],
}

selected = m.get_selected_packages(distro, conf)

assert "skipme" not in selected
assert "libA" in selected


def test_ros2_workspace_auto_injected_with_reason():
distro = FakeDistro({"app": set()}, ros1=False)
conf = {"packages_select_by_deps": ["app"], "packages_skip_by_deps": None}

selected = m.get_selected_packages(distro, conf)

assert "ros_workspace" in selected
assert "ros_environment" in selected
prov = conf["_pkg_provenance"]
assert prov["required_by"]["ros_workspace"] == {"(automatic ROS2 dependency)"}


def test_generation_summary_output(monkeypatch, capsys):
distro = FakeDistro(
{"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}},
ros1=True,
)
conf = {"packages_select_by_deps": ["app", "libA"], "packages_skip_by_deps": None}
conf["_selected_pkgs"] = m.get_selected_packages(distro, conf)

monkeypatch.setattr(
m,
"resolve_pkgname",
lambda shortname, vinca_conf, distro, is_rundep=False: [
"ros-humble-" + shortname.replace("_", "-")
],
)
monkeypatch.setattr(m, "distro", distro, raising=False)
# keep the rich table on one wide line so substrings are not wrapped
monkeypatch.setenv("COLUMNS", "200")

# pretend a recipe was generated for everything except libB, plus a mutex
outputs = [
{"package": {"name": "ros-humble-" + s.replace("_", "-")}}
for s in conf["_selected_pkgs"]
if s != "libB"
]
outputs.append({"package": {"name": "ros-humble-ros2-mutex"}})

m.print_generation_summary(distro, conf, outputs)
out = capsys.readouterr().out

assert "Generated recipes and why they were selected" in out
assert "Requested by config" in out # table header
assert "Depended on by" in out # table header
assert "ros-humble-app" in out
assert "ros-humble-libcommon" in out
assert "ros-humble-ros2-mutex" in out
assert "auxiliary" in out # mutex marked as auxiliary
assert "ros-humble-libB" not in out # not generated -> not listed
# app, libA, libcommon (libB excluded) + mutex auxiliary
assert "Total generated recipes: 4" in out
Loading