From 11c7e041fbec8966ecdd581111650958c7b9b79e Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 15 Aug 2026 13:23:54 -0500 Subject: [PATCH] Synthesize a typed NamedTuple _replace method so extra fields are errors. typeshed exposes _replace as **kwargs: Any, so unknown field names and incompatible values were accepted even though CPython raises TypeError. --- .../src/analyzer/dataClasses.ts | 21 ++++--- .../src/analyzer/namedTuples.ts | 55 +++++++++++++++++++ .../src/tests/samples/dataclassReplace1.py | 12 ++++ .../src/tests/samples/namedTupleReplace1.py | 43 +++++++++++++++ .../src/tests/typeEvaluator4.test.ts | 4 +- .../src/tests/typeEvaluator8.test.ts | 20 ++++++- 6 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 packages/pyright-internal/src/tests/samples/namedTupleReplace1.py diff --git a/packages/pyright-internal/src/analyzer/dataClasses.ts b/packages/pyright-internal/src/analyzer/dataClasses.ts index 89cab8ed0b7e..5e70e613a30a 100644 --- a/packages/pyright-internal/src/analyzer/dataClasses.ts +++ b/packages/pyright-internal/src/analyzer/dataClasses.ts @@ -227,14 +227,14 @@ export function synthesizeDataClassMethods( initType.shared.declaredReturnType = evaluator.getNoneType(); // For Python 3.13 and newer, synthesize a __replace__ method. + // NamedTuple also provides _replace on all supported Python versions. + const synthesizeDunderReplace = PythonVersion.isGreaterOrEqualTo( + AnalyzerNodeInfo.getFileInfo(node).executionEnvironment.pythonVersion, + pythonVersion3_13 + ); let replaceType: FunctionType | undefined; - if ( - PythonVersion.isGreaterOrEqualTo( - AnalyzerNodeInfo.getFileInfo(node).executionEnvironment.pythonVersion, - pythonVersion3_13 - ) - ) { - replaceType = FunctionType.createSynthesizedInstance('__replace__'); + if (synthesizeDunderReplace || isNamedTuple) { + replaceType = FunctionType.createSynthesizedInstance(synthesizeDunderReplace ? '__replace__' : '_replace'); FunctionType.addParam(replaceType, selfParam); FunctionType.addKeywordOnlyParamSeparator(replaceType); replaceType.shared.declaredReturnType = selfType; @@ -796,7 +796,12 @@ export function synthesizeDataClassMethods( symbolTable.set('__new__', Symbol.createWithType(SymbolFlags.ClassMember, newType)); if (replaceType) { - symbolTable.set('__replace__', Symbol.createWithType(SymbolFlags.ClassMember, replaceType)); + if (synthesizeDunderReplace) { + symbolTable.set('__replace__', Symbol.createWithType(SymbolFlags.ClassMember, replaceType)); + } + if (isNamedTuple && !symbolTable.has('_replace')) { + symbolTable.set('_replace', Symbol.createWithType(SymbolFlags.ClassMember, replaceType)); + } } } diff --git a/packages/pyright-internal/src/analyzer/namedTuples.ts b/packages/pyright-internal/src/analyzer/namedTuples.ts index ea3dfcdb217d..a6b4992ae4ed 100644 --- a/packages/pyright-internal/src/analyzer/namedTuples.ts +++ b/packages/pyright-internal/src/analyzer/namedTuples.ts @@ -10,6 +10,7 @@ import { DiagnosticRule } from '../common/diagnosticRules'; import { convertOffsetsToRange } from '../common/positionUtils'; +import { PythonVersion, pythonVersion3_13 } from '../common/pythonVersion'; import { TextRange } from '../common/textRange'; import { LocMessage } from '../localization/localize'; import { ArgCategory, ExpressionNode, ParamCategory, ParseNodeType } from '../parser/parseNodes'; @@ -44,6 +45,8 @@ import { combineTypes, isClassInstance, isInstantiableClass, + isKeywordOnlySeparator, + isPositionOnlySeparator, } from './types'; // Creates a new custom tuple factory class with named values. @@ -376,6 +379,14 @@ export function createNamedTupleType( classFields.set('__new__', Symbol.createWithType(SymbolFlags.ClassMember, constructorType)); classFields.set('__init__', Symbol.createWithType(SymbolFlags.ClassMember, initType)); + synthesizeNamedTupleReplaceMethods( + classFields, + constructorType, + selfParam, + addGenericGetAttribute, + fileInfo.executionEnvironment.pythonVersion + ); + const lenType = FunctionType.createSynthesizedInstance('__len__'); lenType.shared.declaredReturnType = evaluator.getBuiltInObject(errorNode, 'int'); FunctionType.addParam(lenType, selfParam); @@ -422,6 +433,50 @@ export function createNamedTupleType( return classType; } +function synthesizeNamedTupleReplaceMethods( + classFields: Map, + constructorType: FunctionType, + selfParam: FunctionParam, + addGenericGetAttribute: boolean, + pythonVersion: PythonVersion +) { + const synthesizeDunderReplace = PythonVersion.isGreaterOrEqualTo(pythonVersion, pythonVersion3_13); + const replaceType = FunctionType.createSynthesizedInstance(synthesizeDunderReplace ? '__replace__' : '_replace'); + FunctionType.addParam(replaceType, selfParam); + FunctionType.addKeywordOnlyParamSeparator(replaceType); + replaceType.shared.declaredReturnType = selfParam._type; + + if (addGenericGetAttribute) { + FunctionType.addDefaultParams(replaceType); + } else { + constructorType.shared.parameters.forEach((param) => { + if (!param.name || param.name === 'cls' || param.name === 'self') { + return; + } + + if (isPositionOnlySeparator(param) || isKeywordOnlySeparator(param)) { + return; + } + + FunctionType.addParam( + replaceType, + FunctionParam.create( + param.category, + param._type, + param.flags, + param.name, + AnyType.create(/* isEllipsis */ true) + ) + ); + }); + } + + if (synthesizeDunderReplace) { + classFields.set('__replace__', Symbol.createWithType(SymbolFlags.ClassMember, replaceType)); + } + classFields.set('_replace', Symbol.createWithType(SymbolFlags.ClassMember, replaceType)); +} + export function updateNamedTupleBaseClass(classType: ClassType, typeArgs: Type[], isTypeArgExplicit: boolean): boolean { let isUpdateNeeded = false; diff --git a/packages/pyright-internal/src/tests/samples/dataclassReplace1.py b/packages/pyright-internal/src/tests/samples/dataclassReplace1.py index ba93f95e9b94..61d89f2e89ac 100644 --- a/packages/pyright-internal/src/tests/samples/dataclassReplace1.py +++ b/packages/pyright-internal/src/tests/samples/dataclassReplace1.py @@ -43,3 +43,15 @@ class NT1(NamedTuple): # This should generate an error. nt1.__replace__(d="") + + +# _replace is the historical NamedTuple API and should use the same +# keyword-only field signature as __replace__. +nt1_clone2 = nt1._replace(c="") +reveal_type(nt1_clone2, expected_text="NT1") + +# This should generate an error. +nt1._replace(b=2) + +# This should generate an error. +nt1._replace(d="") diff --git a/packages/pyright-internal/src/tests/samples/namedTupleReplace1.py b/packages/pyright-internal/src/tests/samples/namedTupleReplace1.py new file mode 100644 index 000000000000..c47c3e040727 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/namedTupleReplace1.py @@ -0,0 +1,43 @@ +# This sample tests that NamedTuple _replace rejects unknown fields +# and type-incompatible field values, matching runtime TypeError behavior. + + +from collections import namedtuple +from typing import NamedTuple + + +class NT1(NamedTuple): + x: int + y: str + + +nt1 = NT1(1, "") +nt1_clone = nt1._replace(x=2) +reveal_type(nt1_clone, expected_text="NT1") + +# This should generate an error. +nt1._replace(z=1) + +# This should generate an error. +nt1._replace(y=1) + + +NT2 = namedtuple("NT2", ["a", "b"]) +nt2 = NT2(1, 2) +nt2_clone = nt2._replace(a=3) +reveal_type(nt2_clone, expected_text="NT2") + +# This should generate an error. +nt2._replace(c=1) + + +NT3 = NamedTuple("NT3", [("n", int), ("s", str)]) +nt3 = NT3(1, "") +nt3_clone = nt3._replace(s="ok") +reveal_type(nt3_clone, expected_text="NT3") + +# This should generate an error. +nt3._replace(t="no") + +# This should generate an error. +nt3._replace(n="") diff --git a/packages/pyright-internal/src/tests/typeEvaluator4.test.ts b/packages/pyright-internal/src/tests/typeEvaluator4.test.ts index acb7d070a9ab..70a8d552c636 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator4.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator4.test.ts @@ -413,11 +413,11 @@ test('DataClassReplace1', () => { configOptions.defaultPythonVersion = pythonVersion3_12; const analysisResults1 = TestUtils.typeAnalyzeSampleFiles(['dataclassReplace1.py'], configOptions); - TestUtils.validateResults(analysisResults1, 10); + TestUtils.validateResults(analysisResults1, 12); configOptions.defaultPythonVersion = pythonVersion3_13; const analysisResults2 = TestUtils.typeAnalyzeSampleFiles(['dataclassReplace1.py'], configOptions); - TestUtils.validateResults(analysisResults2, 4); + TestUtils.validateResults(analysisResults2, 6); }); test('DataClassFrozen1', () => { diff --git a/packages/pyright-internal/src/tests/typeEvaluator8.test.ts b/packages/pyright-internal/src/tests/typeEvaluator8.test.ts index 83b725e57115..3b4039879715 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator8.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator8.test.ts @@ -13,7 +13,13 @@ import * as assert from 'assert'; import { EvalFlags } from '../analyzer/typeEvaluatorTypes'; import { ClassType, isClassInstance, isInstantiableClass, UnknownType } from '../analyzer/types'; import { ConfigOptions } from '../common/configOptions'; -import { pythonVersion3_10, pythonVersion3_11, pythonVersion3_8, pythonVersion3_12 } from '../common/pythonVersion'; +import { + pythonVersion3_10, + pythonVersion3_11, + pythonVersion3_8, + pythonVersion3_12, + pythonVersion3_13, +} from '../common/pythonVersion'; import { Uri } from '../common/uri/uri'; import { ParseNodeType } from '../parser/parseNodes'; import { getNodeAtMarker, parseAndGetTestState } from './harness/fourslash/testState'; @@ -680,6 +686,18 @@ test('NamedTuple11', () => { TestUtils.validateResults(analysisResults, 3); }); +test('NamedTupleReplace1', () => { + const configOptions = new ConfigOptions(Uri.empty()); + + configOptions.defaultPythonVersion = pythonVersion3_12; + const analysisResults1 = TestUtils.typeAnalyzeSampleFiles(['namedTupleReplace1.py'], configOptions); + TestUtils.validateResults(analysisResults1, 5); + + configOptions.defaultPythonVersion = pythonVersion3_13; + const analysisResults2 = TestUtils.typeAnalyzeSampleFiles(['namedTupleReplace1.py'], configOptions); + TestUtils.validateResults(analysisResults2, 5); +}); + test('NamedTuple12', () => { const configOptions = new ConfigOptions(Uri.empty()); configOptions.defaultPythonVersion = pythonVersion3_12;