From 2ba586de30eae3da8a1f3ed2988cc9bb0ad03fd0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:25:40 -0700 Subject: [PATCH] fix(lint): reject qualified annotations in the LIT002 TypedDict exemption --- scripts/check_type_discipline.py | 17 ++++++++++++----- .../test_litellm/test_check_type_discipline.py | 7 +++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index dfd88d46786..3ddd84eea20 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -36,8 +36,10 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens disqualifies every name, since what it binds is statically invisible), every field it declares or inherits in-module must be `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: `. + count), a TypedDict imported from another module is out of reach, exactly + as in LIT012, and a dotted annotation (`x: mod.Td = {...}`) never + matches, since it cannot name a local class. 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. @@ -499,10 +501,15 @@ 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.""" + """The bare name an AnnAssign annotation spells, looking through a `Final[...]` wrapper. + + Qualified annotations (`x: mod.Td`) yield None: a dotted name refers to another + module's attribute, which can never be a class defined in the file being checked, + so reducing it to its tail would let an imported type borrow a local one's name. + """ if isinstance(annotation, ast.Subscript) and _head_name(annotation.value) == "Final": - return _head_name(annotation.slice) - return _head_name(annotation) + return annotation.slice.id if isinstance(annotation.slice, ast.Name) else None + return annotation.id if isinstance(annotation, ast.Name) else None def _binding_names(node: ast.AST) -> tuple[str, ...]: diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index 0cd63fd4fb1..13f386f8e07 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -250,6 +250,13 @@ def test_typeddict_name_rebound_elsewhere_gets_no_exemption(tmp_path): assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "def scope(Td):\n return Td\nx: Td = {'a': 1}\n") +def test_qualified_annotation_gets_no_exemption(tmp_path): + assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "import elsewhere\nx: elsewhere.Td = {'a': 1}\n") + assert "LIT002" in _codes( + tmp_path, _TYPEDDICT_PREFIX + "import elsewhere\nx: Final[elsewhere.Td] = {'a': 1}\n" + ) + + def test_star_import_disqualifies_typeddict_exemption(tmp_path): assert "LIT002" in _codes(tmp_path, _TYPEDDICT_PREFIX + "from elsewhere import *\nx: Td = {'a': 1}\n")