mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 10:48:32 +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.");
|
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 => {
|
None => {
|
||||||
self.visit_expr(slice);
|
self.visit_expr(slice);
|
||||||
self.visit_expr_context(ctx);
|
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_26.pyi"))]
|
||||||
#[test_case(Rule::UndefinedName, Path::new("F821_27.py"))]
|
#[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_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.py"))]
|
||||||
#[test_case(Rule::UndefinedExport, Path::new("F822_0.pyi"))]
|
#[test_case(Rule::UndefinedExport, Path::new("F822_0.pyi"))]
|
||||||
#[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))]
|
#[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))]
|
||||||
|
@ -325,6 +326,7 @@ mod tests {
|
||||||
assert_messages!(snapshot, diagnostics);
|
assert_messages!(snapshot, diagnostics);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_case(Rule::UnusedImport, Path::new("F401_31.py"))]
|
#[test_case(Rule::UnusedImport, Path::new("F401_31.py"))]
|
||||||
fn f401_allowed_unused_imports_option(rule_code: Rule, path: &Path) -> Result<()> {
|
fn f401_allowed_unused_imports_option(rule_code: Rule, path: &Path) -> Result<()> {
|
||||||
let diagnostics = test_path(
|
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_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_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_standard_library_generic, is_standard_library_generic_member, is_standard_library_literal,
|
||||||
|
is_typed_dict, is_typed_dict_member,
|
||||||
};
|
};
|
||||||
use ruff_text_size::Ranged;
|
use ruff_text_size::Ranged;
|
||||||
|
|
||||||
|
@ -34,6 +35,10 @@ pub enum SubscriptKind {
|
||||||
Generic,
|
Generic,
|
||||||
/// A subscript of the form `typing.Annotated[int, "foo"]`, i.e., a PEP 593 annotation.
|
/// A subscript of the form `typing.Annotated[int, "foo"]`, i.e., a PEP 593 annotation.
|
||||||
PEP593Annotation,
|
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>(
|
pub fn match_annotated_subscript<'a>(
|
||||||
|
@ -62,6 +67,10 @@ pub fn match_annotated_subscript<'a>(
|
||||||
return Some(SubscriptKind::PEP593Annotation);
|
return Some(SubscriptKind::PEP593Annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if is_typed_dict(qualified_name.segments()) {
|
||||||
|
return Some(SubscriptKind::TypedDict);
|
||||||
|
}
|
||||||
|
|
||||||
for module in typing_modules {
|
for module in typing_modules {
|
||||||
let module_qualified_name = QualifiedName::user_defined(module);
|
let module_qualified_name = QualifiedName::user_defined(module);
|
||||||
if qualified_name.starts_with(&module_qualified_name) {
|
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) {
|
if is_pep_593_generic_member(member) {
|
||||||
return Some(SubscriptKind::PEP593Annotation);
|
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`.
|
/// Returns `true` if a call path is `Literal`.
|
||||||
pub fn is_standard_library_literal(qualified_name: &[&str]) -> bool {
|
pub fn is_standard_library_literal(qualified_name: &[&str]) -> bool {
|
||||||
matches!(qualified_name, ["typing" | "typing_extensions", "Literal"])
|
matches!(qualified_name, ["typing" | "typing_extensions", "Literal"])
|
||||||
|
@ -216,6 +223,15 @@ pub fn is_pep_593_generic_member(member: &str) -> bool {
|
||||||
matches!(member, "Annotated")
|
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.
|
/// Returns `true` if a name matches that of the `Literal` generic.
|
||||||
pub fn is_literal_member(member: &str) -> bool {
|
pub fn is_literal_member(member: &str) -> bool {
|
||||||
matches!(member, "Literal")
|
matches!(member, "Literal")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue