From cb93bcd1a7b9a48dbd843272aa3c007bb91fce14 Mon Sep 17 00:00:00 2001 From: Matt Campbell Date: Thu, 27 Aug 2026 14:43:05 -0400 Subject: [PATCH] Python: a composite reports the data tables its children produce RecipeDescriptor.from_recipe returned only the tables a recipe owns, so a composite whose tables all come from its recipe_list advertised none. Java's Recipe#createRecipeDescriptor aggregates child tables (aggregateDataTableDescriptors), so Java composites are unaffected and only Python-authored ones lose them -- including from the marketplace listing, which is what consumers resolve a run's data tables against. The rows are still collected, but nothing downstream can describe the table they belong to. Union a recipe's own tables with its children's, deduped by name and parent-first. Sub-descriptors are built the same way, so the union is recursive. RpcRecipe gains an optional data_tables argument. It references a recipe on another peer by name, and descriptors are built during marketplace registration when no peer is necessarily connected, so the delegate's tables cannot be introspected here -- the composite author declares them. This also stops data_tables from falling into **options and being shipped to the delegate as a recipe option. --- rewrite-python/rewrite/src/rewrite/recipe.py | 22 ++- .../rewrite/src/rewrite/rpc/rpc_recipe.py | 21 ++- .../tests/test_composite_data_tables.py | 178 ++++++++++++++++++ 3 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 rewrite-python/rewrite/tests/test_composite_data_tables.py diff --git a/rewrite-python/rewrite/src/rewrite/recipe.py b/rewrite-python/rewrite/src/rewrite/recipe.py index 89474ea96af..7f003377775 100644 --- a/rewrite-python/rewrite/src/rewrite/recipe.py +++ b/rewrite-python/rewrite/src/rewrite/recipe.py @@ -21,6 +21,7 @@ from typing import ( Any, Callable, + Dict, List, Optional, TYPE_CHECKING, @@ -121,8 +122,21 @@ def from_recipe(cls, recipe: Recipe) -> RecipeDescriptor: value = getattr(recipe, f.name) options.append((f.name, value, descriptor)) - # Extract data table descriptors - data_tables = [dt.descriptor() for dt in recipe.data_tables] + recipe_list = [cls.from_recipe(r) for r in recipe.recipe_list()] + + # A recipe reports the union of the data tables it owns and every table its + # children produce, so consumers that read only the top-level descriptor -- + # the marketplace listing and everything derived from it -- can resolve a + # composite's tables. Mirrors Java's Recipe#aggregateDataTableDescriptors. + # Sub-descriptors are built the same way, so the union is recursive; the + # recipe's own tables come first and the first descriptor for a given name + # wins. + data_tables: Dict[str, dict] = {} + for data_table in recipe.data_tables: + data_tables.setdefault(data_table.name, data_table.descriptor()) + for sub_recipe in recipe_list: + for descriptor in sub_recipe.data_tables: + data_tables.setdefault(descriptor["name"], descriptor) return cls( name=recipe.name, @@ -131,8 +145,8 @@ def from_recipe(cls, recipe: Recipe) -> RecipeDescriptor: tags=recipe.tags, estimated_effort_per_occurrence=recipe.estimated_effort_per_occurrence, options=options, - data_tables=data_tables, - recipe_list=[cls.from_recipe(r) for r in recipe.recipe_list()], + data_tables=list(data_tables.values()), + recipe_list=recipe_list, ) diff --git a/rewrite-python/rewrite/src/rewrite/rpc/rpc_recipe.py b/rewrite-python/rewrite/src/rewrite/rpc/rpc_recipe.py index 44277b5e39c..396f3cd71d5 100644 --- a/rewrite-python/rewrite/src/rewrite/rpc/rpc_recipe.py +++ b/rewrite-python/rewrite/src/rewrite/rpc/rpc_recipe.py @@ -29,8 +29,9 @@ import logging from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional +from rewrite.data_table import DataTable from rewrite.recipe import Recipe from rewrite.tree import Tree from rewrite.visitor import TreeVisitor @@ -83,7 +84,8 @@ def recipe_list(self): ``RpcRecipe`` on the Java and JavaScript sides) is future work. """ - def __init__(self, name: str, **options: Any): + def __init__(self, name: str, data_tables: Optional[List[DataTable]] = None, + **options: Any): self._name = name # ``java_recipe_name`` / ``delegates_to_options`` are the (internal) # attribute names the server's delegatesTo production reads; see @@ -91,12 +93,27 @@ def __init__(self, name: str, **options: Any): # delegatesTo payload is the ecosystem-neutral ``{recipeName, options}``. self.java_recipe_name = name self.delegates_to_options: Dict[str, Any] = dict(options) + self._data_tables: List[DataTable] = list(data_tables or []) self._prepared: Optional["PreparedJavaRecipe"] = None @property def name(self) -> str: return self._name + @property + def data_tables(self) -> List[DataTable]: + """Data tables the delegate produces. + + The reference carries only an id + options, so the delegate lives on another + peer and its tables cannot be introspected from here -- a descriptor is built + during marketplace registration, when no peer is necessarily connected. The + composite author declares them instead, matching the delegate's table names + and columns, so the composite's descriptor advertises everything its run will + produce. Rows are still written by the peer that owns the recipe; declaring + the table here only makes it resolvable by descriptor consumers. + """ + return self._data_tables + @property def display_name(self) -> str: return self._name diff --git a/rewrite-python/rewrite/tests/test_composite_data_tables.py b/rewrite-python/rewrite/tests/test_composite_data_tables.py new file mode 100644 index 00000000000..3f96fd2c6e4 --- /dev/null +++ b/rewrite-python/rewrite/tests/test_composite_data_tables.py @@ -0,0 +1,178 @@ +"""Data tables produced by a composite's children must appear on the composite's descriptor. + +Mirrors Java's ``Recipe#aggregateDataTableDescriptors``: a composite reports the +union of its own tables and every table its ``recipe_list()`` produces, so the +marketplace listing (and everything downstream of it) can resolve them. +""" + +from dataclasses import dataclass, field +from typing import Any, List + +from rewrite import ExecutionContext +from rewrite.data_table import DataTable, column +from rewrite.recipe import Recipe, ScanningRecipe +from rewrite.rpc import RpcRecipe + + +@dataclass +class UsageRow: + source_path: str = field(metadata=column("source_path", "Path of the file.")) + library: str = field(metadata=column("library", "The library used.")) + + +LEAF_TABLE_NAME = "org.example.table.LibraryUsage" +NESTED_TABLE_NAME = "org.example.table.NestedUsage" +DELEGATE_TABLE_NAME = "org.example.table.ComputeResource" + + +@dataclass +class FindLibraryUsage(ScanningRecipe[List[Any]]): + """Leaf recipe that owns a data table.""" + + _table = DataTable(LEAF_TABLE_NAME, "Library usage", "One row per file.", UsageRow) + + @property + def name(self) -> str: + return "org.example.FindLibraryUsage" + + @property + def display_name(self) -> str: + return "Find library usage" + + @property + def description(self) -> str: + return "Leaf recipe that owns a data table." + + @property + def data_tables(self) -> List[DataTable]: + return [self._table] + + def initial_value(self, ctx: ExecutionContext) -> List[Any]: + return [] + + +@dataclass +class FindLibraryUsageByResource(Recipe): + """Composite that owns no table of its own.""" + + @property + def name(self) -> str: + return "org.example.FindLibraryUsageByResource" + + @property + def display_name(self) -> str: + return "Find library usage by resource" + + @property + def description(self) -> str: + return "Composite whose data tables come only from its children." + + def recipe_list(self) -> List[Recipe]: + return [FindLibraryUsage()] + + +@dataclass +class JsonPreset(Recipe): + """Preset one level above the composite, to prove aggregation is recursive.""" + + @property + def name(self) -> str: + return "org.example.JsonPreset" + + @property + def display_name(self) -> str: + return "Json preset" + + @property + def description(self) -> str: + return "Turnkey preset over the composite." + + def recipe_list(self) -> List[Recipe]: + return [FindLibraryUsageByResource()] + + +def _names(descriptor) -> List[str]: + return [dt["name"] for dt in descriptor.data_tables] + + +def test_leaf_reports_its_own_table(): + assert _names(FindLibraryUsage().descriptor()) == [LEAF_TABLE_NAME] + + +def test_composite_aggregates_child_data_tables(): + descriptor = FindLibraryUsageByResource().descriptor() + assert _names(descriptor.recipe_list[0]) == [LEAF_TABLE_NAME] + assert _names(descriptor) == [LEAF_TABLE_NAME] + + +def test_aggregation_is_recursive(): + assert _names(JsonPreset().descriptor()) == [LEAF_TABLE_NAME] + + +def test_aggregated_table_keeps_its_columns(): + (table,) = JsonPreset().descriptor().data_tables + assert [c["name"] for c in table["columns"]] == ["source_path", "library"] + + +def test_own_table_precedes_child_tables_and_duplicates_collapse(): + @dataclass + class Parent(Recipe): + _table = DataTable(NESTED_TABLE_NAME, "Nested", "Parent's own table.", UsageRow) + + @property + def name(self) -> str: + return "org.example.Parent" + + @property + def display_name(self) -> str: + return "Parent" + + @property + def description(self) -> str: + return "Owns a table and also wraps children." + + @property + def data_tables(self) -> List[DataTable]: + return [self._table] + + def recipe_list(self) -> List[Recipe]: + # Same leaf twice: the union must not duplicate it. + return [FindLibraryUsage(), FindLibraryUsage()] + + assert _names(Parent().descriptor()) == [NESTED_TABLE_NAME, LEAF_TABLE_NAME] + + +def test_rpc_recipe_reports_declared_delegate_tables(): + """An RpcRecipe references a recipe on another peer by name only, so the + delegate's tables cannot be introspected locally; the composite author + declares them and they aggregate like any other child's.""" + delegate_table = DataTable( + DELEGATE_TABLE_NAME, "Compute resources", "Resources from the delegate.", UsageRow + ) + + @dataclass + class WithDelegate(Recipe): + @property + def name(self) -> str: + return "org.example.WithDelegate" + + @property + def display_name(self) -> str: + return "With delegate" + + @property + def description(self) -> str: + return "Composite delegating to a recipe on another peer." + + def recipe_list(self) -> List[Recipe]: + return [ + FindLibraryUsage(), + RpcRecipe("org.example.java.FindComputeResources", + data_tables=[delegate_table]), + ] + + assert _names(WithDelegate().descriptor()) == [LEAF_TABLE_NAME, DELEGATE_TABLE_NAME] + + +def test_rpc_recipe_without_declared_tables_reports_none(): + assert RpcRecipe("org.example.java.Whatever").data_tables == []