Skip to content

Commit ebd6fc8

Browse files
fix(preprocess): rename dotted $defs to keep generated paths flat (#29)
* fix(preprocess): flatten dotted $defs names to prevent path-leak codegen Upstream UCP uses reverse-DNS extension mount points such as "dev.ucp.shopping.checkout" in $defs. datamodel-codegen treats dots in def names as path separators, so those defs were being emitted as nested directory trees (e.g. shopping/fulfillment/dev/ucp/shopping.py) instead of inline classes in the parent schema's module. This added a normalization pass that renames such keys before codegen. Strategy: prefer the last dotted component (giving a clean class name like "Checkout"); fall back to dot-replaced-with-underscore if the bare tail would collide with an existing def in the same file (the fulfillment.json case, where the dotted key shares its tail with a sibling "fulfillment" def). Local $ref pointers to renamed defs are rewritten in the same pass so no internal references break. No upstream schemas currently reference the dotted defs by $ref; the rewrite is a safety net. Effect on regen: extension classes return to their natural import paths. For example, ucp_sdk.models.schemas.shopping.fulfillment.Checkout was being relocated to ucp_sdk.models.schemas.shopping.fulfillment.dev.ucp. shopping.Checkout under current upstream; this restores the flat path. * fix(preprocess): handle cross-file references for renamed $defs Address review comments by collecting all renames and rewriting external references in a separate pass. Regenerated models using UCP version 2026-04-08. * Update preprocess_schemas.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * style: fix end of file newlines in generated __init__.py files * chore: bump version to 0.4.1 --------- Co-authored-by: damaz91 <federico.damato91@gmail.com>
1 parent ee3a15c commit ebd6fc8

23 files changed

Lines changed: 230 additions & 402 deletions

File tree

preprocess_schemas.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,107 @@ def preprocess_full_schema(schema, entity_def=None):
267267
distribute_properties_to_branches(node)
268268

269269

270+
# --- Dotted $defs Flattening ---
271+
272+
273+
def _rewrite_local_defs_refs(node, rename_map):
274+
"""Walks a schema tree and rewrites local $defs refs whose target was renamed."""
275+
prefix = "#/$defs/"
276+
for n in iter_nodes(node):
277+
if not isinstance(n, dict):
278+
continue
279+
ref = n.get("$ref")
280+
if not isinstance(ref, str) or not ref.startswith(prefix):
281+
continue
282+
rest = ref[len(prefix) :]
283+
name, sep, tail = rest.partition("/")
284+
if name in rename_map:
285+
n["$ref"] = prefix + rename_map[name] + (sep + tail if sep else "")
286+
287+
288+
def _rewrite_external_defs_refs(schema_path, schema, global_rename_maps):
289+
"""Walks a schema tree and rewrites external $defs refs whose target was renamed."""
290+
path = Path(schema_path)
291+
for n in iter_nodes(schema):
292+
if not isinstance(n, dict):
293+
continue
294+
ref = n.get("$ref")
295+
if not isinstance(ref, str):
296+
continue
297+
if "#" not in ref:
298+
continue
299+
300+
file_part, fragment_part = ref.split("#", 1)
301+
if not file_part or not file_part.endswith(".json"):
302+
continue
303+
304+
prefix = "/$defs/"
305+
if not fragment_part.startswith(prefix):
306+
continue
307+
308+
# Resolve target schema path
309+
target_path = (path.parent / file_part).resolve()
310+
target_path_str = str(target_path)
311+
312+
if target_path_str not in global_rename_maps:
313+
continue
314+
315+
rename_map = global_rename_maps[target_path_str]
316+
317+
rest = fragment_part[len(prefix) :]
318+
name, sep, tail = rest.partition("/")
319+
if name in rename_map:
320+
new_name = rename_map[name]
321+
new_fragment = prefix + new_name + (sep + tail if sep else "")
322+
n["$ref"] = file_part + "#" + new_fragment
323+
324+
325+
def flatten_dotted_defs(schema):
326+
"""
327+
Renames $defs keys containing '.' so codegen does not emit nested directories.
328+
329+
RATIONALE: datamodel-codegen treats dots in $def names as path separators,
330+
so a definition like 'dev.ucp.shopping.checkout' produces
331+
'dev/ucp/shopping/checkout.py' rather than a class in the parent module.
332+
UCP uses reverse-DNS def names as extension mount points (e.g. an extension
333+
schema's contribution to the base Checkout type), so those classes belong
334+
inline with the rest of the schema's output.
335+
336+
Strategy: prefer the last dotted component as the new key (giving a clean
337+
class name like 'Checkout'); fall back to dot-replaced-with-underscore
338+
(e.g. 'DevUcpShoppingFulfillment') if the bare tail would collide with
339+
an existing def in the same file.
340+
"""
341+
defs = schema.get("$defs")
342+
if not isinstance(defs, dict):
343+
return {}
344+
345+
existing = set(defs.keys())
346+
rename_map = {}
347+
for old in list(defs.keys()):
348+
if "." not in old:
349+
continue
350+
tail = old.rsplit(".", 1)[-1]
351+
if tail and tail not in existing:
352+
new = tail
353+
else:
354+
new = old.replace(".", "_")
355+
if new in existing:
356+
# Both candidates collide; leave as-is rather than risk corruption
357+
continue
358+
rename_map[old] = new
359+
existing.discard(old)
360+
existing.add(new)
361+
362+
if not rename_map:
363+
return {}
364+
365+
for old, new in rename_map.items():
366+
defs[new] = defs.pop(old)
367+
_rewrite_local_defs_refs(schema, rename_map)
368+
return rename_map
369+
370+
270371
# --- Variant Generation (Create/Update/Complete) ---
271372

272373

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

535-
# Pass 1: Local flattening and find explicit variant markers
636+
global_rename_maps = {}
637+
# Pass 1a: Local flattening, find explicit variant markers, collect renames
536638
for p_abs, s in schemas.items():
537639
if "ucp.json" in p_abs or "_request.json" in p_abs:
538640
continue
641+
rename_map = flatten_dotted_defs(s)
642+
if rename_map:
643+
global_rename_maps[p_abs] = rename_map
539644
preprocess_full_schema(s, entity_def)
540-
# Write back the flattened core schema
541-
save_json(s, Path(p_abs))
542645

646+
# Pass 1b: Rewrite external references to renamed defs
647+
for p_abs, s in schemas.items():
648+
if "ucp.json" in p_abs or "_request.json" in p_abs:
649+
continue
650+
_rewrite_external_defs_refs(p_abs, s, global_rename_maps)
651+
652+
# Pass 1c: Save and extract refs
653+
for p_abs, s in schemas.items():
654+
if "ucp.json" in p_abs or "_request.json" in p_abs:
655+
continue
656+
save_json(s, Path(p_abs))
543657
schema_refs[p_abs] = extract_external_refs(s, Path(p_abs))
544658

545659
# Check if this schema explicitly asks for variants via 'ucp_request' markers

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ucp-sdk"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
description = "UCP Python SDK"
55
readme = "README.md"
66
license = {file = "LICENSE"}

src/ucp_sdk/models/schemas/common/identity_linking/__init__.py renamed to src/ucp_sdk/models/schemas/common/identity_linking.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from pydantic import BaseModel, ConfigDict, Field, RootModel
2424

25-
from ...shopping.types import description as description_1
25+
from ..shopping.types import description as description_1
2626

2727

2828
class IdentityLinking(RootModel[Any]):
@@ -59,3 +59,10 @@ class ScopeToken(RootModel[str]):
5959
"""
6060
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.
6161
"""
62+
63+
64+
class IdentityLinking1(RootModel[Any]):
65+
model_config = ConfigDict(
66+
frozen=True,
67+
)
68+
root: Any

src/ucp_sdk/models/schemas/common/identity_linking/dev/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/ucp_sdk/models/schemas/common/identity_linking/dev/ucp/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/ucp_sdk/models/schemas/common/identity_linking/dev/ucp/common.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/ucp_sdk/models/schemas/shopping/ap2_mandate/__init__.py renamed to src/ucp_sdk/models/schemas/shopping/ap2_mandate.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
from pydantic import BaseModel, ConfigDict, Field, RootModel
2424

25+
from .checkout import Checkout as Checkout_1
26+
2527

2628
class Ap2MandateExtension(RootModel[Any]):
2729
model_config = ConfigDict(
@@ -117,3 +119,35 @@ class ErrorCode(
117119
"""
118120
Error codes specific to AP2 mandate verification.
119121
"""
122+
123+
124+
class Ap2(BaseModel):
125+
"""
126+
AP2 extension data including merchant authorization.
127+
"""
128+
129+
model_config = ConfigDict(
130+
extra="allow",
131+
)
132+
merchant_authorization: MerchantAuthorization | None = None
133+
"""
134+
Merchant's signature proving checkout terms are authentic.
135+
"""
136+
checkout_mandate: CheckoutMandate | None = None
137+
"""
138+
SD-JWT+kb proving user authorized this checkout.
139+
"""
140+
141+
142+
class Checkout(Checkout_1):
143+
"""
144+
Checkout extended with AP2 mandate support.
145+
"""
146+
147+
model_config = ConfigDict(
148+
extra="allow",
149+
)
150+
ap2: Ap2 | None = None
151+
"""
152+
AP2 extension data including merchant authorization.
153+
"""

src/ucp_sdk/models/schemas/shopping/ap2_mandate/dev/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/ucp_sdk/models/schemas/shopping/ap2_mandate/dev/ucp/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/ucp_sdk/models/schemas/shopping/ap2_mandate/dev/ucp/shopping.py

Lines changed: 0 additions & 56 deletions
This file was deleted.

0 commit comments

Comments
 (0)