Skip to content

Drop inherited dataclass default when an override assigns a field specifier - #11664

Open
Nilesh Patil (nileshpatil6) wants to merge 1 commit into
microsoft:mainfrom
nileshpatil6:fix/dataclass-field-override-drops-default
Open

Drop inherited dataclass default when an override assigns a field specifier#11664
Nilesh Patil (nileshpatil6) wants to merge 1 commit into
microsoft:mainfrom
nileshpatil6:fix/dataclass-field-override-drops-default

Conversation

@nileshpatil6

Copy link
Copy Markdown
Contributor

Fixes #11660.

The bug

When a dataclass field overrides an inherited field that has a default, dataClasses.ts unconditionally re-inherits that default:

// While this isn't documented behavior, it appears that the dataclass implementation
// causes overridden variables to "inherit" default values from parent classes.
if (!dataClassEntry.hasDefault && oldEntry.hasDefault && oldEntry.includeInInit) {
    dataClassEntry.hasDefault = true;
    dataClassEntry.defaultExpr = oldEntry.defaultExpr;
    ...
}

That is correct for a bare annotation, but not when the override assigns a field specifier that supplies no default. I checked the three forms against CPython 3.11 rather than relying on the issue report:

Base (self, x: int = 1) -> None

x: int = field()            -> Foo (self, x: int) -> None        Foo() TypeError: missing 1 required positional argument: 'x'
x: int                      -> Bar (self, x: int = 1) -> None    Bar() Bar(x=1)
x: int = field(init=False)  -> Baz (self) -> None                Baz() Baz(x=1)

So x: int = field() replaces the inherited entry and the parameter becomes required. pyright instead synthesized (self: Foo, a: int = 0) -> None, which produced two wrong results at once:

  • False negative: Foo() was accepted, though it raises TypeError at runtime.
  • False positive: the override line was reported with "x" overrides a field of the same name but is missing a default value, even though in this form nothing is inherited.

There is a knock-on ordering case too. When a defaulted field precedes the override, dropping the default makes a non-default parameter follow a default one, which CPython rejects at class creation:

@dataclass
class B1:
    a: int = 0
    x: int = 1

@dataclass
class F1(B1):
    x: int = field()   # TypeError: non-default argument 'x' follows default argument

pyright did not report that either. With this change the existing "Fields without default values cannot appear after fields with default values" check fires on that line.

The change

Track whether the entry had an assigned value, and only inherit the base default for bare annotations. Behavior for x: int is unchanged: it still inherits the default and still reports the existing diagnostic.

Tests

Added to dataclass4.py, the sample for inherited dataclasses. The new cases pin the synthesized signature so they actually discriminate:

reveal_type(DC13.__init__, expected_text="(self: DC13, a: int) -> None")

Without the fix that reports (self: DC13, a: int = 0) -> None and the suite fails; with it, it passes. The expected error count for DataClass4 goes from 6 to 8, covering the now-reported DC13() call and the ordering case.

npx jest typeEvaluator passes 1212/1212 across all 8 suites, and Prettier reports the changed files clean.

…cifier

When a dataclass field overrides an inherited field that has a default,
pyright always re-inherits that default. That matches the runtime only
for a bare annotation. If the override assigns a field specifier that
supplies no default, the runtime replaces the entry outright and the
parameter becomes required:

    @DataClass
    class Base:
        x: int = 1

    @DataClass
    class Foo(Base):
        x: int = field()

    inspect.signature(Foo.__init__)  # (self, x: int) -> None
    Foo()                            # TypeError: missing argument 'x'

pyright synthesized "(self: Foo, a: int = 0) -> None" instead, so the
call above was not reported, and the override itself was flagged with
"overrides a field of the same name but is missing a default value"
even though nothing is inherited in that case.

Track whether the entry had an assigned value and only inherit the base
default for bare annotations. The bare form is unchanged: it still
inherits and still reports the diagnostic. Verified against CPython for
all three forms, including that the default is dropped for
"x: int = field()", kept for "x: int", and that the parameter is removed
for "x: int = field(init=False)".

Fixes microsoft#11660
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signature of dataclass __init__ inferred incorrectly, when overriding a field with a default without specifying a new default

1 participant