feat(lint): add LIT015 requiring pydantic models to be frozen (#42348)

* feat(lint): add LIT013 requiring pydantic models to be frozen

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* 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>

* refactor(lint): annotate LIT013 helper locals with Final

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(lint): document the frozen pydantic model rule as LIT015

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(lint): honor the frozen class keyword in LIT015

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(lint): annotate main and scan_paths locals as Final

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

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

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lint): resolve LIT015 model bases per class node

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lint): treat the OpenAIObject alias as a LIT015 model base

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lint): resolve shared LIT015 config constants and list frozen-ok in the gate hint

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lint): resolve LIT015 shared config at class definition

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-07 22:18:00 -07:00 • committed by GitHub
parent 33d908e0ae
commit a3b280f69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 458 additions and 23 deletions

View file

@ -97,6 +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. 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

@ -18,8 +18,8 @@ LIT003 noqa suppression without rule codes or without a reason.
LIT004 pyright/mypy ignore without bracketed codes or without a reason.
Required shape: `# pyright: ignore[reportArgumentType] # <reason>`
LIT005 A `# mutable-ok` / `# cast-ok` / `# guard-ok` / `# kwargs-ok` /
`# rebind-ok` / `# writable-ok` / `# comprehension-ok` suppression
without a reason.
`# rebind-ok` / `# writable-ok` / `# comprehension-ok` / `# frozen-ok`
suppression without a reason.
LIT006 `cast(...)` call. typing.cast is an unchecked assertion (the moral equivalent
of TypeScript's `as`); it lies to the type checker with zero runtime guarantee.
Validate into a concrete frozen type at the boundary instead.
@ -91,6 +91,16 @@ LIT014 Comprehension with more than one `for` clause or more than one `if` clau
`# comprehension-ok: <reason>` on any line the comprehension spans. The
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(...)`, `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`, `OpenAIObject`, `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.
@ -110,12 +120,13 @@ import os
import re
import sys
import tokenize
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from itertools import groupby
from multiprocessing import Pool
from pathlib import Path
from collections.abc import Iterable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import NamedTuple
from typing import Final, NamedTuple
# Mutable collection types, banned in *every* annotation. Name-based, so `dict`,
# `typing.Dict`, `collections.deque`, and `collections.abc.MutableMapping` all match
@ -149,6 +160,19 @@ READONLY_QUALIFIER = "ReadOnly"
# first argument is type syntax, the rest is metadata and never qualifies the field.
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",
"LiteLLMBaseModel",
"BaseLiteLLMOpenAIResponseObject",
"LiteLLMPydanticObjectBase",
"OpenAIObject",
"RootModel",
"BaseSettings",
)
)
PYDANTIC_CONFIG_FACTORIES: Final = frozenset(("ConfigDict", "SettingsConfigDict"))
MIN_REASON_LEN = 3
NOQA_RE = re.compile(
@ -166,6 +190,8 @@ KWARGS_OK_RE = re.compile(r"#\s*kwargs-ok(?::\s*(?P<reason>.*))?")
REBIND_OK_RE = re.compile(r"#\s*rebind-ok(?::\s*(?P<reason>.*))?")
WRITABLE_OK_RE = re.compile(r"#\s*writable-ok(?::\s*(?P<reason>.*))?")
COMPREHENSION_OK_RE = re.compile(r"#\s*comprehension-ok(?::\s*(?P<reason>.*))?")
FROZEN_OK_RE: Final = re.compile(r"#\s*frozen-ok(?::\s*(?P<reason>.*))?")
@dataclass(frozen=True, slots=True)
class _OkToken:
@ -185,6 +211,7 @@ OK_SUPPRESSIONS: Final[tuple[_OkToken, ...]] = (
_OkToken("rebind-ok", REBIND_OK_RE, frozenset(("LIT010", "LIT011"))),
_OkToken("writable-ok", WRITABLE_OK_RE, frozenset(("LIT012",))),
_OkToken("comprehension-ok", COMPREHENSION_OK_RE, frozenset(("LIT014",))),
_OkToken("frozen-ok", FROZEN_OK_RE, frozenset(("LIT015",))),
)
@ -833,6 +860,147 @@ def iter_typeddict_violations(path: Path, tree: ast.AST) -> Iterator[Violation]:
)
# --------------------------------------------------------------------------- #
# Unfrozen pydantic models (LIT015)
# --------------------------------------------------------------------------- #
def _pydantic_classes(tree: ast.AST) -> tuple[ast.ClassDef, ...]:
classes: Final = tuple(node for node in ast.walk(tree) if isinstance(node, ast.ClassDef))
def expand(known: frozenset[str]) -> frozenset[str]:
grown: Final = known | frozenset(cls.name for cls in classes if _base_names(cls) & known)
return grown if grown == known else expand(grown)
model_names: Final = expand(PYDANTIC_BASES)
typeddict_names: Final = expand(frozenset((TYPEDDICT_BASE,)))
return tuple(cls for cls in classes if _base_names(cls) & model_names and not _base_names(cls) & typeddict_names)
def _bool_constant(value: ast.expr) -> bool | None:
return value.value if isinstance(value, ast.Constant) and isinstance(value.value, bool) else None
def _module_assignment_items(tree: ast.AST) -> Iterator[tuple[str, tuple[int, ast.expr]]]:
if not isinstance(tree, ast.Module):
return
for stmt in tree.body:
if isinstance(stmt, ast.Assign):
yield from (
(target.id, (stmt.lineno, stmt.value)) for target in stmt.targets if isinstance(target, ast.Name)
)
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
yield stmt.target.id, (stmt.lineno, stmt.value)
def _model_config_frozen(
value: ast.expr,
module_assignments: Mapping[str, tuple[tuple[int, ast.expr], ...]],
class_lineno: int,
) -> bool | None:
config: Final = (
next(
(
expression
for lineno, expression in reversed(module_assignments.get(value.id, ()))
if lineno < class_lineno
),
None,
)
if isinstance(value, ast.Name)
else value
)
if isinstance(config, ast.Call) and _head_name(config.func) in PYDANTIC_CONFIG_FACTORIES:
flags: Final = tuple(_bool_constant(kw.value) for kw in config.keywords if kw.arg == "frozen")
return flags[-1] if flags else None
if isinstance(config, ast.Dict):
flags: Final = tuple(
_bool_constant(item)
for key, item in zip(config.keys, config.values)
if isinstance(key, ast.Constant) and key.value == "frozen"
)
return flags[-1] if flags else None
return None
def _assigns_name(stmt: ast.stmt, name: str) -> ast.expr | None:
value: Final = stmt.value if isinstance(stmt, (ast.Assign, ast.AnnAssign)) else None
targets: Final = (
stmt.targets if isinstance(stmt, ast.Assign) else (stmt.target,) if isinstance(stmt, ast.AnnAssign) else ()
)
if value is not None and any(isinstance(target, ast.Name) and target.id == name for target in targets):
return value
return None
def _config_class_frozen(node: ast.ClassDef) -> bool | None:
values: Final = tuple(_assigns_name(stmt, "frozen") for stmt in node.body)
flags: Final = tuple(_bool_constant(value) for value in values if value is not None)
return flags[-1] if flags else None
def _stmt_frozen_flag(
stmt: ast.stmt,
module_assignments: Mapping[str, tuple[tuple[int, ast.expr], ...]],
class_lineno: int,
) -> bool | None:
config_value: Final = _assigns_name(stmt, "model_config")
if config_value is not None:
return _model_config_frozen(config_value, module_assignments, class_lineno)
if isinstance(stmt, ast.ClassDef) and stmt.name == "Config":
return _config_class_frozen(stmt)
return None
def _class_frozen_override(
cls: ast.ClassDef,
module_assignments: Mapping[str, tuple[tuple[int, ast.expr], ...]],
class_lineno: int,
) -> bool | None:
keyword_flags: Final = tuple(_bool_constant(keyword.value) for keyword in cls.keywords if keyword.arg == "frozen")
if keyword_flags:
return keyword_flags[-1]
body_flags: Final = tuple(_stmt_frozen_flag(stmt, module_assignments, class_lineno) for stmt in cls.body)
return next((flag for flag in reversed(body_flags) if flag is not None), None)
def iter_pydantic_violations(path: Path, tree: ast.AST) -> Iterator[Violation]:
module_assignments: Final = MappingProxyType(
{
name: tuple(binding for _, binding in assignments)
for name, assignments in groupby(
sorted(_module_assignment_items(tree), key=lambda item: item[0]),
key=lambda item: item[0],
)
}
)
models: Final = _pydantic_classes(tree)
bases_of: Final = {cls: _base_names(cls) for cls in models}
override_of: Final = {cls: _class_frozen_override(cls, module_assignments, cls.lineno) 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] 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 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, and "
f"replace in-place field writes with `model_copy(update=...)` "
f"(suppress: `# frozen-ok: <reason>`)",
)
# --------------------------------------------------------------------------- #
# Stacked comprehension clauses (LIT014)
# --------------------------------------------------------------------------- #
@ -978,6 +1146,7 @@ def check_file(path: Path) -> tuple[Violation, ...]:
*iter_final_violations(path, tree),
*iter_param_violations(path, tree),
*iter_typeddict_violations(path, tree),
*iter_pydantic_violations(path, tree),
*(v for v, owned in comprehension_violations if owned),
),
suppressions,
@ -1009,7 +1178,7 @@ def _worker_count(path_count: int) -> int:
def scan_paths(paths: Sequence[Path]) -> tuple[Violation, ...]:
"""check_file over every path. Pure per-file work, so it fans out across
processes; callers sort, which is what keeps output order stable."""
workers = _worker_count(len(paths))
workers: Final = _worker_count(len(paths))
if workers == 1:
return tuple(v for path in paths for v in check_file(path))
with Pool(workers) as pool:
@ -1017,13 +1186,13 @@ def scan_paths(paths: Sequence[Path]) -> tuple[Violation, ...]:
def main(argv: Sequence[str]) -> int:
paths = tuple(a for a in argv if not a.startswith("-"))
paths: Final = tuple(a for a in argv if not a.startswith("-"))
if not paths:
print("usage: check_type_discipline.py <files-or-dirs>...", file=sys.stderr)
return 2
targets = tuple(collect_paths(paths))
violations = sorted(scan_paths(targets))
targets: Final = tuple(collect_paths(paths))
violations: Final = sorted(scan_paths(targets))
for v in violations:
print(v.render())

View file

@ -17,7 +17,8 @@ LIT012 (TypedDict field without a `ReadOnly[...]` qualifier; suppress with
`# writable-ok: <reason>`), and LIT014 (comprehension with more than one `for`
or `if` clause; suppress with `# comprehension-ok: <reason>` on a spanned
line, which belongs to the innermost violating comprehension spanning it and
to any single-line violating comprehension on that line) carry limits at
to any single-line violating comprehension on that line), and LIT015
(pydantic model not frozen; suppress with `# frozen-ok: <reason>`) carry limits at
or above their current count to ratchet down; LIT005 (`*-ok` suppression
without a reason) is frozen at limit 0
so any net-new reasonless suppression trips the gate; LIT013 (`*-ok` suppression
@ -202,7 +203,7 @@ def cmd_check(base: str) -> None:
"`# pyright: ignore[rule] # <reason>`, `# mutable-ok: <reason>`, "
"`# cast-ok: <reason>`, `# guard-ok: <reason>`, `# kwargs-ok: <reason>`, "
"`# rebind-ok: <reason>`, `# writable-ok: <reason>`, "
"`# comprehension-ok: <reason>`), or remove an equal "
"`# comprehension-ok: <reason>`, `# frozen-ok: <reason>`), or remove an equal "
"number elsewhere; the ceiling "
"is the limit in type-discipline-budget.json."
)

View file

@ -13,6 +13,7 @@ import re
import subprocess
import sys
from pathlib import Path
from typing import Final
import pytest
@ -579,6 +580,277 @@ def test_writable_ok_without_reason_is_lit005_and_does_not_suppress(tmp_path):
assert "LIT012" in codes
# --------------------------------------------------------------------------- #
# Unfrozen pydantic models (LIT015)
# --------------------------------------------------------------------------- #
def test_unfrozen_basemodel_is_flagged(tmp_path):
src = "from pydantic import BaseModel\nclass P(BaseModel):\n a: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_configdict_frozen_true_is_clean(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class P(BaseModel):\n"
" model_config = ConfigDict(extra='allow', frozen=True)\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_shared_frozen_configdict_constant_is_clean(tmp_path):
src: Final = (
"from typing import Final\n"
"from pydantic import BaseModel, ConfigDict\n"
"_RESPONSE_CONFIG: Final = ConfigDict(frozen=True)\n"
"class Foo(BaseModel):\n"
" model_config = _RESPONSE_CONFIG\n"
" x: int\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_mutable_config_before_class_is_not_hidden_by_frozen_reassignment(tmp_path):
src: Final = (
"from pydantic import BaseModel, ConfigDict\n"
"_RESPONSE_CONFIG = ConfigDict(frozen=False)\n"
"class Foo(BaseModel):\n"
" model_config = _RESPONSE_CONFIG\n"
" x: int\n"
"_RESPONSE_CONFIG = ConfigDict(frozen=True)\n"
)
assert "LIT015" in _codes(tmp_path, src)
def test_frozen_config_before_class_survives_mutable_reassignment(tmp_path):
src: Final = (
"from pydantic import BaseModel, ConfigDict\n"
"_RESPONSE_CONFIG = ConfigDict(frozen=True)\n"
"class Foo(BaseModel):\n"
" model_config = _RESPONSE_CONFIG\n"
" x: int\n"
"_RESPONSE_CONFIG = ConfigDict(frozen=False)\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_config_assigned_only_after_class_is_unresolved(tmp_path):
src: Final = (
"from pydantic import BaseModel, ConfigDict\n"
"class Foo(BaseModel):\n"
" model_config = _RESPONSE_CONFIG\n"
" x: int\n"
"_RESPONSE_CONFIG = ConfigDict(frozen=True)\n"
)
assert "LIT015" in _codes(tmp_path, src)
def test_unknown_model_config_name_is_flagged(tmp_path):
src: Final = (
"from pydantic import BaseModel\nclass Foo(BaseModel):\n model_config = UNKNOWN_CONFIG\n x: int\n"
)
assert "LIT015" in _codes(tmp_path, src)
def test_dict_literal_model_config_frozen_true_is_clean(tmp_path):
src = "from pydantic import BaseModel\nclass P(BaseModel):\n model_config = {'frozen': True, 'extra': 'allow'}\n"
assert "LIT015" not in _codes(tmp_path, src)
def test_subclass_of_in_file_frozen_model_is_clean(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class Base(BaseModel):\n"
" model_config = ConfigDict(frozen=True)\n"
"class Child(Base):\n"
" a: int\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_subclass_of_in_file_unfrozen_model_flags_both(tmp_path):
src = "from pydantic import BaseModel\nclass Base(BaseModel):\n pass\nclass Child(Base):\n a: int\n"
assert _codes(tmp_path, src).count("LIT015") == 2
def test_litellm_pydantic_object_base_without_frozen_is_flagged(tmp_path):
src = "class P(LiteLLMPydanticObjectBase):\n a: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_inner_config_class_frozen_true_is_clean(tmp_path):
src = "from pydantic import BaseModel\nclass P(BaseModel):\n class Config:\n frozen = True\n"
assert "LIT015" not in _codes(tmp_path, src)
def test_frozen_false_is_flagged(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\nclass P(BaseModel):\n model_config = ConfigDict(frozen=False)\n"
)
assert "LIT015" 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 "LIT015" 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("LIT015") == 1
def test_root_model_without_frozen_is_flagged(tmp_path):
src = "from pydantic import RootModel\nclass P(RootModel):\n root: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_qualified_pydantic_basemodel_is_flagged(tmp_path):
src = "import pydantic\nclass P(pydantic.BaseModel):\n a: int\n"
assert "LIT015" in _codes(tmp_path, src)
def test_keyword_frozen_model_is_clean(tmp_path):
src = "from pydantic import BaseModel\nclass M(BaseModel, frozen=True):\n a: int\n"
assert "LIT015" not in _codes(tmp_path, src)
def test_subclass_of_keyword_frozen_model_is_clean(tmp_path):
src = (
"from pydantic import BaseModel\n"
"class Parent(BaseModel, frozen=True):\n"
" pass\n"
"class Child(Parent):\n"
" a: int\n"
)
assert "LIT015" not in _codes(tmp_path, src)
def test_frozen_false_keyword_overrides_body_frozen_config(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class M(BaseModel, frozen=False):\n"
" model_config = ConfigDict(frozen=True)\n"
)
assert "LIT015" in _codes(tmp_path, src)
def test_frozen_ok_with_reason_suppresses_and_is_not_lit013(tmp_path):
src = (
"from pydantic import BaseModel\n"
"class P(BaseModel): # frozen-ok: mutated during build before handoff\n"
" a: int\n"
)
codes = _codes(tmp_path, src)
assert "LIT015" not in codes
assert "LIT013" not in codes
def test_frozen_ok_on_frozen_model_is_lit013(tmp_path):
src = (
"from pydantic import BaseModel\n"
"class P(BaseModel, frozen=True): # frozen-ok: mutable during construction\n"
" a: int\n"
)
assert _codes(tmp_path, src) == ["LIT013"]
def test_frozen_ok_without_reason_is_lit005_and_does_not_suppress(tmp_path):
src = "from pydantic import BaseModel\nclass P(BaseModel): # frozen-ok\n a: int\n"
codes = _codes(tmp_path, src)
assert "LIT005" in codes
assert "LIT015" in codes
def test_typeddict_and_plain_classes_are_not_models(tmp_path):
src = "from typing import TypedDict\nclass T(TypedDict):\n a: int\nclass C:\n a: int\n"
assert "LIT015" not in _codes(tmp_path, src)
def test_extra_allow_does_not_exempt(tmp_path):
src = (
"from pydantic import BaseModel, ConfigDict\n"
"class P(BaseModel):\n"
" model_config = ConfigDict(extra='allow')\n"
)
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_openai_object_base_is_flagged_and_can_be_frozen(tmp_path):
assert "LIT015" in _codes(tmp_path, "class Foo(OpenAIObject):\n x: int\n")
assert "LIT015" not in _codes(
tmp_path,
"from pydantic import ConfigDict\n"
"class Foo(OpenAIObject):\n"
" model_config = ConfigDict(frozen=True)\n"
" x: int\n",
)
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"]
def test_pydantic_model_discovery_uses_each_class_nodes_bases(tmp_path):
src: Final = (
"from pydantic import BaseModel\n"
"class Config(BaseModel):\n"
" x: int\n"
"class Consumer(BaseModel, frozen=True):\n"
" class Config:\n"
" arbitrary_types_allowed = True\n"
)
path: Final = tmp_path / "snippet.py"
path.write_text(src, encoding="utf-8")
violations: Final = checker.check_file(path)
assert [(violation.line, violation.code) for violation in violations] == [(2, "LIT015")]
# --------------------------------------------------------------------------- #
# Stacked comprehension clauses (LIT014)
# --------------------------------------------------------------------------- #
@ -619,23 +891,12 @@ def test_comprehension_ok_with_reason_suppresses_lit014(tmp_path: Path):
def test_comprehension_ok_on_any_spanned_line_suppresses_lit014(tmp_path: Path):
src = (
"y = [\n"
" x for a in xs\n"
" for x in a\n"
"] # comprehension-ok: cartesian product is the clearest form\n"
)
src = "y = [\n x for a in xs\n for x in a\n] # comprehension-ok: cartesian product is the clearest form\n"
assert "LIT014" not in _codes(tmp_path, src)
def test_comprehension_ok_after_the_closing_line_does_not_suppress(tmp_path: Path):
src = (
"y = [\n"
" x for a in xs\n"
" for x in a\n"
"]\n"
"# comprehension-ok: cartesian product is the clearest form\n"
)
src = "y = [\n x for a in xs\n for x in a\n]\n# comprehension-ok: cartesian product is the clearest form\n"
f = tmp_path / "snippet.py"
f.write_text(src, encoding="utf-8")
violations = checker.check_file(f)

View file

@ -37,5 +37,8 @@
},
"LIT014": {
"limit": 369
},
"LIT015": {
"limit": 1448
}
}