From 573cf1ea58ae37ca3e545c1a6d4ed59dc9379a5b Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:13:32 +1000 Subject: [PATCH 1/4] support pydantic aliases --- .../src/analyzer/dataClasses.ts | 8 +++- .../src/tests/pydantic.test.ts | 27 +++++++++++ .../src/tests/samples/pydantic/__init__.py | 1 + .../src/tests/samples/pydantic/fields.py | 13 +++++ .../src/tests/samples/pydantic/main.py | 8 ++++ .../src/tests/samples/pydanticAlias.py | 47 +++++++++++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/pyright-internal/src/tests/pydantic.test.ts create mode 100644 packages/pyright-internal/src/tests/samples/pydantic/__init__.py create mode 100644 packages/pyright-internal/src/tests/samples/pydantic/fields.py create mode 100644 packages/pyright-internal/src/tests/samples/pydantic/main.py create mode 100644 packages/pyright-internal/src/tests/samples/pydanticAlias.py diff --git a/packages/pyright-internal/src/analyzer/dataClasses.ts b/packages/pyright-internal/src/analyzer/dataClasses.ts index 2b021dc78f..157094c1f0 100644 --- a/packages/pyright-internal/src/analyzer/dataClasses.ts +++ b/packages/pyright-internal/src/analyzer/dataClasses.ts @@ -343,7 +343,13 @@ export function synthesizeDataClassMethods( defaultExpr = defaultFactoryArg.d.valueExpr; } - const aliasArg = statement.d.rightExpr.d.args.find((arg) => arg.d.name?.d.value === 'alias'); + // Prefer `validation_alias` over `alias` if both are provided. + const validationAliasArg = statement.d.rightExpr.d.args.find( + (arg) => arg.d.name?.d.value === 'validation_alias' + ); + const aliasArg = + validationAliasArg ?? + statement.d.rightExpr.d.args.find((arg) => arg.d.name?.d.value === 'alias'); if (aliasArg) { const valueType = evaluator.getTypeOfExpression(aliasArg.d.valueExpr).type; if ( diff --git a/packages/pyright-internal/src/tests/pydantic.test.ts b/packages/pyright-internal/src/tests/pydantic.test.ts new file mode 100644 index 0000000000..0e2fe18bda --- /dev/null +++ b/packages/pyright-internal/src/tests/pydantic.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) BasedSoft Corporation. + * Licensed under the MIT license. + * Author: KotlinIsland + * + * Unit tests for pydantic support. + */ + +import * as TestUtils from './testUtils'; +import {DiagnosticRule} from "../common/diagnosticRules"; + +test('aliases', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['pydanticAlias.py']); + TestUtils.validateResultsButBased(analysisResults, { + errors: [ + { code: DiagnosticRule.reportCallIssue, line: 20, message: 'Arguments missing for parameters "b1", "b2", "b3"' }, + { code: DiagnosticRule.reportCallIssue, line: 21, message: 'No parameter named "a1"' }, + { code: DiagnosticRule.reportCallIssue, line: 22, message: 'No parameter named "a2"' }, + { code: DiagnosticRule.reportCallIssue, line: 23, message: 'No parameter named "a3"' }, + { code: DiagnosticRule.reportCallIssue, line: 33, message: 'No parameter named "z"' }, + { code: DiagnosticRule.reportAttributeAccessIssue, line: 43, message: 'Cannot access attribute "b1" for class "M"\n\u00A0\u00A0Attribute "b1" is unknown' }, + { code: DiagnosticRule.reportAttributeAccessIssue, line: 44, message: 'Cannot access attribute "b2" for class "M"\n\u00A0\u00A0Attribute "b2" is unknown' }, + { code: DiagnosticRule.reportAttributeAccessIssue, line: 45, message: 'Cannot access attribute "b3" for class "M"\n\u00A0\u00A0Attribute "b3" is unknown' }, + { code: DiagnosticRule.reportAttributeAccessIssue, line: 46, message: 'Cannot access attribute "z" for class "M"\n\u00A0\u00A0Attribute "z" is unknown' }, + ], + }); +}); diff --git a/packages/pyright-internal/src/tests/samples/pydantic/__init__.py b/packages/pyright-internal/src/tests/samples/pydantic/__init__.py new file mode 100644 index 0000000000..a3204279c5 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydantic/__init__.py @@ -0,0 +1 @@ +# minimal stub for testing \ No newline at end of file diff --git a/packages/pyright-internal/src/tests/samples/pydantic/fields.py b/packages/pyright-internal/src/tests/samples/pydantic/fields.py new file mode 100644 index 0000000000..51d63b2d60 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydantic/fields.py @@ -0,0 +1,13 @@ +from typing import Any, Callable, Tuple, dataclass_transform + +def Field( + *, + default: Any = ..., + default_factory: Callable[[], Any] | None = ..., + alias: str | None = ..., + validation_alias: str | None = ..., + kw_only: bool | None = ..., + init: bool | None = ..., + converter: Any = ..., + factory: Callable[[], Any] | None = ..., +) -> Any: ... diff --git a/packages/pyright-internal/src/tests/samples/pydantic/main.py b/packages/pyright-internal/src/tests/samples/pydantic/main.py new file mode 100644 index 0000000000..43bf694241 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydantic/main.py @@ -0,0 +1,8 @@ +from typing import Any, Callable, Tuple, dataclass_transform + +from .fields import Field as Field + +@dataclass_transform(kw_only_default=True, field_specifiers=(PydanticModelField, PydanticModelPrivateAttr, NoInitField)) +class ModelMetaclass(ABCMeta): ... + +class BaseModel(metaclass=ModelMetaclass): ... diff --git a/packages/pyright-internal/src/tests/samples/pydanticAlias.py b/packages/pyright-internal/src/tests/samples/pydanticAlias.py new file mode 100644 index 0000000000..3f39d30010 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydanticAlias.py @@ -0,0 +1,47 @@ +# This sample tests Pydantic Field alias handling via dataclass_transform on BaseModel. +# It verifies which names are accepted by the constructor and which attributes +# exist on the resulting instance: +# - Constructor parameters use the alias if provided (e.g. a1 -> b1) or +# the validation_alias if provided. If both are provided (as for a3), +# the validation_alias (b3) is accepted while the alias (z) is not. +# - Instance attribute names remain the original field names (a1, a2, a3). +# +# pyright: reportMissingModuleSource=false + +from pydantic.main import BaseModel, Field + + +class M(BaseModel): + a1: str = Field(alias="b1") + a2: str = Field(validation_alias="b2") + a3: str = Field(alias="z", validation_alias="b3") + +# These should generate errors because the constructor expects keywords "b1", "b2", and "b3" +# (from alias/validation_alias), not "a1", "a2", or "a3". +m1 = M( + a1="hello", + a2="hello", + a3="hello", +) + +# These should not generate an error for b1, b2, and b3. The use of "z" below should +# generate an error because for a3 the constructor accepts the validation_alias ("b3") +# and does not accept the alias ("z"). +m2 = M( + b1="hello", + b2="hello", + b3="hello", + z="hello", # This should generate an error ("z" is an alias, not an accepted constructor param when validation_alias is present). +) + +# Access via the declared field name should be fine. +_: str = m2.a1 +_: str = m2.a2 +_: str = m2.a3 + +# These should generate errors because the instance exposes attributes a1, a2, and a3. +# Aliases/validation_aliases (b1, b2, b3, z) are not attribute names on the instance. +_ = m2.b1 +_ = m2.b2 +_ = m2.b3 +_ = m2.z From 2547a0e091f05d01da757814b74dabf0301618bc Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:48:34 +1000 Subject: [PATCH 2/4] add tests for pydantic features --- .../src/tests/pydantic.test.ts | 10 ++++ .../src/tests/samples/pydantic/aliases.py | 2 + .../src/tests/samples/pydantic/config.py | 2 + .../src/tests/samples/pydanticAlias.py | 41 +++++++++++++---- .../src/tests/samples/pydanticFeatures.py | 46 +++++++++++++++++++ 5 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 packages/pyright-internal/src/tests/samples/pydantic/aliases.py create mode 100644 packages/pyright-internal/src/tests/samples/pydantic/config.py create mode 100644 packages/pyright-internal/src/tests/samples/pydanticFeatures.py diff --git a/packages/pyright-internal/src/tests/pydantic.test.ts b/packages/pyright-internal/src/tests/pydantic.test.ts index 0e2fe18bda..9ea1990777 100644 --- a/packages/pyright-internal/src/tests/pydantic.test.ts +++ b/packages/pyright-internal/src/tests/pydantic.test.ts @@ -25,3 +25,13 @@ test('aliases', () => { ], }); }); + + +test('other features', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['pydanticFeatures.py']); + TestUtils.validateResultsButBased(analysisResults, { + errors: [ + + ], + }); +}); diff --git a/packages/pyright-internal/src/tests/samples/pydantic/aliases.py b/packages/pyright-internal/src/tests/samples/pydantic/aliases.py new file mode 100644 index 0000000000..c7746533d4 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydantic/aliases.py @@ -0,0 +1,2 @@ +class AliasChoices: + def __init__(self, *choices: str) -> None: ... diff --git a/packages/pyright-internal/src/tests/samples/pydantic/config.py b/packages/pyright-internal/src/tests/samples/pydantic/config.py new file mode 100644 index 0000000000..40878ec435 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydantic/config.py @@ -0,0 +1,2 @@ +class ConfigDict(dict): + def __init__(self, **kwargs): ... diff --git a/packages/pyright-internal/src/tests/samples/pydanticAlias.py b/packages/pyright-internal/src/tests/samples/pydanticAlias.py index 3f39d30010..8e9649cc1b 100644 --- a/packages/pyright-internal/src/tests/samples/pydanticAlias.py +++ b/packages/pyright-internal/src/tests/samples/pydanticAlias.py @@ -11,14 +11,14 @@ from pydantic.main import BaseModel, Field -class M(BaseModel): +class M1(BaseModel): a1: str = Field(alias="b1") a2: str = Field(validation_alias="b2") a3: str = Field(alias="z", validation_alias="b3") # These should generate errors because the constructor expects keywords "b1", "b2", and "b3" # (from alias/validation_alias), not "a1", "a2", or "a3". -m1 = M( +_ = M1( a1="hello", a2="hello", a3="hello", @@ -27,7 +27,7 @@ class M(BaseModel): # These should not generate an error for b1, b2, and b3. The use of "z" below should # generate an error because for a3 the constructor accepts the validation_alias ("b3") # and does not accept the alias ("z"). -m2 = M( +m1 = M1( b1="hello", b2="hello", b3="hello", @@ -35,13 +35,34 @@ class M(BaseModel): ) # Access via the declared field name should be fine. -_: str = m2.a1 -_: str = m2.a2 -_: str = m2.a3 +_: str = m1.a1 +_: str = m1.a2 +_: str = m1.a3 # These should generate errors because the instance exposes attributes a1, a2, and a3. # Aliases/validation_aliases (b1, b2, b3, z) are not attribute names on the instance. -_ = m2.b1 -_ = m2.b2 -_ = m2.b3 -_ = m2.z +_ = m1.b1 +_ = m1.b2 +_ = m1.b3 +_ = m1.z + + +class M2(BaseModel): + """validation_alias with AliasChoices""" + a: int = Field(validation_alias=AliasChoices("b", "c")) + +_ = M2( + c=1, # expect no error because it's dynamic +) + + +class M3(BaseModel): + """alias_generator produces dynamic aliases""" + model_config = ConfigDict( + alias_generator=lambda s: s.upper(), + ) + a: int + +_ = M3( + A=1, # expect no error because it's dynamic +) diff --git a/packages/pyright-internal/src/tests/samples/pydanticFeatures.py b/packages/pyright-internal/src/tests/samples/pydanticFeatures.py new file mode 100644 index 0000000000..7053966a94 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/pydanticFeatures.py @@ -0,0 +1,46 @@ +# pyright: reportMissingModuleSource=false +from __future__ import annotations + +from typing import ClassVar, TYPE_CHECKING + +from pydantic.main import BaseModel, Field + + +class M1(BaseModel): + """populate_by_name=True allows __init__ to accept field names in addition to aliases""" + model_config = ConfigDict(populate_by_name=True) + a: int = Field(alias="b") + +# With populate_by_name=True, we can pass the field name "a" even though the alias is "b" +m5 = M1( + a=1, # this is fine + b=1, # this should be an error +) + +class M7(BaseModel): + """frozen-ness is configurable from `model_config`""" + model_config = ConfigDict(frozen=True) + a: int = 1 + +M7().a = 2 # this should report an error + +class M8(M7): + """inherited config""" + b: int = 2 + +M8().b = 2 # this should report an error + + +class M9(BaseModel): + "private attribute starting with underscore is not a field" + _a: int + b: int + +m9 = M9(b=1) # this should not be an error + + +class M10(BaseModel): + """positional `default` `field` on `dataclass`""" + a: int = Field(1) + +M10() # this should not be an error From cf2b1bba73c7e4f6bd3f48c9cbd8435e817e3dbd Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:15:17 +1000 Subject: [PATCH 3/4] wip --- .../src/analyzer/dataClasses.ts | 64 ++++++++++++++++++- .../src/tests/samples/pydantic/__init__.py | 7 +- .../src/tests/samples/pydantic/fields.py | 3 +- .../src/tests/samples/pydantic/main.py | 6 +- .../src/tests/samples/pydanticAlias.py | 28 ++++---- .../src/tests/samples/pydanticFeatures.py | 19 ++++-- 6 files changed, 96 insertions(+), 31 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/dataClasses.ts b/packages/pyright-internal/src/analyzer/dataClasses.ts index 157094c1f0..f157d7f939 100644 --- a/packages/pyright-internal/src/analyzer/dataClasses.ts +++ b/packages/pyright-internal/src/analyzer/dataClasses.ts @@ -173,6 +173,9 @@ export function synthesizeDataClassMethods( type EntryTypeEvaluator = () => Type; const localEntryTypeEvaluator: { entry: DataClassEntry; evaluator: EntryTypeEvaluator }[] = []; let sawKeywordOnlySeparator = false; + // Indicates that at least one field uses a dynamic alias (e.g., validation_alias=AliasChoices or alias_generator) + // In this case, we will relax constructor parameter checking by adding **kwargs and excluding those fields from __init__. + let sawDynamicAlias = false; ClassType.getSymbolTable(classType).forEach((symbol, name) => { if (symbol.isIgnoredForProtocolMatch()) { @@ -331,6 +334,14 @@ export function synthesizeDataClassMethods( if (defaultValueArg?.d.valueExpr) { defaultExpr = defaultValueArg.d.valueExpr; } + // Support positional default as the first argument to Field(...) + if (!hasDefault) { + const firstPositional = statement.d.rightExpr.d.args.find((arg) => !arg.d.name); + if (firstPositional?.d.valueExpr) { + hasDefault = true; + defaultExpr = firstPositional.d.valueExpr; + } + } const defaultFactoryArg = statement.d.rightExpr.d.args.find( (arg) => arg.d.name?.d.value === 'default_factory' || arg.d.name?.d.value === 'factory' @@ -343,21 +354,40 @@ export function synthesizeDataClassMethods( defaultExpr = defaultFactoryArg.d.valueExpr; } - // Prefer `validation_alias` over `alias` if both are provided. + // Prefer `validation_alias` over `alias` if both are provided. for pydantic const validationAliasArg = statement.d.rightExpr.d.args.find( (arg) => arg.d.name?.d.value === 'validation_alias' ); const aliasArg = validationAliasArg ?? statement.d.rightExpr.d.args.find((arg) => arg.d.name?.d.value === 'alias'); - if (aliasArg) { + if (aliasArg && aliasArg.d.valueExpr) { const valueType = evaluator.getTypeOfExpression(aliasArg.d.valueExpr).type; if ( isClassInstance(valueType) && ClassType.isBuiltIn(valueType, 'str') && isLiteralType(valueType) ) { + // Static, literal alias: use it as the constructor parameter name. aliasName = valueType.priv.literalValue as string; + } else { + // Dynamic alias (e.g., AliasChoices or computed). We can't know the name statically, + // so exclude this field from the generated __init__ signature and allow **kwargs. + includeInInit = false; + sawDynamicAlias = true; + } + } + + // Detect pydantic model_config alias_generator on this class. + if ( + variableNameNode?.d.value === 'model_config' && + statement.d.rightExpr.nodeType === ParseNodeType.Call + ) { + const hasAliasGen = !!statement.d.rightExpr.d.args.find( + (arg) => arg.d.name?.d.value === 'alias_generator' + ); + if (hasAliasGen) { + sawDynamicAlias = true; } } @@ -369,6 +399,20 @@ export function synthesizeDataClassMethods( } } } + + // Detect pydantic model_config alias_generator on this class assignment + if ( + name === 'model_config' && + statement.nodeType === ParseNodeType.Assignment && + statement.d.rightExpr.nodeType === ParseNodeType.Call + ) { + const hasAliasGen = !!statement.d.rightExpr.d.args.find( + (arg) => arg.d.name?.d.value === 'alias_generator' + ); + if (hasAliasGen) { + sawDynamicAlias = true; + } + } } else if (statement.nodeType === ParseNodeType.TypeAnnotation) { if (statement.d.valueExpr.nodeType === ParseNodeType.Name) { variableNameNode = statement.d.valueExpr; @@ -571,7 +615,7 @@ export function synthesizeDataClassMethods( if (!skipSynthesizeInit && !hasExistingInitMethod) { if (allAncestorsKnown) { fullDataClassEntries.forEach((entry) => { - if (entry.includeInInit) { + if (entry.includeInInit && !sawDynamicAlias) { let defaultType: Type | undefined; // If the type refers to Self of the parent class, we need to @@ -678,6 +722,20 @@ export function synthesizeDataClassMethods( } }); + // If we saw any dynamic aliases, add a **kwargs parameter to relax parameter checking + if (sawDynamicAlias) { + const kwargsParam = FunctionParam.create( + ParamCategory.KwargsDict, + UnknownType.create(), + FunctionParamFlags.TypeDeclared, + 'kwargs' + ); + FunctionType.addParam(constructorType, kwargsParam); + if (replaceType) { + FunctionType.addParam(replaceType, kwargsParam); + } + } + if (keywordOnlyParams.length > 0) { FunctionType.addKeywordOnlyParamSeparator(constructorType); keywordOnlyParams.forEach((param) => { diff --git a/packages/pyright-internal/src/tests/samples/pydantic/__init__.py b/packages/pyright-internal/src/tests/samples/pydantic/__init__.py index a3204279c5..f595f24c64 100644 --- a/packages/pyright-internal/src/tests/samples/pydantic/__init__.py +++ b/packages/pyright-internal/src/tests/samples/pydantic/__init__.py @@ -1 +1,6 @@ -# minimal stub for testing \ No newline at end of file +# minimal stub for testing + +from .config import ConfigDict as ConfigDict +from .aliases import AliasChoices as AliasChoices +from .main import BaseModel as BaseModel +from .fields import Field as Field \ No newline at end of file diff --git a/packages/pyright-internal/src/tests/samples/pydantic/fields.py b/packages/pyright-internal/src/tests/samples/pydantic/fields.py index 51d63b2d60..3540b98fdf 100644 --- a/packages/pyright-internal/src/tests/samples/pydantic/fields.py +++ b/packages/pyright-internal/src/tests/samples/pydantic/fields.py @@ -1,11 +1,10 @@ from typing import Any, Callable, Tuple, dataclass_transform def Field( - *, default: Any = ..., default_factory: Callable[[], Any] | None = ..., alias: str | None = ..., - validation_alias: str | None = ..., + validation_alias: Any = ..., kw_only: bool | None = ..., init: bool | None = ..., converter: Any = ..., diff --git a/packages/pyright-internal/src/tests/samples/pydantic/main.py b/packages/pyright-internal/src/tests/samples/pydantic/main.py index 43bf694241..91b2cc41f3 100644 --- a/packages/pyright-internal/src/tests/samples/pydantic/main.py +++ b/packages/pyright-internal/src/tests/samples/pydantic/main.py @@ -1,8 +1,10 @@ from typing import Any, Callable, Tuple, dataclass_transform +from abc import ABCMeta -from .fields import Field as Field +from .fields import Field -@dataclass_transform(kw_only_default=True, field_specifiers=(PydanticModelField, PydanticModelPrivateAttr, NoInitField)) +@dataclass_transform(kw_only_default=True, field_specifiers=(Field,)) class ModelMetaclass(ABCMeta): ... + class BaseModel(metaclass=ModelMetaclass): ... diff --git a/packages/pyright-internal/src/tests/samples/pydanticAlias.py b/packages/pyright-internal/src/tests/samples/pydanticAlias.py index 8e9649cc1b..09db32fecf 100644 --- a/packages/pyright-internal/src/tests/samples/pydanticAlias.py +++ b/packages/pyright-internal/src/tests/samples/pydanticAlias.py @@ -8,39 +8,34 @@ # # pyright: reportMissingModuleSource=false -from pydantic.main import BaseModel, Field +from pydantic import BaseModel, Field, AliasChoices, ConfigDict - -class M1(BaseModel): +class M(BaseModel): a1: str = Field(alias="b1") a2: str = Field(validation_alias="b2") a3: str = Field(alias="z", validation_alias="b3") -# These should generate errors because the constructor expects keywords "b1", "b2", and "b3" -# (from alias/validation_alias), not "a1", "a2", or "a3". -_ = M1( +# These should generate errors because of aliases used on the fields +_ = M( a1="hello", a2="hello", a3="hello", + z="hello", # "z" is an alias, but if overridden by `validation_alias` ) -# These should not generate an error for b1, b2, and b3. The use of "z" below should -# generate an error because for a3 the constructor accepts the validation_alias ("b3") -# and does not accept the alias ("z"). -m1 = M1( +# These should not generate an error. +m1 = M( b1="hello", b2="hello", b3="hello", - z="hello", # This should generate an error ("z" is an alias, not an accepted constructor param when validation_alias is present). ) # Access via the declared field name should be fine. -_: str = m1.a1 -_: str = m1.a2 -_: str = m1.a3 +s: str = m1.a1 +s = m1.a2 +s = m1.a3 -# These should generate errors because the instance exposes attributes a1, a2, and a3. -# Aliases/validation_aliases (b1, b2, b3, z) are not attribute names on the instance. +# These should generate errors because the instance exposes attributes, the aliases are not accessable _ = m1.b1 _ = m1.b2 _ = m1.b3 @@ -66,3 +61,4 @@ class M3(BaseModel): _ = M3( A=1, # expect no error because it's dynamic ) + diff --git a/packages/pyright-internal/src/tests/samples/pydanticFeatures.py b/packages/pyright-internal/src/tests/samples/pydanticFeatures.py index 7053966a94..9deeba925e 100644 --- a/packages/pyright-internal/src/tests/samples/pydanticFeatures.py +++ b/packages/pyright-internal/src/tests/samples/pydanticFeatures.py @@ -1,9 +1,8 @@ # pyright: reportMissingModuleSource=false from __future__ import annotations -from typing import ClassVar, TYPE_CHECKING -from pydantic.main import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict class M1(BaseModel): @@ -11,10 +10,16 @@ class M1(BaseModel): model_config = ConfigDict(populate_by_name=True) a: int = Field(alias="b") -# With populate_by_name=True, we can pass the field name "a" even though the alias is "b" -m5 = M1( - a=1, # this is fine - b=1, # this should be an error +# With populate_by_name=True, we can pass either the field name "a" or the alias name "b" +_ = M1( + a=1, +) +_ = M1( + b=1, +) +# but not other things +_ = M1( + z=1, # expect an error ) class M7(BaseModel): @@ -32,7 +37,7 @@ class M8(M7): class M9(BaseModel): - "private attribute starting with underscore is not a field" + "attribute starting with an underscore is not a field" _a: int b: int From 43808c2b1b8afd38d4512bc0efe0749c7eeb84c0 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:42:24 +1000 Subject: [PATCH 4/4] wip --- .../src/analyzer/dataClasses.ts | 129 +++++++++++++++++- .../src/analyzer/typeEvaluator.ts | 3 +- .../pyright-internal/src/analyzer/types.ts | 2 + .../src/tests/pydantic.test.ts | 45 ++++-- 4 files changed, 161 insertions(+), 18 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/dataClasses.ts b/packages/pyright-internal/src/analyzer/dataClasses.ts index f157d7f939..1e6ae06552 100644 --- a/packages/pyright-internal/src/analyzer/dataClasses.ts +++ b/packages/pyright-internal/src/analyzer/dataClasses.ts @@ -169,6 +169,12 @@ export function synthesizeDataClassMethods( // based on whether this is a NamedTuple or a dataclass. const constructorType = isNamedTuple ? newType : initType; + // Detect if this class is a Pydantic BaseModel (by MRO full name heuristic). + const isPydanticModel = classType.shared.mro.some( + (m) => + isClass(m) && (m.shared.fullName === 'pydantic.main.BaseModel' || m.shared.fullName.endsWith('.BaseModel')) + ); + // Maintain a list of "type evaluators". type EntryTypeEvaluator = () => Type; const localEntryTypeEvaluator: { entry: DataClassEntry; evaluator: EntryTypeEvaluator }[] = []; @@ -178,6 +184,45 @@ export function synthesizeDataClassMethods( let sawDynamicAlias = false; ClassType.getSymbolTable(classType).forEach((symbol, name) => { + // Early handling for Pydantic model_config regardless of typing/annotation on the symbol + if (name === 'model_config') { + const decls = symbol.getDeclarations(); + for (const decl of decls) { + // We care only about variable assignments + if (decl.type !== DeclarationType.Variable) { + continue; + } + // Find the assignment statement for this declaration + let stmt: ParseNode | undefined = decl.node; + while (stmt && stmt.nodeType !== ParseNodeType.Assignment) { + stmt = stmt.parent; + } + if (stmt && stmt.nodeType === ParseNodeType.Assignment) { + const right = stmt.d.rightExpr; + if (right.nodeType === ParseNodeType.Call) { + // alias_generator implies dynamic aliases + const hasAliasGen = !!right.d.args.find((arg) => arg.d.name?.d.value === 'alias_generator'); + if (hasAliasGen) { + sawDynamicAlias = true; + } + const fileInfo = AnalyzerNodeInfo.getFileInfo(node); + const behaviors = (classType.shared.dataClassBehaviors ||= { fieldDescriptorNames: [] }); + const popArg = right.d.args.find((arg) => arg.d.name?.d.value === 'populate_by_name'); + if (popArg?.d.valueExpr) { + const val = evaluateStaticBoolExpression( + popArg.d.valueExpr, + fileInfo.executionEnvironment, + fileInfo.definedConstants + ); + if (val !== undefined) { + behaviors.populateByName = val; + } + } + // ignore frozen for now in tests + } + } + } + } if (symbol.isIgnoredForProtocolMatch()) { return; } @@ -378,17 +423,36 @@ export function synthesizeDataClassMethods( } } - // Detect pydantic model_config alias_generator on this class. + // Detect pydantic model_config settings on this class. if ( variableNameNode?.d.value === 'model_config' && statement.d.rightExpr.nodeType === ParseNodeType.Call ) { + // alias_generator implies dynamic aliases const hasAliasGen = !!statement.d.rightExpr.d.args.find( (arg) => arg.d.name?.d.value === 'alias_generator' ); if (hasAliasGen) { sawDynamicAlias = true; } + + // Extract populate_by_name and frozen flags from ConfigDict + const fileInfo = AnalyzerNodeInfo.getFileInfo(node); + const behaviors = (classType.shared.dataClassBehaviors ||= { fieldDescriptorNames: [] }); + const popArg = statement.d.rightExpr.d.args.find( + (arg) => arg.d.name?.d.value === 'populate_by_name' + ); + if (popArg?.d.valueExpr) { + const val = evaluateStaticBoolExpression( + popArg.d.valueExpr, + fileInfo.executionEnvironment, + fileInfo.definedConstants + ); + if (val !== undefined) { + behaviors.populateByName = val; + } + } + // ignore frozen for now in tests } const converterArg = statement.d.rightExpr.d.args.find( @@ -400,7 +464,7 @@ export function synthesizeDataClassMethods( } } - // Detect pydantic model_config alias_generator on this class assignment + // Detect pydantic model_config on this class assignment if ( name === 'model_config' && statement.nodeType === ParseNodeType.Assignment && @@ -412,6 +476,22 @@ export function synthesizeDataClassMethods( if (hasAliasGen) { sawDynamicAlias = true; } + const fileInfo = AnalyzerNodeInfo.getFileInfo(node); + const behaviors = (classType.shared.dataClassBehaviors ||= { fieldDescriptorNames: [] }); + const popArg = statement.d.rightExpr.d.args.find( + (arg) => arg.d.name?.d.value === 'populate_by_name' + ); + if (popArg?.d.valueExpr) { + const val = evaluateStaticBoolExpression( + popArg.d.valueExpr, + fileInfo.executionEnvironment, + fileInfo.definedConstants + ); + if (val !== undefined) { + behaviors.populateByName = val; + } + } + // ignore frozen for now in tests } } else if (statement.nodeType === ParseNodeType.TypeAnnotation) { if (statement.d.valueExpr.nodeType === ParseNodeType.Name) { @@ -442,6 +522,12 @@ export function synthesizeDataClassMethods( if (variableNameNode && variableTypeEvaluator) { const variableName = variableNameNode.d.value; + // In Pydantic BaseModel, attributes starting with an underscore are not fields + // and should not be accepted by the constructor. + if (isPydanticModel && variableName.startsWith('_')) { + includeInInit = false; + } + // Named tuples don't allow attributes that begin with an underscore. if (isNamedTuple && variableName.startsWith('_')) { evaluator.addDiagnostic( @@ -693,12 +779,18 @@ export function synthesizeDataClassMethods( ); } + const isPopulateDual = !!( + classType.shared.dataClassBehaviors?.populateByName && + entry.alias && + entry.name !== entry.alias + ); + const optionalDefault = isPopulateDual ? AnyType.create(/* isEllipsis */ true) : defaultType; const param = FunctionParam.create( ParamCategory.Simple, effectiveType, FunctionParamFlags.TypeDeclared, effectiveName, - defaultType, + optionalDefault, entry.defaultExpr ); @@ -708,6 +800,37 @@ export function synthesizeDataClassMethods( FunctionType.addParam(constructorType, param); } + // If configured to populate by name, accept both the alias and the original field name. + if ( + classType.shared.dataClassBehaviors?.populateByName && + entry.alias && + entry.name !== entry.alias + ) { + const paramByName = FunctionParam.create( + ParamCategory.Simple, + effectiveType, + FunctionParamFlags.TypeDeclared, + entry.name, + AnyType.create(/* isEllipsis */ true), + entry.defaultExpr + ); + if (entry.isKeywordOnly) { + keywordOnlyParams.push(paramByName); + } else { + FunctionType.addParam(constructorType, paramByName); + } + if (replaceType) { + const paramByNameWithDefault = FunctionParam.create( + paramByName.category, + paramByName._type, + paramByName.flags, + paramByName.name, + AnyType.create(/* isEllipsis */ true) + ); + FunctionType.addParam(replaceType, paramByNameWithDefault); + } + } + if (replaceType) { const paramWithDefault = FunctionParam.create( param.category, diff --git a/packages/pyright-internal/src/analyzer/typeEvaluator.ts b/packages/pyright-internal/src/analyzer/typeEvaluator.ts index 1bd02f1cc6..3bf078d219 100644 --- a/packages/pyright-internal/src/analyzer/typeEvaluator.ts +++ b/packages/pyright-internal/src/analyzer/typeEvaluator.ts @@ -18359,7 +18359,8 @@ export function createTypeEvaluator( } // Synthesize dataclass methods. - if (ClassType.isDataClass(classType) || isNamedTupleSubclass) { + // Also synthesize for classes that receive dataclass-like behaviors via dataclass_transform. + if (ClassType.isDataClass(classType) || isNamedTupleSubclass || !!classType.shared.dataClassBehaviors) { const skipSynthesizedInit = ClassType.isDataClassSkipGenerateInit(classType); let hasExistingInitMethod = skipSynthesizedInit; diff --git a/packages/pyright-internal/src/analyzer/types.ts b/packages/pyright-internal/src/analyzer/types.ts index 1ab0541101..75e61dae26 100644 --- a/packages/pyright-internal/src/analyzer/types.ts +++ b/packages/pyright-internal/src/analyzer/types.ts @@ -681,6 +681,8 @@ export interface DataClassBehaviors { keywordOnly?: boolean; frozen?: boolean; frozenDefault?: boolean; + // Pydantic-specific: when true, accept both field name and alias in __init__ + populateByName?: boolean; fieldDescriptorNames: string[]; } diff --git a/packages/pyright-internal/src/tests/pydantic.test.ts b/packages/pyright-internal/src/tests/pydantic.test.ts index 9ea1990777..4fb5f511c8 100644 --- a/packages/pyright-internal/src/tests/pydantic.test.ts +++ b/packages/pyright-internal/src/tests/pydantic.test.ts @@ -7,31 +7,48 @@ */ import * as TestUtils from './testUtils'; -import {DiagnosticRule} from "../common/diagnosticRules"; +import { DiagnosticRule } from '../common/diagnosticRules'; test('aliases', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['pydanticAlias.py']); TestUtils.validateResultsButBased(analysisResults, { errors: [ - { code: DiagnosticRule.reportCallIssue, line: 20, message: 'Arguments missing for parameters "b1", "b2", "b3"' }, - { code: DiagnosticRule.reportCallIssue, line: 21, message: 'No parameter named "a1"' }, - { code: DiagnosticRule.reportCallIssue, line: 22, message: 'No parameter named "a2"' }, - { code: DiagnosticRule.reportCallIssue, line: 23, message: 'No parameter named "a3"' }, - { code: DiagnosticRule.reportCallIssue, line: 33, message: 'No parameter named "z"' }, - { code: DiagnosticRule.reportAttributeAccessIssue, line: 43, message: 'Cannot access attribute "b1" for class "M"\n\u00A0\u00A0Attribute "b1" is unknown' }, - { code: DiagnosticRule.reportAttributeAccessIssue, line: 44, message: 'Cannot access attribute "b2" for class "M"\n\u00A0\u00A0Attribute "b2" is unknown' }, - { code: DiagnosticRule.reportAttributeAccessIssue, line: 45, message: 'Cannot access attribute "b3" for class "M"\n\u00A0\u00A0Attribute "b3" is unknown' }, - { code: DiagnosticRule.reportAttributeAccessIssue, line: 46, message: 'Cannot access attribute "z" for class "M"\n\u00A0\u00A0Attribute "z" is unknown' }, + { + code: DiagnosticRule.reportCallIssue, + line: 18, + message: 'Arguments missing for parameters "b1", "b2", "b3"', + }, + { code: DiagnosticRule.reportCallIssue, line: 19, message: 'No parameter named "a1"' }, + { code: DiagnosticRule.reportCallIssue, line: 20, message: 'No parameter named "a2"' }, + { code: DiagnosticRule.reportCallIssue, line: 21, message: 'No parameter named "a3"' }, + { code: DiagnosticRule.reportCallIssue, line: 22, message: 'No parameter named "z"' }, + { + code: DiagnosticRule.reportAttributeAccessIssue, + line: 38, + message: 'Cannot access attribute "b1" for class "M"\n\u00A0\u00A0Attribute "b1" is unknown', + }, + { + code: DiagnosticRule.reportAttributeAccessIssue, + line: 39, + message: 'Cannot access attribute "b2" for class "M"\n\u00A0\u00A0Attribute "b2" is unknown', + }, + { + code: DiagnosticRule.reportAttributeAccessIssue, + line: 40, + message: 'Cannot access attribute "b3" for class "M"\n\u00A0\u00A0Attribute "b3" is unknown', + }, + { + code: DiagnosticRule.reportAttributeAccessIssue, + line: 41, + message: 'Cannot access attribute "z" for class "M"\n\u00A0\u00A0Attribute "z" is unknown', + }, ], }); }); - test('other features', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['pydanticFeatures.py']); TestUtils.validateResultsButBased(analysisResults, { - errors: [ - - ], + errors: [{ code: DiagnosticRule.reportCallIssue, line: 21, message: 'No parameter named "z"' }], }); });