fix(lint): reject qualified annotations in the LIT002 TypedDict exemption

This commit is contained in:
mateo-berri 2026-08-13 18:25:40 -07:00
parent 9914e3c5da
commit 2ba586de30
2 changed files with 19 additions and 5 deletions

View file

@ -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: <reason>`.
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: <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.
@ -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, ...]:

View file

@ -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")