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/preprocess_schemas.py b/preprocess_schemas.py index 10a4206..a9bba53 100644 --- a/preprocess_schemas.py +++ b/preprocess_schemas.py @@ -558,6 +558,12 @@ def _create_single_variant( variant, 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 @@ -626,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"] @@ -640,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 @@ -656,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/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/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/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/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..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 @@ -23,7 +23,7 @@ from pydantic import Field, AfterValidator from typing_extensions import TypeAliasType -from . import total +from . import total_create_request def _enforce_contains_totals_create_request(value): @@ -98,7 +98,7 @@ def _enforce_contains_totals_create_request(value): 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..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 @@ -23,7 +23,7 @@ from pydantic import Field, AfterValidator from typing_extensions import TypeAliasType -from . import total +from . import total_update_request def _enforce_contains_totals_update_request(value): @@ -98,7 +98,7 @@ def _enforce_contains_totals_update_request(value): 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/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 diff --git a/tests/test_codegen_pipeline.py b/tests/test_codegen_pipeline.py index 26b6252..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 @@ -514,6 +522,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 +704,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: @@ -1441,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}, @@ -1562,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(