From 08925249860c24ca8a6befd7c130afacc77a0fed Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 9 Aug 2026 19:37:27 -0500 Subject: [PATCH 1/2] Fix type inference for TypedDict unpacking in dictionary displays --- .../src/analyzer/typeEvaluator.ts | 6 +-- .../src/tests/samples/typedDict28.py | 39 +++++++++++++++++++ .../src/tests/typeEvaluator7.test.ts | 9 +++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/pyright-internal/src/tests/samples/typedDict28.py diff --git a/packages/pyright-internal/src/analyzer/typeEvaluator.ts b/packages/pyright-internal/src/analyzer/typeEvaluator.ts index 7f759ee2af06..bb695b3d6d3a 100644 --- a/packages/pyright-internal/src/analyzer/typeEvaluator.ts +++ b/packages/pyright-internal/src/analyzer/typeEvaluator.ts @@ -14656,7 +14656,7 @@ export function createTypeEvaluator( ); tdEntries.knownItems.forEach((entry, name) => { - if (entry.isRequired || entry.isProvided) { + if (!expectedTypedDictEntries || entry.isRequired || entry.isProvided) { keyTypes.push({ node: entryNode, type: ClassType.cloneWithLiteral(strObject, name), @@ -14665,11 +14665,11 @@ export function createTypeEvaluator( } }); - if (!expectedTypedDictEntries) { + if (tdEntries.extraItems) { keyTypes.push({ node: entryNode, type: ClassType.cloneAsInstance(strObject) }); valueTypes.push({ node: entryNode, - type: tdEntries.extraItems?.valueType ?? getObjectType(), + type: tdEntries.extraItems.valueType, }); } diff --git a/packages/pyright-internal/src/tests/samples/typedDict28.py b/packages/pyright-internal/src/tests/samples/typedDict28.py new file mode 100644 index 000000000000..bb03fd0892d9 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/typedDict28.py @@ -0,0 +1,39 @@ +# This sample tests dictionary expansion for TypedDicts without an expected +# TypedDict target type context. + +from typing import NotRequired, TypedDict, reveal_type + + +class HomogeneousTD(TypedDict): + a: int + b: int + + +class HeterogeneousTD(TypedDict): + a: int + b: str + + +class OptionalTD(TypedDict): + a: int + b: NotRequired[float] + + +def test_homogeneous(td: HomogeneousTD): + res1 = {**td} + reveal_type(res1, expected_text="dict[str, int]") + + +def test_heterogeneous(td: HeterogeneousTD): + res2 = {**td} + reveal_type(res2, expected_text="dict[str, int | str]") + + +def test_optional(td: OptionalTD): + res3 = {**td} + reveal_type(res3, expected_text="dict[str, int | float]") + + +def test_multiple(td1: HomogeneousTD, td2: HeterogeneousTD, td3: OptionalTD): + res4 = {**td1, **td2, **td3} + reveal_type(res4, expected_text="dict[str, int | str | float]") diff --git a/packages/pyright-internal/src/tests/typeEvaluator7.test.ts b/packages/pyright-internal/src/tests/typeEvaluator7.test.ts index 0776c53fb944..cb2b44b89345 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator7.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator7.test.ts @@ -808,6 +808,15 @@ test('TypedDict27', () => { TestUtils.validateResults(analysisResults, 7); }); +test('TypedDict28', () => { + const configOptions = new ConfigOptions(Uri.empty()); + configOptions.diagnosticRuleSet.strictDictionaryInference = true; + + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typedDict28.py'], configOptions); + + TestUtils.validateResults(analysisResults, 0); +}); + test('TypedDictInline1', () => { const configOptions = new ConfigOptions(Uri.empty()); configOptions.diagnosticRuleSet.enableExperimentalFeatures = true; From 7907f6c380e8497c52e99b2639adb632f44ac6f4 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 9 Aug 2026 23:32:01 -0500 Subject: [PATCH 2/2] Address review feedback: retain expectedTypedDictEntries guard, add closed/extra_items TypedDict coverage & fourslash test --- .../src/analyzer/typeEvaluator.ts | 2 +- ...ypedDictUnpacking.dictDisplay.fourslash.ts | 15 +++++++ .../src/tests/samples/typedDict28.py | 45 +++++++++++++++---- .../src/tests/samples/typedDict29.py | 42 +++++++++++++++++ .../src/tests/typeEvaluator7.test.ts | 12 ++++- 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 packages/pyright-internal/src/tests/fourslash/hover.typedDictUnpacking.dictDisplay.fourslash.ts create mode 100644 packages/pyright-internal/src/tests/samples/typedDict29.py diff --git a/packages/pyright-internal/src/analyzer/typeEvaluator.ts b/packages/pyright-internal/src/analyzer/typeEvaluator.ts index bb695b3d6d3a..086524072aa3 100644 --- a/packages/pyright-internal/src/analyzer/typeEvaluator.ts +++ b/packages/pyright-internal/src/analyzer/typeEvaluator.ts @@ -14665,7 +14665,7 @@ export function createTypeEvaluator( } }); - if (tdEntries.extraItems) { + if (!expectedTypedDictEntries && tdEntries.extraItems) { keyTypes.push({ node: entryNode, type: ClassType.cloneAsInstance(strObject) }); valueTypes.push({ node: entryNode, diff --git a/packages/pyright-internal/src/tests/fourslash/hover.typedDictUnpacking.dictDisplay.fourslash.ts b/packages/pyright-internal/src/tests/fourslash/hover.typedDictUnpacking.dictDisplay.fourslash.ts new file mode 100644 index 000000000000..39bef71fcbc2 --- /dev/null +++ b/packages/pyright-internal/src/tests/fourslash/hover.typedDictUnpacking.dictDisplay.fourslash.ts @@ -0,0 +1,15 @@ +/// + +// @filename: test.py +//// from typing import TypedDict +//// +//// class User(TypedDict): +//// name: str +//// age: int +//// +//// user: User = {"name": "Alice", "age": 30} +//// [|/*marker1*/res|] = {**user} + +helper.verifyHover('markdown', { + marker1: '```python\n(variable) res: dict[str, Unknown]\n```', +}); diff --git a/packages/pyright-internal/src/tests/samples/typedDict28.py b/packages/pyright-internal/src/tests/samples/typedDict28.py index bb03fd0892d9..190a5a65878d 100644 --- a/packages/pyright-internal/src/tests/samples/typedDict28.py +++ b/packages/pyright-internal/src/tests/samples/typedDict28.py @@ -1,5 +1,5 @@ # This sample tests dictionary expansion for TypedDicts without an expected -# TypedDict target type context. +# TypedDict target type context, as well as unpacking into typed targets. from typing import NotRequired, TypedDict, reveal_type @@ -19,21 +19,50 @@ class OptionalTD(TypedDict): b: NotRequired[float] +class ClosedTD(TypedDict, closed=True): + a: int + + +class TargetTD(TypedDict): + a: int + + +class ExtraItemsTD(TypedDict, extra_items=str): + a: int + + +class TargetExtraTD(TypedDict, extra_items=str): + a: int + + def test_homogeneous(td: HomogeneousTD): res1 = {**td} reveal_type(res1, expected_text="dict[str, int]") -def test_heterogeneous(td: HeterogeneousTD): +def test_heterogeneous_default_mode(td: HeterogeneousTD): + # In default mode (without strictDictionaryInference), heterogeneous value types + # fall back to Unknown. res2 = {**td} - reveal_type(res2, expected_text="dict[str, int | str]") + reveal_type(res2, expected_text="dict[str, Unknown]") -def test_optional(td: OptionalTD): +def test_optional_default_mode(td: OptionalTD): res3 = {**td} - reveal_type(res3, expected_text="dict[str, int | float]") + reveal_type(res3, expected_text="dict[str, Unknown]") + + +def test_unpack_closed_into_target(td: ClosedTD): + # Unpacking a closed TypedDict into a typed target should not produce assignment errors. + target: TargetTD = {**td} + + +def test_unpack_extra_items_into_target(td: ExtraItemsTD): + target: TargetExtraTD = {**td} -def test_multiple(td1: HomogeneousTD, td2: HeterogeneousTD, td3: OptionalTD): - res4 = {**td1, **td2, **td3} - reveal_type(res4, expected_text="dict[str, int | str | float]") +def test_unpack_extra_items_into_dict(td: ExtraItemsTD): + res5 = {**td} + # Unpacking extra_items=str TypedDict into dict display under strictDictionaryInference (or combineTypes) + # includes extraItems valueType str. In default mode, int | str falls back to Unknown. + reveal_type(res5, expected_text="dict[str, Unknown]") diff --git a/packages/pyright-internal/src/tests/samples/typedDict29.py b/packages/pyright-internal/src/tests/samples/typedDict29.py new file mode 100644 index 000000000000..e5613b5bd89d --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/typedDict29.py @@ -0,0 +1,42 @@ +# This sample tests dictionary expansion for TypedDicts when strictDictionaryInference is enabled. + +from typing import NotRequired, TypedDict, reveal_type + + +class HomogeneousTD(TypedDict): + a: int + b: int + + +class HeterogeneousTD(TypedDict): + a: int + b: str + + +class OptionalTD(TypedDict): + a: int + b: NotRequired[float] + + +class ExtraItemsTD(TypedDict, extra_items=str): + a: int + + +def test_heterogeneous_strict(td: HeterogeneousTD): + res1 = {**td} + reveal_type(res1, expected_text="dict[str, int | str]") + + +def test_optional_strict(td: OptionalTD): + res2 = {**td} + reveal_type(res2, expected_text="dict[str, int | float]") + + +def test_multiple_strict(td1: HomogeneousTD, td2: HeterogeneousTD, td3: OptionalTD): + res3 = {**td1, **td2, **td3} + reveal_type(res3, expected_text="dict[str, int | str | float]") + + +def test_extra_items_strict(td: ExtraItemsTD): + res4 = {**td} + reveal_type(res4, expected_text="dict[str, int | str]") diff --git a/packages/pyright-internal/src/tests/typeEvaluator7.test.ts b/packages/pyright-internal/src/tests/typeEvaluator7.test.ts index cb2b44b89345..f28ef59c72f3 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator7.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator7.test.ts @@ -810,13 +810,23 @@ test('TypedDict27', () => { test('TypedDict28', () => { const configOptions = new ConfigOptions(Uri.empty()); - configOptions.diagnosticRuleSet.strictDictionaryInference = true; + configOptions.defaultPythonVersion = pythonVersion3_13; const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typedDict28.py'], configOptions); TestUtils.validateResults(analysisResults, 0); }); +test('TypedDict29', () => { + const configOptions = new ConfigOptions(Uri.empty()); + configOptions.defaultPythonVersion = pythonVersion3_13; + configOptions.diagnosticRuleSet.strictDictionaryInference = true; + + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typedDict29.py'], configOptions); + + TestUtils.validateResults(analysisResults, 0); +}); + test('TypedDictInline1', () => { const configOptions = new ConfigOptions(Uri.empty()); configOptions.diagnosticRuleSet.enableExperimentalFeatures = true;