diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py new file mode 100644 index 0000000000000..4152c06da7b1c --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py @@ -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} +}] diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index e88d87d0e9cdf..36a9972b1e465 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -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); diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index c01397206f2be..77cd2e94f83b8 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -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"))] @@ -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( diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_30.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_30.py.snap new file mode 100644 index 0000000000000..d39ce19805628 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_30.py.snap @@ -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 | }] + | diff --git a/crates/ruff_python_semantic/src/analyze/typing.rs b/crates/ruff_python_semantic/src/analyze/typing.rs index ecd59aa002a5c..7455e0360e3f9 100644 --- a/crates/ruff_python_semantic/src/analyze/typing.rs +++ b/crates/ruff_python_semantic/src/analyze/typing.rs @@ -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; @@ -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>( @@ -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) { @@ -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); + } } } } diff --git a/crates/ruff_python_stdlib/src/typing.rs b/crates/ruff_python_stdlib/src/typing.rs index ef250d2df68cf..54dd23093199d 100644 --- a/crates/ruff_python_stdlib/src/typing.rs +++ b/crates/ruff_python_stdlib/src/typing.rs @@ -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"]) @@ -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: +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")