Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 117 additions & 3 deletions preprocess_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,107 @@ def preprocess_full_schema(schema, entity_def=None):
distribute_properties_to_branches(node)


# --- Dotted $defs Flattening ---


def _rewrite_local_defs_refs(node, rename_map):
"""Walks a schema tree and rewrites local $defs refs whose target was renamed."""
prefix = "#/$defs/"
for n in iter_nodes(node):
if not isinstance(n, dict):
continue
ref = n.get("$ref")
if not isinstance(ref, str) or not ref.startswith(prefix):
continue
rest = ref[len(prefix) :]
name, sep, tail = rest.partition("/")
if name in rename_map:
n["$ref"] = prefix + rename_map[name] + (sep + tail if sep else "")
Comment thread
damaz91 marked this conversation as resolved.


def _rewrite_external_defs_refs(schema_path, schema, global_rename_maps):
"""Walks a schema tree and rewrites external $defs refs whose target was renamed."""
path = Path(schema_path)
for n in iter_nodes(schema):
if not isinstance(n, dict):
continue
ref = n.get("$ref")
if not isinstance(ref, str):
continue
if "#" not in ref:
continue

file_part, fragment_part = ref.split("#", 1)
if not file_part or not file_part.endswith(".json"):
continue

prefix = "/$defs/"
if not fragment_part.startswith(prefix):
continue

# Resolve target schema path
target_path = (path.parent / file_part).resolve()
target_path_str = str(target_path)

if target_path_str not in global_rename_maps:
continue

rename_map = global_rename_maps[target_path_str]

rest = fragment_part[len(prefix) :]
name, sep, tail = rest.partition("/")
if name in rename_map:
new_name = rename_map[name]
new_fragment = prefix + new_name + (sep + tail if sep else "")
n["$ref"] = file_part + "#" + new_fragment


def flatten_dotted_defs(schema):
"""
Renames $defs keys containing '.' so codegen does not emit nested directories.

RATIONALE: datamodel-codegen treats dots in $def names as path separators,
so a definition like 'dev.ucp.shopping.checkout' produces
'dev/ucp/shopping/checkout.py' rather than a class in the parent module.
UCP uses reverse-DNS def names as extension mount points (e.g. an extension
schema's contribution to the base Checkout type), so those classes belong
inline with the rest of the schema's output.

Strategy: prefer the last dotted component as the new key (giving a clean
class name like 'Checkout'); fall back to dot-replaced-with-underscore
(e.g. 'DevUcpShoppingFulfillment') if the bare tail would collide with
an existing def in the same file.
"""
defs = schema.get("$defs")
if not isinstance(defs, dict):
return {}

existing = set(defs.keys())
rename_map = {}
for old in list(defs.keys()):
if "." not in old:
continue
tail = old.rsplit(".", 1)[-1]
if tail and tail not in existing:
new = tail
else:
new = old.replace(".", "_")
if new in existing:
# Both candidates collide; leave as-is rather than risk corruption
continue
Comment thread
damaz91 marked this conversation as resolved.
rename_map[old] = new
existing.discard(old)
existing.add(new)

if not rename_map:
return {}

for old, new in rename_map.items():
defs[new] = defs.pop(old)
_rewrite_local_defs_refs(schema, rename_map)
return rename_map


# --- Variant Generation (Create/Update/Complete) ---


Expand Down Expand Up @@ -532,14 +633,27 @@ def main():
"Entity definition not found! 'ucp.json' must define '$defs.entity'"
)

# Pass 1: Local flattening and find explicit variant markers
global_rename_maps = {}
# Pass 1a: Local flattening, find explicit variant markers, collect renames
for p_abs, s in schemas.items():
if "ucp.json" in p_abs or "_request.json" in p_abs:
continue
rename_map = flatten_dotted_defs(s)
if rename_map:
global_rename_maps[p_abs] = rename_map
preprocess_full_schema(s, entity_def)
# Write back the flattened core schema
save_json(s, Path(p_abs))

# Pass 1b: Rewrite external references to renamed defs
for p_abs, s in schemas.items():
if "ucp.json" in p_abs or "_request.json" in p_abs:
continue
_rewrite_external_defs_refs(p_abs, s, global_rename_maps)

# Pass 1c: Save and extract refs
for p_abs, s in schemas.items():
if "ucp.json" in p_abs or "_request.json" in p_abs:
continue
save_json(s, Path(p_abs))
schema_refs[p_abs] = extract_external_refs(s, Path(p_abs))

# Check if this schema explicitly asks for variants via 'ucp_request' markers
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ucp-sdk"
version = "0.4.0"
version = "0.4.1"
description = "UCP Python SDK"
readme = "README.md"
license = {file = "LICENSE"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from pydantic import BaseModel, ConfigDict, Field, RootModel

from ...shopping.types import description as description_1
from ..shopping.types import description as description_1


class IdentityLinking(RootModel[Any]):
Expand Down Expand Up @@ -59,3 +59,10 @@ class ScopeToken(RootModel[str]):
"""
OAuth scope string formed by joining a capability name and a scope name with a colon: '{capability}:{scope}', e.g. 'dev.ucp.shopping.order:read'. Capability names use reverse-DNS naming; scope names denote the permission granted, defined by each capability's spec (e.g. 'read', 'manage', 'create'). Platforms request these strings verbatim in OAuth 'scope' parameters; issued tokens carry them in the 'scope' claim.
"""


class IdentityLinking1(RootModel[Any]):
model_config = ConfigDict(
frozen=True,
)
root: Any
17 changes: 0 additions & 17 deletions src/ucp_sdk/models/schemas/common/identity_linking/dev/__init__.py

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

from pydantic import BaseModel, ConfigDict, Field, RootModel

from .checkout import Checkout as Checkout_1


class Ap2MandateExtension(RootModel[Any]):
model_config = ConfigDict(
Expand Down Expand Up @@ -117,3 +119,35 @@ class ErrorCode(
"""
Error codes specific to AP2 mandate verification.
"""


class Ap2(BaseModel):
"""
AP2 extension data including merchant authorization.
"""

model_config = ConfigDict(
extra="allow",
)
merchant_authorization: MerchantAuthorization | None = None
"""
Merchant's signature proving checkout terms are authentic.
"""
checkout_mandate: CheckoutMandate | None = None
"""
SD-JWT+kb proving user authorized this checkout.
"""


class Checkout(Checkout_1):
"""
Checkout extended with AP2 mandate support.
"""

model_config = ConfigDict(
extra="allow",
)
ap2: Ap2 | None = None
"""
AP2 extension data including merchant authorization.
"""
17 changes: 0 additions & 17 deletions src/ucp_sdk/models/schemas/shopping/ap2_mandate/dev/__init__.py

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@

from pydantic import BaseModel, ConfigDict, Field, RootModel

from ..types.buyer import Buyer as Buyer_1
from .checkout import Checkout as Checkout_1
from .types.buyer import Buyer as Buyer_1


class BuyerConsentExtension(RootModel[Any]):
Expand Down Expand Up @@ -73,3 +74,17 @@ class Buyer(Buyer_1):
"""
Consent tracking fields.
"""


class Checkout(Checkout_1):
"""
Checkout extended with consent tracking via buyer object.
"""

model_config = ConfigDict(
extra="allow",
)
buyer: Buyer | None = None
"""
Buyer with consent tracking.
"""
Loading
Loading