From cbf46f872c9768dcb029af3f7747ae3a52718a86 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 19 Aug 2026 13:38:44 +0000 Subject: [PATCH 1/2] fix: resolve entity local references before inlining (#72) Recursively resolve same-document $refs on the entity definition (such as #/$defs/version) upon extraction in preprocess_schemas.py before inlining into capability.json, service.json, and payment_handler.json. This prevents dangling references in preprocessed schemas, enables valid pattern constraints on generated models, and eliminates Version = TypeAliasType("Version", Any). Fixes #72 --- preprocess_schemas.py | 36 ++++- src/ucp_sdk/models/schemas/capability.py | 95 +++++++------ src/ucp_sdk/models/schemas/payment_handler.py | 11 +- src/ucp_sdk/models/schemas/service.py | 65 +++++---- tests/test_codegen_pipeline.py | 128 ++++++++++++++++++ 5 files changed, 244 insertions(+), 91 deletions(-) diff --git a/preprocess_schemas.py b/preprocess_schemas.py index cd0041a..10a4206 100644 --- a/preprocess_schemas.py +++ b/preprocess_schemas.py @@ -86,6 +86,37 @@ def resolve_local_ref(ref, root): return current +def resolve_local_refs(fragment, root, seen=None): + """ + Recursively resolves and inlines local $ref pointers (#/...) within a schema fragment. + """ + if seen is None: + seen = set() + + if isinstance(fragment, dict): + if "$ref" in fragment: + ref = fragment["$ref"] + if ( + isinstance(ref, str) + and ref.startswith("#/") + and ref not in seen + ): + target = resolve_local_ref(ref, root) + if target is not None: + resolved = copy.deepcopy(target) + resolve_local_refs(resolved, root, seen | {ref}) + for k, v in fragment.items(): + if k != "$ref": + resolved[k] = v + fragment.clear() + fragment.update(resolved) + for v in list(fragment.values()): + resolve_local_refs(v, root, seen) + elif isinstance(fragment, list): + for item in fragment: + resolve_local_refs(item, root, seen) + + # --- Schema Normalization and Flattening --- @@ -679,7 +710,10 @@ def main(): ucp_path = str((target_dir / "ucp.json").resolve()) entity_def = {} if ucp_path in schemas: - entity_def = schemas[ucp_path].get("$defs", {}).get("entity", {}) + entity_def = copy.deepcopy( + schemas[ucp_path].get("$defs", {}).get("entity", {}) + ) + resolve_local_refs(entity_def, schemas[ucp_path]) if not entity_def: raise ValueError( "Entity definition not found! 'ucp.json' must define '$defs.entity'" diff --git a/src/ucp_sdk/models/schemas/capability.py b/src/ucp_sdk/models/schemas/capability.py index 81213b0..1a53ae0 100644 --- a/src/ucp_sdk/models/schemas/capability.py +++ b/src/ucp_sdk/models/schemas/capability.py @@ -58,56 +58,11 @@ """ -Extends2 = TypeAliasType("Extends2", Extends) - - -Extends3Item = TypeAliasType("Extends3Item", Extends1Item) - - -Extends3 = TypeAliasType( - "Extends3", Annotated[list[Extends3Item], Field(..., min_length=1)] -) -""" -Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. -""" - - -Extends4 = TypeAliasType("Extends4", Extends) - - -Extends5Item = TypeAliasType("Extends5Item", Extends1Item) - - -Extends5 = TypeAliasType( - "Extends5", Annotated[list[Extends5Item], Field(..., min_length=1)] -) -""" -Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. -""" - - -Extends6 = TypeAliasType("Extends6", Extends) - - -Extends7Item = TypeAliasType("Extends7Item", Extends1Item) - - -Extends7 = TypeAliasType( - "Extends7", Annotated[list[Extends7Item], Field(..., min_length=1)] -) -""" -Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. -""" - - -Version = TypeAliasType("Version", Any) - - class Base(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -133,6 +88,20 @@ class Base(BaseModel): """ +Extends2 = TypeAliasType("Extends2", Extends) + + +Extends3Item = TypeAliasType("Extends3Item", Extends1Item) + + +Extends3 = TypeAliasType( + "Extends3", Annotated[list[Extends3Item], Field(..., min_length=1)] +) +""" +Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. +""" + + class PlatformSchema(BaseModel): """ Full capability declaration for platform-level discovery. Includes spec/schema URLs for agent fetching. @@ -141,7 +110,7 @@ class PlatformSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -167,6 +136,20 @@ class PlatformSchema(BaseModel): """ +Extends4 = TypeAliasType("Extends4", Extends) + + +Extends5Item = TypeAliasType("Extends5Item", Extends1Item) + + +Extends5 = TypeAliasType( + "Extends5", Annotated[list[Extends5Item], Field(..., min_length=1)] +) +""" +Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. +""" + + class BusinessSchema(BaseModel): """ Capability configuration for business/merchant level. May include business-specific config overrides. @@ -175,7 +158,7 @@ class BusinessSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -201,6 +184,20 @@ class BusinessSchema(BaseModel): """ +Extends6 = TypeAliasType("Extends6", Extends) + + +Extends7Item = TypeAliasType("Extends7Item", Extends1Item) + + +Extends7 = TypeAliasType( + "Extends7", Annotated[list[Extends7Item], Field(..., min_length=1)] +) +""" +Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions. +""" + + class ResponseSchema(BaseModel): """ Capability reference in responses. Only name/version required to confirm active capabilities. @@ -209,7 +206,7 @@ class ResponseSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ diff --git a/src/ucp_sdk/models/schemas/payment_handler.py b/src/ucp_sdk/models/schemas/payment_handler.py index cb36177..2428186 100644 --- a/src/ucp_sdk/models/schemas/payment_handler.py +++ b/src/ucp_sdk/models/schemas/payment_handler.py @@ -33,14 +33,11 @@ """ -Version = TypeAliasType("Version", Any) - - class Base(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -76,7 +73,7 @@ class PlatformSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -112,7 +109,7 @@ class BusinessSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -148,7 +145,7 @@ class ResponseSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ diff --git a/src/ucp_sdk/models/schemas/service.py b/src/ucp_sdk/models/schemas/service.py index efe34c7..cf33914 100644 --- a/src/ucp_sdk/models/schemas/service.py +++ b/src/ucp_sdk/models/schemas/service.py @@ -31,32 +31,11 @@ """ -class Config(BaseModel): - """ - Entity-specific configuration. Structure defined by each entity's schema. - """ - - model_config = ConfigDict( - extra="allow", - ) - delegate: list[str] | None = None - """ - Delegations the business allows. At service-level, declares available delegations. In UCP responses, confirms accepted delegations for this session. - """ - color_scheme: list[Literal["light", "dark"]] | None = None - """ - Color schemes the business supports. Hosts use ec_color_scheme query parameter to request a scheme from this list. - """ - - -Version = TypeAliasType("Version", Any) - - class Base(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -94,7 +73,7 @@ class PlatformSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -132,7 +111,7 @@ class PlatformSchema7(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -170,7 +149,7 @@ class PlatformSchema8(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -208,7 +187,7 @@ class PlatformSchema9(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -258,7 +237,7 @@ class BusinessSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -296,7 +275,7 @@ class BusinessSchema4(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -334,7 +313,7 @@ class BusinessSchema5(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -364,6 +343,24 @@ class BusinessSchema5(BaseModel): """ +class Config(BaseModel): + """ + Entity-specific configuration. Structure defined by each entity's schema. + """ + + model_config = ConfigDict( + extra="allow", + ) + delegate: list[str] | None = None + """ + Delegations the business allows. At service-level, declares available delegations. In UCP responses, confirms accepted delegations for this session. + """ + color_scheme: list[Literal["light", "dark"]] | None = None + """ + Color schemes the business supports. Hosts use ec_color_scheme query parameter to request a scheme from this list. + """ + + class BusinessSchema6(BaseModel): """ Service binding for business/merchant configuration. May override platform endpoints. @@ -372,7 +369,7 @@ class BusinessSchema6(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -422,7 +419,7 @@ class ResponseSchema(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -460,7 +457,7 @@ class ResponseSchema4(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -498,7 +495,7 @@ class ResponseSchema5(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ @@ -536,7 +533,7 @@ class ResponseSchema6(BaseModel): model_config = ConfigDict( extra="allow", ) - version: Version + version: str = Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$") """ Entity version in YYYY-MM-DD format. """ diff --git a/tests/test_codegen_pipeline.py b/tests/test_codegen_pipeline.py index a6dd3ab..26b6252 100644 --- a/tests/test_codegen_pipeline.py +++ b/tests/test_codegen_pipeline.py @@ -63,6 +63,103 @@ def test_resolve_local_ref_supports_objects_and_arrays(self) -> None: preprocess_schemas.resolve_local_ref("other.json", schema) ) + def test_resolve_local_refs_inlines_nested_and_transitive_pointers( + self, + ) -> None: + """Local references in fragment are inlined recursively with overrides.""" + root = { + "$defs": { + "base_version": { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + "description": "Default version description", + }, + "version_alias": { + "$ref": "#/$defs/base_version", + }, + } + } + fragment = { + "type": "object", + "properties": { + "version": { + "$ref": "#/$defs/version_alias", + "description": "Entity version in YYYY-MM-DD format.", + } + }, + } + + preprocess_schemas.resolve_local_refs(fragment, root) + + self.assertEqual( + fragment["properties"]["version"], + { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + "description": "Entity version in YYYY-MM-DD format.", + }, + ) + + def test_main_inlines_entity_local_refs_without_dangling_pointers( + self, + ) -> None: + """Entity local refs like $defs/version are resolved before inlining into child schemas.""" + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + preprocess_schemas.save_json( + { + "$defs": { + "version": { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + }, + "entity": { + "type": "object", + "properties": { + "version": {"$ref": "#/$defs/version"}, + "id": {"type": "string"}, + }, + "required": ["version"], + }, + } + }, + root / "ucp.json", + ) + preprocess_schemas.save_json( + { + "$id": "https://ucp.dev/schemas/capability.json", + "title": "Capability", + "$defs": { + "base": { + "allOf": [{"$ref": "ucp.json#/$defs/entity"}], + } + }, + }, + root / "capability.json", + ) + + with ( + mock.patch.object( + sys, + "argv", + ["preprocess_schemas.py", str(root)], + ), + contextlib.redirect_stdout(io.StringIO()), + ): + preprocess_schemas.main() + + capability = preprocess_schemas.load_json(root / "capability.json") + base = capability["$defs"]["base"] + self.assertNotIn("allOf", base) + self.assertEqual( + base["properties"]["version"], + { + "type": "string", + "pattern": r"^\d{4}-\d{2}-\d{2}$", + }, + ) + self.assertNotIn("$ref", base["properties"]["version"]) + def test_preprocess_flattens_and_distributes_properties(self) -> None: """Flattened base fields are distributed to polymorphic branches.""" schema = { @@ -1847,5 +1944,36 @@ def test_sibling_config_keeps_extra_allow(self) -> None: self.assertEqual(config.model_extra, {"bogus": "x"}) +@unittest.skipUnless( + HAVE_SDK, "requires the installed package (pip install -e .)" +) +class EntityVersionValidationSemanticTest(unittest.TestCase): + """Committed entity-derived models enforce version pattern validation.""" + + def test_capability_base_accepts_valid_version(self) -> None: + from ucp_sdk.models.schemas.capability import Base + + model = Base.model_validate({"version": "2026-04-08", "id": "test"}) + self.assertEqual(model.version, "2026-04-08") + + def test_capability_base_rejects_invalid_version(self) -> None: + from ucp_sdk.models.schemas.capability import Base + + with self.assertRaises(ValidationError): + Base.model_validate({"version": "not-a-version", "id": "test"}) + + def test_service_base_rejects_invalid_version(self) -> None: + from ucp_sdk.models.schemas.service import Base + + with self.assertRaises(ValidationError): + Base.model_validate({"version": "invalid-format"}) + + def test_payment_handler_base_rejects_invalid_version(self) -> None: + from ucp_sdk.models.schemas.payment_handler import Base + + with self.assertRaises(ValidationError): + Base.model_validate({"version": {"not": "a version"}}) + + if __name__ == "__main__": unittest.main() From afb6ba42dac4ff3eb869ceed1ae423a7e0cf8214 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 20 Aug 2026 14:12:45 +0000 Subject: [PATCH 2/2] chore: bump version to 0.4.5 and update authors --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0e49d63..cd24552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,10 @@ [project] name = "ucp-sdk" -version = "0.4.4" +version = "0.4.5" description = "UCP Python SDK" readme = "README.md" license = {file = "LICENSE"} authors = [ - { name = "Enric Cusell", email = "cusell@google.com" }, { name = "Federico D'Amato", email = "damaz@google.com" } ] classifiers = [