diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 0f50fbbe25..4fb2efb2ac 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -442,7 +442,49 @@ reveal_type(TooMany) # revealed: ### Keyword arguments for `collections.namedtuple` -The `collections.namedtuple` function accepts `rename`, `defaults`, and `module` keyword arguments: +The `collections.namedtuple` function accepts `typename` and `field_names` as keyword arguments, as +well as `rename`, `defaults`, and `module`: + +```py +import collections +from ty_extensions import reveal_mro + +# Both `typename` and `field_names` can be passed as keyword arguments +NT1 = collections.namedtuple(typename="NT1", field_names="x y") +reveal_type(NT1) # revealed: +reveal_mro(NT1) # revealed: (, , ) + +nt1 = NT1(1, 2) +reveal_type(nt1.x) # revealed: Any +reveal_type(nt1.y) # revealed: Any + +# Only `field_names` as keyword argument +NT2 = collections.namedtuple("NT2", field_names=["a", "b", "c"]) +reveal_type(NT2) # revealed: +reveal_mro(NT2) # revealed: (, , ) + +nt2 = NT2(1, 2, 3) +reveal_type(nt2.a) # revealed: Any +reveal_type(nt2.b) # revealed: Any +reveal_type(nt2.c) # revealed: Any + +# Keyword arguments can be combined with other kwargs like `defaults` +NT3 = collections.namedtuple(typename="NT3", field_names="x y z", defaults=[None]) +reveal_type(NT3) # revealed: +reveal_type(NT3.__new__) # revealed: [Self](cls: type[Self], x: Any, y: Any, z: Any = None) -> Self + +nt3 = NT3(1, 2) +reveal_type(nt3.z) # revealed: Any + +# Passing the same argument positionally and as a keyword is an error +# error: [parameter-already-assigned] "Multiple values provided for parameter `typename` of `namedtuple`" +Bad1 = collections.namedtuple("Bad1", "x y", typename="Bad1") + +# error: [parameter-already-assigned] "Multiple values provided for parameter `field_names` of `namedtuple`" +Bad2 = collections.namedtuple("Bad2", "x y", field_names="a b") +``` + +The `rename`, `defaults`, and `module` keyword arguments: ```py import collections @@ -541,13 +583,26 @@ reveal_type(Bad5) # revealed: ### Keyword arguments for `typing.NamedTuple` -The `typing.NamedTuple` function does not accept any keyword arguments: +Unlike `collections.namedtuple`, the `typing.NamedTuple` function does not accept `typename` or +`fields` as keyword arguments. It also does not accept `rename`, `defaults`, or `module`: ```py from typing import NamedTuple +# `typename` and `fields` are not valid as keyword arguments for typing.NamedTuple +# (We only report the missing-argument error in this case since we return early) +# error: [missing-argument] +Bad1 = NamedTuple(typename="Bad1", fields=[("x", int)]) + # error: [unknown-argument] -Bad3 = NamedTuple("Bad3", [("x", int)], rename=True) +Bad2 = NamedTuple("Bad2", [("x", int)], typename="Bad2") + +# error: [unknown-argument] +Bad3 = NamedTuple("Bad3", [("x", int)], fields=[("y", str)]) + +# `rename`, `defaults`, and `module` are also not valid for typing.NamedTuple +# error: [unknown-argument] +Bad4 = NamedTuple("Bad4", [("x", int)], rename=True) # error: [unknown-argument] Bad4 = NamedTuple("Bad4", [("x", int)], defaults=[0]) @@ -575,8 +630,9 @@ reveal_type(Bad8) # revealed: ### Missing required arguments -`NamedTuple` and `namedtuple` require at least two positional arguments: `typename` and -`fields`/`field_names`. +`NamedTuple` and `namedtuple` require `typename` and `fields`/`field_names` arguments. For +`collections.namedtuple`, these can be positional or keyword; for `typing.NamedTuple`, they must be +positional. ```py import collections diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index a5f4f96856..0d2ed6c391 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -74,11 +74,11 @@ use crate::types::diagnostic::{ INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_GUARD_DEFINITION, INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPED_DICT_STATEMENT, IncompatibleBases, - MISSING_ARGUMENT, NO_MATCHING_OVERLOAD, NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE, - POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, - TOO_MANY_POSITIONAL_ARGUMENTS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, - UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, - UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, + MISSING_ARGUMENT, NO_MATCHING_OVERLOAD, NOT_SUBSCRIPTABLE, PARAMETER_ALREADY_ASSIGNED, + POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, + SUBCLASS_OF_FINAL_CLASS, TOO_MANY_POSITIONAL_ARGUMENTS, TypedDictDeleteErrorKind, + UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, + UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, @@ -6521,8 +6521,52 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )); } - // Need at least typename and fields/field_names. - let [name_arg, fields_arg, rest @ ..] = &**args else { + // Extract typename and fields from positional or keyword arguments. + // For `collections.namedtuple`, both `typename` and `field_names` can be keyword arguments. + // For `typing.NamedTuple`, only positional arguments are supported. + let (name_arg, fields_arg, rest, name_from_keyword, fields_from_keyword): ( + Option<&ast::Expr>, + Option<&ast::Expr>, + &[ast::Expr], + bool, + bool, + ) = match kind { + NamedTupleKind::Collections => { + let find_keyword = |name: &str| -> Option<&ast::Keyword> { + keywords + .iter() + .find(|kw| kw.arg.as_ref().is_some_and(|arg| arg.id.as_str() == name)) + }; + let typename_kw = find_keyword("typename"); + let field_names_kw = find_keyword("field_names"); + + match &**args { + [name, fields, rest @ ..] => (Some(name), Some(fields), rest, false, false), + [name, rest @ ..] => ( + Some(name), + field_names_kw.map(|kw| &kw.value), + rest, + false, + field_names_kw.is_some(), + ), + [] => ( + typename_kw.map(|kw| &kw.value), + field_names_kw.map(|kw| &kw.value), + &[], + typename_kw.is_some(), + field_names_kw.is_some(), + ), + } + } + NamedTupleKind::Typing => match &**args { + [name, fields, rest @ ..] => (Some(name), Some(fields), rest, false, false), + [name, rest @ ..] => (Some(name), None, rest, false, false), + [] => (None, None, &[], false, false), + }, + }; + + // Check if we have both required arguments. + let (Some(name_arg), Some(fields_arg)) = (name_arg, fields_arg) else { for arg in args { self.infer_expression(arg, TypeContext::default()); } @@ -6531,19 +6575,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } if !has_starred && !has_double_starred { - let fields_param = match kind { + let fields_param_name = match kind { NamedTupleKind::Typing => "fields", NamedTupleKind::Collections => "field_names", }; - let missing = if args.is_empty() { - format!("`typename` and `{fields_param}`") - } else { - format!("`{fields_param}`") + let missing = match (name_arg.is_none(), fields_arg.is_none()) { + (true, true) => format!("`typename` and `{fields_param_name}`"), + (true, false) => "`typename`".to_string(), + (false, true) => format!("`{fields_param_name}`"), + (false, false) => unreachable!(), }; + let plural = name_arg.is_none() && fields_arg.is_none(); if let Some(builder) = self.context.report_lint(&MISSING_ARGUMENT, call_expr) { builder.into_diagnostic(format_args!( "Missing required argument{} {missing} to `{kind}()`", - if args.is_empty() { "s" } else { "" } + if plural { "s" } else { "" } )); } } @@ -6585,11 +6631,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut rename_type = None; for kw in keywords { + // `kw.arg` is `None` for double-starred kwargs (`**kwargs`), but we already + // returned early above if there were any, so this should always be `Some`. + let arg = kw + .arg + .as_ref() + .expect("double-starred kwargs should have been handled above"); + + // Skip keywords that were used for the required arguments (already inferred above). + // These flags are only true for `collections.namedtuple`. + if name_from_keyword && arg.id.as_str() == "typename" { + continue; + } + if fields_from_keyword && arg.id.as_str() == "field_names" { + continue; + } + let kw_type = self.infer_expression(&kw.value, TypeContext::default()); - let Some(arg) = &kw.arg else { - continue; - }; match arg.id.as_str() { "defaults" if kind.is_collections() => { defaults_kw = Some(kw); @@ -6682,6 +6741,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { )); } } + // `typename` is valid as a keyword argument only for `collections.namedtuple`. + // If it was already provided positionally, emit an error. + "typename" if kind.is_collections() => { + if !args.is_empty() { + if let Some(builder) = + self.context.report_lint(&PARAMETER_ALREADY_ASSIGNED, kw) + { + builder.into_diagnostic(format_args!( + "Multiple values provided for parameter `typename` of `{kind}`" + )); + } + } + } + // `field_names` is valid only for `collections.namedtuple`. + // If it was already provided positionally, emit an error. + "field_names" if kind.is_collections() => { + if args.len() >= 2 { + if let Some(builder) = + self.context.report_lint(&PARAMETER_ALREADY_ASSIGNED, kw) + { + builder.into_diagnostic(format_args!( + "Multiple values provided for parameter `field_names` of `{kind}`" + )); + } + } + } unknown_kwarg => { // Report unknown keyword argument. if let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) {