From 997c1cab9bf6b3c9b44fb7ac749e64259b2a9c18 Mon Sep 17 00:00:00 2001 From: mateo Date: Mon, 21 Sep 2026 22:57:53 +0000 Subject: [PATCH] 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> --- scripts/check_type_discipline.py | 65 +++++++++++-------- .../test_check_type_discipline.py | 23 +++++++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index 359ace379ef..257f90edfaa 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -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) diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index 35fc807234b..811be5fa95a 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -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)