5050# `root-model-extra`), so skip them. Their behavior is governed by the
5151# inner type, which on its own enforces strict validation.
5252CLASS_RE = re .compile (r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$" )
53- CONFIG_LINE = " model_config = ConfigDict(extra='forbid', populate_by_name=True)"
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)"
55+
56+
57+ def _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" )):
62+ return False
63+ return bool (
64+ class_name .endswith (("Dto" , "Response" ))
65+ or class_name .startswith (("SingleValueResponse" , "TableValueResult" , "CursorPage" ))
66+ )
67+
68+
69+ # Keep the old name for backward compat in case anything imports it
70+ CONFIG_LINE = CONFIG_LINE_STRICT
5471
5572# Doc-banner injections keyed by class name. Inserted as a leading docstring
5673# inside the target class so the note shows up in IDE hovers and stays put
@@ -130,6 +147,7 @@ def inject(source: str) -> tuple[str, int]:
130147 i += 1
131148 continue
132149 class_name = m .group (1 )
150+ config_line = CONFIG_LINE_TOLERANT if _is_response_shape (class_name ) else CONFIG_LINE_STRICT
133151 # Look at the very next line. If it's already model_config or pass,
134152 # leave the class alone (idempotency / empty class).
135153 next_idx = i + 1
@@ -141,10 +159,10 @@ def inject(source: str) -> tuple[str, int]:
141159 out .append (f' """{ banner } """\n ' )
142160 modified += 1
143161 if "model_config" in next_line :
144- # Upgrade the existing config line to include populate_by_name=True
145- # if it isn't already there. Idempotent across re-runs .
146- if "populate_by_name" not in next_line :
147- out .append (CONFIG_LINE + "\n " )
162+ # Replace the existing config line if it doesn't match the
163+ # desired strictness or is missing populate_by_name .
164+ if next_line . strip () != config_line . strip () :
165+ out .append (config_line + "\n " )
148166 i += 2 # replace the existing model_config line
149167 modified += 1
150168 continue
@@ -154,11 +172,11 @@ def inject(source: str) -> tuple[str, int]:
154172 # exact match (NOT startswith) — fields like `passed: Annotated[...]`
155173 # also start with "pass" but are not empty class markers.
156174 if next_line .strip () in ("pass" , "pass\n " ):
157- out .append (CONFIG_LINE + "\n " )
175+ out .append (config_line + "\n " )
158176 i += 2 # skip the pass
159177 modified += 1
160178 continue
161- out .append (CONFIG_LINE + "\n " )
179+ out .append (config_line + "\n " )
162180 modified += 1
163181 i += 1
164182 return "" .join (out ), modified
0 commit comments