Skip to content

Commit

Permalink
Visit PEP 764 inline TypedDicts' keys as non-type-expressions (#15073)
Browse files Browse the repository at this point in the history
## Summary

Resolves #10812.

## Test Plan

`cargo nextest run` and `cargo insta test`.
  • Loading branch information
InSyncWithFoo authored Dec 30, 2024
1 parent 8a98d88 commit d4ee6ab
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 0 deletions.
28 changes: 28 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Regression tests for:
# https://github.com/astral-sh/ruff/issues/10812

from typing import Annotated, Literal, TypedDict


# No errors
single: TypedDict[{"foo": int}]

# Error at `qux`
multiple: TypedDict[{
"bar": str,
"baz": list["qux"],
}]

# Error at `dolor`
nested: TypedDict[
"lorem": TypedDict[{
"ipsum": "dolor"
}],
"sit": Literal["amet"]
]

# Error at `adipiscing`, `eiusmod`, `tempor`
unpack: TypedDict[{
"consectetur": Annotated["adipiscing", "elit"]
**{"sed do": str, int: "eiusmod", **tempor}
}]
14 changes: 14 additions & 0 deletions crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,20 @@ impl<'a> Visitor<'a> for Checker<'a> {
debug!("Found non-Expr::Tuple argument to PEP 593 Annotation.");
}
}
Some(typing::SubscriptKind::TypedDict) => {
if let Expr::Dict(ast::ExprDict { items, range: _ }) = slice.as_ref() {
for item in items {
if let Some(key) = &item.key {
self.visit_non_type_definition(key);
self.visit_type_definition(&item.value);
} else {
self.visit_non_type_definition(&item.value);
}
}
} else {
self.visit_non_type_definition(slice);
}
}
None => {
self.visit_expr(slice);
self.visit_expr_context(ctx);
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/pyflakes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ mod tests {
#[test_case(Rule::UndefinedName, Path::new("F821_26.pyi"))]
#[test_case(Rule::UndefinedName, Path::new("F821_27.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_28.py"))]
#[test_case(Rule::UndefinedName, Path::new("F821_30.py"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_0.py"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_0.pyi"))]
#[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))]
Expand Down Expand Up @@ -325,6 +326,7 @@ mod tests {
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test_case(Rule::UnusedImport, Path::new("F401_31.py"))]
fn f401_allowed_unused_imports_option(rule_code: Rule, path: &Path) -> Result<()> {
let diagnostics = test_path(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
snapshot_kind: text
---
F821_30.py:13:18: F821 Undefined name `qux`
|
11 | multiple: TypedDict[{
12 | "bar": str,
13 | "baz": list["qux"],
| ^^^ F821
14 | }]
|

F821_30.py:19:19: F821 Undefined name `dolor`
|
17 | nested: TypedDict[
18 | "lorem": TypedDict[{
19 | "ipsum": "dolor"
| ^^^^^ F821
20 | }],
21 | "sit": Literal["amet"]
|

F821_30.py:26:31: F821 Undefined name `adipiscing`
|
24 | # Error at `adipiscing`, `eiusmod`, `tempor`
25 | unpack: TypedDict[{
26 | "consectetur": Annotated["adipiscing", "elit"]
| ^^^^^^^^^^ F821
27 | **{"sed do": str, int: "eiusmod", **tempor}
28 | }]
|

F821_30.py:27:29: F821 Undefined name `eiusmod`
|
25 | unpack: TypedDict[{
26 | "consectetur": Annotated["adipiscing", "elit"]
27 | **{"sed do": str, int: "eiusmod", **tempor}
| ^^^^^^^ F821
28 | }]
|

F821_30.py:27:41: F821 Undefined name `tempor`
|
25 | unpack: TypedDict[{
26 | "consectetur": Annotated["adipiscing", "elit"]
27 | **{"sed do": str, int: "eiusmod", **tempor}
| ^^^^^^ F821
28 | }]
|
12 changes: 12 additions & 0 deletions crates/ruff_python_semantic/src/analyze/typing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use ruff_python_stdlib::typing::{
is_immutable_non_generic_type, is_immutable_return_type, is_literal_member,
is_mutable_return_type, is_pep_593_generic_member, is_pep_593_generic_type,
is_standard_library_generic, is_standard_library_generic_member, is_standard_library_literal,
is_typed_dict, is_typed_dict_member,
};
use ruff_text_size::Ranged;

Expand All @@ -34,6 +35,10 @@ pub enum SubscriptKind {
Generic,
/// A subscript of the form `typing.Annotated[int, "foo"]`, i.e., a PEP 593 annotation.
PEP593Annotation,
/// A subscript of the form `typing.TypedDict[{"key": Type}]`, i.e., a [PEP 764] annotation.
///
/// [PEP 764]: https://github.com/python/peps/pull/4082
TypedDict,
}

pub fn match_annotated_subscript<'a>(
Expand Down Expand Up @@ -62,6 +67,10 @@ pub fn match_annotated_subscript<'a>(
return Some(SubscriptKind::PEP593Annotation);
}

if is_typed_dict(qualified_name.segments()) {
return Some(SubscriptKind::TypedDict);
}

for module in typing_modules {
let module_qualified_name = QualifiedName::user_defined(module);
if qualified_name.starts_with(&module_qualified_name) {
Expand All @@ -75,6 +84,9 @@ pub fn match_annotated_subscript<'a>(
if is_pep_593_generic_member(member) {
return Some(SubscriptKind::PEP593Annotation);
}
if is_typed_dict_member(member) {
return Some(SubscriptKind::TypedDict);
}
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions crates/ruff_python_stdlib/src/typing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ pub fn is_pep_593_generic_type(qualified_name: &[&str]) -> bool {
)
}

pub fn is_typed_dict(qualified_name: &[&str]) -> bool {
matches!(
qualified_name,
["typing" | "typing_extensions", "TypedDict"]
)
}

/// Returns `true` if a call path is `Literal`.
pub fn is_standard_library_literal(qualified_name: &[&str]) -> bool {
matches!(qualified_name, ["typing" | "typing_extensions", "Literal"])
Expand Down Expand Up @@ -216,6 +223,15 @@ pub fn is_pep_593_generic_member(member: &str) -> bool {
matches!(member, "Annotated")
}

/// Returns `true` if a name matches that of `TypedDict`.
///
/// See: <https://docs.python.org/3/library/typing.html>
pub fn is_typed_dict_member(member: &str) -> bool {
// Constructed by taking every pattern from `is_pep_593_generic`, removing all but
// the last element in each pattern, and de-duplicating the values.
matches!(member, "TypedDict")
}

/// Returns `true` if a name matches that of the `Literal` generic.
pub fn is_literal_member(member: &str) -> bool {
matches!(member, "Literal")
Expand Down

0 comments on commit d4ee6ab

Please sign in to comment.