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();