[ty] Validate field names for typing.NamedTuple(...) (#22599)

## Summary

Closes https://github.com/astral-sh/ty/issues/2511.
This commit is contained in:
Charlie Marsh
2026-01-15 09:15:45 -05:00
committed by GitHub
parent 2a29ce3e41
commit fd7cc1f9c9
3 changed files with 154 additions and 47 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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)]