fix(lint): cover LiteLLMBaseModel, BaseSettings, and SettingsConfigDict in LIT015

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-10-08 04:31:33 +00:00
parent 4798285bea
commit 5de9ca14c8
4 changed files with 64 additions and 16 deletions

View file

@ -97,7 +97,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- Annotate every variable with `: Final` (LIT010). Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011); `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: <reason>`
- Qualify every TypedDict field with `ReadOnly[...]` (LIT012), which nests freely with `Required` / `NotRequired` / `Annotated` in any order. If making the key writable is truly unavoidable, suppress with `# writable-ok: <reason>`
- Comprehensions take at most one `for` clause and one `if` clause (LIT014); split stacked clauses into a helper generator, a named intermediate, or a plain loop. Suppress with `# comprehension-ok: <reason>` only when unavoidable
- Every pydantic model must be frozen (LIT015), set via `model_config = ConfigDict(frozen=True)`, a dict-literal `model_config`, an inner `class Config`, or the class keywords. Subclasses inherit it unless they override it. If making the model mutable is truly unavoidable, suppress with `# frozen-ok: <reason>` on the `class` line
- Every pydantic model must be frozen (LIT015), set via `model_config = ConfigDict(frozen=True)`, a dict-literal `model_config`, an inner `class Config`, or the class keywords. Subclasses inherit it unless they override it. Replace in-place field writes with `model_copy(update=...)`. If making the model mutable is truly unavoidable, suppress with `# frozen-ok: <reason>` on the `class` line
- Use dependency injection
- Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
- Use tagged unions + match

View file

@ -92,13 +92,15 @@ LIT014 Comprehension with more than one `for` clause or more than one `if` clau
marker belongs to the innermost violating comprehension spanning that
line, and also to any single-line violating comprehension on that line.
LIT015 Pydantic model class that is not frozen. Set `frozen=True` in
`model_config = ConfigDict(...)`, a dict-literal `model_config`, an inner
`class Config`, or the class keywords. Classes inherit the frozen setting
from in-module model bases, unless their own configuration overrides it.
Detection is name-based: `BaseModel`, `pydantic.BaseModel`,
`LiteLLMPydanticObjectBase`, and `RootModel` identify models, while
`TypedDict` classes are exempt. Suppress with `# frozen-ok: <reason>` on
the `class` line.
`model_config = ConfigDict(...)`, `SettingsConfigDict(...)`, a dict-literal
`model_config`, an inner `class Config`, or the class keywords. Classes
inherit the frozen setting from in-module model bases, unless their own
configuration overrides it. Detection is name-based: `BaseModel`,
`pydantic.BaseModel`, `LiteLLMBaseModel`, `BaseLiteLLMOpenAIResponseObject`,
`LiteLLMPydanticObjectBase`, `RootModel`, and `BaseSettings` identify
models, while `TypedDict` classes are exempt. Replace in-place field writes
with `model_copy(update=...)`. Suppress with `# frozen-ok: <reason>` on the
`class` line.
LIT000 Setup failure: a target file could not be read, or contains a syntax error.
Reported as a violation rather than crashing the run.
@ -158,7 +160,17 @@ READONLY_QUALIFIER = "ReadOnly"
FIELD_QUALIFIER_WRAPPERS = frozenset(("Required", "NotRequired", "Annotated"))
TYPEDDICT_BASE = "TypedDict"
# Base names that mark a class as a pydantic model (LIT015).
PYDANTIC_BASES: Final = frozenset(("BaseModel", "LiteLLMPydanticObjectBase", "RootModel"))
PYDANTIC_BASES: Final = frozenset(
(
"BaseModel",
"LiteLLMBaseModel",
"BaseLiteLLMOpenAIResponseObject",
"LiteLLMPydanticObjectBase",
"RootModel",
"BaseSettings",
)
)
PYDANTIC_CONFIG_FACTORIES: Final = frozenset(("ConfigDict", "SettingsConfigDict"))
MIN_REASON_LEN = 3
NOQA_RE = re.compile(
@ -868,7 +880,7 @@ def _bool_constant(value: ast.expr) -> bool | None:
def _model_config_frozen(value: ast.expr) -> bool | None:
if isinstance(value, ast.Call) and _head_name(value.func) == "ConfigDict":
if isinstance(value, ast.Call) and _head_name(value.func) in PYDANTIC_CONFIG_FACTORIES:
flags: Final = 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):
@ -916,27 +928,28 @@ def _class_frozen_override(cls: ast.ClassDef) -> bool | None:
def iter_pydantic_violations(path: Path, tree: ast.AST) -> Iterator[Violation]:
models: Final = _pydantic_classes(tree)
bases_of: Final = {cls.name: _base_names(cls) for cls in models}
override_of: Final = {cls.name: _class_frozen_override(cls) for cls in models}
bases_of: Final = {cls: _base_names(cls) for cls in models}
override_of: Final = {cls: _class_frozen_override(cls) for cls in models}
def frozen(known: frozenset[str]) -> frozenset[str]:
grown: Final = known | frozenset(
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)
if override_of[cls] is True or (override_of[cls] is None and bases_of[cls] & known)
)
return grown if grown == known else frozen(grown)
frozen_names: Final = frozen(frozenset())
for cls in models:
if cls.name in frozen_names:
if override_of[cls] is True or (override_of[cls] is None and bases_of[cls] & frozen_names):
continue
yield Violation(
path,
cls.lineno,
"LIT015",
f"pydantic model `{cls.name}` is not frozen: set `frozen=True` in "
f"`model_config`, an inner `class Config`, or the class keywords "
f"`model_config`, an inner `class Config`, or the class keywords, and "
f"replace in-place field writes with `model_copy(update=...)` "
f"(suppress: `# frozen-ok: <reason>`)",
)

View file

@ -735,6 +735,41 @@ def test_extra_allow_does_not_exempt(tmp_path):
assert "LIT015" in _codes(tmp_path, src)
def test_litellm_base_model_is_flagged(tmp_path):
src = "class Foo(LiteLLMBaseModel):\n x: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_litellm_openai_response_base_is_flagged(tmp_path):
src = "class R(BaseLiteLLMOpenAIResponseObject):\n x: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_base_settings_is_flagged(tmp_path):
src = "class S(BaseSettings):\n x: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_settings_config_dict_frozen_true_is_clean(tmp_path):
src = (
"from pydantic_settings import BaseSettings, SettingsConfigDict\n"
"class S(BaseSettings):\n"
" model_config = SettingsConfigDict(frozen=True)\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_same_named_models_use_each_classes_own_override(tmp_path):
src = (
"from pydantic import BaseModel\n"
"class Dup(BaseModel, frozen=True):\n"
" x: int\n"
"class Dup(BaseModel):\n"
" x: int\n"
)
assert _codes(tmp_path, src) == ["LIT015"]
# --------------------------------------------------------------------------- #
# Stacked comprehension clauses (LIT014)
# --------------------------------------------------------------------------- #

View file

@ -39,6 +39,6 @@
"limit": 369
},
"LIT015": {
"limit": 230
"limit": 1424
}
}