From fd7cc1f9c9162045ecec531300897e09e5e41301 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 15 Jan 2026 09:15:45 -0500 Subject: [PATCH] [ty] Validate field names for `typing.NamedTuple(...)` (#22599) ## Summary Closes https://github.com/astral-sh/ty/issues/2511. --- .../resources/mdtest/named_tuple.md | 22 ++++ ...edTuples_cannot_h…_(e2ed186fe2b2fc35).snap | 69 +++++++++++ .../src/types/infer/builder.rs | 110 ++++++++++-------- 3 files changed, 154 insertions(+), 47 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 9a38edfce1..46c6502c72 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -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: + +# 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: + +# error: [invalid-named-tuple] "Duplicate field name `x` in `NamedTuple()`" +Duplicate = NamedTuple("Duplicate", [("x", int), ("y", str), ("x", float)]) +reveal_type(Duplicate) # revealed: + +# 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: +``` + ## Prohibited NamedTuple attributes `NamedTuple` classes have certain synthesized attributes that cannot be overwritten. Attempting to diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_NamedTuples_cannot_h…_(e2ed186fe2b2fc35).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_NamedTuples_cannot_h…_(e2ed186fe2b2fc35).snap index e9666e7b76..8ed293fd20 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_NamedTuples_cannot_h…_(e2ed186fe2b2fc35).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_NamedTuples_cannot_h…_(e2ed186fe2b2fc35).snap @@ -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: +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: +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: +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: ``` # 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: + | +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: + | +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: + | +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: + | +info: rule `invalid-named-tuple` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 97b85d94b8..c4ed3b83df 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -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)]