diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypedDictType.kt b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypedDictType.kt
index 771b841d5a5d..f4589e9a7220 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypedDictType.kt
+++ b/python/python-psi-impl/src/com/jetbrains/python/psi/types/PyTypedDictType.kt
@@ -136,7 +136,10 @@ class PyTypedDictType @JvmOverloads constructor(private val name: String,
*/
data class TypedDictFieldQualifiers(val isRequired: Boolean? = true, val isReadOnly: Boolean = false)
- data class FieldTypeAndTotality(val value: PyExpression?, val type: PyType?, val qualifiers: TypedDictFieldQualifiers = TypedDictFieldQualifiers())
+ data class FieldTypeAndTotality(val value: PyExpression?, val type: PyType?, val qualifiers: TypedDictFieldQualifiers = TypedDictFieldQualifiers()) {
+ val isRequired: Boolean get() = qualifiers.isRequired ?: true
+ val isReadOnly: Boolean get() = qualifiers.isReadOnly
+ }
companion object {
@@ -302,16 +305,34 @@ class PyTypedDictType @JvmOverloads constructor(private val name: String,
return null
}
- expected.fields.forEach {
- val expectedTypeAndTotality = it.value
- val actualTypeAndTotality = actual.fields[it.key]
-
- if (actualTypeAndTotality == null
- || !strictUnionMatch(expectedTypeAndTotality.type, actualTypeAndTotality.type, context)
- || !strictUnionMatch(actualTypeAndTotality.type, expectedTypeAndTotality.type, context)
- || expectedTypeAndTotality.qualifiers.isRequired != actualTypeAndTotality.qualifiers.isRequired) {
+ for ((expectedKey, expectedField) in expected.fields) {
+ if (expectedField.isReadOnly && !expectedField.isRequired && expectedField.type?.name == PyNames.OBJECT) {
+ continue
+ }
+ val actualField = actual.fields[expectedKey]
+ if (actualField == null) {
return TypeCheckingResult(false)
}
+ if (!strictUnionMatch(expectedField.type, actualField.type, context)) {
+ return TypeCheckingResult(false)
+ }
+ if (!expectedField.isReadOnly) {
+ if (!(strictUnionMatch(actualField.type, expectedField.type, context) && !actualField.isReadOnly)) {
+ return TypeCheckingResult(false)
+ }
+ }
+ if (expectedField.isRequired) {
+ if (!actualField.isRequired) {
+ return TypeCheckingResult(false)
+ }
+ }
+ else {
+ if (!expectedField.isReadOnly) {
+ if (actualField.isRequired) {
+ return TypeCheckingResult(false)
+ }
+ }
+ }
}
return TypeCheckingResult(true)
}
diff --git a/python/testSrc/com/jetbrains/python/inspections/Py3TypeCheckerInspectionTest.java b/python/testSrc/com/jetbrains/python/inspections/Py3TypeCheckerInspectionTest.java
index 35974d2aee4e..3a521710b396 100644
--- a/python/testSrc/com/jetbrains/python/inspections/Py3TypeCheckerInspectionTest.java
+++ b/python/testSrc/com/jetbrains/python/inspections/Py3TypeCheckerInspectionTest.java
@@ -1230,6 +1230,38 @@ public class Py3TypeCheckerInspectionTest extends PyInspectionTestCase {
);
}
+ public void testTypedDictsReadonlyConsistency() {
+ doTestByText("""
+ from typing import TypedDict, Required, NotRequired, ReadOnly
+
+ class A1(TypedDict):
+ x: NotRequired[str]
+
+ class B1(TypedDict):
+ x: NotRequired[ReadOnly[str]]
+
+ class B2(TypedDict):
+ x: ReadOnly[NotRequired[str]]
+
+ class C(TypedDict):
+ x: Required[str]
+
+ def func1(b1: B1, b2: B2, c: C):
+ v1: A1 = b1
+ v2: A1 = b2
+ v3: B1 = c
+
+ class A2(TypedDict):
+ x: ReadOnly[NotRequired[object]]
+
+ class B3(TypedDict):
+ pass
+
+ def func2(b: B3):
+ a: A2 = b
+ """);
+ }
+
// PY-53611
public void testTypingRequiredTypeSpecificationsMultiFile() {
doMultiFileTest();