mirror of
https://github.com/astral-sh/ruff
synced 2026-01-21 05:20:49 -05:00
[ty] Validate field names for typing.NamedTuple(...) (#22599)
## Summary Closes https://github.com/astral-sh/ty/issues/2511.
This commit is contained in:
@@ -1178,6 +1178,28 @@ class Baz(Bar):
|
||||
_whatever: str # `Baz` is not a NamedTuple class, so this is fine
|
||||
```
|
||||
|
||||
The same validation applies to the functional `typing.NamedTuple` syntax:
|
||||
|
||||
```py
|
||||
from typing import NamedTuple
|
||||
|
||||
# error: [invalid-named-tuple] "Field name `_x` in `NamedTuple()` cannot start with an underscore"
|
||||
Underscore = NamedTuple("Underscore", [("_x", int), ("y", str)])
|
||||
reveal_type(Underscore) # revealed: <class 'Underscore'>
|
||||
|
||||
# error: [invalid-named-tuple] "Field name `class` in `NamedTuple()` cannot be a Python keyword"
|
||||
Keyword = NamedTuple("Keyword", [("x", int), ("class", str)])
|
||||
reveal_type(Keyword) # revealed: <class 'Keyword'>
|
||||
|
||||
# error: [invalid-named-tuple] "Duplicate field name `x` in `NamedTuple()`"
|
||||
Duplicate = NamedTuple("Duplicate", [("x", int), ("y", str), ("x", float)])
|
||||
reveal_type(Duplicate) # revealed: <class 'Duplicate'>
|
||||
|
||||
# error: [invalid-named-tuple] "Field name `not valid` in `NamedTuple()` is not a valid identifier"
|
||||
Invalid = NamedTuple("Invalid", [("not valid", int), ("ok", str)])
|
||||
reveal_type(Invalid) # revealed: <class 'Invalid'>
|
||||
```
|
||||
|
||||
## Prohibited NamedTuple attributes
|
||||
|
||||
`NamedTuple` classes have certain synthesized attributes that cannot be overwritten. Attempting to
|
||||
|
||||
@@ -24,6 +24,23 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/named_tuple.md
|
||||
9 |
|
||||
10 | class Baz(Bar):
|
||||
11 | _whatever: str # `Baz` is not a NamedTuple class, so this is fine
|
||||
12 | from typing import NamedTuple
|
||||
13 |
|
||||
14 | # error: [invalid-named-tuple] "Field name `_x` in `NamedTuple()` cannot start with an underscore"
|
||||
15 | Underscore = NamedTuple("Underscore", [("_x", int), ("y", str)])
|
||||
16 | reveal_type(Underscore) # revealed: <class 'Underscore'>
|
||||
17 |
|
||||
18 | # error: [invalid-named-tuple] "Field name `class` in `NamedTuple()` cannot be a Python keyword"
|
||||
19 | Keyword = NamedTuple("Keyword", [("x", int), ("class", str)])
|
||||
20 | reveal_type(Keyword) # revealed: <class 'Keyword'>
|
||||
21 |
|
||||
22 | # error: [invalid-named-tuple] "Duplicate field name `x` in `NamedTuple()`"
|
||||
23 | Duplicate = NamedTuple("Duplicate", [("x", int), ("y", str), ("x", float)])
|
||||
24 | reveal_type(Duplicate) # revealed: <class 'Duplicate'>
|
||||
25 |
|
||||
26 | # error: [invalid-named-tuple] "Field name `not valid` in `NamedTuple()` is not a valid identifier"
|
||||
27 | Invalid = NamedTuple("Invalid", [("not valid", int), ("ok", str)])
|
||||
28 | reveal_type(Invalid) # revealed: <class 'Invalid'>
|
||||
```
|
||||
|
||||
# Diagnostics
|
||||
@@ -42,3 +59,55 @@ error[invalid-named-tuple]: NamedTuple field name cannot start with an underscor
|
||||
info: rule `invalid-named-tuple` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-named-tuple]: Field name `_x` in `NamedTuple()` cannot start with an underscore
|
||||
--> src/mdtest_snippet.py:15:39
|
||||
|
|
||||
14 | # error: [invalid-named-tuple] "Field name `_x` in `NamedTuple()` cannot start with an underscore"
|
||||
15 | Underscore = NamedTuple("Underscore", [("_x", int), ("y", str)])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^ Will raise `ValueError` at runtime
|
||||
16 | reveal_type(Underscore) # revealed: <class 'Underscore'>
|
||||
|
|
||||
info: rule `invalid-named-tuple` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-named-tuple]: Field name `class` in `NamedTuple()` cannot be a Python keyword
|
||||
--> src/mdtest_snippet.py:19:33
|
||||
|
|
||||
18 | # error: [invalid-named-tuple] "Field name `class` in `NamedTuple()` cannot be a Python keyword"
|
||||
19 | Keyword = NamedTuple("Keyword", [("x", int), ("class", str)])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Will raise `ValueError` at runtime
|
||||
20 | reveal_type(Keyword) # revealed: <class 'Keyword'>
|
||||
|
|
||||
info: rule `invalid-named-tuple` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-named-tuple]: Duplicate field name `x` in `NamedTuple()`
|
||||
--> src/mdtest_snippet.py:23:37
|
||||
|
|
||||
22 | # error: [invalid-named-tuple] "Duplicate field name `x` in `NamedTuple()`"
|
||||
23 | Duplicate = NamedTuple("Duplicate", [("x", int), ("y", str), ("x", float)])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Field `x` already defined; will raise `ValueError` at runtime
|
||||
24 | reveal_type(Duplicate) # revealed: <class 'Duplicate'>
|
||||
|
|
||||
info: rule `invalid-named-tuple` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
error[invalid-named-tuple]: Field name `not valid` in `NamedTuple()` is not a valid identifier
|
||||
--> src/mdtest_snippet.py:27:33
|
||||
|
|
||||
26 | # error: [invalid-named-tuple] "Field name `not valid` in `NamedTuple()` is not a valid identifier"
|
||||
27 | Invalid = NamedTuple("Invalid", [("not valid", int), ("ok", str)])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Will raise `ValueError` at runtime
|
||||
28 | reveal_type(Invalid) # revealed: <class 'Invalid'>
|
||||
|
|
||||
info: rule `invalid-named-tuple` is enabled by default
|
||||
|
||||
```
|
||||
|
||||
@@ -6700,6 +6700,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||
.extract_typing_namedtuple_fields(fields_arg, fields_type)
|
||||
.or_else(|| self.extract_typing_namedtuple_fields_from_ast(fields_arg));
|
||||
|
||||
// Validate field names if we have known fields.
|
||||
if let Some(ref fields) = fields {
|
||||
let field_names: Vec<_> =
|
||||
fields.iter().map(|(name, _, _)| name.clone()).collect();
|
||||
self.report_invalid_namedtuple_field_names(
|
||||
&field_names,
|
||||
fields_arg,
|
||||
NamedTupleKind::Typing,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit diagnostic if the type is outright invalid (not an iterable).
|
||||
if fields.is_none() {
|
||||
let iterable_any =
|
||||
@@ -6782,53 +6793,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||
// invalid names are automatically replaced with `_0`, `_1`, etc., so no
|
||||
// diagnostic is needed.
|
||||
if !rename {
|
||||
for (i, field_name) in field_names.iter().enumerate() {
|
||||
let name_str = field_name.as_str();
|
||||
|
||||
if field_names[..i].iter().any(|f| f.as_str() == name_str)
|
||||
&& let Some(builder) =
|
||||
self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Duplicate field name `{name_str}` in `namedtuple()`"
|
||||
));
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Field `{name_str}` already defined; will raise `ValueError` at runtime"
|
||||
));
|
||||
}
|
||||
|
||||
if name_str.starts_with('_')
|
||||
&& let Some(builder) =
|
||||
self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `namedtuple()` cannot start with an underscore"
|
||||
));
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Will raise `ValueError` at runtime"
|
||||
));
|
||||
} else if is_keyword(name_str)
|
||||
&& let Some(builder) =
|
||||
self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `namedtuple()` cannot be a Python keyword"
|
||||
));
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Will raise `ValueError` at runtime"
|
||||
));
|
||||
} else if !is_identifier(name_str)
|
||||
&& let Some(builder) =
|
||||
self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `namedtuple()` is not a valid identifier"
|
||||
));
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Will raise `ValueError` at runtime"
|
||||
));
|
||||
}
|
||||
}
|
||||
self.report_invalid_namedtuple_field_names(
|
||||
&field_names,
|
||||
fields_arg,
|
||||
NamedTupleKind::Collections,
|
||||
);
|
||||
} else {
|
||||
// Apply rename logic.
|
||||
let mut seen_names = FxHashSet::<&str>::default();
|
||||
@@ -6962,6 +6931,53 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||
fields
|
||||
}
|
||||
|
||||
/// Report diagnostics for invalid field names in a namedtuple definition.
|
||||
fn report_invalid_namedtuple_field_names(
|
||||
&self,
|
||||
field_names: &[Name],
|
||||
fields_arg: &ast::Expr,
|
||||
kind: NamedTupleKind,
|
||||
) {
|
||||
for (i, field_name) in field_names.iter().enumerate() {
|
||||
let name_str = field_name.as_str();
|
||||
|
||||
// Check for duplicate field names.
|
||||
if field_names[..i].iter().any(|f| f.as_str() == name_str)
|
||||
&& let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Duplicate field name `{name_str}` in `{kind}()`"
|
||||
));
|
||||
diagnostic.set_primary_message(format_args!(
|
||||
"Field `{name_str}` already defined; will raise `ValueError` at runtime"
|
||||
));
|
||||
}
|
||||
|
||||
if name_str.starts_with('_')
|
||||
&& let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `{kind}()` cannot start with an underscore"
|
||||
));
|
||||
diagnostic.set_primary_message("Will raise `ValueError` at runtime");
|
||||
} else if is_keyword(name_str)
|
||||
&& let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `{kind}()` cannot be a Python keyword"
|
||||
));
|
||||
diagnostic.set_primary_message("Will raise `ValueError` at runtime");
|
||||
} else if !is_identifier(name_str)
|
||||
&& let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg)
|
||||
{
|
||||
let mut diagnostic = builder.into_diagnostic(format_args!(
|
||||
"Field name `{name_str}` in `{kind}()` is not a valid identifier"
|
||||
));
|
||||
diagnostic.set_primary_message("Will raise `ValueError` at runtime");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract fields from a typing.NamedTuple fields argument by looking at the AST directly.
|
||||
/// This handles list/tuple literals that contain (name, type) pairs.
|
||||
#[expect(clippy::type_complexity)]
|
||||
|
||||
Reference in New Issue
Block a user