mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
33d908e0ae
commit
a3b280f69d
5 changed files with 458 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -37,5 +37,8 @@
|
|||
},
|
||||
"LIT014": {
|
||||
"limit": 369
|
||||
},
|
||||
"LIT015": {
|
||||
"limit": 1448
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue