Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/pyright-internal/src/analyzer/typeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,12 @@ import { getLastTypedDeclarationForSymbol, isEffectivelyClassVar } from './symbo
import { assignTupleTypeArgs, expandTuple, getSlicedTupleType, getTypeOfTuple, makeTupleObject } from './tuples';
import { SpeculativeModeOptions, SpeculativeTypeTracker } from './typeCacheUtils';
import {
applyTypedDictMethodTransform,
assignToTypedDict,
assignTypedDictToTypedDict,
createTypedDictType,
createTypedDictTypeInlined,
getTypedDictClassFromMethod,
getTypedDictDictEquivalent,
getTypedDictMappingEquivalent,
getTypedDictMembersForClass,
Expand Down Expand Up @@ -10544,6 +10546,21 @@ export function createTypeEvaluator(
return { returnType: evaluateCastCall(argList, errorNode) };
}

const tdMethodInfo = getTypedDictClassFromMethod(expandedCallType);
if (tdMethodInfo) {
const tdResult = applyTypedDictMethodTransform(
evaluatorInterface,
errorNode,
argList,
tdMethodInfo.classType,
tdMethodInfo.methodName,
tdMethodInfo.isBound
);
if (tdResult) {
return tdResult;
}
}

const callResult = validateOverloadedArgTypes(
errorNode,
argList,
Expand Down
240 changes: 240 additions & 0 deletions packages/pyright-internal/src/analyzer/typedDicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { getLastTypedDeclarationForSymbol } from './symbolUtils';
import {
Arg,
AssignTypeFlags,
CallResult,
EvaluatorUsage,
TypeEvaluator,
TypeResult,
Expand All @@ -52,7 +53,10 @@ import {
isClass,
isClassInstance,
isInstantiableClass,
isMethodType,
isNever,
isOverloaded,
isUnion,
maxTypeRecursionCount,
NeverType,
OverloadedType,
Expand Down Expand Up @@ -1611,6 +1615,242 @@ export function getTypeOfIndexedTypedDict(
return { type: resultingType, isIncomplete: !!indexTypeResult.isIncomplete };
}

export function getTypedDictClassFromMethod(
type: FunctionType | OverloadedType
): { classType: ClassType; methodName: string; isBound: boolean } | undefined {
const overload = isOverloaded(type) ? OverloadedType.getOverloads(type)[0] : type;
if (!overload || !FunctionType.isSynthesizedMethod(overload)) {
return undefined;
}

const name = overload.shared?.name;
if (name !== 'get' && name !== 'pop' && name !== 'setdefault') {
return undefined;
}

const isBound = isMethodType(overload);
let boundType: Type | undefined;
if (isBound) {
boundType = overload.priv.strippedFirstParamType;
} else if (overload.shared?.parameters && overload.shared.parameters.length > 0) {
boundType = FunctionType.getParamType(overload, 0);
}

if (boundType && isClassInstance(boundType) && ClassType.isTypedDictClass(boundType)) {
return { classType: boundType, methodName: name, isBound };
}

return undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

Use isMethodType(overload) rather than inferring bound status solely from strippedFirstParamType. The shared helper also handles pre-bound constructor methods, so duplicating only part of its logic can misclassify synthesized methods if their binding form changes.

}

export function applyTypedDictMethodTransform(
evaluator: TypeEvaluator,
errorNode: ExpressionNode,
argList: Arg[],
typedDictClass: ClassType,
methodName: string,
isBound: boolean
): CallResult | undefined {
// Validate argument counts and shape:
// Bound call: 1 or 2 positional args for get/pop, 2 for setdefault.
// Unbound call: 2 or 3 positional args for get/pop, 3 for setdefault.
const minArgs = isBound ? (methodName === 'setdefault' ? 2 : 1) : methodName === 'setdefault' ? 3 : 2;
const maxArgs = isBound ? 2 : 3;
if (argList.length < minArgs || argList.length > maxArgs) {
return undefined;
}

// Require all arguments to be simple positional (no keyword names, no *args/**kwargs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

setdefault requires its default argument, but the bound-call minimum here is one argument. A union-key call like p.setdefault(k) is intercepted and returns a field type without an error, whereas normal overload validation rejects it. Require the default argument for setdefault, or fall through to normal validation when it is absent.

if (!argList.every((arg) => !arg.name && arg.argCategory === ArgCategory.Simple)) {
return undefined;
}

const keyIndex = isBound ? 0 : 1;
const defaultIndex = isBound ? 1 : 2;

// Validate receiver type for unbound method calls
if (!isBound) {
const selfArg = argList[0];
const selfNode = selfArg.valueExpression ?? errorNode;
const selfType = (selfArg.typeResult ?? evaluator.getTypeOfExpression(selfNode)).type;
const expectedSelfType = ClassType.cloneAsInstance(typedDictClass);
if (!evaluator.assignType(expectedSelfType, selfType)) {
return undefined;
}
}

const keyArg = argList[keyIndex];
const keyNode = keyArg.valueExpression ?? errorNode;
const keyTypeResult = keyArg.typeResult ?? evaluator.getTypeOfExpression(keyNode);
const keyType = keyTypeResult.type;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

When typeResult is absent, the receiver and default are evaluated here but their isIncomplete flags are not propagated into the returned CallResult. Please retain those TypeResults and include their incompleteness so incremental analysis does not treat a call with incompletely evaluated arguments as complete.


if (!isUnion(keyType)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

This early return bypasses validateOverloadedArgTypes, so union-key calls no longer receive normal argument validation. For example, extra arguments are ignored, keyword arguments do not match the positional indexes, and an unbound receiver is never validated. Preserve normal overload validation or fall back to it for anything other than the supported positional call shape.

return undefined;
}

// Verify all key subtypes are valid string types. If any subtype is not assignable to str,
// fall back to standard overload validation so argument type errors are reported.
const strType = evaluator.getBuiltInObject(errorNode, 'str');
let hasInvalidKeySubtype = false;
mapSubtypes(keyType, (keySubtype) => {
if (isAnyOrUnknown(keySubtype)) {
return keySubtype;
}
if (!evaluator.assignType(strType, keySubtype)) {
hasInvalidKeySubtype = true;
}
return keySubtype;
});

if (hasInvalidKeySubtype) {
return undefined;
}

let isTypeIncomplete = !!keyTypeResult.isIncomplete;
const defaultArg = argList.length > defaultIndex ? argList[defaultIndex] : undefined;
if (defaultArg?.typeResult?.isIncomplete) {
isTypeIncomplete = true;
}
if (!isBound && argList[0].typeResult?.isIncomplete) {
isTypeIncomplete = true;
}

const defaultNode = defaultArg?.valueExpression ?? errorNode;
const defaultType = defaultArg
? (defaultArg.typeResult ?? evaluator.getTypeOfExpression(defaultNode)).type
: undefined;

const entries = getTypedDictMembersForClass(evaluator, typedDictClass, /* allowNarrowed */ methodName === 'get');
let argumentErrors = false;

const returnType = mapSubtypes(keyType, (keySubtype) => {
if (isAnyOrUnknown(keySubtype)) {
return keySubtype;
}

if (isClassInstance(keySubtype) && ClassType.isBuiltIn(keySubtype, 'str')) {
if (keySubtype.priv.literalValue === undefined) {
if (methodName === 'get') {
if (ClassType.isTypedDictEffectivelyClosed(typedDictClass)) {
const extraType = entries.extraItems?.valueType ?? NeverType.createNever();
return defaultType
? combineTypes([extraType, defaultType])
: combineTypes([extraType, evaluator.getNoneType()]);
}
return defaultType
? combineTypes([AnyType.create(), defaultType])
: combineTypes([AnyType.create(), evaluator.getNoneType()]);
} else if (methodName === 'pop') {
return defaultType ? combineTypes([UnknownType.create(), defaultType]) : UnknownType.create();
} else {
return UnknownType.create();
}
}

const entryName = keySubtype.priv.literalValue as string;
const entry = entries.knownItems.get(entryName) ?? entries.extraItems;

if (methodName === 'get') {
if (entry && !isNever(entry.valueType)) {
if (entry.isRequired || entry.isProvided) {
return entry.valueType;
}
return combineTypes([entry.valueType, defaultType ?? evaluator.getNoneType()]);
}
if (ClassType.isTypedDictEffectivelyClosed(typedDictClass)) {
const extraType = entries.extraItems?.valueType;
if (extraType) {
return combineTypes([extraType, defaultType ?? evaluator.getNoneType()]);
}
return defaultType ?? evaluator.getNoneType();
}
return combineTypes([AnyType.create(), defaultType ?? evaluator.getNoneType()]);
} else if (methodName === 'pop') {
if (entry && !isNever(entry.valueType)) {
if (entry.isReadOnly) {
evaluator.addDiagnostic(
DiagnosticRule.reportTypedDictNotRequiredAccess,
LocAddendum.keyReadOnly().format({
name: entryName,
type: evaluator.printType(typedDictClass),
}),
keyNode
);
argumentErrors = true;
return UnknownType.create();
}
if (entry.isRequired) {
return entry.valueType;
}
return defaultType ? combineTypes([entry.valueType, defaultType]) : entry.valueType;
}
if (defaultType) {
return defaultType;
}
evaluator.addDiagnostic(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

This gives required and ReadOnly keys precise pop return types, but the synthesized single-key overloads intentionally specialize pop only for non-required, non-ReadOnly keys; other keys use the pop(str) -> object fallback. Make the union transform follow that same rule so union-key calls do not diverge from single-key behavior.

DiagnosticRule.reportGeneralTypeIssues,
LocAddendum.keyUndefined().format({
name: entryName,
type: evaluator.printType(typedDictClass),
}),
keyNode
);
argumentErrors = true;
return UnknownType.create();
} else if (methodName === 'setdefault') {
if (entry && !isNever(entry.valueType)) {
if (entry.isReadOnly) {
evaluator.addDiagnostic(
DiagnosticRule.reportGeneralTypeIssues,
LocAddendum.keyReadOnly().format({
name: entryName,
type: evaluator.printType(typedDictClass),
}),
keyNode
);
argumentErrors = true;
return UnknownType.create();
}
if (defaultType && defaultArg) {
const diag = new DiagnosticAddendum();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

The ReadOnly setdefault diagnostic uses reportGeneralTypeIssues, unlike the corresponding pop and indexed-assignment paths, which use reportTypedDictNotRequiredAccess. This prevents users from consistently suppressing the ReadOnly TypedDict-access diagnostic; align this branch with the established rule.

if (!evaluator.assignType(entry.valueType, defaultType, diag)) {
evaluator.addDiagnostic(
DiagnosticRule.reportArgumentType,
LocMessage.argAssignmentParam().format({
paramName: 'default',
paramType: evaluator.printType(entry.valueType),
argType: evaluator.printType(defaultType),
}) + diag.getString(),
defaultNode
);
argumentErrors = true;
}
}
return entry.valueType;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

A union key containing a non-string subtype reaches this fallback and returns Unknown without setting argumentErrors or emitting a diagnostic. For example, Literal["name"] | int should still reject the int component as the normal str key overload does. Preserve normal argument validation for unsupported key subtypes.

evaluator.addDiagnostic(
DiagnosticRule.reportGeneralTypeIssues,
LocAddendum.keyUndefined().format({
name: entryName,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info · Optional note

Propagate isTypeIncomplete from the evaluated key, default, and unbound receiver into this CallResult. Returning only the type and argument-error flag can cause an incomplete call result to be treated as final.

type: evaluator.printType(typedDictClass),
}),
keyNode
);
argumentErrors = true;
return UnknownType.create();
}
}

return UnknownType.create();
});

return {
returnType,
argumentErrors,
isTypeIncomplete,
};
}

// If the specified type has a non-required key, this method marks the
// key as present.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

For an open TypedDict, an unknown literal union arm reaches this keyUndefined diagnostic even though the synthesized pop(str) fallback accepts it and returns object. Apply the same open/closed and extra_items fallback policy here, and add coverage for a union containing a known optional key and an unknown key.

export function narrowForKeyAssignment(classType: ClassType, key: string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info · Optional note

This transform duplicates TypedDict method legality, fallback, diagnostic, and return-type policy. The pop fallback discrepancy demonstrates the resulting drift risk; prefer distributing union arguments through the existing overload-validation path rather than maintaining a second semantic implementation.

Expand Down
75 changes: 75 additions & 0 deletions packages/pyright-internal/src/tests/samples/typedDict28.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# This sample tests type inference and diagnostic behavior for TypedDict
# methods (get, pop, setdefault) when called with union key types.

from typing import Any, Literal, NotRequired, ReadOnly, TypedDict, assert_type, overload

class Person(TypedDict):
name: str
age: int
nickname: NotRequired[str]

class Config(TypedDict):
host: ReadOnly[str]
port: int

def test_get_union_literal_keys(p: Person, k: Literal["name", "age"]):
v = p.get(k)
assert_type(v, str | int)

def test_get_union_with_not_required(p: Person, k: Literal["name", "nickname"]):
v = p.get(k)
assert_type(v, str | None)

def test_get_union_with_unknown_key(p: Person, k: Literal["name", "missing"], default_val: int):
v1 = p.get(k)
assert_type(v1, str | Any | None)

v2 = p.get(k, default_val)
assert_type(v2, str | Any | int)

def test_pop_union_literal_keys(p: Person, k: Literal["name", "age"]):
v = p.pop(k)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info · Optional note

Coverage omits pop defaults and mixed required/optional key unions, which exercise branches introduced by this transform. Add focused cases for both, including expected diagnostics and inferred return types.

assert_type(v, str | int)

def test_pop_readonly_diagnostic(c: Config, k: Literal["host", "port"]):
# This should report an error because "host" is ReadOnly.
c.pop(k)

def test_setdefault_union_literal_keys(p: Person, k: Literal["name", "age"]):
# This should report an error for default "val" not matching int ("age").
v = p.setdefault(k, "val")
assert_type(v, str | int)

def test_unbound_method_union_keys(p: Person, k: Literal["name", "age"]):
v = Person.get(p, k)
assert_type(v, str | int)

class CustomContainer:
@overload
def get(self, key: Literal["a"]) -> int: ...
@overload
def get(self, key: Literal["b"]) -> str: ...
def get(self, key: str) -> Any:
pass

def test_custom_overloaded_get_not_intercepted(c: CustomContainer, k: Literal["a", "b"]):
# User-defined overloaded function should NOT be intercepted by TypedDict transform.
# Standard overload resolution should apply.
v = c.get(k)

def test_unbound_invalid_receiver(k: Literal["name", "age"]):
# Unbound call with invalid receiver should fall back to normal overload validation.
Person.get(123, k)

def test_keyword_and_extra_args(p: Person, k: Literal["name", "age"]):
# Keyword arguments or extra arguments fall back to standard overload validation.
p.get(k, default=0)
p.get(k, 0, 1)

def test_setdefault_missing_default(p: Person, k: Literal["name", "age"]):
# setdefault requires default argument; missing default falls back to normal validation and errors.
p.setdefault(k)

def test_union_with_non_string_subtype(p: Person, k: Literal["name"] | int):
# Union containing non-string subtype falls back to normal validation and errors on int.
p.get(k)
6 changes: 6 additions & 0 deletions packages/pyright-internal/src/tests/typeEvaluator7.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,12 @@ test('TypedDict27', () => {
TestUtils.validateResults(analysisResults, 7);
});

test('TypedDict28', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typedDict28.py']);

TestUtils.validateResults(analysisResults, 8);
});

test('TypedDictInline1', () => {
const configOptions = new ConfigOptions(Uri.empty());
configOptions.diagnosticRuleSet.enableExperimentalFeatures = true;
Expand Down
Loading