Skip to content

Commit b3a6f0c

Browse files
authored
[flake8-pyi] Fix PYI049 false negatives on call-based TypedDicts (#9567)
## Summary Fixes another of the bullet points from #8771 ## Test Plan `cargo test` / `cargo insta review`
1 parent 7be7066 commit b3a6f0c

File tree

5 files changed

+86
-12
lines changed

5 files changed

+86
-12
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI049.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ class _UsedTypedDict(TypedDict):
1616

1717
class _CustomClass(_UsedTypedDict):
1818
bar: list[int]
19+
20+
_UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
21+
_UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
22+
23+
def uses_UsedTypedDict3(arg: _UsedTypedDict3) -> None: ...

crates/ruff_linter/resources/test/fixtures/flake8_pyi/PYI049.pyi

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ else:
3030

3131
class _CustomClass2(_UsedTypedDict2):
3232
bar: list[int]
33+
34+
_UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
35+
_UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
36+
37+
def uses_UsedTypedDict3(arg: _UsedTypedDict3) -> None: ...

crates/ruff_linter/src/rules/flake8_pyi/rules/unused_private_type_definition.rs

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,16 @@ pub(crate) fn unused_private_typed_dict(
323323
scope: &Scope,
324324
diagnostics: &mut Vec<Diagnostic>,
325325
) {
326+
let semantic = checker.semantic();
327+
326328
for binding in scope
327329
.binding_ids()
328-
.map(|binding_id| checker.semantic().binding(binding_id))
330+
.map(|binding_id| semantic.binding(binding_id))
329331
{
330-
if !(binding.kind.is_class_definition() && binding.is_private_declaration()) {
332+
if !binding.is_private_declaration() {
333+
continue;
334+
}
335+
if !(binding.kind.is_class_definition() || binding.kind.is_assignment()) {
331336
continue;
332337
}
333338
if binding.is_used() {
@@ -337,23 +342,64 @@ pub(crate) fn unused_private_typed_dict(
337342
let Some(source) = binding.source else {
338343
continue;
339344
};
340-
let Stmt::ClassDef(class_def) = checker.semantic().statement(source) else {
341-
continue;
342-
};
343345

344-
if !class_def
345-
.bases()
346-
.iter()
347-
.any(|base| checker.semantic().match_typing_expr(base, "TypedDict"))
348-
{
346+
let Some(class_name) = extract_typeddict_name(semantic.statement(source), semantic) else {
349347
continue;
350-
}
348+
};
351349

352350
diagnostics.push(Diagnostic::new(
353351
UnusedPrivateTypedDict {
354-
name: class_def.name.to_string(),
352+
name: class_name.to_string(),
355353
},
356354
binding.range(),
357355
));
358356
}
359357
}
358+
359+
fn extract_typeddict_name<'a>(stmt: &'a Stmt, semantic: &SemanticModel) -> Option<&'a str> {
360+
let is_typeddict = |expr: &ast::Expr| semantic.match_typing_expr(expr, "TypedDict");
361+
match stmt {
362+
// E.g. return `Some("Foo")` for the first one of these classes,
363+
// and `Some("Bar")` for the second:
364+
//
365+
// ```python
366+
// import typing
367+
// from typing import TypedDict
368+
//
369+
// class Foo(TypedDict):
370+
// x: int
371+
//
372+
// T = typing.TypeVar("T")
373+
//
374+
// class Bar(typing.TypedDict, typing.Generic[T]):
375+
// y: T
376+
// ```
377+
Stmt::ClassDef(class_def @ ast::StmtClassDef { name, .. }) => {
378+
if class_def.bases().iter().any(is_typeddict) {
379+
Some(name)
380+
} else {
381+
None
382+
}
383+
}
384+
// E.g. return `Some("Baz")` for this assignment,
385+
// which is an accepted alternative way of creating a TypedDict type:
386+
//
387+
// ```python
388+
// import typing
389+
// Baz = typing.TypedDict("Baz", {"z": bytes})
390+
// ```
391+
Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
392+
let [target] = targets.as_slice() else {
393+
return None;
394+
};
395+
let ast::ExprName { id, .. } = target.as_name_expr()?;
396+
let ast::ExprCall { func, .. } = value.as_call_expr()?;
397+
if is_typeddict(func) {
398+
Some(id)
399+
} else {
400+
None
401+
}
402+
}
403+
_ => None,
404+
}
405+
}

crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.py.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,13 @@ PYI049.py:9:7: PYI049 Private TypedDict `_UnusedTypedDict2` is never used
1515
10 | bar: int
1616
|
1717

18+
PYI049.py:20:1: PYI049 Private TypedDict `_UnusedTypedDict3` is never used
19+
|
20+
18 | bar: list[int]
21+
19 |
22+
20 | _UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
23+
| ^^^^^^^^^^^^^^^^^ PYI049
24+
21 | _UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
25+
|
26+
1827

crates/ruff_linter/src/rules/flake8_pyi/snapshots/ruff_linter__rules__flake8_pyi__tests__PYI049_PYI049.pyi.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,13 @@ PYI049.pyi:10:7: PYI049 Private TypedDict `_UnusedTypedDict2` is never used
1515
11 | bar: int
1616
|
1717

18+
PYI049.pyi:34:1: PYI049 Private TypedDict `_UnusedTypedDict3` is never used
19+
|
20+
32 | bar: list[int]
21+
33 |
22+
34 | _UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
23+
| ^^^^^^^^^^^^^^^^^ PYI049
24+
35 | _UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
25+
|
26+
1827

0 commit comments

Comments
 (0)