Compare commits

...

2 Commits

Author SHA1 Message Date
Charlie Marsh
1bfdfafecf [ty] Emit diagnostics for invalid dynamic namedtuple fields 2026-01-14 13:22:24 -05:00
Charlie Marsh
c3909525c6 [ty] Show dynamic NamedTuple defaults in signature 2026-01-14 13:22:09 -05:00
2 changed files with 138 additions and 25 deletions

View File

@@ -466,10 +466,32 @@ Point2 = collections.namedtuple("Point2", ["_x", "class"], rename=1)
reveal_type(Point2) # revealed: <class 'Point2'>
reveal_type(Point2.__new__) # revealed: (cls: type, _0: Any, _1: Any) -> Point2
# Without `rename=True`, invalid field names emit diagnostics:
# - Field names starting with underscore
# error: [invalid-named-tuple] "Field name `_x` in `namedtuple()` cannot start with an underscore"
Underscore = collections.namedtuple("Underscore", ["_x", "y"])
reveal_type(Underscore) # revealed: <class 'Underscore'>
# - Python keywords
# error: [invalid-named-tuple] "Field name `class` in `namedtuple()` cannot be a Python keyword"
Keyword = collections.namedtuple("Keyword", ["x", "class"])
reveal_type(Keyword) # revealed: <class 'Keyword'>
# - Duplicate field names
# error: [invalid-named-tuple] "Duplicate field name `x` in `namedtuple()`"
Duplicate = collections.namedtuple("Duplicate", ["x", "y", "x"])
reveal_type(Duplicate) # revealed: <class 'Duplicate'>
# - Invalid identifiers (e.g., containing spaces)
# error: [invalid-named-tuple] "Field name `not valid` in `namedtuple()` is not a valid identifier"
Invalid = collections.namedtuple("Invalid", ["not valid", "ok"])
reveal_type(Invalid) # revealed: <class 'Invalid'>
# `defaults` provides default values for the rightmost fields
Person = collections.namedtuple("Person", ["name", "age", "city"], defaults=["Unknown"])
reveal_type(Person) # revealed: <class 'Person'>
reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = ...) -> Person
reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = "Unknown") -> Person
reveal_mro(Person) # revealed: (<class 'Person'>, <class 'tuple[Any, Any, Any]'>, <class 'object'>)
# Can create with all fields
person1 = Person("Alice", 30, "NYC")
@@ -482,11 +504,11 @@ reveal_type(person2.city) # revealed: Any
Config = collections.namedtuple("Config", ["host", "port"], module="myapp")
reveal_type(Config) # revealed: <class 'Config'>
# When more defaults are provided than fields, we treat all fields as having defaults.
# TODO: This should emit a diagnostic since it would fail at runtime.
# When more defaults are provided than fields, an error is emitted.
# error: [invalid-named-tuple] "Too many defaults for `namedtuple()`"
TooManyDefaults = collections.namedtuple("TooManyDefaults", ["x", "y"], defaults=("a", "b", "c"))
reveal_type(TooManyDefaults) # revealed: <class 'TooManyDefaults'>
reveal_type(TooManyDefaults.__new__) # revealed: (cls: type, x: Any = ..., y: Any = ...) -> TooManyDefaults
reveal_type(TooManyDefaults.__new__) # revealed: (cls: type, x: Any = "a", y: Any = "b") -> TooManyDefaults
# Unknown keyword arguments produce an error
# error: [unknown-argument]

View File

@@ -6564,7 +6564,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
// Infer keyword arguments.
let mut defaults_count = None;
let mut default_types: Vec<Type<'db>> = vec![];
let mut defaults_kw: Option<&ast::Keyword> = None;
let mut rename_type = None;
for kw in keywords {
@@ -6575,11 +6576,43 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
};
match arg.id.as_str() {
"defaults" if kind.is_collections() => {
defaults_count = kw_type
.exact_tuple_instance_spec(db)
.and_then(|spec| spec.len().maximum())
.or_else(|| kw.value.as_list_expr().map(|list| list.elts.len()));
defaults_kw = Some(kw);
// Extract element types from AST literals (using already-inferred types)
// or fall back to the inferred tuple spec.
match &kw.value {
ast::Expr::List(list) => {
// Elements were already inferred when we inferred kw.value above.
default_types = list
.elts
.iter()
.map(|elt| self.expression_type(elt))
.collect();
}
ast::Expr::Tuple(tuple) => {
// Elements were already inferred when we inferred kw.value above.
default_types = tuple
.elts
.iter()
.map(|elt| self.expression_type(elt))
.collect();
}
_ => {
// Fall back to using the already-inferred type.
// Try to extract element types from tuple.
if let Some(spec) = kw_type.exact_tuple_instance_spec(db)
&& let Some(fixed) = spec.as_fixed_length()
{
default_types = fixed.all_elements().to_vec();
} else {
// Can't determine individual types; use Any for each element.
let count = kw_type
.exact_tuple_instance_spec(db)
.and_then(|spec| spec.len().maximum())
.unwrap_or(0);
default_types = vec![Type::any(); count];
}
}
}
// Emit diagnostic for invalid types (not Iterable[Any] | None).
let iterable_any =
KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]);
@@ -6644,8 +6677,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
let defaults_count = defaults_count.unwrap_or_default();
// Extract name.
let name = if let Type::StringLiteral(literal) = name_type {
Name::new(literal.value(db))
@@ -6750,16 +6781,60 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
if let Some(mut field_names) = maybe_field_names {
// TODO: When `rename` is false (or not specified), emit diagnostics for:
// - Duplicate field names (e.g., `namedtuple("Foo", "x x")`)
// - Field names starting with underscore (e.g., `namedtuple("Bar", "_x")`)
// - Field names that are Python keywords (e.g., `namedtuple("Baz", "class")`)
// - Field names that are not valid identifiers
// These all raise ValueError at runtime. When `rename=True`, invalid names
// are automatically replaced with `_0`, `_1`, etc., so no diagnostic is needed.
// When `rename` is false (or not specified), emit diagnostics for invalid
// field names. These all raise ValueError at runtime. When `rename=True`,
// 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();
// Apply rename logic.
if rename {
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"
));
}
}
} else {
// Apply rename logic.
let mut seen_names = FxHashSet::<&str>::default();
for (i, field_name) in field_names.iter_mut().enumerate() {
let name_str = field_name.as_str();
@@ -6774,10 +6849,23 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
// Build fields with `Any` type and optional defaults.
// TODO: emit a diagnostic when `defaults_count > num_fields` (which would
// fail at runtime with `TypeError: Got more default values than field names`).
let num_fields = field_names.len();
let defaults_count = default_types.len();
if defaults_count > num_fields
&& let Some(defaults_kw) = defaults_kw
&& let Some(builder) =
self.context.report_lint(&INVALID_NAMED_TUPLE, defaults_kw)
{
let mut diagnostic = builder.into_diagnostic(format_args!(
"Too many defaults for `namedtuple()`"
));
diagnostic.set_primary_message(format_args!(
"Got {defaults_count} default values but only {num_fields} field names; \
will raise `TypeError` at runtime"
));
}
let defaults_count = defaults_count.min(num_fields);
let fields = field_names
.iter()
@@ -6785,7 +6873,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
.map(|(i, field_name)| {
let default =
if defaults_count > 0 && i >= num_fields - defaults_count {
Some(Type::any())
// Index into default_types: first default corresponds to first
// field that has a default.
let default_idx = i - (num_fields - defaults_count);
Some(default_types[default_idx])
} else {
None
};