fix(ci): read spread config mappings when detecting extra="allow"

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-08-08 19:30:12 +00:00
parent 934cfe1dd5
commit b1a8a011dc
2 changed files with 27 additions and 1 deletions

View file

@ -7,6 +7,10 @@ anything new must declare its fields.
That list is a budget like the others, so ``scripts/budget_ratchet_check.py`` is what
reds when it grows, and its ``limit`` must equal the number of models it lists.
What it reads is the opt-in a module spells out, so it says nothing about a model that
inherits ``extra="allow"`` from a base declared elsewhere. Subclassing a listed model
still widens it, and only review catches that.
"""
import ast
@ -138,12 +142,18 @@ def _is_allow_literal(node: ast.expr, scope: Scope) -> bool:
def _is_extra_allow_keyword(keyword: ast.keyword, scope: Scope) -> bool:
"""A keyword with no ``arg`` is a ``**`` spread, so what it spreads is read as a config itself."""
if keyword.arg is None:
return _is_extra_allow_value(keyword.value, scope)
return keyword.arg == "extra" and _is_allow_literal(keyword.value, scope)
def _mapping_sets_extra_allow(node: ast.Dict, scope: Scope) -> bool:
"""A ``None`` key is a ``**`` spread, so what it spreads is read as a config itself."""
return any(
isinstance(key, ast.Constant) and key.value == "extra" and _is_allow_literal(value, scope)
_is_extra_allow_value(value, scope)
if key is None
else isinstance(key, ast.Constant) and key.value == "extra" and _is_allow_literal(value, scope)
for key, value in zip(node.keys, node.values)
)

View file

@ -137,6 +137,18 @@ _ratchet_spec.loader.exec_module(ratchet)
'class Foo(BaseModel):\n class Config:\n _EXTRA = "allow"\n extra = _EXTRA\n',
id="legacy_inner_config_reading_its_own_constant",
),
pytest.param(
'class Foo(BaseModel):\n model_config = ConfigDict(**{"extra": "allow"})\n',
id="config_dict_kwargs_spread",
),
pytest.param(
'ALLOW = ConfigDict(extra="allow")\n\n\nclass Foo(BaseModel):\n model_config = {**ALLOW}\n',
id="permissive_constant_spread_into_a_dict",
),
pytest.param(
'ALLOW = {"extra": "allow"}\n\n\nclass Foo(BaseModel, **ALLOW):\n pass\n',
id="permissive_constant_spread_into_the_class_keywords",
),
],
)
def test_detects_extra_allow(source):
@ -203,6 +215,10 @@ def test_detects_extra_allow(source):
'class Foo(BaseModel):\n model_config = LATER\n LATER = ConfigDict(extra="allow")\n',
id="class_local_constant_bound_below_the_line_reading_it",
),
pytest.param(
'FORBID = ConfigDict(extra="forbid")\n\n\nclass Foo(BaseModel):\n model_config = {**FORBID}\n',
id="harmless_constant_spread_into_a_dict",
),
],
)
def test_ignores_non_violations(source):