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
21 changes: 13 additions & 8 deletions packages/pyright-internal/src/analyzer/dataClasses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
rchiodo marked this conversation as resolved.
Expand Down Expand Up @@ -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));
}
}
}

Expand Down
55 changes: 55 additions & 0 deletions packages/pyright-internal/src/analyzer/namedTuples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +45,8 @@ import {
combineTypes,
isClassInstance,
isInstantiableClass,
isKeywordOnlySeparator,
isPositionOnlySeparator,
} from './types';

// Creates a new custom tuple factory class with named values.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -422,6 +433,50 @@ export function createNamedTupleType(
return classType;
}

function synthesizeNamedTupleReplaceMethods(
classFields: Map<string, Symbol>,
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) => {

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

Filtering parameters by the names self and cls removes legal NamedTuple fields with those names. For example, NamedTuple("NT", [("self", int)]) will not accept _replace(self=2). Skip only the actual receiver parameter and add regression coverage for both field names.

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,

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 derives replacement fields from the synthesized constructor while the class-syntax NamedTuple path builds the corresponding signature in dataClasses.ts. The duplicated policy can drift between class and factory NamedTuples; extract a shared field-signature builder or otherwise centralize this logic.

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;

Expand Down
12 changes: 12 additions & 0 deletions packages/pyright-internal/src/tests/samples/dataclassReplace1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")
43 changes: 43 additions & 0 deletions packages/pyright-internal/src/tests/samples/namedTupleReplace1.py
Original file line number Diff line number Diff line change
@@ -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="")
4 changes: 2 additions & 2 deletions packages/pyright-internal/src/tests/typeEvaluator4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
20 changes: 19 additions & 1 deletion packages/pyright-internal/src/tests/typeEvaluator8.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down