chore(lint): move MappingProxyType to the dynamic tail of the LIT002 freeze menu

Revert the LIT001 build-clause inserts, phrase the LIT002 menu as
'or (if it really must be dynamic) a MappingProxyType wrapping a dict
literal or comprehension', and fold the two freezing-wrapper exemption
sentences into one that names MappingProxyType beside tuple/frozenset.
This commit is contained in:
mateo-berri 2026-08-06 03:57:44 -07:00
parent 818e319b53
commit 20a94a9100
2 changed files with 17 additions and 20 deletions

View file

@ -11,21 +11,20 @@ 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 / MappingProxyType({...}), not
append-in-a-loop).
build it functionally (comprehension / map, not append-in-a-loop).
Suppress with `# mutable-ok: <reason>` 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, `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
it can escape, though anything mutable nested inside it still counts.
Suppress with `# mutable-ok: <reason>`.
generator (`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass /
NamedTuple / ReadOnly TypedDict, or (if it really must be dynamic) a
MappingProxyType wrapping a dict literal or comprehension. Generator expressions
and freezing-wrapper calls (`tuple(...)`, `frozenset(...)`,
`MappingProxyType(...)`) are not construction and pass, as does the value passed
directly to a wrapper: it is frozen before it can escape, though anything
mutable nested inside it still counts. Annotation-internal lists
(`Callable[[int], str]`) are exempt. Suppress with `# mutable-ok: <reason>`.
LIT003 noqa suppression without rule codes or without a reason.
Required shape: `# noqa: TID251 # <reason>`
LIT004 pyright/mypy ignore without bracketed codes or without a reason.
@ -281,8 +280,7 @@ 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 "
f"(comprehension / map / `MappingProxyType({{...}})`), not by "
f"NamedTuple / ReadOnly TypedDict -- and build it functionally, not by "
f"append-in-a-loop (suppress: `# mutable-ok: <reason>`)",
)
@ -491,9 +489,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, `MappingProxyType({{...}})` for a "
f"dict-shaped value, or a frozen dataclass / NamedTuple / ReadOnly TypedDict "
f"(suppress: `# mutable-ok: <reason>`)",
f"(`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass / NamedTuple "
f"/ ReadOnly TypedDict, or (if it really must be dynamic) a MappingProxyType "
f"wrapping a dict literal or comprehension (suppress: `# mutable-ok: <reason>`)",
)

View file

@ -175,12 +175,11 @@ 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):
def test_lit002_fix_message_names_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"]
f.write_text("x = {'a': 1}\n", encoding="utf-8")
messages = [v.message for v in checker.check_file(f) if v.code == "LIT002"]
assert "MappingProxyType" in messages[0]
def test_mutable_ok_with_reason_suppresses_both_rules(tmp_path):