Skip to content

Commit 4d9015c

Browse files
ruben-artsclaude
andauthored
Add provenance summary of why each recipe was generated (#123)
* Add provenance summary of why each recipe was generated Track why each package is selected during recipe generation and print a summary after the recipes are written. For every generated recipe it now reports whether it was requested directly by the config and which already selected packages depend on it. - get_selected_packages records a _pkg_provenance map (requested_by_config and required_by) into vinca_conf - print_generation_summary lists generated recipes with their reasons and is called after write_recipe in main() - add tests covering provenance tracking and the summary output https://claude.ai/code/session_01AonWy19EgSJ17hQVFgDFbC * Render recipe generation summary with rich table Use the rich library (already a project dependency, used in generate_azure and generate_gha) to print the why-was-this-generated summary as a table with Recipe / Requested by config / Depended on by columns. https://claude.ai/code/session_01AonWy19EgSJ17hQVFgDFbC * Apply ruff formatting https://claude.ai/code/session_01AonWy19EgSJ17hQVFgDFbC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 939fb0b commit 4d9015c

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

vinca/main.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,8 +797,17 @@ def get_selected_packages(distro, vinca_conf):
797797
selected_packages = set()
798798
skipped_packages = set()
799799

800+
# Provenance tracking: record *why* each package ended up being selected.
801+
# requested_by_config: package was explicitly listed in the config
802+
# (packages_select_by_deps / build_all / additional recipes)
803+
# required_by: map of package -> set of config-requested packages whose
804+
# (transitive) dependency closure pulled this package in
805+
requested_by_config = set()
806+
required_by = {}
807+
800808
if vinca_conf.get("build_all", False):
801809
selected_packages = set(distro._distro.release_packages.keys())
810+
requested_by_config |= selected_packages
802811
# Add packages from rosdistro_additional_recipes.yaml when build_all is True
803812
if (
804813
"_additional_packages_snapshot" in vinca_conf
@@ -808,6 +817,7 @@ def get_selected_packages(distro, vinca_conf):
808817
vinca_conf["_additional_packages_snapshot"].keys()
809818
)
810819
selected_packages = selected_packages.union(additional_packages)
820+
requested_by_config |= additional_packages
811821
elif vinca_conf["packages_select_by_deps"]:
812822
if (
813823
"packages_skip_by_deps" in vinca_conf
@@ -820,6 +830,7 @@ def get_selected_packages(distro, vinca_conf):
820830
for i in vinca_conf["packages_select_by_deps"]:
821831
i = i.replace("-", "_")
822832
selected_packages = selected_packages.union([i])
833+
requested_by_config.add(i)
823834
if i in skipped_packages:
824835
continue
825836
try:
@@ -830,7 +841,13 @@ def get_selected_packages(distro, vinca_conf):
830841
pkgs = distro.get_depends(i.replace("_", "-"))
831842
selected_packages.remove(i)
832843
selected_packages.add(i.replace("_", "-"))
844+
requested_by_config.discard(i)
845+
i = i.replace("_", "-")
846+
requested_by_config.add(i)
833847
selected_packages = selected_packages.union(pkgs)
848+
# record that the (config-requested) package `i` depends on each `dep`
849+
for dep in pkgs:
850+
required_by.setdefault(dep, set()).add(i)
834851

835852
# Automatically include ros_workspace and ros_environment for ROS2 distributions
836853
# if any ROS2 packages are selected (these are added as dependencies automatically)
@@ -849,8 +866,20 @@ def get_selected_packages(distro, vinca_conf):
849866
if has_ros_packages:
850867
if distro.check_package("ros_workspace"):
851868
selected_packages.add("ros_workspace")
869+
required_by.setdefault("ros_workspace", set()).add(
870+
"(automatic ROS2 dependency)"
871+
)
852872
if distro.check_package("ros_environment"):
853873
selected_packages.add("ros_environment")
874+
required_by.setdefault("ros_environment", set()).add(
875+
"(automatic ROS2 dependency)"
876+
)
877+
878+
# expose provenance so a summary of *why* each recipe was generated can be printed
879+
vinca_conf["_pkg_provenance"] = {
880+
"requested_by_config": requested_by_config,
881+
"required_by": required_by,
882+
}
854883

855884
result = sorted(list(selected_packages))
856885
return result
@@ -1089,6 +1118,68 @@ def parse_package(pkg, distro, vinca_conf, path):
10891118
return recipe
10901119

10911120

1121+
def print_generation_summary(distro, vinca_conf, outputs):
1122+
"""Print a summary of the generated recipes and, for each, *why* it was
1123+
generated: whether it was requested directly by the config and which
1124+
already-selected packages depend on it."""
1125+
from rich.console import Console
1126+
from rich.table import Table
1127+
1128+
provenance = vinca_conf.get("_pkg_provenance", {})
1129+
requested = provenance.get("requested_by_config", set())
1130+
required_by = provenance.get("required_by", {})
1131+
1132+
# names that actually produced a recipe in this run
1133+
generated_names = {o["package"]["name"] for o in outputs}
1134+
matched_names = set()
1135+
1136+
rows = []
1137+
for shortname in vinca_conf.get("_selected_pkgs", []):
1138+
try:
1139+
pkg_names = resolve_pkgname(shortname, vinca_conf, distro)
1140+
except Exception:
1141+
pkg_names = []
1142+
if not pkg_names or pkg_names[0] not in generated_names:
1143+
continue
1144+
name = pkg_names[0]
1145+
matched_names.add(name)
1146+
1147+
is_requested = shortname in requested
1148+
deps = sorted(required_by.get(shortname, set()))
1149+
if deps:
1150+
depended_on = ", ".join(deps[:8])
1151+
if len(deps) > 8:
1152+
depended_on += f", ... (+{len(deps) - 8} more)"
1153+
else:
1154+
depended_on = ""
1155+
rows.append((name, is_requested, depended_on))
1156+
1157+
# auxiliary recipes that don't map back to a selected package (e.g. mutex)
1158+
leftovers = sorted(generated_names - matched_names)
1159+
1160+
table = Table(
1161+
title="Generated recipes and why they were selected",
1162+
title_style="bold",
1163+
header_style="bold",
1164+
)
1165+
table.add_column("Recipe", style="cyan", no_wrap=True)
1166+
table.add_column("Requested by config", justify="center")
1167+
table.add_column("Depended on by")
1168+
1169+
for name, is_requested, depended_on in sorted(rows):
1170+
table.add_row(
1171+
name,
1172+
"[green]yes[/green]" if is_requested else "[dim]no[/dim]",
1173+
depended_on or "[dim]-[/dim]",
1174+
)
1175+
for name in leftovers:
1176+
table.add_row(name, "[dim]no[/dim]", "[dim]auxiliary (e.g. mutex)[/dim]")
1177+
1178+
console = Console()
1179+
console.print(table)
1180+
console.print(f"Total generated recipes: [bold]{len(rows) + len(leftovers)}[/bold]")
1181+
1182+
10921183
def main():
10931184
global distro, unsatisfied_deps
10941185

@@ -1232,6 +1323,8 @@ def main():
12321323
else:
12331324
write_recipe(source, outputs, vinca_conf)
12341325

1326+
print_generation_summary(distro, vinca_conf, outputs)
1327+
12351328
if unsatisfied_deps:
12361329
print("Unsatisfied dependencies:", unsatisfied_deps)
12371330

vinca/test_provenance.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for package-selection provenance tracking and the generation summary.
2+
3+
These cover *why* a recipe gets generated: whether a package was requested
4+
directly by the config and which already-selected packages depend on it.
5+
"""
6+
7+
import vinca.main as m
8+
9+
10+
class FakeDistro:
11+
"""Minimal Distro stand-in for selection/provenance tests."""
12+
13+
def __init__(self, depends, ros1=False):
14+
self._depends = depends
15+
self._ros1 = ros1
16+
17+
def check_ros1(self):
18+
return self._ros1
19+
20+
def check_package(self, pkg):
21+
return True
22+
23+
def get_depends(self, pkg, ignore_pkgs=None):
24+
deps = set(self._depends.get(pkg, set()))
25+
if ignore_pkgs:
26+
deps -= set(ignore_pkgs)
27+
return deps
28+
29+
30+
def test_provenance_requested_and_required_by():
31+
distro = FakeDistro(
32+
{"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}},
33+
ros1=True, # keep ROS2 auto-injection out of this assertion
34+
)
35+
conf = {"packages_select_by_deps": ["app", "libA"], "packages_skip_by_deps": None}
36+
37+
selected = m.get_selected_packages(distro, conf)
38+
39+
assert set(selected) == {"app", "libA", "libB", "libcommon"}
40+
41+
prov = conf["_pkg_provenance"]
42+
# both seeds are recorded as requested by config
43+
assert prov["requested_by_config"] == {"app", "libA"}
44+
# libA is requested *and* depended on by app
45+
assert prov["required_by"]["libA"] == {"app"}
46+
# transitive dep pulled in by the requested seeds
47+
assert prov["required_by"]["libcommon"] == {"libA"}
48+
assert prov["required_by"]["libB"] == {"app"}
49+
# purely-requested package has no reverse-dep entry
50+
assert "app" not in prov["required_by"]
51+
52+
53+
def test_provenance_skip_by_deps_excludes_package():
54+
distro = FakeDistro({"app": {"libA", "skipme"}}, ros1=True)
55+
conf = {
56+
"packages_select_by_deps": ["app"],
57+
"packages_skip_by_deps": ["skipme"],
58+
}
59+
60+
selected = m.get_selected_packages(distro, conf)
61+
62+
assert "skipme" not in selected
63+
assert "libA" in selected
64+
65+
66+
def test_ros2_workspace_auto_injected_with_reason():
67+
distro = FakeDistro({"app": set()}, ros1=False)
68+
conf = {"packages_select_by_deps": ["app"], "packages_skip_by_deps": None}
69+
70+
selected = m.get_selected_packages(distro, conf)
71+
72+
assert "ros_workspace" in selected
73+
assert "ros_environment" in selected
74+
prov = conf["_pkg_provenance"]
75+
assert prov["required_by"]["ros_workspace"] == {"(automatic ROS2 dependency)"}
76+
77+
78+
def test_generation_summary_output(monkeypatch, capsys):
79+
distro = FakeDistro(
80+
{"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}},
81+
ros1=True,
82+
)
83+
conf = {"packages_select_by_deps": ["app", "libA"], "packages_skip_by_deps": None}
84+
conf["_selected_pkgs"] = m.get_selected_packages(distro, conf)
85+
86+
monkeypatch.setattr(
87+
m,
88+
"resolve_pkgname",
89+
lambda shortname, vinca_conf, distro, is_rundep=False: [
90+
"ros-humble-" + shortname.replace("_", "-")
91+
],
92+
)
93+
monkeypatch.setattr(m, "distro", distro, raising=False)
94+
# keep the rich table on one wide line so substrings are not wrapped
95+
monkeypatch.setenv("COLUMNS", "200")
96+
97+
# pretend a recipe was generated for everything except libB, plus a mutex
98+
outputs = [
99+
{"package": {"name": "ros-humble-" + s.replace("_", "-")}}
100+
for s in conf["_selected_pkgs"]
101+
if s != "libB"
102+
]
103+
outputs.append({"package": {"name": "ros-humble-ros2-mutex"}})
104+
105+
m.print_generation_summary(distro, conf, outputs)
106+
out = capsys.readouterr().out
107+
108+
assert "Generated recipes and why they were selected" in out
109+
assert "Requested by config" in out # table header
110+
assert "Depended on by" in out # table header
111+
assert "ros-humble-app" in out
112+
assert "ros-humble-libcommon" in out
113+
assert "ros-humble-ros2-mutex" in out
114+
assert "auxiliary" in out # mutex marked as auxiliary
115+
assert "ros-humble-libB" not in out # not generated -> not listed
116+
# app, libA, libcommon (libB excluded) + mutex auxiliary
117+
assert "Total generated recipes: 4" in out

0 commit comments

Comments
 (0)