Differentiating between initialized and uninitialized assignments on class toplevel

I welcome a standardized way to differentiate between a “pure instance variable” and a “class+instance variable” (in stubs and elsewhere), but the proposed mechanism is still not entirely clear to me.

Leaving aside stubs for a moment, let’s consider the following sample.

class A:
    x: int = 0
    y: int

    def __init__(self) -> None:
        self.z: int = 0

    @classmethod
    def init_me(cls) -> None:
        cls.y = 0

Which of the following subsequent statements would you expect to be flagged as a type error?

A.x = 1
A.y = 1
A.z = 1

Mypy is fine with all three of these statements. Pyright flags the third one as an error because it considers z a “pure instance variable” — one that cannot be accessed directly on the class object. Pyright allows the first two statements because it considers x and y to be “class+instance variables”.

If I understand your proposal correctly, the statement y: int within the class body would imply that y is a “pure instance variable”. That would mean A.y = 1 would be an error — as would cls.y = 0. I’m not convinced that’s the right behavior. It would definitely be a breaking change for pyright users.

Perhaps you’re proposing that these meanings apply only to classes defined in “.pyi” files but not in “.py” files? I don’t think we should special-case stubs in this manner. I think we can agree that ascribing different meaning in stubs versus non-stubs is confusing for users. I’d prefer to focus on solutions that work consistently regardless of where a class is defined.

Here’s another edge case that needs to be considered. What if we split the declaration and the initial assignment into separate lines? By your proposal, would x and y in the sample below be a “pure instance variable” or a “class+instance variable”?

class A:
    x: int
    x = 0

    y: int
    if get_condition():
        y = 0

print(A.x)  # Allowed?
print(A.y)  # Allowed?
2 Likes