Skip to content

Commit 6cd5e5a

Browse files
vishkatyxiaoxuanz-hubdamaz91katyalai
authored
fix: rewrite $ref in top level oneOf/anyOf/allOf branches during variant generation (#83)
* rewrite $ref in top-level oneOf/anyOf branches during variant generation (cherry picked from commit 2a9beb9) * update (cherry picked from commit e652462) * test: add coverage for composition variant propagation and rewriting (cherry picked from commit 34c3107) * 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. * 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). --------- Co-authored-by: xiaoxuan-hub <i.travel@live.com> Co-authored-by: damaz91 <federico.damato91@gmail.com> Co-authored-by: Vishal Katyal <vishal@katyal.ai>
1 parent d650f0b commit 6cd5e5a

29 files changed

Lines changed: 951 additions & 81 deletions

postprocess_models.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,26 @@ def inject_array_contains(source, alias_name, groups, item_condition=None):
667667
break
668668
if close is None:
669669
return source
670-
out = source[:close] + f", AfterValidator({func_name})" + source[close:]
670+
# Insert right after the last real token inside Annotated[...], not
671+
# blindly right before the closing bracket. When the annotation is
672+
# line-wrapped -- which ruff/black do once the item type reference is
673+
# long enough to push the line past the wrap width, e.g. a
674+
# request-variant $ref such as total_create_request.TotalCreateRequest
675+
# replacing the shorter total.Total -- there is already a trailing
676+
# comma just before the whitespace that precedes "]". Splicing before
677+
# that whitespace would leave the existing trailing comma and our own
678+
# leading comma separated by nothing but whitespace: two commas with no
679+
# expression between them, a SyntaxError (see #34/#35).
680+
scan = close - 1
681+
while scan >= 0 and source[scan] in " \t\n":
682+
scan -= 1
683+
if scan >= 0 and source[scan] == ",":
684+
insert_at = scan + 1
685+
addition = f" AfterValidator({func_name}),"
686+
else:
687+
insert_at = scan + 1
688+
addition = f", AfterValidator({func_name})"
689+
out = source[:insert_at] + addition + source[insert_at:]
671690
func_src = _build_contains_function(func_name, groups, item_condition)
672691
insert_at = assign_re.search(out).start()
673692
out = out[:insert_at] + func_src + "\n\n" + out[insert_at:]

preprocess_schemas.py

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,12 @@ def _create_single_variant(
558558
variant, op, file_path, global_variant_requirements
559559
)
560560

561+
# Rewrite all external $refs in the variant schema to point to their
562+
# corresponding request variants where applicable. This covers top-level
563+
# oneOf/anyOf/allOf branches as well as array items.
564+
rewrite_refs_to_variants(
565+
variant, op, file_path, global_variant_requirements
566+
)
561567
return variant
562568

563569

@@ -626,20 +632,29 @@ def normalize_metadata_schemas(schemas, target_dir):
626632

627633

628634
def extract_external_refs(schema, path):
629-
"""Finds all relative external file references in the schema properties."""
635+
"""Finds all relative external file references in the schema."""
630636
refs = []
631-
props = schema.get("properties", {})
632-
if not isinstance(props, dict):
633-
return refs
634637

635-
for name, data in props.items():
638+
def _scan(name, data):
636639
for node in iter_nodes(data):
637640
if isinstance(node, dict) and "$ref" in node:
638641
ref = node["$ref"]
639642
ref_file, _, _ = ref.partition("#")
640643
if ref_file:
641644
abs_path = str((path.parent / ref_file).resolve())
642645
refs.append((name, abs_path))
646+
647+
props = schema.get("properties", {})
648+
if isinstance(props, dict):
649+
for name, data in props.items():
650+
_scan(name, data)
651+
652+
# Also scan top-level composition keywords (oneOf, anyOf, allOf, items)
653+
for key in ["oneOf", "anyOf", "allOf"]:
654+
if key in schema:
655+
_scan(key, schema[key])
656+
if "items" in schema:
657+
_scan("items", schema["items"])
643658
return refs
644659

645660

@@ -656,23 +671,28 @@ def propagate_needs_transitive(variant_needs, schema_refs, schemas):
656671
continue
657672

658673
for op in list(variant_needs[path]):
659-
for prop_name, child_path in refs:
674+
for ref_name, child_path in refs:
660675
if child_path not in schemas:
661676
continue
662677

663-
# Only propagate if the property isn't 'omit'ted for this op
664-
data = (
665-
schemas[path].get("properties", {}).get(prop_name, {})
666-
)
667-
include, _ = eval_prop_inclusion(
668-
prop_name, data, op, schemas[path].get("required", [])
669-
)
670-
671-
if include:
672-
target_set = variant_needs.setdefault(child_path, set())
673-
if op not in target_set:
674-
target_set.add(op)
675-
changed = True
678+
# For property refs, check if the property is included for this op.
679+
# For non-property refs (oneOf, anyOf, allOf, items), always propagate.
680+
props = schemas[path].get("properties", {})
681+
if ref_name in props:
682+
data = props[ref_name]
683+
include, _ = eval_prop_inclusion(
684+
ref_name,
685+
data,
686+
op,
687+
schemas[path].get("required", []),
688+
)
689+
if not include:
690+
continue
691+
692+
target_set = variant_needs.setdefault(child_path, set())
693+
if op not in target_set:
694+
target_set.add(op)
695+
changed = True
676696

677697

678698
# --- Main Flow ---

src/ucp_sdk/models/schemas/shopping/cart_create_request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from pydantic import BaseModel, ConfigDict
2222

23-
from .checkout import Checkout as Checkout_1
23+
from .checkout_create_request import CheckoutCreateRequest
2424
from .types import (
2525
attribution_create_request,
2626
buyer_create_request,
@@ -56,7 +56,7 @@ class CartCreateRequest(BaseModel):
5656
"""
5757

5858

59-
class Checkout(Checkout_1):
59+
class Checkout(CheckoutCreateRequest):
6060
"""
6161
Checkout extended with cart capability. Adds cart_id to create_checkout for cart-to-checkout conversion.
6262
"""

src/ucp_sdk/models/schemas/shopping/cart_update_request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from pydantic import BaseModel, ConfigDict
2222

23-
from .checkout import Checkout as Checkout_1
23+
from .checkout_update_request import CheckoutUpdateRequest
2424
from .types import (
2525
attribution_update_request,
2626
buyer_update_request,
@@ -60,7 +60,7 @@ class CartUpdateRequest(BaseModel):
6060
"""
6161

6262

63-
class Checkout(Checkout_1):
63+
class Checkout(CheckoutUpdateRequest):
6464
"""
6565
Checkout extended with cart capability. Adds cart_id to create_checkout for cart-to-checkout conversion.
6666
"""
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2026 UCP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# generated by datamodel-codegen
16+
# pylint: disable=all
17+
# pyformat: disable
18+
19+
from __future__ import annotations
20+
21+
from typing import Annotated
22+
23+
from pydantic import Field
24+
from typing_extensions import TypeAliasType
25+
26+
ErrorCodeCreateRequest = TypeAliasType(
27+
"ErrorCodeCreateRequest",
28+
Annotated[
29+
str,
30+
Field(
31+
...,
32+
examples=[
33+
"not_found",
34+
"out_of_stock",
35+
"item_unavailable",
36+
"address_undeliverable",
37+
"payment_failed",
38+
"eligibility_invalid",
39+
"identity_required",
40+
"insufficient_scope",
41+
],
42+
title="Error Code Create Request",
43+
),
44+
],
45+
)
46+
"""
47+
Error code identifying the type of error. Standard errors are defined in specification (see examples), and have standardized semantics; freeform codes are permitted.
48+
"""
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2026 UCP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# generated by datamodel-codegen
16+
# pylint: disable=all
17+
# pyformat: disable
18+
19+
from __future__ import annotations
20+
21+
from typing import Annotated
22+
23+
from pydantic import Field
24+
from typing_extensions import TypeAliasType
25+
26+
ErrorCodeUpdateRequest = TypeAliasType(
27+
"ErrorCodeUpdateRequest",
28+
Annotated[
29+
str,
30+
Field(
31+
...,
32+
examples=[
33+
"not_found",
34+
"out_of_stock",
35+
"item_unavailable",
36+
"address_undeliverable",
37+
"payment_failed",
38+
"eligibility_invalid",
39+
"identity_required",
40+
"insufficient_scope",
41+
],
42+
title="Error Code Update Request",
43+
),
44+
],
45+
)
46+
"""
47+
Error code identifying the type of error. Standard errors are defined in specification (see examples), and have standardized semantics; freeform codes are permitted.
48+
"""

src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_create_request.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
from pydantic import Field
2424
from typing_extensions import TypeAliasType
2525

26-
from . import retail_location, shipping_destination
26+
from . import (
27+
retail_location_create_request,
28+
shipping_destination_create_request,
29+
)
2730

2831
FulfillmentDestinationCreateRequest = TypeAliasType(
2932
"FulfillmentDestinationCreateRequest",
3033
Annotated[
31-
shipping_destination.ShippingDestination
32-
| retail_location.RetailLocation,
34+
shipping_destination_create_request.ShippingDestinationCreateRequest
35+
| retail_location_create_request.RetailLocationCreateRequest,
3336
Field(..., title="Fulfillment Destination Create Request"),
3437
],
3538
)

src/ucp_sdk/models/schemas/shopping/types/fulfillment_destination_update_request.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
from pydantic import Field
2424
from typing_extensions import TypeAliasType
2525

26-
from . import retail_location, shipping_destination
26+
from . import (
27+
retail_location_update_request,
28+
shipping_destination_update_request,
29+
)
2730

2831
FulfillmentDestinationUpdateRequest = TypeAliasType(
2932
"FulfillmentDestinationUpdateRequest",
3033
Annotated[
31-
shipping_destination.ShippingDestination
32-
| retail_location.RetailLocation,
34+
shipping_destination_update_request.ShippingDestinationUpdateRequest
35+
| retail_location_update_request.RetailLocationUpdateRequest,
3336
Field(..., title="Fulfillment Destination Update Request"),
3437
],
3538
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 2026 UCP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# generated by datamodel-codegen
16+
# pylint: disable=all
17+
# pyformat: disable
18+
19+
from __future__ import annotations
20+
21+
from typing import Annotated
22+
23+
from pydantic import Field
24+
from typing_extensions import TypeAliasType
25+
26+
InfoCodeCreateRequest = TypeAliasType(
27+
"InfoCodeCreateRequest",
28+
Annotated[
29+
str,
30+
Field(
31+
...,
32+
examples=[
33+
"identity_optional",
34+
"signal",
35+
"free_shipping",
36+
"not_found",
37+
],
38+
title="Info Code Create Request",
39+
),
40+
],
41+
)
42+
"""
43+
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.
44+
"""
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 2026 UCP Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# generated by datamodel-codegen
16+
# pylint: disable=all
17+
# pyformat: disable
18+
19+
from __future__ import annotations
20+
21+
from typing import Annotated
22+
23+
from pydantic import Field
24+
from typing_extensions import TypeAliasType
25+
26+
InfoCodeUpdateRequest = TypeAliasType(
27+
"InfoCodeUpdateRequest",
28+
Annotated[
29+
str,
30+
Field(
31+
...,
32+
examples=[
33+
"identity_optional",
34+
"signal",
35+
"free_shipping",
36+
"not_found",
37+
],
38+
title="Info Code Update Request",
39+
),
40+
],
41+
)
42+
"""
43+
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.
44+
"""

0 commit comments

Comments
 (0)