Skip to content

Commit 3171a95

Browse files
authored
Merge pull request #160 from KonstantinMirin/fix/rootmodel-merge-followup-155
fix: flatten validation-only oneOf schemas for consumer subclassing
2 parents 35740b3 + c242a7d commit 3171a95

27 files changed

Lines changed: 352 additions & 1216 deletions

scripts/generate_ergonomic_coercion.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,7 @@
5050
("GetProductsRequest1", "media_buy.get_products_request"),
5151
("GetProductsRequest2", "media_buy.get_products_request"),
5252
("GetProductsRequest3", "media_buy.get_products_request"),
53-
("PackageUpdate1", "media_buy.package_update"),
54-
("PackageUpdate2", "media_buy.package_update"),
53+
("PackageUpdate", "media_buy.package_update"),
5554
]
5655

5756
# Types that should get subclass_list coercion (for list variance)
@@ -234,7 +233,7 @@ def generate_code() -> str:
234233
from adcp.types.generated_poc.media_buy.list_creatives_request import ListCreativesRequest, Sort
235234
from adcp.types.generated_poc.media_buy.list_creatives_response import ListCreativesResponse
236235
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
237-
from adcp.types.generated_poc.media_buy.package_update import PackageUpdate1, PackageUpdate2
236+
from adcp.types.generated_poc.media_buy.package_update import PackageUpdate
238237

239238
# Map names to classes
240239
request_classes = {
@@ -257,8 +256,7 @@ def generate_code() -> str:
257256
"GetProductsRequest1": GetProductsRequest1,
258257
"GetProductsRequest2": GetProductsRequest2,
259258
"GetProductsRequest3": GetProductsRequest3,
260-
"PackageUpdate1": PackageUpdate1,
261-
"PackageUpdate2": PackageUpdate2,
259+
"PackageUpdate": PackageUpdate,
262260
}
263261

264262
# Analyze all types
@@ -377,10 +375,7 @@ def generate_code() -> str:
377375
lines.append(" Sort,")
378376
lines.append(")")
379377
lines.append("from adcp.types.generated_poc.media_buy.package_request import PackageRequest")
380-
lines.append("from adcp.types.generated_poc.media_buy.package_update import (")
381-
lines.append(" PackageUpdate1,")
382-
lines.append(" PackageUpdate2,")
383-
lines.append(")")
378+
lines.append("from adcp.types.generated_poc.media_buy.package_update import PackageUpdate")
384379

385380
# Add response type imports
386381
lines.append("from adcp.types.generated_poc.media_buy.create_media_buy_response import (")
@@ -424,8 +419,7 @@ def generate_code() -> str:
424419
"GetProductsRequest3",
425420
"PackageRequest",
426421
"CreateMediaBuyRequest",
427-
"PackageUpdate1",
428-
"PackageUpdate2",
422+
"PackageUpdate",
429423
# Response types
430424
"GetProductsResponse",
431425
"ListCreativesResponse",
@@ -518,8 +512,7 @@ def generate_code() -> str:
518512
lines.append(f" {type_name}.model_rebuild(force=True)")
519513
lines.append("")
520514

521-
# Handle PackageUpdate1 and PackageUpdate2 together if they have same coercions
522-
# (they're already handled in the loop above)
515+
# PackageUpdate is now a single class (flattened from validation-only oneOf)
523516

524517
# Add helper function
525518
lines.append("")

scripts/generate_types.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,63 @@ def rewrite_refs(obj, current_schema_rel_path: Path):
7070
return obj
7171

7272

73+
def flatten_validation_oneof(schema: dict) -> dict:
74+
"""Flatten anyOf/oneOf that only express required-field constraints.
75+
76+
JSON Schema uses anyOf/oneOf with required-only branches to express
77+
"at least one of these field groups must be set." datamodel-code-generator
78+
misinterprets this as a type union, generating separate variant classes
79+
(e.g., FrequencyCap1, FrequencyCap2, FrequencyCap3) plus a RootModel wrapper.
80+
81+
This function detects the pattern and removes the anyOf/oneOf, keeping
82+
only the intersection of required fields so a single class is generated.
83+
84+
Follow-up to #155: enables consumer subclassing without RootModel or
85+
Union type alias barriers.
86+
"""
87+
if "properties" not in schema:
88+
return schema
89+
90+
branch_key = None
91+
branches = None
92+
for key in ("anyOf", "oneOf"):
93+
if key in schema:
94+
branch_key = key
95+
branches = schema[key]
96+
break
97+
98+
if not branches:
99+
return schema
100+
101+
# All branches must contain only 'required' (and optionally 'not')
102+
if not all(set(b.keys()) <= {"required", "not"} for b in branches):
103+
return schema
104+
105+
# All branches are required-only — this is a validation constraint, not a type union
106+
# Compute the intersection of required fields (fields required in ALL branches)
107+
branch_required = [set(b.get("required", [])) for b in branches]
108+
always_required = set.intersection(*branch_required) if branch_required else set()
109+
110+
# Include any top-level required fields
111+
top_required = set(schema.get("required", []))
112+
always_required |= top_required
113+
114+
title = schema.get("title", "unknown")
115+
branch_count = len(branches)
116+
117+
# Remove the anyOf/oneOf
118+
del schema[branch_key]
119+
120+
# Set required to the intersection (or remove if empty)
121+
if always_required:
122+
schema["required"] = sorted(always_required)
123+
elif "required" in schema:
124+
del schema["required"]
125+
126+
print(f" flattened {branch_key} ({branch_count} branches) in {title}")
127+
return schema
128+
129+
73130
def flatten_schemas():
74131
"""
75132
Copy schemas to temp directory, preserving directory structure.
@@ -113,6 +170,9 @@ def flatten_schemas():
113170
# Rewrite $ref paths: convert absolute paths to relative, hyphens to underscores
114171
schema = rewrite_refs(schema, rel_path)
115172

173+
# Flatten validation-only anyOf/oneOf into single-class schemas
174+
schema = flatten_validation_oneof(schema)
175+
116176
with open(output_file, "w") as f:
117177
json.dump(schema, f, indent=2)
118178

@@ -361,6 +421,16 @@ def main():
361421
restore_unchanged_files()
362422

363423
# Generate ergonomic coercion module (type coercion for better API ergonomics)
424+
# Reset _ergonomic.py first — the old version may import variant classes
425+
# that no longer exist after schema flattening (e.g., PackageUpdate1).
426+
ergonomic_file = REPO_ROOT / "src" / "adcp" / "types" / "_ergonomic.py"
427+
if ergonomic_file.exists():
428+
ergonomic_file.write_text(
429+
'"""Auto-generated ergonomic coercion — regenerating..."""\n'
430+
"\ndef apply_ergonomic_coercion() -> None:\n"
431+
" pass\n"
432+
)
433+
364434
ergonomic_script = REPO_ROOT / "scripts" / "generate_ergonomic_coercion.py"
365435
if ergonomic_script.exists():
366436
print("\nGenerating ergonomic coercion module...")

scripts/post_generate_fixes.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,13 @@ def fix_constr_type_annotations():
298298

299299

300300
# Types to unwrap from RootModel to Union type alias.
301-
# ALL Request/Response types are unwrapped so consumers can subclass them
302-
# with model_config overrides (extra='forbid', custom validators, etc.).
303-
# Value-type RootModels (PricingOption, Destination, etc.) keep the RootModel
304-
# wrapper + __getattr__ proxy since nobody subclasses them.
301+
# Only genuine discriminated unions (different field shapes per variant) belong here.
302+
# "Validation-only" oneOf types (same fields, different required combos) are now
303+
# handled at the schema level by flatten_validation_oneof() in generate_types.py,
304+
# which produces a single BaseModel class — no RootModel or unwrapping needed.
305+
# Removed from this set (now single classes): GetCreativeDeliveryRequest,
306+
# GetSignalsRequest, ProvidePerformanceFeedbackRequest, SiSendMessageRequest,
307+
# UpdateMediaBuyRequest.
305308
# See: https://github.com/adcontextprotocol/adcp-client-python/issues/155
306309
_UNWRAP_TO_UNION: set[str] = {
307310
"ActivateSignalResponse",
@@ -311,25 +314,20 @@ def fix_constr_type_annotations():
311314
"CreateMediaBuyResponse",
312315
"GetAccountFinancialsResponse",
313316
"GetContentStandardsResponse",
314-
"GetCreativeDeliveryRequest",
315317
"GetCreativeFeaturesResponse",
316318
"GetMediaBuyArtifactsResponse",
317319
"GetProductsRequest",
318-
"GetSignalsRequest",
319320
"ListContentStandardsResponse",
320321
"LogEventResponse",
321322
"PreviewCreativeRequest",
322323
"PreviewCreativeResponse",
323-
"ProvidePerformanceFeedbackRequest",
324324
"ProvidePerformanceFeedbackResponse",
325-
"SiSendMessageRequest",
326325
"SyncAccountsResponse",
327326
"SyncAudiencesResponse",
328327
"SyncCatalogsResponse",
329328
"SyncCreativesResponse",
330329
"SyncEventSourcesResponse",
331330
"UpdateContentStandardsResponse",
332-
"UpdateMediaBuyRequest",
333331
"UpdateMediaBuyResponse",
334332
"ValidateContentDeliveryResponse",
335333
}

src/adcp/types/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@
184184
GetProductsResponse,
185185
GetPropertyListRequest,
186186
GetPropertyListResponse,
187+
GetSignalsRequest,
187188
GetSignalsResponse,
188189
Gtin,
189190
HtmlAsset,
@@ -398,7 +399,6 @@
398399
GetProductsWholesaleRequest,
399400
GetSignalsDiscoveryRequest,
400401
GetSignalsLookupRequest,
401-
GetSignalsRequest,
402402
HtmlPreviewRender,
403403
InlineDaastAsset,
404404
InlineVastAsset,

src/adcp/types/_ergonomic.py

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,7 @@
7575
Sort,
7676
)
7777
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
78-
from adcp.types.generated_poc.media_buy.package_update import (
79-
PackageUpdate1,
80-
PackageUpdate2,
81-
)
78+
from adcp.types.generated_poc.media_buy.package_update import PackageUpdate
8279
from adcp.types.generated_poc.media_buy.create_media_buy_response import (
8380
CreateMediaBuyResponse1,
8481
)
@@ -317,71 +314,38 @@ def _apply_coercion() -> None:
317314
)
318315
CreateMediaBuyRequest.model_rebuild(force=True)
319316

320-
# Apply coercion to PackageUpdate1
321-
# - creative_assignments: list[CreativeAssignment] (accepts subclass instances)
322-
# - creatives: list[CreativeAsset] (accepts subclass instances)
323-
# - ext: ExtensionObject | dict | None
324-
# - pacing: Pacing | str | None
325-
_patch_field_annotation(
326-
PackageUpdate1,
327-
"creative_assignments",
328-
Annotated[
329-
list[CreativeAssignment] | None,
330-
BeforeValidator(coerce_subclass_list(CreativeAssignment)),
331-
],
332-
)
333-
_patch_field_annotation(
334-
PackageUpdate1,
335-
"creatives",
336-
Annotated[
337-
list[CreativeAsset] | None,
338-
BeforeValidator(coerce_subclass_list(CreativeAsset)),
339-
],
340-
)
341-
_patch_field_annotation(
342-
PackageUpdate1,
343-
"ext",
344-
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
345-
)
346-
_patch_field_annotation(
347-
PackageUpdate1,
348-
"pacing",
349-
Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))],
350-
)
351-
PackageUpdate1.model_rebuild(force=True)
352-
353-
# Apply coercion to PackageUpdate2
317+
# Apply coercion to PackageUpdate
354318
# - creative_assignments: list[CreativeAssignment] (accepts subclass instances)
355319
# - creatives: list[CreativeAsset] (accepts subclass instances)
356320
# - ext: ExtensionObject | dict | None
357321
# - pacing: Pacing | str | None
358322
_patch_field_annotation(
359-
PackageUpdate2,
323+
PackageUpdate,
360324
"creative_assignments",
361325
Annotated[
362326
list[CreativeAssignment] | None,
363327
BeforeValidator(coerce_subclass_list(CreativeAssignment)),
364328
],
365329
)
366330
_patch_field_annotation(
367-
PackageUpdate2,
331+
PackageUpdate,
368332
"creatives",
369333
Annotated[
370334
list[CreativeAsset] | None,
371335
BeforeValidator(coerce_subclass_list(CreativeAsset)),
372336
],
373337
)
374338
_patch_field_annotation(
375-
PackageUpdate2,
339+
PackageUpdate,
376340
"ext",
377341
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
378342
)
379343
_patch_field_annotation(
380-
PackageUpdate2,
344+
PackageUpdate,
381345
"pacing",
382346
Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))],
383347
)
384-
PackageUpdate2.model_rebuild(force=True)
348+
PackageUpdate.model_rebuild(force=True)
385349

386350
# Apply coercion to GetProductsResponse
387351
# - context: ContextObject | dict | None

0 commit comments

Comments
 (0)