mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 18:58:04 +00:00
Visit PEP 764 inline TypedDict
s' keys as non-type-expressions (#15073)
## Summary Resolves #10812. ## Test Plan `cargo nextest run` and `cargo insta test`.
This commit is contained in:
parent
8a98d88847
commit
d4ee6abf4a
6 changed files with 122 additions and 0 deletions
28
crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py
vendored
Normal file
28
crates/ruff_linter/resources/test/fixtures/pyflakes/F821_30.py
vendored
Normal file
|
@ -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}
|
||||
}]
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 | }]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: <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")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue