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
49 changes: 48 additions & 1 deletion crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -1849,7 +1849,7 @@ msg = Message(id=1, content="Hello")
# No errors for yet-unsupported features (`closed`):
OtherMessage = TypedDict("OtherMessage", {"id": int, "content": str}, closed=True)

reveal_type(Message.__required_keys__) # revealed: @Todo(Support for functional `TypedDict`)
reveal_type(Message.__required_keys__) # revealed: @Todo(Functional TypedDicts)

# TODO: this should be an error
msg.content
Expand Down Expand Up @@ -1883,6 +1883,53 @@ def bad(
): ...
```

### `Required` and `NotRequired` not allowed outside `TypedDict`

```py
from typing_extensions import Required, NotRequired, TypedDict

# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
x: Required[int]
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
y: NotRequired[str]

class MyClass:
# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
x: Required[int]
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
y: NotRequired[str]

def f():
# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
x: Required[int] = 1
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
y: NotRequired[str] = ""

# fine
MyFunctionalTypedDict = TypedDict("MyFunctionalTypedDict", {"not-an-identifier": Required[int]})

class FunctionalTypedDictSubclass(MyFunctionalTypedDict):
y: NotRequired[int] # fine
```

### Nested `Required` and `NotRequired`

`Required` and `NotRequired` cannot be nested inside each other:

```py
from typing_extensions import TypedDict, Required, NotRequired

class TD(TypedDict):
# error: [invalid-type-form] "`typing.Required` cannot be nested inside `Required` or `NotRequired`"
a: Required[Required[int]]
# error: [invalid-type-form] "`typing.NotRequired` cannot be nested inside `Required` or `NotRequired`"
b: NotRequired[NotRequired[int]]
# error: [invalid-type-form] "`typing.Required` cannot be nested inside `Required` or `NotRequired`"
c: Required[NotRequired[int]]
# error: [invalid-type-form] "`typing.NotRequired` cannot be nested inside `Required` or `NotRequired`"
d: NotRequired[Required[int]]
```

### `dict`-subclass inhabitants

Values that inhabit a `TypedDict` type must be instances of `dict` itself, not a subclass:
Expand Down
17 changes: 15 additions & 2 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,7 @@ impl<'db> Type<'db> {
DynamicType::Todo(_)
| DynamicType::TodoStarredExpression
| DynamicType::TodoUnpack
| DynamicType::TodoFunctionalTypedDict
| DynamicType::TodoTypeVarTuple => true,
})
}
Expand Down Expand Up @@ -4221,7 +4222,7 @@ impl<'db> Type<'db> {
.with_annotated_type(Type::any()),
],
),
Type::unknown(),
Type::Dynamic(DynamicType::TodoFunctionalTypedDict),
),
)
.into()
Expand Down Expand Up @@ -6817,7 +6818,15 @@ impl<'db> Type<'db> {
Self::AlwaysFalsy => Type::SpecialForm(SpecialFormType::AlwaysFalsy).definition(db),

// These types have no definition
Self::Dynamic(DynamicType::Divergent(_) | DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression | DynamicType::TodoTypeVarTuple | DynamicType::UnspecializedTypeVar)
Self::Dynamic(
DynamicType::Divergent(_)
| DynamicType::Todo(_)
| DynamicType::TodoUnpack
| DynamicType::TodoStarredExpression
| DynamicType::TodoTypeVarTuple
| DynamicType::UnspecializedTypeVar
| DynamicType::TodoFunctionalTypedDict
)
| Self::Callable(_)
| Self::TypeIs(_)
| Self::TypeGuard(_) => None,
Expand Down Expand Up @@ -7538,6 +7547,8 @@ pub enum DynamicType<'db> {
TodoStarredExpression,
/// A special Todo-variant for `TypeVarTuple` instances encountered in type expressions
TodoTypeVarTuple,
/// A special Todo-variant for functional `TypedDict`s.
TodoFunctionalTypedDict,
/// A type that is determined to be divergent during recursive type inference.
Divergent(DivergentType),
}
Expand All @@ -7564,6 +7575,7 @@ impl std::fmt::Display for DynamicType<'_> {
DynamicType::TodoUnpack => f.write_str("@Todo(typing.Unpack)"),
DynamicType::TodoStarredExpression => f.write_str("@Todo(StarredExpression)"),
DynamicType::TodoTypeVarTuple => f.write_str("@Todo(TypeVarTuple)"),
DynamicType::TodoFunctionalTypedDict => f.write_str("@Todo(Functional TypedDicts)"),
DynamicType::Divergent(_) => f.write_str("Divergent"),
}
}
Expand Down Expand Up @@ -8411,6 +8423,7 @@ impl<'db> TypeVarInstance<'db> {
DynamicType::Todo(_)
| DynamicType::TodoUnpack
| DynamicType::TodoStarredExpression
| DynamicType::TodoFunctionalTypedDict
| DynamicType::TodoTypeVarTuple => Parameters::todo(),
DynamicType::Any
| DynamicType::Unknown
Expand Down
5 changes: 3 additions & 2 deletions crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ use crate::types::{
KnownClass, KnownInstanceType, LiteralValueTypeKind, MemberLookupPolicy, NominalInstanceType,
PropertyInstanceType, SpecialFormType, TypeAliasType, TypeContext, TypeVarBoundOrConstraints,
TypeVarVariance, UnionBuilder, UnionType, WrapperDescriptorKind, enums, list_members,
todo_type,
};
use crate::unpack::EvaluationMode;
use crate::{DisplaySettings, Program};
Expand Down Expand Up @@ -1956,7 +1955,9 @@ impl<'db> Bindings<'db> {
},

Type::SpecialForm(SpecialFormType::TypedDict) => {
overload.set_return_type(todo_type!("Support for functional `TypedDict`"));
overload.set_return_type(Type::Dynamic(
crate::types::DynamicType::TodoFunctionalTypedDict,
));
}

// Not a special case
Expand Down
1 change: 1 addition & 0 deletions crates/ty_python_semantic/src/types/class_base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ impl<'db> ClassBase<'db> {
ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar",
ClassBase::Dynamic(
DynamicType::Todo(_)
| DynamicType::TodoFunctionalTypedDict
| DynamicType::TodoUnpack
| DynamicType::TodoStarredExpression
| DynamicType::TodoTypeVarTuple,
Expand Down
42 changes: 40 additions & 2 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ use crate::types::generics::{
GenericContext, InferableTypeVars, SpecializationBuilder, bind_typevar,
enclosing_generic_contexts, typing_self,
};
use crate::types::infer::nearest_enclosing_function;
use crate::types::infer::{nearest_enclosing_class, nearest_enclosing_function};
use crate::types::mro::{DynamicMroErrorKind, StaticMroErrorKind};
use crate::types::newtype::NewType;
use crate::types::special_form::AliasSpec;
Expand Down Expand Up @@ -9225,6 +9225,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
}

// `Required`, `NotRequired`, and `ReadOnly` are only valid inside TypedDict classes.
if declared.qualifiers.intersects(
TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED | TypeQualifiers::READ_ONLY,
) {
let in_typed_dict = current_scope.kind() == ScopeKind::Class
&& nearest_enclosing_class(self.db(), self.index, self.scope()).is_some_and(
|class| {
class.iter_mro(self.db(), None).any(|base| {
matches!(
base,
ClassBase::TypedDict
| ClassBase::Dynamic(DynamicType::TodoFunctionalTypedDict)
)
})
},
);
if !in_typed_dict {
for qualifier in [
TypeQualifiers::REQUIRED,
TypeQualifiers::NOT_REQUIRED,
TypeQualifiers::READ_ONLY,
] {
if declared.qualifiers.contains(qualifier)
&& let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
{
builder.into_diagnostic(format_args!(
"`{name}` is only allowed in TypedDict fields",
name = qualifier.name()
));
}
}
}
}
}

if target
Expand Down Expand Up @@ -11608,7 +11643,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {

// Avoid false positives for the functional `TypedDict` form, which is currently
// unsupported.
if let Some(Type::Dynamic(DynamicType::Todo(_))) = tcx.annotation {
if let Some(Type::Dynamic(DynamicType::TodoFunctionalTypedDict)) = tcx.annotation {
return KnownClass::Dict
.to_specialized_instance(self.db(), &[Type::unknown(), Type::unknown()]);
}
Expand Down Expand Up @@ -14318,6 +14353,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
(typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _, _)
| (_, typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _) => Some(typevar),

(todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _, _)
| (_, todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _) => Some(todo),

// When both operands are the same constrained TypeVar (e.g., `T: (int, str)`),
// we check if the operation is valid for each constraint paired with itself.
// This is different from treating it as a union, where we'd check all combinations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,23 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
"`ClassVar` cannot contain type variables",
);
}

// Reject nested `Required`/`NotRequired`, e.g.
// `Required[Required[int]]` or `Required[NotRequired[int]]`.
if matches!(
qualifier,
TypeQualifier::Required | TypeQualifier::NotRequired
) && type_and_qualifiers.qualifiers.intersects(
TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED,
) && let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, subscript)
{
builder.into_diagnostic(format_args!(
"`{qualifier}` cannot be nested inside \
`Required` or `NotRequired`",
));
}

type_and_qualifiers.with_qualifier(TypeQualifiers::from(qualifier))
} else {
for element in arguments {
Expand Down
1 change: 1 addition & 0 deletions crates/ty_test/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ fn discard_todo_metadata(ty: &str) -> Cow<'_, str> {
"@Todo(StarredExpression)",
"@Todo(typing.Unpack)",
"@Todo(TypeVarTuple)",
"@Todo(Functional TypedDicts)",
];

static TODO_METADATA_REGEX: LazyLock<regex::Regex> =
Expand Down