From a3b280f69d068e8ed2350dcd4c5314073f69ce0f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:18:00 -0700 Subject: [PATCH] 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 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 1 + scripts/check_type_discipline.py | 185 ++++++++++++++- scripts/type_discipline_gate.py | 5 +- tests/unit/test_check_type_discipline.py | 287 ++++++++++++++++++++++- type-discipline-budget.json | 3 + 5 files changed, 458 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7d7256eeb4..ce3bfa94a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` - 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: ` - 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: ` 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: ` 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 diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index 8adc3ac27b7..cafec8d249b 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -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] # ` 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: ` 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: ` 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.*))?") REBIND_OK_RE = re.compile(r"#\s*rebind-ok(?::\s*(?P.*))?") WRITABLE_OK_RE = re.compile(r"#\s*writable-ok(?::\s*(?P.*))?") COMPREHENSION_OK_RE = re.compile(r"#\s*comprehension-ok(?::\s*(?P.*))?") +FROZEN_OK_RE: Final = re.compile(r"#\s*frozen-ok(?::\s*(?P.*))?") + @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: `)", + ) + + # --------------------------------------------------------------------------- # # 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 ...", 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()) diff --git a/scripts/type_discipline_gate.py b/scripts/type_discipline_gate.py index 3293f32d565..3a0564c3928 100644 --- a/scripts/type_discipline_gate.py +++ b/scripts/type_discipline_gate.py @@ -17,7 +17,8 @@ LIT012 (TypedDict field without a `ReadOnly[...]` qualifier; suppress with `# writable-ok: `), and LIT014 (comprehension with more than one `for` or `if` clause; suppress with `# comprehension-ok: ` 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: `) 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] # `, `# mutable-ok: `, " "`# cast-ok: `, `# guard-ok: `, `# kwargs-ok: `, " "`# rebind-ok: `, `# writable-ok: `, " - "`# comprehension-ok: `), or remove an equal " + "`# comprehension-ok: `, `# frozen-ok: `), or remove an equal " "number elsewhere; the ceiling " "is the limit in type-discipline-budget.json." ) diff --git a/tests/unit/test_check_type_discipline.py b/tests/unit/test_check_type_discipline.py index 5b8ea7b56ac..abb7ea1408c 100644 --- a/tests/unit/test_check_type_discipline.py +++ b/tests/unit/test_check_type_discipline.py @@ -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) diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 11bc9722d81..aeb77212e8e 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -37,5 +37,8 @@ }, "LIT014": { "limit": 369 + }, + "LIT015": { + "limit": 1448 } }