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 {

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

Does FunctionType.addDefaultParams add an *args: Any parameter here? If so, dynamically shaped NamedTuples will accept positional _replace updates even though _replace is keyword-only. Please add only permissive **kwargs support and cover rejection of _replace(123).

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

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

On Python 3.13, this installs the function named __replace__ under _replace as well. Consumers of shared.name, including signature and diagnostic rendering, can therefore identify _replace as __replace__. Please synthesize separate function instances while sharing the parameter construction.

}
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.

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

The comment says incompatible annotated values match runtime TypeError behavior, but NamedTuple annotations are not enforced at runtime. Please limit the runtime claim to unexpected field names and describe incompatible values as static type-checking errors.



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