diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index ef42b9714fcfc..f210e5ca811ec 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -97,11 +97,15 @@ x: list[int | str] = list1(42) * 3 `typed_dict.py`: ```py -from typing import Callable, Hashable, Mapping, TypedDict +from typing import Any, Callable, Hashable, Mapping, TypedDict +from typing_extensions import Never class TD(TypedDict): x: int +class BadTD(TypedDict): + x: str + d1_literal = {"x": 1} d1_dict = dict(x=1) @@ -110,9 +114,11 @@ reveal_type(d1_dict) # revealed: dict[str, int] d2_literal: TD = {"x": 1} d2_dict: TD = dict(x=1) +d2_unpack: TD = dict(**d2_literal) reveal_type(d2_literal) # revealed: TD reveal_type(d2_dict) # revealed: TD +reveal_type(d2_unpack) # revealed: TD d3_literal: dict[str, int] = {"x": 1} d3_dict: dict[str, int] = dict(x=1) @@ -126,6 +132,30 @@ d4_invalid_dict: TD = dict(x="1") # error: [invalid-argument-type] reveal_type(d4_invalid_literal) # revealed: TD reveal_type(d4_invalid_dict) # revealed: TD +def unpack_invalid_typed_dict(src: BadTD) -> TD: + # The fast path should validate TypedDict-shaped unpacks even when they are not assignable to + # the target. That preserves the key-level TypedDict diagnostic instead of falling back to a + # broad `dict[str, str]` assignment error. + # error: [invalid-argument-type] "Invalid argument to key "x" with declared type `int` on TypedDict `TD`: value of type `str`" + return dict(**src) + +def return_any_unpack(src: Any) -> TD: + return dict(**src) + +def pass_never_unpack(src: Never) -> None: + takes_td(dict(**src)) + +def takes_mapping(value: Mapping[str, object]) -> None: + pass + +def keep_keyword_diagnostics(kwargs: Mapping[str, object]) -> None: + # The TypedDict-aware `dict(...)` fast path should not lose diagnostics from named keywords + # when unsupported `**kwargs` forces it to fall back to ordinary dict inference. + # error: [unresolved-reference] "Name `missing` used when not defined" + # error: [invalid-assignment] + maybe_td: TD = dict(x=missing, **kwargs) + takes_mapping(maybe_td) + # Note: the second variant (`d5_dict`) is not technically allowed by the `dict.__init__` overloads # in typeshed, which require the key type to be `str` when using keyword arguments. However, we # special-case this pattern to match the behavior of `d5_literal`. @@ -140,6 +170,15 @@ def return_literal() -> TD: def return_dict() -> TD: return dict(x=1) +def return_unpack(src: TD) -> TD: + return dict(**src) + +def takes_td(value: TD) -> None: + pass + +def pass_unpack(src: TD) -> None: + takes_td(dict(**src)) + def return_invalid_literal() -> TD: # TODO: ideally, this would only emit the first error, but not `invalid-return-type` (like the `return_invalid_dict` case below). # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" diff --git a/crates/ty_python_semantic/src/types/infer/builder/dict.rs b/crates/ty_python_semantic/src/types/infer/builder/dict.rs index 77cdf711b2943..72d98a403bddd 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/dict.rs @@ -3,7 +3,10 @@ use ruff_python_ast::{self as ast, HasNodeIndex}; use rustc_hash::FxHashMap; use super::{ArgExpr, TypeInferenceBuilder}; -use crate::types::typed_dict::validate_typed_dict_constructor; +use crate::types::typed_dict::{ + extract_unpacked_typed_dict_keys_from_value_type, infer_unpacked_keyword_types, + validate_typed_dict_constructor, +}; use crate::types::{KnownClass, Type, TypeContext}; impl<'db> TypeInferenceBuilder<'db, '_> { @@ -13,42 +16,66 @@ impl<'db> TypeInferenceBuilder<'db, '_> { arguments: &ast::Arguments, call_expression_tcx: TypeContext<'db>, ) -> Option> { - if !arguments.args.is_empty() - || arguments - .keywords - .iter() - .any(|keyword| keyword.arg.is_none()) - { + if !arguments.args.is_empty() { return None; } // Fast-path dict(...) in TypedDict context: infer keyword values against fields, - // then validate and return the TypedDict type. + // then validate and return the TypedDict type. This also covers `dict(**src)` when `src` + // is `TypedDict`-shaped. if let Some(tcx) = call_expression_tcx.annotation && let Some(typed_dict) = tcx .filter_union(self.db(), Type::is_typed_dict) .as_typed_dict() { - let items = typed_dict.items(self.db()); - for keyword in &arguments.keywords { - if let Some(arg_name) = &keyword.arg { - let value_tcx = items - .get(arg_name.id.as_str()) - .map(|field| TypeContext::new(Some(field.declared_ty))) - .unwrap_or_default(); - self.infer_expression(&keyword.value, value_tcx); - } - } + // Only speculate the `**kwargs` applicability check. Assignability handles inputs that + // are already valid for the target, including gradual and bottom types. The additional + // TypedDict-shape check keeps invalid-but-analyzable unpacks on this path so validation + // can emit key-level diagnostics instead of falling back to a broad `dict[...]` + // assignment error. Unsupported unpacks still fall back to ordinary `dict(...)` + // inference. + // + // Named keyword values are inferred on the real builder so their diagnostics are either + // committed with the fast path or left for ordinary `dict(...)` inference when we fall + // back. + let supports_typed_dict_context = { + let mut speculative_builder = self.speculate(); + infer_unpacked_keyword_types(arguments, &mut |expr, tcx| { + speculative_builder.infer_expression(expr, tcx) + }) + .into_iter() + .flatten() + .all(|keyword_ty| { + keyword_ty + .is_assignable_to(speculative_builder.db(), Type::TypedDict(typed_dict)) + || extract_unpacked_typed_dict_keys_from_value_type( + speculative_builder.db(), + keyword_ty, + ) + .is_some() + }) + }; + + if supports_typed_dict_context { + self.infer_typed_dict_constructor_keyword_values(typed_dict, arguments); + validate_typed_dict_constructor( + &self.context, + typed_dict, + arguments, + func.into(), + |expr, _| self.expression_type(expr), + ); - validate_typed_dict_constructor( - &self.context, - typed_dict, - arguments, - func.into(), - |expr, _| self.expression_type(expr), - ); + return Some(Type::TypedDict(typed_dict)); + } + } - return Some(Type::TypedDict(typed_dict)); + if arguments + .keywords + .iter() + .any(|keyword| keyword.arg.is_none()) + { + return None; } // Lower `dict(a=..., b=...)` to synthetic `(Literal["a"], value)` pairs so we can diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index a58f782938403..f06b5bfcd56b4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -402,7 +402,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { /// Named keywords are inferred against the declared type of the matching `TypedDict` field. /// Unpacked `**kwargs` and unknown keys fall back to default inference because they do not /// map to a single field declaration at this stage. - fn infer_typed_dict_constructor_keyword_values( + pub(super) fn infer_typed_dict_constructor_keyword_values( &mut self, typed_dict: TypedDictType<'db>, arguments: &ast::Arguments,