diff --git a/CLAUDE.md b/CLAUDE.md index 1bc4d108da7..81e560af849 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ When you fix violations gated by `ruff-strict-budget.json`, `type-discipline-bud If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in -If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason +If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()`, and freeze dict-shaped values with `types.MappingProxyType({...})` (annotated as `Mapping[...]`), instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # `. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing @@ -72,7 +72,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega - Composition over inheritance - Never-nester: early returns over deep nesting - Don't throw; model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never) -- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), etc. +- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `types.MappingProxyType`, etc. - 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: ` explaining why - Use dependency injection - Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed diff --git a/ruff-strict.toml b/ruff-strict.toml index d58885fe848..66d8f281fce 100644 --- a/ruff-strict.toml +++ b/ruff-strict.toml @@ -15,7 +15,7 @@ max-args = 5 "typing.Any".msg = "Use a concrete type. Frozen slots=True dataclass (preferred) / NamedTuple / ReadOnly TypedDict for payloads." "typing_extensions.Any".msg = "Same as typing.Any." "typing.List".msg = "tuple[X, ...] for state, Sequence[X] for params." -"typing.Dict".msg = "Frozen dataclass / NamedTuple / ReadOnly TypedDict; create a Mapping alias with concrete value types if truly dynamic." +"typing.Dict".msg = "Frozen dataclass / NamedTuple / ReadOnly TypedDict; if truly dynamic, a Mapping alias with concrete value types, frozen at runtime with types.MappingProxyType({...})." "typing.Set".msg = "frozenset[X] or AbstractSet[X]." "typing.MutableSequence".msg = "Sequence[X]." "typing.MutableMapping".msg = "See typing.Dict." diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index c303fbaffce..d73f1095895 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -11,14 +11,16 @@ LIT001 Mutable collection in a type annotation, anywhere it appears: function collection lets whoever holds it grow or rewrite it after the fact; annotate a read-only view instead (Mapping/Sequence/AbstractSet/tuple[X, ...]/ frozenset[X], or a frozen dataclass / NamedTuple / ReadOnly TypedDict) and - build it functionally (comprehension / map, not append-in-a-loop). + build it functionally (comprehension / map / MappingProxyType({...}), not + append-in-a-loop). Suppress with `# mutable-ok: ` on the offending line. LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehension, or a call to a mutable constructor (list/dict/set/deque/defaultdict/Counter/...). Catches the unannotated seed-then-mutate pattern LIT001 cannot see (`acc = []`). Build the value in one shot and freeze it: a `tuple`/`frozenset` wrapping a - generator (`tuple(f(x) for x in xs)`), a tuple literal, or a frozen dataclass / - NamedTuple / ReadOnly TypedDict. Generator expressions and `tuple`/`frozenset` + generator (`tuple(f(x) for x in xs)`), a tuple literal, `MappingProxyType({...})` + for a dict-shaped value, or a frozen dataclass / NamedTuple / ReadOnly TypedDict. + Generator expressions and `tuple`/`frozenset` calls are not construction and pass. Annotation-internal lists (`Callable[[int], str]`) are exempt, as is a value passed directly to a freezing wrapper (`tuple(...)`, `frozenset(...)`, `MappingProxyType(...)`): it is frozen before @@ -279,7 +281,8 @@ def _mutable_ann(path: Path, line: int, name: str, where: str) -> Violation: f"mutable `{name}` in {where}: a mutable collection can be grown or rewritten " f"by whoever holds it. Annotate a read-only view -- Mapping[...], Sequence[...], " f"AbstractSet[...], tuple[X, ...], frozenset[X], or a frozen dataclass / " - f"NamedTuple / ReadOnly TypedDict -- and build it functionally, not by " + f"NamedTuple / ReadOnly TypedDict -- and build it functionally " + f"(comprehension / map / `MappingProxyType({{...}})`), not by " f"append-in-a-loop (suppress: `# mutable-ok: `)", ) @@ -488,8 +491,9 @@ def iter_construction_violations(path: Path, tree: ast.AST, comments: Comments) path, node.lineno, "LIT002", f"mutable {kind}: this builds a collection that can be grown or rewritten. " f"Build it in one shot and freeze it -- a tuple/frozenset wrapping a generator " - f"(`tuple(f(x) for x in xs)`), a tuple literal, or a frozen dataclass / NamedTuple " - f"/ ReadOnly TypedDict (suppress: `# mutable-ok: `)", + f"(`tuple(f(x) for x in xs)`), a tuple literal, `MappingProxyType({{...}})` for a " + f"dict-shaped value, or a frozen dataclass / NamedTuple / ReadOnly TypedDict " + f"(suppress: `# mutable-ok: `)", ) diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index f2bfc637095..81efbf0a4f8 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -175,6 +175,14 @@ def test_unfrozen_literal_still_counts(tmp_path): assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nd = {'a': 1}\nm = MappingProxyType(d)\n") +def test_fix_messages_name_mappingproxytype(tmp_path): + f = tmp_path / "snippet.py" + f.write_text("x: dict[str, int] = {}\n", encoding="utf-8") + messages = {v.code: v.message for v in checker.check_file(f)} + assert "MappingProxyType" in messages["LIT001"] + assert "MappingProxyType" in messages["LIT002"] + + def test_mutable_ok_with_reason_suppresses_both_rules(tmp_path): codes = _codes(tmp_path, "x: dict[str, int] = {} # mutable-ok: in-place buffer mutated hot path\n") assert "LIT001" not in codes