11#!/usr/bin/env python3
2- """Inject ``model_config = ConfigDict(extra='forbid', populate_by_name=True)``
3- into every generated Pydantic BaseModel class, and add Pydantic v2
4- ``Field(discriminator=...)`` annotations on tagged-union fields.
2+ """Inject ``model_config`` into every generated Pydantic BaseModel class,
3+ and add Pydantic v2 ``Field(discriminator=...)`` annotations on tagged-union
4+ fields.
5+
6+ Request / Params models get ``extra='forbid'`` so typos fail before the HTTP
7+ call. Every other generated model — response DTOs and nested value objects
8+ used when decoding API responses — gets ``extra='ignore'`` (Postel's Law).
9+ Additive API fields, including nullable ones the published surface has never
10+ seen, must not crash the client.
511
612datamodel-code-generator does not emit a config block when the source
713OpenAPI spec lacks ``additionalProperties: false``. Springdoc never emits
3036value and reports only that subtype's errors (typically 1).
3137Implements P0.Bug4 from the round-3 DevEx audit.
3238
33- This implements policies P1 (response extras forbidden) and P2 (request
34- extras forbidden) from `mini/cowork/design/040-codegen-policies.md` plus
35- the two DevEx fixes above.
39+ This implements Postel's Law on the wire (`runbooks/api-contract.md` § 2.2:
40+ tolerant response decoders, strict request authoring) plus the two DevEx
41+ fixes above. P2 (request extras forbidden) stays; P1 is now "response extras
42+ ignored", not rejected.
3643
3744The transform is purely syntactic so we can run it on the codegen output
3845without parsing Python AST. Idempotent: re-runs upgrade an existing
5057# `root-model-extra`), so skip them. Their behavior is governed by the
5158# inner type, which on its own enforces strict validation.
5259CLASS_RE = re .compile (r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$" )
53- CONFIG_LINE_STRICT = " model_config = ConfigDict(extra='forbid', populate_by_name=True)"
54- CONFIG_LINE_TOLERANT = " model_config = ConfigDict(extra='ignore', populate_by_name=True)"
60+ CONFIG_LINE_STRICT = (
61+ " model_config = ConfigDict(extra='forbid', populate_by_name=True)"
62+ )
63+ CONFIG_LINE_TOLERANT = (
64+ " model_config = ConfigDict(extra='ignore', populate_by_name=True)"
65+ )
5566
5667
5768def _is_response_shape (class_name : str ) -> bool :
58- """Response-shape classes tolerate unknown fields (Postel's Law)."""
59- if class_name [0 ].islower ():
60- return False
61- if class_name .endswith (("Request" , "Params" )):
69+ """Tolerate unknown fields on every non-authoring model (Postel's Law).
70+
71+ ``*Request`` / ``*Params`` stay ``extra='forbid'``. Everything else —
72+ ``*Dto``, nested value objects on those DTOs (``StatusPageBranding``,
73+ check-detail variants, channel configs), envelopes — ignores unknown
74+ keys so an additive API field is a non-event. Shared nested types used
75+ on both request and response follow the response rule: crashing a
76+ ``get`` / ``list`` is worse than dropping an unknown nested request key
77+ the API would ignore anyway.
78+ """
79+ if not class_name or class_name [0 ].islower ():
6280 return False
63- return bool (
64- class_name .endswith (("Dto" , "Response" ))
65- or class_name .startswith (("SingleValueResponse" , "TableValueResult" , "CursorPage" ))
66- )
81+ return not class_name .endswith (("Request" , "Params" ))
6782
6883
6984# Keep the old name for backward compat in case anything imports it
@@ -78,7 +93,7 @@ def _is_response_shape(class_name: str) -> bool:
7893 "Note: ``currentStatus`` was removed from this DTO. "
7994 "Inspect ``enabled`` and the incident-policy API to derive a "
8095 "live status for a monitor instead."
81- ),
96+ )
8297}
8398
8499
@@ -104,9 +119,7 @@ def inject(source: str) -> tuple[str, int]:
104119 """Return (new_source, count_of_classes_modified)."""
105120 if "from pydantic import" in source and "ConfigDict" not in source :
106121 source = source .replace (
107- "from pydantic import" ,
108- "from pydantic import ConfigDict, " ,
109- 1 ,
122+ "from pydantic import" , "from pydantic import ConfigDict, " , 1
110123 )
111124 source = source .replace ("ConfigDict, ConfigDict, " , "ConfigDict, " , 1 )
112125
@@ -147,7 +160,11 @@ def inject(source: str) -> tuple[str, int]:
147160 i += 1
148161 continue
149162 class_name = m .group (1 )
150- config_line = CONFIG_LINE_TOLERANT if _is_response_shape (class_name ) else CONFIG_LINE_STRICT
163+ config_line = (
164+ CONFIG_LINE_TOLERANT
165+ if _is_response_shape (class_name )
166+ else CONFIG_LINE_STRICT
167+ )
151168 # Look at the very next line. If it's already model_config or pass,
152169 # leave the class alone (idempotency / empty class).
153170 next_idx = i + 1
0 commit comments