From 339d7900d23685e76d67df8b59edcc779353ff7c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:47:02 -0700 Subject: [PATCH] feat(lint): exempt TypedDict-annotated dict literals from LIT002 --- scripts/check_type_discipline.py | 46 ++++++++++++++++--- .../test_check_type_discipline.py | 31 +++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index ce9eb391d55..57925efb6fa 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -24,7 +24,13 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens `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: `. + (`Callable[[int], str]`) are exempt, as is a dict display assigned directly + to a name annotated with a same-module TypedDict (`x: Final[MyTd] = {...}`, + bare or under `Final[...]`): it is the literal spelling of the `MyTd(...)` + call, which was never construction, and LIT012 keeps those fields ReadOnly. + Only the display itself is exempt (nested mutables still count), and a + TypedDict imported from another module is out of reach, exactly as in + LIT012. Suppress with `# mutable-ok: `. LIT003 noqa suppression without rule codes or without a reason. Required shape: `# noqa: TID251 # ` LIT004 pyright/mypy ignore without bracketed codes or without a reason. @@ -485,6 +491,33 @@ def _frozen_argument_ids(tree: ast.AST) -> frozenset[int]: ) +def _declared_type_name(annotation: ast.expr) -> str | None: + """The head name of an AnnAssign annotation, looking through a `Final[...]` wrapper.""" + if isinstance(annotation, ast.Subscript) and _head_name(annotation.value) == "Final": + return _head_name(annotation.slice) + return _head_name(annotation) + + +def _typeddict_assigned_value_ids(tree: ast.AST) -> frozenset[int]: + """ids() of every dict display assigned directly to a TypedDict-annotated name. + + `x: MyTd = {...}` is the literal spelling of the `MyTd(...)` call, which was + never construction to begin with, and LIT012 keeps every same-module TypedDict + field ReadOnly, so neither spelling yields a payload the holder can grow. Only + the display itself is exempt; anything mutable nested inside it still trips + LIT002. A TypedDict imported from another module is invisible here, exactly as + in LIT012's base-class resolution. + """ + typeddict_names = frozenset(cls.name for cls in _typeddict_classes(tree)) + return frozenset( + id(node.value) + for node in ast.walk(tree) + if isinstance(node, ast.AnnAssign) + and isinstance(node.value, ast.Dict) + and _declared_type_name(node.annotation) in typeddict_names + ) + + def _construction_kind(node: ast.expr) -> str | None: """Human label if `node` builds a mutable collection, else None.""" if isinstance(node, ast.List): @@ -509,10 +542,9 @@ def _construction_kind(node: ast.expr) -> str | None: def iter_construction_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]: - in_annotation = _annotation_node_ids(tree) - frozen_arguments = _frozen_argument_ids(tree) + exempt = _annotation_node_ids(tree) | _frozen_argument_ids(tree) | _typeddict_assigned_value_ids(tree) for node in ast.walk(tree): - if not isinstance(node, ast.expr) or id(node) in in_annotation or id(node) in frozen_arguments: + if not isinstance(node, ast.expr) or id(node) in exempt: continue kind = _construction_kind(node) if kind is None or node.lineno in comments.mutable_ok_lines: @@ -522,8 +554,10 @@ def iter_construction_violations(path: Path, tree: ast.AST, comments: Comments) 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, 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: `)", + f"/ ReadOnly TypedDict (a dict literal assigned to a name annotated with a " + f"same-module TypedDict is exempt), or (if it really must be dynamic) a " + f"MappingProxyType wrapping a dict literal or comprehension " + 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 2870a803db8..84c0b7bb3ca 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -193,6 +193,37 @@ def test_lit002_fix_message_names_mappingproxytype(tmp_path): assert "MappingProxyType" in messages[0] +_TYPEDDICT_PREFIX = ( + "from typing import Final, NotRequired, ReadOnly, TypedDict\n" + "class Td(TypedDict):\n" + " a: ReadOnly[int]\n" + " b: NotRequired[ReadOnly[str]]\n" +) + + +def test_dict_literal_annotated_as_typeddict_is_exempt(tmp_path): + assert "LIT002" not in _codes(tmp_path, _TYPEDDICT_PREFIX + "x: Td = {'a': 1}\n") + assert "LIT002" not in _codes(tmp_path, _TYPEDDICT_PREFIX + "y: Final[Td] = {'a': 1, 'b': 's'}\n") + + +def test_dict_literal_annotated_as_transitive_typeddict_subclass_is_exempt(tmp_path): + src = _TYPEDDICT_PREFIX + "class Sub(Td):\n c: ReadOnly[int]\nz: Final[Sub] = {'a': 1, 'c': 2}\n" + assert "LIT002" not in _codes(tmp_path, src) + + +def test_mutable_nested_inside_typeddict_literal_still_counts(tmp_path): + assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "x: Td = {'a': 1, 'b': str([1])}\n") + + +def test_dict_literal_annotated_as_imported_or_unknown_type_still_counts(tmp_path): + assert "LIT002" in _codes(tmp_path, "from other_module import Td\nx: Td = {'a': 1}\n") + assert "LIT002" in _codes(tmp_path, "from typing import Final\ny: Final = {'a': 1}\n") + + +def test_typeddict_call_spelling_is_not_construction(tmp_path): + assert "LIT002" not in _codes(tmp_path, _TYPEDDICT_PREFIX + "x: Final[Td] = Td(a=1)\n") + + 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