fix(lint): let an explicit frozen=False override earlier or inherited frozen=True in LIT013

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-09-21 22:57:53 +00:00
parent c8cbdb3897
commit 997c1cab9b
2 changed files with 60 additions and 28 deletions

View file

@ -1073,25 +1073,23 @@ def _pydantic_classes(tree: ast.AST) -> tuple[ast.ClassDef, ...]:
return tuple(cls for cls in classes if cls.name in names)
def _model_config_is_frozen(value: ast.expr) -> bool:
"""`ConfigDict(frozen=True)` in any kwarg position, or a dict literal carrying
`"frozen": True`."""
def _bool_constant(value: ast.expr) -> bool | None:
return value.value if isinstance(value, ast.Constant) and isinstance(value.value, bool) else None
def _model_config_frozen(value: ast.expr) -> bool | None:
"""The `frozen` flag a `ConfigDict(...)` call or dict literal sets, None when it sets none."""
if isinstance(value, ast.Call) and _head_name(value.func) == "ConfigDict":
return any(
kw.arg == "frozen"
and isinstance(kw.value, ast.Constant)
and kw.value.value is True
for kw in value.keywords
)
flags = tuple(_bool_constant(kw.value) for kw in value.keywords if kw.arg == "frozen")
return flags[-1] if flags else None
if isinstance(value, ast.Dict):
return any(
isinstance(key, ast.Constant)
and key.value == "frozen"
and isinstance(item, ast.Constant)
and item.value is True
flags = tuple(
_bool_constant(item)
for key, item in zip(value.keys, value.values)
if isinstance(key, ast.Constant) and key.value == "frozen"
)
return False
return flags[-1] if flags else None
return None
def _assigns_name(stmt: ast.stmt, name: str) -> ast.expr | None:
@ -1103,33 +1101,44 @@ def _assigns_name(stmt: ast.stmt, name: str) -> ast.expr | None:
return None
def _config_class_is_frozen(node: ast.ClassDef) -> bool:
"""An inner `class Config:` counts only when it binds `frozen = True`."""
return any(
isinstance(value, ast.Constant) and value.value is True
def _config_class_frozen(node: ast.ClassDef) -> bool | None:
"""The `frozen = ...` flag an inner `class Config:` binds, None when it binds none."""
flags = tuple(
_bool_constant(value)
for stmt in node.body
for value in (_assigns_name(stmt, "frozen"),)
if value is not None
)
return flags[-1] if flags else None
def _class_is_frozen(cls: ast.ClassDef) -> bool:
for stmt in cls.body:
config_value = _assigns_name(stmt, "model_config")
if config_value is not None and _model_config_is_frozen(config_value):
return True
if isinstance(stmt, ast.ClassDef) and stmt.name == "Config" and _config_class_is_frozen(stmt):
return True
return False
def _stmt_frozen_flag(stmt: ast.stmt) -> bool | None:
config_value = _assigns_name(stmt, "model_config")
if config_value is not None:
return _model_config_frozen(config_value)
if isinstance(stmt, ast.ClassDef) and stmt.name == "Config":
return _config_class_frozen(stmt)
return None
def _class_frozen_override(cls: ast.ClassDef) -> bool | None:
"""The `frozen` flag the class body itself sets; the last statement that sets one wins,
like pydantic. None means the class inherits its parent's setting."""
flags = tuple(flag for flag in map(_stmt_frozen_flag, cls.body) if flag is not None)
return flags[-1] if flags else None
def iter_pydantic_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]:
models = _pydantic_classes(tree)
bases_of = {cls.name: _base_names(cls) for cls in models}
override_of = {cls.name: _class_frozen_override(cls) for cls in models}
def frozen(known: frozenset[str]) -> frozenset[str]:
grown = known | frozenset(
cls.name for cls in models if _class_is_frozen(cls) or bases_of[cls.name] & known
cls.name
for cls in models
if override_of[cls.name] is True or (override_of[cls.name] is None and bases_of[cls.name] & known)
)
return grown if grown == known else frozen(grown)

View file

@ -761,6 +761,29 @@ def test_frozen_false_is_flagged(tmp_path):
assert "LIT013" in _codes(tmp_path, src)
def test_later_model_config_frozen_false_overrides_earlier_frozen_true(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class P(BaseModel):\n"
" model_config = ConfigDict(frozen=True)\n"
" model_config = ConfigDict(frozen=False)\n"
)
assert "LIT013" in _codes(tmp_path, src)
def test_subclass_frozen_false_overrides_frozen_parent(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class Base(BaseModel):\n"
" model_config = ConfigDict(frozen=True)\n"
"class Writable(Base):\n"
" model_config = ConfigDict(frozen=False)\n"
"class StillFrozen(Base):\n"
" model_config = ConfigDict(extra='allow')\n"
)
assert _codes(tmp_path, src).count("LIT013") == 1
def test_root_model_without_frozen_is_flagged(tmp_path):
src = "from pydantic import RootModel\nclass P(RootModel):\n root: int\n"
assert "LIT013" in _codes(tmp_path, src)