From 1c290723babe1057c3f99b0b20596ab32df8f3c7 Mon Sep 17 00:00:00 2001 From: xiaoxuan-hub Date: Wed, 29 Apr 2026 17:37:18 -0700 Subject: [PATCH 1/5] rewrite $ref in top-level oneOf/anyOf branches during variant generation (cherry picked from commit 2a9beb9243f906117d382bc74818f8c86b2a33c0) --- preprocess_schemas.py | 7 +++++++ src/ucp_sdk/models/schemas/__init__.py | 1 + src/ucp_sdk/models/schemas/common/__init__.py | 1 + src/ucp_sdk/models/schemas/shopping/__init__.py | 1 + src/ucp_sdk/models/schemas/shopping/types/__init__.py | 1 + .../types/fulfillment_destination_create_request.py | 9 ++++++--- .../types/fulfillment_destination_update_request.py | 9 ++++++--- src/ucp_sdk/models/schemas/transports/__init__.py | 1 + 8 files changed, 24 insertions(+), 6 deletions(-) diff --git a/preprocess_schemas.py b/preprocess_schemas.py index 10a4206..d2e699a 100644 --- a/preprocess_schemas.py +++ b/preprocess_schemas.py @@ -558,6 +558,13 @@ def _create_single_variant( variant, op, file_path, global_variant_requirements ) + # Rewrite $refs in top-level oneOf/anyOf branches (e.g. fulfillment_destination + # has no properties, only oneOf with external $refs that need variant rewriting). + for poly_key in ["oneOf", "anyOf"]: + if poly_key in variant: + rewrite_refs_to_variants( + variant[poly_key], op, file_path, global_variant_requirements + ) return variant diff --git a/src/ucp_sdk/models/schemas/__init__.py b/src/ucp_sdk/models/schemas/__init__.py index 1252d6b..421dc21 100644 --- a/src/ucp_sdk/models/schemas/__init__.py +++ b/src/ucp_sdk/models/schemas/__init__.py @@ -15,3 +15,4 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable + diff --git a/src/ucp_sdk/models/schemas/common/__init__.py b/src/ucp_sdk/models/schemas/common/__init__.py index 1252d6b..421dc21 100644 --- a/src/ucp_sdk/models/schemas/common/__init__.py +++ b/src/ucp_sdk/models/schemas/common/__init__.py @@ -15,3 +15,4 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable + diff --git a/src/ucp_sdk/models/schemas/shopping/__init__.py b/src/ucp_sdk/models/schemas/shopping/__init__.py index 1252d6b..421dc21 100644 --- a/src/ucp_sdk/models/schemas/shopping/__init__.py +++ b/src/ucp_sdk/models/schemas/shopping/__init__.py @@ -15,3 +15,4 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable + diff --git a/src/ucp_sdk/models/schemas/shopping/types/__init__.py b/src/ucp_sdk/models/schemas/shopping/types/__init__.py index 1252d6b..421dc21 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/__init__.py +++ b/src/ucp_sdk/models/schemas/shopping/types/__init__.py @@ -15,3 +15,4 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable + diff --git a/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_create_request.py index 2eeb290..4d7bbb5 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_create_request.py @@ -23,13 +23,16 @@ from pydantic import Field from typing_extensions import TypeAliasType -from . import retail_location, shipping_destination +from . import ( + retail_location_create_request, + shipping_destination_create_request, +) FulfillmentDestinationCreateRequest = TypeAliasType( "FulfillmentDestinationCreateRequest", Annotated[ - shipping_destination.ShippingDestination - | retail_location.RetailLocation, + shipping_destination_create_request.ShippingDestinationCreateRequest + | retail_location_create_request.RetailLocationCreateRequest, Field(..., title="Fulfillment Destination Create Request"), ], ) diff --git a/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_update_request.py index e04ace3..add2079 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_update_request.py @@ -23,13 +23,16 @@ from pydantic import Field from typing_extensions import TypeAliasType -from . import retail_location, shipping_destination +from . import ( + retail_location_update_request, + shipping_destination_update_request, +) FulfillmentDestinationUpdateRequest = TypeAliasType( "FulfillmentDestinationUpdateRequest", Annotated[ - shipping_destination.ShippingDestination - | retail_location.RetailLocation, + shipping_destination_update_request.ShippingDestinationUpdateRequest + | retail_location_update_request.RetailLocationUpdateRequest, Field(..., title="Fulfillment Destination Update Request"), ], ) diff --git a/src/ucp_sdk/models/schemas/transports/__init__.py b/src/ucp_sdk/models/schemas/transports/__init__.py index 1252d6b..421dc21 100644 --- a/src/ucp_sdk/models/schemas/transports/__init__.py +++ b/src/ucp_sdk/models/schemas/transports/__init__.py @@ -15,3 +15,4 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable + From d179838c29899f41408b30e8e63adebfd6519df1 Mon Sep 17 00:00:00 2001 From: xiaoxuan-hub Date: Wed, 29 Apr 2026 19:02:13 -0700 Subject: [PATCH 2/5] update (cherry picked from commit e652462c7dca64fde8a9374fc3552276d1f477c5) --- preprocess_schemas.py | 65 +++++++++------- src/ucp_sdk/models/schemas/__init__.py | 1 - src/ucp_sdk/models/schemas/common/__init__.py | 1 - .../models/schemas/shopping/__init__.py | 1 - .../schemas/shopping/cart_create_request.py | 4 +- .../schemas/shopping/cart_update_request.py | 4 +- .../models/schemas/shopping/types/__init__.py | 1 - .../types/error_code_create_request.py | 48 ++++++++++++ .../types/error_code_update_request.py | 48 ++++++++++++ .../types/info_code_create_request.py | 44 +++++++++++ .../types/info_code_update_request.py | 44 +++++++++++ .../shopping/types/message_create_request.py | 12 ++- .../types/message_error_create_request.py | 57 ++++++++++++++ .../types/message_error_update_request.py | 57 ++++++++++++++ .../types/message_info_create_request.py | 48 ++++++++++++ .../types/message_info_update_request.py | 48 ++++++++++++ .../shopping/types/message_update_request.py | 12 ++- .../types/message_warning_create_request.py | 60 +++++++++++++++ .../types/message_warning_update_request.py | 60 +++++++++++++++ .../shipping_destination_create_request.py | 4 +- .../shipping_destination_update_request.py | 4 +- .../types/signed_amount_create_request.py | 32 ++++++++ .../types/signed_amount_update_request.py | 32 ++++++++ .../shopping/types/totals_create_request.py | 77 +------------------ .../shopping/types/totals_update_request.py | 77 +------------------ .../types/warning_code_create_request.py | 44 +++++++++++ .../types/warning_code_update_request.py | 44 +++++++++++ .../models/schemas/transports/__init__.py | 1 - .../models/schemas/ucp_create_request.py | 51 ++++++++---- .../models/schemas/ucp_update_request.py | 51 ++++++++---- 30 files changed, 803 insertions(+), 229 deletions(-) create mode 100644 src/ucp_sdk/models/schemas/shopping/types/error_code_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/error_code_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/info_code_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/info_code_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_error_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_error_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_info_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_info_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_warning_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/message_warning_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/signed_amount_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/signed_amount_update_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/warning_code_create_request.py create mode 100644 src/ucp_sdk/models/schemas/shopping/types/warning_code_update_request.py diff --git a/preprocess_schemas.py b/preprocess_schemas.py index d2e699a..a9bba53 100644 --- a/preprocess_schemas.py +++ b/preprocess_schemas.py @@ -558,13 +558,12 @@ def _create_single_variant( variant, op, file_path, global_variant_requirements ) - # Rewrite $refs in top-level oneOf/anyOf branches (e.g. fulfillment_destination - # has no properties, only oneOf with external $refs that need variant rewriting). - for poly_key in ["oneOf", "anyOf"]: - if poly_key in variant: - rewrite_refs_to_variants( - variant[poly_key], op, file_path, global_variant_requirements - ) + # Rewrite all external $refs in the variant schema to point to their + # corresponding request variants where applicable. This covers top-level + # oneOf/anyOf/allOf branches as well as array items. + rewrite_refs_to_variants( + variant, op, file_path, global_variant_requirements + ) return variant @@ -633,13 +632,10 @@ 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 the schema.""" refs = [] - props = schema.get("properties", {}) - if not isinstance(props, dict): - return refs - for name, data in props.items(): + def _scan(name, data): for node in iter_nodes(data): if isinstance(node, dict) and "$ref" in node: ref = node["$ref"] @@ -647,6 +643,18 @@ def extract_external_refs(schema, path): if ref_file: abs_path = str((path.parent / ref_file).resolve()) refs.append((name, abs_path)) + + props = schema.get("properties", {}) + if isinstance(props, dict): + for name, data in props.items(): + _scan(name, data) + + # Also scan top-level composition keywords (oneOf, anyOf, allOf, items) + for key in ["oneOf", "anyOf", "allOf"]: + if key in schema: + _scan(key, schema[key]) + if "items" in schema: + _scan("items", schema["items"]) return refs @@ -663,23 +671,28 @@ def propagate_needs_transitive(variant_needs, schema_refs, schemas): continue for op in list(variant_needs[path]): - for prop_name, child_path in refs: + for ref_name, child_path in refs: if child_path not in schemas: continue - # Only propagate if the property isn't 'omit'ted for this op - data = ( - schemas[path].get("properties", {}).get(prop_name, {}) - ) - include, _ = eval_prop_inclusion( - prop_name, data, op, schemas[path].get("required", []) - ) - - if include: - target_set = variant_needs.setdefault(child_path, set()) - if op not in target_set: - target_set.add(op) - changed = True + # For property refs, check if the property is included for this op. + # For non-property refs (oneOf, anyOf, allOf, items), always propagate. + props = schemas[path].get("properties", {}) + if ref_name in props: + data = props[ref_name] + include, _ = eval_prop_inclusion( + ref_name, + data, + op, + schemas[path].get("required", []), + ) + if not include: + continue + + target_set = variant_needs.setdefault(child_path, set()) + if op not in target_set: + target_set.add(op) + changed = True # --- Main Flow --- diff --git a/src/ucp_sdk/models/schemas/__init__.py b/src/ucp_sdk/models/schemas/__init__.py index 421dc21..1252d6b 100644 --- a/src/ucp_sdk/models/schemas/__init__.py +++ b/src/ucp_sdk/models/schemas/__init__.py @@ -15,4 +15,3 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable - diff --git a/src/ucp_sdk/models/schemas/common/__init__.py b/src/ucp_sdk/models/schemas/common/__init__.py index 421dc21..1252d6b 100644 --- a/src/ucp_sdk/models/schemas/common/__init__.py +++ b/src/ucp_sdk/models/schemas/common/__init__.py @@ -15,4 +15,3 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable - diff --git a/src/ucp_sdk/models/schemas/shopping/__init__.py b/src/ucp_sdk/models/schemas/shopping/__init__.py index 421dc21..1252d6b 100644 --- a/src/ucp_sdk/models/schemas/shopping/__init__.py +++ b/src/ucp_sdk/models/schemas/shopping/__init__.py @@ -15,4 +15,3 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable - diff --git a/src/ucp_sdk/models/schemas/shopping/cart_create_request.py b/src/ucp_sdk/models/schemas/shopping/cart_create_request.py index 636e8aa..d7a93f6 100644 --- a/src/ucp_sdk/models/schemas/shopping/cart_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/cart_create_request.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, ConfigDict -from .checkout import Checkout as Checkout_1 +from .checkout_create_request import CheckoutCreateRequest from .types import ( attribution_create_request, buyer_create_request, @@ -56,7 +56,7 @@ class CartCreateRequest(BaseModel): """ -class Checkout(Checkout_1): +class Checkout(CheckoutCreateRequest): """ Checkout extended with cart capability. Adds cart_id to create_checkout for cart-to-checkout conversion. """ diff --git a/src/ucp_sdk/models/schemas/shopping/cart_update_request.py b/src/ucp_sdk/models/schemas/shopping/cart_update_request.py index c4d35f9..7c523ac 100644 --- a/src/ucp_sdk/models/schemas/shopping/cart_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/cart_update_request.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, ConfigDict -from .checkout import Checkout as Checkout_1 +from .checkout_update_request import CheckoutUpdateRequest from .types import ( attribution_update_request, buyer_update_request, @@ -60,7 +60,7 @@ class CartUpdateRequest(BaseModel): """ -class Checkout(Checkout_1): +class Checkout(CheckoutUpdateRequest): """ Checkout extended with cart capability. Adds cart_id to create_checkout for cart-to-checkout conversion. """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/__init__.py b/src/ucp_sdk/models/schemas/shopping/types/__init__.py index 421dc21..1252d6b 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/__init__.py +++ b/src/ucp_sdk/models/schemas/shopping/types/__init__.py @@ -15,4 +15,3 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable - diff --git a/src/ucp_sdk/models/schemas/shopping/types/error_code_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/error_code_create_request.py new file mode 100644 index 0000000..c70c8e7 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/error_code_create_request.py @@ -0,0 +1,48 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +ErrorCodeCreateRequest = TypeAliasType( + "ErrorCodeCreateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "not_found", + "out_of_stock", + "item_unavailable", + "address_undeliverable", + "payment_failed", + "eligibility_invalid", + "identity_required", + "insufficient_scope", + ], + title="Error Code Create Request", + ), + ], +) +""" +Error code identifying the type of error. Standard errors are defined in specification (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/error_code_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/error_code_update_request.py new file mode 100644 index 0000000..fab61ba --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/error_code_update_request.py @@ -0,0 +1,48 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +ErrorCodeUpdateRequest = TypeAliasType( + "ErrorCodeUpdateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "not_found", + "out_of_stock", + "item_unavailable", + "address_undeliverable", + "payment_failed", + "eligibility_invalid", + "identity_required", + "insufficient_scope", + ], + title="Error Code Update Request", + ), + ], +) +""" +Error code identifying the type of error. Standard errors are defined in specification (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/info_code_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/info_code_create_request.py new file mode 100644 index 0000000..5a065eb --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/info_code_create_request.py @@ -0,0 +1,44 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +InfoCodeCreateRequest = TypeAliasType( + "InfoCodeCreateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "identity_optional", + "signal", + "free_shipping", + "not_found", + ], + title="Info Code Create Request", + ), + ], +) +""" +Info code identifying the type of informational message. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/info_code_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/info_code_update_request.py new file mode 100644 index 0000000..ff78415 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/info_code_update_request.py @@ -0,0 +1,44 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +InfoCodeUpdateRequest = TypeAliasType( + "InfoCodeUpdateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "identity_optional", + "signal", + "free_shipping", + "not_found", + ], + title="Info Code Update Request", + ), + ], +) +""" +Info code identifying the type of informational message. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_create_request.py index 3fa159b..123e75d 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/message_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/message_create_request.py @@ -23,14 +23,18 @@ from pydantic import Field from typing_extensions import TypeAliasType -from . import message_error, message_info, message_warning +from . import ( + message_error_create_request, + message_info_create_request, + message_warning_create_request, +) MessageCreateRequest = TypeAliasType( "MessageCreateRequest", Annotated[ - message_error.MessageError - | message_warning.MessageWarning - | message_info.MessageInfo, + message_error_create_request.MessageErrorCreateRequest + | message_warning_create_request.MessageWarningCreateRequest + | message_info_create_request.MessageInfoCreateRequest, Field(..., title="Message Create Request"), ], ) diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_error_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_error_create_request.py new file mode 100644 index 0000000..9375f6c --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_error_create_request.py @@ -0,0 +1,57 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from . import error_code_create_request + + +class MessageErrorCreateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["error"] + """ + Message type discriminator. + """ + code: error_code_create_request.ErrorCodeCreateRequest + path: str | None = None + """ + RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]). + """ + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + content: str + """ + Human-readable message. + """ + severity: Literal[ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + "unrecoverable", + ] + """ + Reflects the resource state and recommended action. 'recoverable': platform can resolve by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires information their API doesn't support collecting programmatically (checkout incomplete). 'requires_buyer_review': buyer must authorize before order placement due to policy, regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, retry with new resource or inputs. Errors with 'requires_*' severity contribute to 'status: requires_escalation'. + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_error_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_error_update_request.py new file mode 100644 index 0000000..3ad566d --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_error_update_request.py @@ -0,0 +1,57 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from . import error_code_update_request + + +class MessageErrorUpdateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["error"] + """ + Message type discriminator. + """ + code: error_code_update_request.ErrorCodeUpdateRequest + path: str | None = None + """ + RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]). + """ + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + content: str + """ + Human-readable message. + """ + severity: Literal[ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + "unrecoverable", + ] + """ + Reflects the resource state and recommended action. 'recoverable': platform can resolve by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires information their API doesn't support collecting programmatically (checkout incomplete). 'requires_buyer_review': buyer must authorize before order placement due to policy, regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, retry with new resource or inputs. Errors with 'requires_*' severity contribute to 'status: requires_escalation'. + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_info_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_info_create_request.py new file mode 100644 index 0000000..aa715d7 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_info_create_request.py @@ -0,0 +1,48 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from . import info_code_create_request + + +class MessageInfoCreateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["info"] + """ + Message type discriminator. + """ + path: str | None = None + """ + RFC 9535 JSONPath to the component the message refers to. + """ + code: info_code_create_request.InfoCodeCreateRequest | None = None + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + content: str + """ + Human-readable message. + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_info_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_info_update_request.py new file mode 100644 index 0000000..5f206ad --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_info_update_request.py @@ -0,0 +1,48 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from . import info_code_update_request + + +class MessageInfoUpdateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["info"] + """ + Message type discriminator. + """ + path: str | None = None + """ + RFC 9535 JSONPath to the component the message refers to. + """ + code: info_code_update_request.InfoCodeUpdateRequest | None = None + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + content: str + """ + Human-readable message. + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_update_request.py index 7a86d1f..06e63e4 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/message_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/message_update_request.py @@ -23,14 +23,18 @@ from pydantic import Field from typing_extensions import TypeAliasType -from . import message_error, message_info, message_warning +from . import ( + message_error_update_request, + message_info_update_request, + message_warning_update_request, +) MessageUpdateRequest = TypeAliasType( "MessageUpdateRequest", Annotated[ - message_error.MessageError - | message_warning.MessageWarning - | message_info.MessageInfo, + message_error_update_request.MessageErrorUpdateRequest + | message_warning_update_request.MessageWarningUpdateRequest + | message_info_update_request.MessageInfoUpdateRequest, Field(..., title="Message Update Request"), ], ) diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_warning_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_warning_create_request.py new file mode 100644 index 0000000..5294a25 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_warning_create_request.py @@ -0,0 +1,60 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import AnyUrl, BaseModel, ConfigDict + +from . import warning_code_create_request + + +class MessageWarningCreateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["warning"] + """ + Message type discriminator. + """ + path: str | None = None + """ + JSONPath (RFC 9535) to related field (e.g., $.line_items[0]). + """ + code: warning_code_create_request.WarningCodeCreateRequest + content: str + """ + Human-readable warning message that MUST be displayed. + """ + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + presentation: str | None = "notice" + """ + Rendering contract for this warning. 'notice' (default): platform MUST display, MAY dismiss. 'disclosure': platform MUST display in proximity to the path-referenced component, MUST NOT hide or auto-dismiss. See specification for full contract. + """ + image_url: AnyUrl | None = None + """ + URL to a required visual element (e.g., warning symbol, energy class label). + """ + url: AnyUrl | None = None + """ + Reference URL for more information (e.g., regulatory site, registry entry, policy page). + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/message_warning_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/message_warning_update_request.py new file mode 100644 index 0000000..44cd08d --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/message_warning_update_request.py @@ -0,0 +1,60 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Literal + +from pydantic import AnyUrl, BaseModel, ConfigDict + +from . import warning_code_update_request + + +class MessageWarningUpdateRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + type: Literal["warning"] + """ + Message type discriminator. + """ + path: str | None = None + """ + JSONPath (RFC 9535) to related field (e.g., $.line_items[0]). + """ + code: warning_code_update_request.WarningCodeUpdateRequest + content: str + """ + Human-readable warning message that MUST be displayed. + """ + content_type: Literal["plain", "markdown"] | None = "plain" + """ + Content format, default = plain. + """ + presentation: str | None = "notice" + """ + Rendering contract for this warning. 'notice' (default): platform MUST display, MAY dismiss. 'disclosure': platform MUST display in proximity to the path-referenced component, MUST NOT hide or auto-dismiss. See specification for full contract. + """ + image_url: AnyUrl | None = None + """ + URL to a required visual element (e.g., warning symbol, energy class label). + """ + url: AnyUrl | None = None + """ + Reference URL for more information (e.g., regulatory site, registry entry, policy page). + """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_create_request.py index 46cfc64..b454152 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_create_request.py @@ -20,10 +20,10 @@ from pydantic import ConfigDict -from .postal_address import PostalAddress +from .postal_address_create_request import PostalAddressCreateRequest -class ShippingDestinationCreateRequest(PostalAddress): +class ShippingDestinationCreateRequest(PostalAddressCreateRequest): """ Shipping destination. """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_update_request.py index fea30e9..3ab1733 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/shipping_destination_update_request.py @@ -20,10 +20,10 @@ from pydantic import ConfigDict -from .postal_address import PostalAddress +from .postal_address_update_request import PostalAddressUpdateRequest -class ShippingDestinationUpdateRequest(PostalAddress): +class ShippingDestinationUpdateRequest(PostalAddressUpdateRequest): """ Shipping destination. """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/signed_amount_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/signed_amount_create_request.py new file mode 100644 index 0000000..436d0f2 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/signed_amount_create_request.py @@ -0,0 +1,32 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +SignedAmountCreateRequest = TypeAliasType( + "SignedAmountCreateRequest", + Annotated[int, Field(..., title="Signed Amount Create Request")], +) +""" +Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive). +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/signed_amount_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/signed_amount_update_request.py new file mode 100644 index 0000000..985bd10 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/signed_amount_update_request.py @@ -0,0 +1,32 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +SignedAmountUpdateRequest = TypeAliasType( + "SignedAmountUpdateRequest", + Annotated[int, Field(..., title="Signed Amount Update Request")], +) +""" +Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive). +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py index bb78c2d..eaa5dc0 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py @@ -20,87 +20,16 @@ from typing import Annotated -from pydantic import Field, AfterValidator +from pydantic import Field from typing_extensions import TypeAliasType -from . import total - - -def _enforce_contains_totals_create_request(value): - """JSON Schema contains/minContains/maxContains (see #49).""" - _matched_0 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "subtotal" - ) - if _matched_0 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='subtotal' (schema minContains=1)" - ) - if _matched_0 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='subtotal' (schema maxContains=1)" - ) - _matched_1 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "total" - ) - if _matched_1 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='total' (schema minContains=1)" - ) - if _matched_1 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='total' (schema maxContains=1)" - ) - _excluded = [ - "subtotal", - "items_discount", - "discount", - "fulfillment", - "tax", - "fee", - "total", - ] - for _item in value: - _actual = ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - if _actual in _excluded: - continue - if isinstance(_item, dict) and "display_text" not in _item: - raise ValueError("Field 'display_text' is required for custom type") - if ( - not isinstance(_item, dict) - and "display_text" not in _item.model_fields_set - ): - raise ValueError("Field 'display_text' is required for custom type") - return value - +from . import total_create_request TotalsCreateRequest = TypeAliasType( "TotalsCreateRequest", Annotated[ - list[total.Total], + list[total_create_request.TotalCreateRequest], Field(..., title="Totals Create Request"), - AfterValidator(_enforce_contains_totals_create_request), ], ) """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py index 9559f7d..c032a8b 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py @@ -20,87 +20,16 @@ from typing import Annotated -from pydantic import Field, AfterValidator +from pydantic import Field from typing_extensions import TypeAliasType -from . import total - - -def _enforce_contains_totals_update_request(value): - """JSON Schema contains/minContains/maxContains (see #49).""" - _matched_0 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "subtotal" - ) - if _matched_0 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='subtotal' (schema minContains=1)" - ) - if _matched_0 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='subtotal' (schema maxContains=1)" - ) - _matched_1 = sum( - 1 - for _item in value - if ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - == "total" - ) - if _matched_1 < 1: - raise ValueError( - "Array must contain at least 1 entry " - "matching type=='total' (schema minContains=1)" - ) - if _matched_1 > 1: - raise ValueError( - "Array must contain at most 1 entry " - "matching type=='total' (schema maxContains=1)" - ) - _excluded = [ - "subtotal", - "items_discount", - "discount", - "fulfillment", - "tax", - "fee", - "total", - ] - for _item in value: - _actual = ( - _item.get("type") - if isinstance(_item, dict) - else getattr(_item, "type", None) - ) - if _actual in _excluded: - continue - if isinstance(_item, dict) and "display_text" not in _item: - raise ValueError("Field 'display_text' is required for custom type") - if ( - not isinstance(_item, dict) - and "display_text" not in _item.model_fields_set - ): - raise ValueError("Field 'display_text' is required for custom type") - return value - +from . import total_update_request TotalsUpdateRequest = TypeAliasType( "TotalsUpdateRequest", Annotated[ - list[total.Total], + list[total_update_request.TotalUpdateRequest], Field(..., title="Totals Update Request"), - AfterValidator(_enforce_contains_totals_update_request), ], ) """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/warning_code_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/warning_code_create_request.py new file mode 100644 index 0000000..e6dd78d --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/warning_code_create_request.py @@ -0,0 +1,44 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +WarningCodeCreateRequest = TypeAliasType( + "WarningCodeCreateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "final_sale", + "prop65", + "fulfillment_changed", + "age_restricted", + ], + title="Warning Code Create Request", + ), + ], +) +""" +Warning code identifying the type of warning. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/shopping/types/warning_code_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/warning_code_update_request.py new file mode 100644 index 0000000..0ef6133 --- /dev/null +++ b/src/ucp_sdk/models/schemas/shopping/types/warning_code_update_request.py @@ -0,0 +1,44 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# generated by datamodel-codegen +# pylint: disable=all +# pyformat: disable + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field +from typing_extensions import TypeAliasType + +WarningCodeUpdateRequest = TypeAliasType( + "WarningCodeUpdateRequest", + Annotated[ + str, + Field( + ..., + examples=[ + "final_sale", + "prop65", + "fulfillment_changed", + "age_restricted", + ], + title="Warning Code Update Request", + ), + ], +) +""" +Warning code identifying the type of warning. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted. +""" diff --git a/src/ucp_sdk/models/schemas/transports/__init__.py b/src/ucp_sdk/models/schemas/transports/__init__.py index 421dc21..1252d6b 100644 --- a/src/ucp_sdk/models/schemas/transports/__init__.py +++ b/src/ucp_sdk/models/schemas/transports/__init__.py @@ -15,4 +15,3 @@ # generated by datamodel-codegen # pylint: disable=all # pyformat: disable - diff --git a/src/ucp_sdk/models/schemas/ucp_create_request.py b/src/ucp_sdk/models/schemas/ucp_create_request.py index 830d878..bba6e04 100644 --- a/src/ucp_sdk/models/schemas/ucp_create_request.py +++ b/src/ucp_sdk/models/schemas/ucp_create_request.py @@ -24,7 +24,7 @@ from typing_extensions import TypeAliasType from . import capability, payment_handler, service -from .shopping.types import reverse_domain_name +from .shopping.types import reverse_domain_name_create_request Version = TypeAliasType( "Version", Annotated[str, Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$")] @@ -65,7 +65,11 @@ class Requires(BaseModel): Required protocol version. """ capabilities: ( - dict[reverse_domain_name.ReverseDomainName, VersionConstraint] | None + dict[ + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + VersionConstraint, + ] + | None ) = None """ Required capability versions, keyed by capability name. Keys must be a subset of the extension's $defs keys. @@ -116,20 +120,30 @@ class Base(BaseModel): Application-level status of the UCP operation. """ services: ( - dict[reverse_domain_name.ReverseDomainName, list[service.Base]] | None + dict[ + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[service.Base], + ] + | None ) = None """ Service registry keyed by reverse-domain name. """ capabilities: ( - dict[reverse_domain_name.ReverseDomainName, list[capability.Base]] + dict[ + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[capability.Base], + ] | None ) = None """ Capability registry keyed by reverse-domain name. """ payment_handlers: ( - dict[reverse_domain_name.ReverseDomainName, list[payment_handler.Base]] + dict[ + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[payment_handler.Base], + ] | None ) = None """ @@ -174,14 +188,15 @@ class PlatformSchema(Base): extra="allow", ) services: dict[ - reverse_domain_name.ReverseDomainName, list[service.PlatformSchema5] + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[service.PlatformSchema5], ] """ Service registry keyed by reverse-domain name. """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.PlatformSchema], ] | None @@ -190,7 +205,7 @@ class PlatformSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[payment_handler.PlatformSchema], ] """ @@ -211,14 +226,15 @@ class BusinessSchema(Base): Previous protocol versions this business supports, mapped to profile URIs. Businesses that support older protocol versions SHOULD advertise each version and link to its profile. Each URI points to a complete, self-contained profile for that version. When omitted, only `version` is supported. """ services: dict[ - reverse_domain_name.ReverseDomainName, list[service.BusinessSchema2] + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[service.BusinessSchema2], ] """ Service registry keyed by reverse-domain name. """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.BusinessSchema], ] | None @@ -227,7 +243,7 @@ class BusinessSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[payment_handler.BusinessSchema], ] """ @@ -245,7 +261,8 @@ class ResponseCheckoutSchema(Base): ) services: ( dict[ - reverse_domain_name.ReverseDomainName, list[service.ResponseSchema2] + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, + list[service.ResponseSchema2], ] | None ) = None @@ -254,7 +271,7 @@ class ResponseCheckoutSchema(Base): """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.ResponseSchema], ] | None @@ -263,7 +280,7 @@ class ResponseCheckoutSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[payment_handler.ResponseSchema], ] """ @@ -281,7 +298,7 @@ class ResponseOrderSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.ResponseSchema], ] | None @@ -301,7 +318,7 @@ class ResponseCartSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.ResponseSchema], ] | None @@ -321,7 +338,7 @@ class ResponseCatalogSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_create_request.ReverseDomainNameCreateRequest, list[capability.ResponseSchema], ] | None diff --git a/src/ucp_sdk/models/schemas/ucp_update_request.py b/src/ucp_sdk/models/schemas/ucp_update_request.py index 423f664..698fc4e 100644 --- a/src/ucp_sdk/models/schemas/ucp_update_request.py +++ b/src/ucp_sdk/models/schemas/ucp_update_request.py @@ -24,7 +24,7 @@ from typing_extensions import TypeAliasType from . import capability, payment_handler, service -from .shopping.types import reverse_domain_name +from .shopping.types import reverse_domain_name_update_request Version = TypeAliasType( "Version", Annotated[str, Field(..., pattern="^\\d{4}-\\d{2}-\\d{2}$")] @@ -65,7 +65,11 @@ class Requires(BaseModel): Required protocol version. """ capabilities: ( - dict[reverse_domain_name.ReverseDomainName, VersionConstraint] | None + dict[ + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + VersionConstraint, + ] + | None ) = None """ Required capability versions, keyed by capability name. Keys must be a subset of the extension's $defs keys. @@ -116,20 +120,30 @@ class Base(BaseModel): Application-level status of the UCP operation. """ services: ( - dict[reverse_domain_name.ReverseDomainName, list[service.Base]] | None + dict[ + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[service.Base], + ] + | None ) = None """ Service registry keyed by reverse-domain name. """ capabilities: ( - dict[reverse_domain_name.ReverseDomainName, list[capability.Base]] + dict[ + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[capability.Base], + ] | None ) = None """ Capability registry keyed by reverse-domain name. """ payment_handlers: ( - dict[reverse_domain_name.ReverseDomainName, list[payment_handler.Base]] + dict[ + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[payment_handler.Base], + ] | None ) = None """ @@ -174,14 +188,15 @@ class PlatformSchema(Base): extra="allow", ) services: dict[ - reverse_domain_name.ReverseDomainName, list[service.PlatformSchema5] + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[service.PlatformSchema5], ] """ Service registry keyed by reverse-domain name. """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.PlatformSchema], ] | None @@ -190,7 +205,7 @@ class PlatformSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[payment_handler.PlatformSchema], ] """ @@ -211,14 +226,15 @@ class BusinessSchema(Base): Previous protocol versions this business supports, mapped to profile URIs. Businesses that support older protocol versions SHOULD advertise each version and link to its profile. Each URI points to a complete, self-contained profile for that version. When omitted, only `version` is supported. """ services: dict[ - reverse_domain_name.ReverseDomainName, list[service.BusinessSchema2] + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[service.BusinessSchema2], ] """ Service registry keyed by reverse-domain name. """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.BusinessSchema], ] | None @@ -227,7 +243,7 @@ class BusinessSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[payment_handler.BusinessSchema], ] """ @@ -245,7 +261,8 @@ class ResponseCheckoutSchema(Base): ) services: ( dict[ - reverse_domain_name.ReverseDomainName, list[service.ResponseSchema2] + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, + list[service.ResponseSchema2], ] | None ) = None @@ -254,7 +271,7 @@ class ResponseCheckoutSchema(Base): """ capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.ResponseSchema], ] | None @@ -263,7 +280,7 @@ class ResponseCheckoutSchema(Base): Capability registry keyed by reverse-domain name. """ payment_handlers: dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[payment_handler.ResponseSchema], ] """ @@ -281,7 +298,7 @@ class ResponseOrderSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.ResponseSchema], ] | None @@ -301,7 +318,7 @@ class ResponseCartSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.ResponseSchema], ] | None @@ -321,7 +338,7 @@ class ResponseCatalogSchema(Base): ) capabilities: ( dict[ - reverse_domain_name.ReverseDomainName, + reverse_domain_name_update_request.ReverseDomainNameUpdateRequest, list[capability.ResponseSchema], ] | None From 2ffac8c3203a8e3df9241868d7bac4feda88e4a0 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Fri, 17 Jul 2026 08:26:17 +0000 Subject: [PATCH 3/5] test: add coverage for composition variant propagation and rewriting (cherry picked from commit 34c310754e4678e3b1aa3951bfcbe95bba2a744c) --- tests/test_codegen_pipeline.py | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_codegen_pipeline.py b/tests/test_codegen_pipeline.py index 26b6252..85d4a51 100644 --- a/tests/test_codegen_pipeline.py +++ b/tests/test_codegen_pipeline.py @@ -514,6 +514,49 @@ def test_array_variant_preserves_root_and_filters_nested_objects( self.assertEqual(set(item_schema["required"]), {"amount", "label"}) self.assertEqual(variant["title"], "Totals Create Request") + def test_composition_variant_rewrites_refs(self) -> None: + """Composition variants (oneOf/anyOf/allOf) rewrite refs to variants.""" + schema = { + "$id": "https://ucp.dev/schemas/poly.json", + "title": "Poly", + "oneOf": [{"$ref": "child_a.json"}, {"$ref": "child_b.json"}], + "allOf": [{"$ref": "parent.json"}], + "anyOf": [{"$ref": "other.json"}], + } + file_path = Path("/schemas/poly.json") + child_a_path = str((file_path.parent / "child_a.json").resolve()) + child_b_path = str((file_path.parent / "child_b.json").resolve()) + parent_path = str((file_path.parent / "parent.json").resolve()) + other_path = str((file_path.parent / "other.json").resolve()) + + variant_needs = { + child_a_path: {"create"}, + child_b_path: {"create"}, + parent_path: {"create"}, + other_path: {"create"}, + } + + variant = preprocess_schemas._create_single_variant( + schema, + "create", + "poly", + file_path, + variant_needs, + ) + + self.assertEqual( + variant["oneOf"][0]["$ref"], "child_a_create_request.json" + ) + self.assertEqual( + variant["oneOf"][1]["$ref"], "child_b_create_request.json" + ) + self.assertEqual( + variant["allOf"][0]["$ref"], "parent_create_request.json" + ) + self.assertEqual( + variant["anyOf"][0]["$ref"], "other_create_request.json" + ) + def test_generate_variants_writes_operation_specific_files(self) -> None: """Variant generation writes one filtered file per operation.""" schema = { @@ -653,6 +696,26 @@ def test_variant_needs_propagate_transitively_and_respect_omit( self.assertEqual(variant_needs[child_path], {"create"}) self.assertEqual(variant_needs[grandchild_path], {"create"}) + def test_variant_needs_propagate_through_composition_keywords(self) -> None: + """Variant dependencies propagate unconditionally through oneOf/anyOf/allOf/items.""" + parent_path = "/schemas/parent.json" + child_path = "/schemas/child.json" + schemas = { + parent_path: {"oneOf": [{"$ref": "child.json"}]}, + child_path: {"properties": {}}, + } + schema_refs = { + parent_path: [("oneOf", child_path)], + child_path: [], + } + variant_needs = {parent_path: {"create", "update"}} + + preprocess_schemas.propagate_needs_transitive( + variant_needs, schema_refs, schemas + ) + + self.assertEqual(variant_needs[child_path], {"create", "update"}) + def test_main_preprocesses_schema_tree_end_to_end(self) -> None: """The full pipeline normalizes schemas and writes linked variants.""" with tempfile.TemporaryDirectory() as temp_dir: From 5d26457ea1642a887425e3ad274df2fd55aa97ed Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Thu, 20 Aug 2026 11:43:16 -0400 Subject: [PATCH 4/5] fix: tolerate line-wrapped Annotated[...] in inject_array_contains _create_single_variant now rewrites $ref inside top-level oneOf/anyOf/ allOf/items branches (see the preceding two commits, cherry-picked from #35), which lengthens some array-root item type references, e.g. total.Total becomes total_create_request.TotalCreateRequest for TotalsCreateRequest. That extra length pushes ruff's formatter to wrap Annotated[...] onto multiple lines with a trailing comma before the closing bracket. inject_array_contains spliced AfterValidator(...) in right before that closing bracket without checking for the trailing comma, landing the new element after an orphaned comma with nothing between them - a SyntaxError, not just a formatting nit. Because tests/test_codegen_pipeline.py imported the generated Totals*Request classes inside a bare "except ImportError", the SyntaxError went uncaught and took the whole test module down at collection time, failing every test in the file. Fix the splice to insert after the last real token before the closing bracket instead of blindly before it, reusing an existing trailing comma when present. Also widen the import guard to catch SyntaxError so one broken generated file degrades to HAVE_SDK = False instead of failing collection for the whole module. --- postprocess_models.py | 21 ++++++++++++- tests/test_codegen_pipeline.py | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/postprocess_models.py b/postprocess_models.py index 3da3c9b..5043742 100644 --- a/postprocess_models.py +++ b/postprocess_models.py @@ -667,7 +667,26 @@ def inject_array_contains(source, alias_name, groups, item_condition=None): break if close is None: return source - out = source[:close] + f", AfterValidator({func_name})" + source[close:] + # Insert right after the last real token inside Annotated[...], not + # blindly right before the closing bracket. When the annotation is + # line-wrapped -- which ruff/black do once the item type reference is + # long enough to push the line past the wrap width, e.g. a + # request-variant $ref such as total_create_request.TotalCreateRequest + # replacing the shorter total.Total -- there is already a trailing + # comma just before the whitespace that precedes "]". Splicing before + # that whitespace would leave the existing trailing comma and our own + # leading comma separated by nothing but whitespace: two commas with no + # expression between them, a SyntaxError (see #34/#35). + scan = close - 1 + while scan >= 0 and source[scan] in " \t\n": + scan -= 1 + if scan >= 0 and source[scan] == ",": + insert_at = scan + 1 + addition = f" AfterValidator({func_name})," + else: + insert_at = scan + 1 + addition = f", AfterValidator({func_name})" + out = source[:insert_at] + addition + source[insert_at:] func_src = _build_contains_function(func_name, groups, item_condition) insert_at = assign_re.search(out).start() out = out[:insert_at] + func_src + "\n\n" + out[insert_at:] diff --git a/tests/test_codegen_pipeline.py b/tests/test_codegen_pipeline.py index 85d4a51..e708e3e 100644 --- a/tests/test_codegen_pipeline.py +++ b/tests/test_codegen_pipeline.py @@ -14,6 +14,7 @@ """Tests for the schema preprocessing pipeline.""" +import ast import contextlib import copy import io @@ -40,7 +41,14 @@ ) HAVE_SDK = True -except ImportError: # pragma: no cover +except (ImportError, SyntaxError): # pragma: no cover + # A generated model with invalid Python (e.g. a postprocessing splice + # that lands beside a stray trailing comma - see + # ArrayContainsInjectorTest.test_injects_cleanly_when_annotated_is_line_wrapped) + # raises SyntaxError on import, not ImportError. Without catching it + # here too, one broken generated file takes the whole test module down + # at collection time and every other test in this file - most of which + # have nothing to do with the SDK build - never runs. HAVE_SDK = False @@ -1504,6 +1512,31 @@ class ArrayContainsInjectorTest(unittest.TestCase): ")\n" ) + #: The same alias, but formatted the way ruff/black renders it once the + #: item type name is long enough to force line-wrapping (e.g. once a + #: request-variant $ref like ``total_create_request.TotalCreateRequest`` + #: replaces the short ``Total`` reference). The trailing comma after + #: ``Field(...)`` before the closing ``]`` is the shape that matters here. + MODULE_LINE_WRAPPED = ( + "from __future__ import annotations\n" + "\n" + "from typing import Annotated\n" + "\n" + "from pydantic import Field\n" + "from typing_extensions import TypeAliasType\n" + "\n" + "from . import total_create_request\n" + "\n" + "\n" + "TotalsCreateRequest = TypeAliasType(\n" + ' "TotalsCreateRequest",\n' + " Annotated[\n" + " list[total_create_request.TotalCreateRequest],\n" + ' Field(..., title="Totals Create Request"),\n' + " ],\n" + ")\n" + ) + #: subtotal AND total, mirroring the real totals.json. GROUPS = [ {"pairs": [("type", "subtotal")], "min": 1, "max": 1}, @@ -1625,6 +1658,27 @@ def test_injection_is_idempotent(self): ) self.assertEqual(once, twice) + def test_injects_cleanly_when_annotated_is_line_wrapped(self): + """A line-wrapped Annotated[...] with a trailing comma before the + closing bracket must still parse (see #34/#35: a longer item-type + reference, such as a request-variant $ref, pushes the formatter to + wrap the annotation onto multiple lines with a trailing comma; a + naive "insert before the closing bracket" splice then lands after + that comma and produces "Field(...),\\n, AfterValidator(...)]" - + two commas with nothing between them, a SyntaxError).""" + out = postprocess_models.inject_array_contains( + self.MODULE_LINE_WRAPPED, "TotalsCreateRequest", self.GROUPS + ) + + # The regression: this must be syntactically valid Python. + ast.parse(out) + + self.assertIn( + "AfterValidator(_enforce_contains_totals_create_request)", out + ) + # No orphaned comma left behind by the splice. + self.assertNotRegex(out, r",\s*,") + @unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic") def test_injected_validator_enforces_both_bounds(self): out = postprocess_models.inject_array_contains( From c5ba1cc6008a55392630cd9d8cf01eb18286e79c Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Thu, 20 Aug 2026 11:44:16 -0400 Subject: [PATCH 5/5] chore: regenerate models against pinned UCP spec 2026-04-08 Runs generate_models.sh 2026-04-08 (README compat table: SDK 0.4.x -> UCP schema 2026-04-08) against the three preceding commits (the #35 ref-rewrite cherry-picks plus the inject_array_contains fix). Only totals_create_request.py/totals_update_request.py change beyond what the cherry-picks already carried, because those two needed both fixes together: the ref rewrite (to point at total_create_request.json instead of total.json) and the trailing-comma-safe splice (to still parse once that longer reference pushes Annotated[...] onto multiple lines). --- .../shopping/types/totals_create_request.py | 73 ++++++++++++++++++- .../shopping/types/totals_update_request.py | 73 ++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py b/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py index eaa5dc0..e2f89df 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/totals_create_request.py @@ -20,16 +20,87 @@ from typing import Annotated -from pydantic import Field +from pydantic import Field, AfterValidator from typing_extensions import TypeAliasType from . import total_create_request + +def _enforce_contains_totals_create_request(value): + """JSON Schema contains/minContains/maxContains (see #49).""" + _matched_0 = sum( + 1 + for _item in value + if ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + == "subtotal" + ) + if _matched_0 < 1: + raise ValueError( + "Array must contain at least 1 entry " + "matching type=='subtotal' (schema minContains=1)" + ) + if _matched_0 > 1: + raise ValueError( + "Array must contain at most 1 entry " + "matching type=='subtotal' (schema maxContains=1)" + ) + _matched_1 = sum( + 1 + for _item in value + if ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + == "total" + ) + if _matched_1 < 1: + raise ValueError( + "Array must contain at least 1 entry " + "matching type=='total' (schema minContains=1)" + ) + if _matched_1 > 1: + raise ValueError( + "Array must contain at most 1 entry " + "matching type=='total' (schema maxContains=1)" + ) + _excluded = [ + "subtotal", + "items_discount", + "discount", + "fulfillment", + "tax", + "fee", + "total", + ] + for _item in value: + _actual = ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + if _actual in _excluded: + continue + if isinstance(_item, dict) and "display_text" not in _item: + raise ValueError("Field 'display_text' is required for custom type") + if ( + not isinstance(_item, dict) + and "display_text" not in _item.model_fields_set + ): + raise ValueError("Field 'display_text' is required for custom type") + return value + + TotalsCreateRequest = TypeAliasType( "TotalsCreateRequest", Annotated[ list[total_create_request.TotalCreateRequest], Field(..., title="Totals Create Request"), + AfterValidator(_enforce_contains_totals_create_request), ], ) """ diff --git a/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py b/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py index c032a8b..15eba47 100644 --- a/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py +++ b/src/ucp_sdk/models/schemas/shopping/types/totals_update_request.py @@ -20,16 +20,87 @@ from typing import Annotated -from pydantic import Field +from pydantic import Field, AfterValidator from typing_extensions import TypeAliasType from . import total_update_request + +def _enforce_contains_totals_update_request(value): + """JSON Schema contains/minContains/maxContains (see #49).""" + _matched_0 = sum( + 1 + for _item in value + if ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + == "subtotal" + ) + if _matched_0 < 1: + raise ValueError( + "Array must contain at least 1 entry " + "matching type=='subtotal' (schema minContains=1)" + ) + if _matched_0 > 1: + raise ValueError( + "Array must contain at most 1 entry " + "matching type=='subtotal' (schema maxContains=1)" + ) + _matched_1 = sum( + 1 + for _item in value + if ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + == "total" + ) + if _matched_1 < 1: + raise ValueError( + "Array must contain at least 1 entry " + "matching type=='total' (schema minContains=1)" + ) + if _matched_1 > 1: + raise ValueError( + "Array must contain at most 1 entry " + "matching type=='total' (schema maxContains=1)" + ) + _excluded = [ + "subtotal", + "items_discount", + "discount", + "fulfillment", + "tax", + "fee", + "total", + ] + for _item in value: + _actual = ( + _item.get("type") + if isinstance(_item, dict) + else getattr(_item, "type", None) + ) + if _actual in _excluded: + continue + if isinstance(_item, dict) and "display_text" not in _item: + raise ValueError("Field 'display_text' is required for custom type") + if ( + not isinstance(_item, dict) + and "display_text" not in _item.model_fields_set + ): + raise ValueError("Field 'display_text' is required for custom type") + return value + + TotalsUpdateRequest = TypeAliasType( "TotalsUpdateRequest", Annotated[ list[total_update_request.TotalUpdateRequest], Field(..., title="Totals Update Request"), + AfterValidator(_enforce_contains_totals_update_request), ], ) """