From ac77b08629c9b8dda08cc22c27c8b82f9250a531 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 14:51:50 +0000 Subject: [PATCH 1/3] 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 --- vinca/main.py | 87 ++++++++++++++++++++++++++++++ vinca/test_provenance.py | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 vinca/test_provenance.py diff --git a/vinca/main.py b/vinca/main.py index 823b1ba..68e8495 100644 --- a/vinca/main.py +++ b/vinca/main.py @@ -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 @@ -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 @@ -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: @@ -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) @@ -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 @@ -1089,6 +1118,62 @@ def parse_package(pkg, distro, vinca_conf, path): return recipe +def print_generation_summary(distro, vinca_conf, outputs): + """Print a human-readable 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.""" + 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) + + reasons = [] + if shortname in requested: + reasons.append("requested by config") + deps = sorted(d for d in required_by.get(shortname, set())) + if deps: + shown = ", ".join(deps[:8]) + if len(deps) > 8: + shown += f", ... (+{len(deps) - 8} more)" + reasons.append(f"depended on by: {shown}") + if not reasons: + reasons.append("selected (reason not recorded)") + rows.append((name, reasons)) + + print("\n" + "=" * 72) + print("Generated recipes and why they were selected") + print("=" * 72) + for name, reasons in sorted(rows): + print(f"- {name}") + for r in reasons: + print(f" - {r}") + + # auxiliary recipes that don't map back to a selected package (e.g. mutex) + leftovers = sorted(generated_names - matched_names) + for name in leftovers: + print(f"- {name}") + print(" - auxiliary package (e.g. mutex)") + + total = len(rows) + len(leftovers) + print("-" * 72) + print(f"Total generated recipes: {total}") + print("=" * 72 + "\n") + + def main(): global distro, unsatisfied_deps @@ -1232,6 +1317,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) diff --git a/vinca/test_provenance.py b/vinca/test_provenance.py new file mode 100644 index 0000000..b6a315b --- /dev/null +++ b/vinca/test_provenance.py @@ -0,0 +1,113 @@ +"""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) + + # 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 "ros-humble-app" in out + assert "requested by config" in out + assert "depended on by: app" in out # libA reverse dep + assert "auxiliary package" in out # mutex + assert "ros-humble-lib-b" not in out # not generated -> not listed + # app, libA, libcommon (libB excluded) + mutex auxiliary + assert "Total generated recipes: 4" in out From 8a3d0a55b3e81b375b2f9a3db458d5867e95afc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 14:57:05 +0000 Subject: [PATCH 2/3] 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 --- vinca/main.py | 62 +++++++++++++++++++++++----------------- vinca/test_provenance.py | 12 +++++--- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/vinca/main.py b/vinca/main.py index 68e8495..f65a7f9 100644 --- a/vinca/main.py +++ b/vinca/main.py @@ -1119,9 +1119,12 @@ def parse_package(pkg, distro, vinca_conf, path): def print_generation_summary(distro, vinca_conf, outputs): - """Print a human-readable 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.""" + """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", {}) @@ -1141,37 +1144,42 @@ def print_generation_summary(distro, vinca_conf, outputs): name = pkg_names[0] matched_names.add(name) - reasons = [] - if shortname in requested: - reasons.append("requested by config") - deps = sorted(d for d in required_by.get(shortname, set())) + is_requested = shortname in requested + deps = sorted(required_by.get(shortname, set())) if deps: - shown = ", ".join(deps[:8]) + depended_on = ", ".join(deps[:8]) if len(deps) > 8: - shown += f", ... (+{len(deps) - 8} more)" - reasons.append(f"depended on by: {shown}") - if not reasons: - reasons.append("selected (reason not recorded)") - rows.append((name, reasons)) - - print("\n" + "=" * 72) - print("Generated recipes and why they were selected") - print("=" * 72) - for name, reasons in sorted(rows): - print(f"- {name}") - for r in reasons: - print(f" - {r}") + 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: - print(f"- {name}") - print(" - auxiliary package (e.g. mutex)") + table.add_row(name, "[dim]no[/dim]", "[dim]auxiliary (e.g. mutex)[/dim]") - total = len(rows) + len(leftovers) - print("-" * 72) - print(f"Total generated recipes: {total}") - print("=" * 72 + "\n") + console = Console() + console.print(table) + console.print( + f"Total generated recipes: [bold]{len(rows) + len(leftovers)}[/bold]" + ) def main(): diff --git a/vinca/test_provenance.py b/vinca/test_provenance.py index b6a315b..93801e4 100644 --- a/vinca/test_provenance.py +++ b/vinca/test_provenance.py @@ -91,6 +91,8 @@ def test_generation_summary_output(monkeypatch, capsys): ], ) 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 = [ @@ -104,10 +106,12 @@ def test_generation_summary_output(monkeypatch, capsys): 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 "requested by config" in out - assert "depended on by: app" in out # libA reverse dep - assert "auxiliary package" in out # mutex - assert "ros-humble-lib-b" not in out # not generated -> not listed + 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 From baf15331e6332a88e8ecdb01b1534f0bfc2c0cae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 07:34:09 +0000 Subject: [PATCH 3/3] Apply ruff formatting https://claude.ai/code/session_01AonWy19EgSJ17hQVFgDFbC --- vinca/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vinca/main.py b/vinca/main.py index f65a7f9..eb5b148 100644 --- a/vinca/main.py +++ b/vinca/main.py @@ -1177,9 +1177,7 @@ def print_generation_summary(distro, vinca_conf, outputs): console = Console() console.print(table) - console.print( - f"Total generated recipes: [bold]{len(rows) + len(leftovers)}[/bold]" - ) + console.print(f"Total generated recipes: [bold]{len(rows) + len(leftovers)}[/bold]") def main():