Skip to content
Open
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
78 changes: 51 additions & 27 deletions preprocess_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,21 +408,26 @@ class name like 'Checkout'); fall back to dot-replaced-with-underscore

def get_required_ops(schema):
"""
Scans a schema for the custom 'ucp_request' metadata.
Scans a schema's properties and $defs for custom 'ucp_request' metadata.
Returns a set of operation keys (e.g. {'create', 'update'}) that need distinct models.
"""
ops = set()
properties = schema.get("properties", {})
if not isinstance(properties, dict):
return ops

for data in properties.values():
if isinstance(data, dict):
marker = data.get("ucp_request")
if isinstance(marker, str):
ops.update(["create", "update"]) # Standard shortcut
elif isinstance(marker, dict):
ops.update(marker.keys())
containers = []
if isinstance(schema.get("properties"), dict):
containers.append(schema["properties"])
if isinstance(schema.get("$defs"), dict):
containers.append(schema["$defs"])

for container in containers:
for node in iter_nodes(container):
if not isinstance(node, dict):
continue
marker = node.get("ucp_request")
if marker is not None:
if isinstance(marker, str):
ops.update(["create", "update"]) # Standard shortcut
elif isinstance(marker, dict):
ops.update(marker.keys())
return ops


Expand Down Expand Up @@ -558,6 +563,20 @@ def _create_single_variant(
variant, op, file_path, global_variant_requirements
)

# Apply request rules to top-level definitions in $defs
defs = variant.get("$defs", {})
if isinstance(defs, dict):
for node in defs.values():
if isinstance(node, dict) and (
"properties" in node or node.get("type") == "object"
):
_apply_request_rules_to_object(
node, op, file_path, global_variant_requirements
)

# Rewrite all external references across the entire variant tree
rewrite_refs_to_variants(variant, op, file_path, global_variant_requirements)

return variant


Expand Down Expand Up @@ -626,20 +645,23 @@ def normalize_metadata_schemas(schemas, target_dir):


def extract_external_refs(schema, path):
"""Finds all relative external file references in the schema properties."""
"""Finds all relative external file references in properties and $defs."""
refs = []
props = schema.get("properties", {})
if not isinstance(props, dict):
return refs

for name, data in props.items():
for node in iter_nodes(data):
if isinstance(node, dict) and "$ref" in node:
ref = node["$ref"]
ref_file, _, _ = ref.partition("#")
if ref_file:
abs_path = str((path.parent / ref_file).resolve())
refs.append((name, abs_path))
containers = []
if isinstance(schema.get("properties"), dict):
containers.append(schema["properties"])
if isinstance(schema.get("$defs"), dict):
containers.append(schema["$defs"])

for container in containers:
for name, data in container.items():
for node in iter_nodes(data):
if isinstance(node, dict) and "$ref" in node:
ref = node["$ref"]
ref_file, _, _ = ref.partition("#")
if ref_file:
abs_path = str((path.parent / ref_file).resolve())
refs.append((name, abs_path))
return refs


Expand All @@ -660,9 +682,10 @@ def propagate_needs_transitive(variant_needs, schema_refs, schemas):
if child_path not in schemas:
continue

# Only propagate if the property isn't 'omit'ted for this op
# Check properties first, then $defs for any operation override
data = (
schemas[path].get("properties", {}).get(prop_name, {})
schemas[path].get("properties", {}).get(prop_name)
or schemas[path].get("$defs", {}).get(prop_name, {})
)
include, _ = eval_prop_inclusion(
prop_name, data, op, schemas[path].get("required", [])
Expand Down Expand Up @@ -760,3 +783,4 @@ def main():

if __name__ == "__main__":
main()

233 changes: 233 additions & 0 deletions tests/test_codegen_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,31 @@ def test_get_required_ops_collects_all_declared_operations(self) -> None:
{"create", "update", "complete"},
)

def test_get_required_ops_collects_from_nested_defs(self) -> None:
"""Markers inside $defs contribute their operations even if root has none."""
schema = {
"title": "Extension",
"type": "object",
"$defs": {
"custom": {
"type": "object",
"properties": {
"secret": {"ucp_request": "omit"},
"action": {
"ucp_request": {
"complete": "optional",
}
},
},
}
},
}

self.assertEqual(
preprocess_schemas.get_required_ops(schema),
{"create", "update", "complete"},
)

def test_eval_prop_inclusion_applies_operation_overrides(self) -> None:
"""Operation markers override base required and inclusion rules."""
cases = [
Expand Down Expand Up @@ -553,6 +578,94 @@ def test_generate_variants_writes_operation_specific_files(self) -> None:
self.assertEqual(set(update_variant["properties"]), {"id"})
self.assertEqual(update_variant["required"], ["id"])

def test_nested_defs_filtered_and_refs_rewritten(self) -> None:
"""Nested $defs properties are filtered and external refs rewritten."""
schema = {
"$id": "https://ucp.dev/schemas/food/cart.json",
"title": "Cart",
"type": "object",
"$defs": {
"checkout": {
"title": "Checkout with Cart",
"type": "object",
"properties": {
"cart_id": {
"type": "string",
"ucp_request": {
"create": "optional",
"update": "omit",
"complete": "omit",
},
}
},
"allOf": [{"$ref": "checkout.json"}],
}
},
"properties": {
"restaurant": {"$ref": "restaurant.json"},
},
}
file_path = Path("/schemas/food/cart.json")
checkout_path = str((file_path.parent / "checkout.json").resolve())
restaurant_path = str((file_path.parent / "restaurant.json").resolve())
variant_needs = {
checkout_path: {"create", "update", "complete"},
restaurant_path: {"create", "update"},
}

create_variant = preprocess_schemas._create_single_variant(
schema,
"create",
"cart",
file_path,
variant_needs,
)
update_variant = preprocess_schemas._create_single_variant(
schema,
"update",
"cart",
file_path,
variant_needs,
)
complete_variant = preprocess_schemas._create_single_variant(
schema,
"complete",
"cart",
file_path,
variant_needs,
)

# Create variant includes cart_id (without ucp_request) and rewrites checkout.json
checkout_def_create = create_variant["$defs"]["checkout"]
self.assertIn("cart_id", checkout_def_create["properties"])
self.assertNotIn(
"ucp_request", checkout_def_create["properties"]["cart_id"]
)
self.assertEqual(
checkout_def_create["allOf"][0]["$ref"],
"checkout_create_request.json",
)
self.assertEqual(
create_variant["properties"]["restaurant"]["$ref"],
"restaurant_create_request.json",
)

# Update variant omits cart_id and rewrites checkout.json
checkout_def_update = update_variant["$defs"]["checkout"]
self.assertEqual(checkout_def_update["properties"], {})
self.assertEqual(
checkout_def_update["allOf"][0]["$ref"],
"checkout_update_request.json",
)

# Complete variant omits cart_id and rewrites checkout.json
checkout_def_complete = complete_variant["$defs"]["checkout"]
self.assertEqual(checkout_def_complete["properties"], {})
self.assertEqual(
checkout_def_complete["allOf"][0]["$ref"],
"checkout_complete_request.json",
)


class PipelineDependencyTest(unittest.TestCase):
"""Tests metadata normalization and transitive variant dependencies."""
Expand Down Expand Up @@ -814,6 +927,125 @@ def test_propagation_with_fragment(self) -> None:
"child_create_request.json#/$defs/item",
)

def test_main_preprocesses_nested_capability_extensions_end_to_end(
self,
) -> None:
"""Capability extensions in $defs trigger variants and propagate refs."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
preprocess_schemas.save_json(
{
"$defs": {
"entity": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
}
}
},
root / "ucp.json",
)
preprocess_schemas.save_json(
{
"$id": "https://ucp.dev/schemas/checkout.json",
"title": "Checkout",
"type": "object",
"properties": {
"id": {
"type": "string",
"ucp_request": {
"create": "omit",
"update": "required",
},
}
},
},
root / "checkout.json",
)
preprocess_schemas.save_json(
{
"$id": "https://ucp.dev/schemas/cart.json",
"title": "Cart",
"type": "object",
"$defs": {
"checkout": {
"title": "Checkout with Cart",
"type": "object",
"properties": {
"cart_id": {
"type": "string",
"ucp_request": {
"create": "optional",
"update": "omit",
},
}
},
"allOf": [{"$ref": "checkout.json"}],
}
},
"properties": {
"items": {"type": "array"},
},
},
root / "cart.json",
)

with (
mock.patch.object(
sys,
"argv",
["preprocess_schemas.py", str(root)],
),
contextlib.redirect_stdout(io.StringIO()),
):
preprocess_schemas.main()

self.assertTrue(
(root / "checkout_create_request.json").exists(),
"checkout_create_request.json was not generated",
)
self.assertTrue(
(root / "checkout_update_request.json").exists(),
"checkout_update_request.json was not generated",
)
self.assertTrue(
(root / "cart_create_request.json").exists(),
"cart_create_request.json was not generated",
)
self.assertTrue(
(root / "cart_update_request.json").exists(),
"cart_update_request.json was not generated",
)

cart_create = preprocess_schemas.load_json(
root / "cart_create_request.json"
)
cart_update = preprocess_schemas.load_json(
root / "cart_update_request.json"
)

# Check cart_create_request.json
self.assertIn(
"cart_id", cart_create["$defs"]["checkout"]["properties"]
)
self.assertNotIn(
"ucp_request",
cart_create["$defs"]["checkout"]["properties"]["cart_id"],
)
self.assertEqual(
cart_create["$defs"]["checkout"]["allOf"][0]["$ref"],
"checkout_create_request.json",
)

# Check cart_update_request.json
self.assertEqual(
cart_update["$defs"]["checkout"]["properties"], {}
)
self.assertEqual(
cart_update["$defs"]["checkout"]["allOf"][0]["$ref"],
"checkout_update_request.json",
)


class MetadataUnionTest(unittest.TestCase):
"""The UcpMetadata root union is derived from ucp.json $defs."""
Expand Down Expand Up @@ -1977,3 +2209,4 @@ def test_payment_handler_base_rejects_invalid_version(self) -> None:

if __name__ == "__main__":
unittest.main()

Loading