Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion crates/ty_python_semantic/resources/mdtest/bidirectional.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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`.
Expand All @@ -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"
Expand Down
79 changes: 53 additions & 26 deletions crates/ty_python_semantic/src/types/infer/builder/dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '_> {
Expand All @@ -13,42 +16,66 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
arguments: &ast::Arguments,
call_expression_tcx: TypeContext<'db>,
) -> Option<Type<'db>> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading