mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-04 18:58:04 +00:00
[red-knot] Initial tests for instance attributes (#15474)
## Summary Adds some initial tests for class and instance attributes, mostly to document (and discuss) what we want to support eventually. These tests are not exhaustive yet. The idea is to specify the coarse-grained behavior first. Things that we'll eventually want to test: - Interplay with inheritance - Support `Final` in addition to `ClassVar` - Specific tests for `ClassVar`, like making sure that we support things like `x: Annotated[ClassVar[int], "metadata"]` - … or making sure that we raise an error here: ```py class Foo: def __init__(self): self.x: ClassVar[str] = "x" ``` - Add tests for `__new__` in addition to the tests for `__init__` - Add tests that show that we use the union of types if multiple methods define the symbol with different types - Make sure that diagnostics are raised if, e.g., the inferred type of an assignment within a method does not match the declared type in the class body. - https://github.com/astral-sh/ruff/pull/15474#discussion_r1916556284 - Method calls are completely left out for now. - Same for `@property` - … and the descriptor protocol ## Test Plan New Markdown tests Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
parent
b5dbb2a1d7
commit
8712438aec
3 changed files with 281 additions and 13 deletions
|
@ -2,6 +2,264 @@
|
|||
|
||||
Tests for attribute access on various kinds of types.
|
||||
|
||||
## Class and instance variables
|
||||
|
||||
### Pure instance variables
|
||||
|
||||
#### Variable only declared/bound in `__init__`
|
||||
|
||||
Variables only declared and/or bound in `__init__` are pure instance variables. They cannot be
|
||||
accessed on the class itself.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def __init__(self, value2: int, flag: bool = False) -> None:
|
||||
# bound but not declared
|
||||
self.pure_instance_variable1 = "value set in __init__"
|
||||
|
||||
# bound but not declared - with type inferred from parameter
|
||||
self.pure_instance_variable2 = value2
|
||||
|
||||
# declared but not bound
|
||||
self.pure_instance_variable3: bytes
|
||||
|
||||
# declared and bound
|
||||
self.pure_instance_variable4: bool = True
|
||||
|
||||
# possibly undeclared/unbound
|
||||
if flag:
|
||||
self.pure_instance_variable5: str = "possibly set in __init__"
|
||||
|
||||
c_instance = C(1)
|
||||
|
||||
# TODO: should be `Literal["value set in __init__"]` (or `str` which would probably be more generally useful)
|
||||
reveal_type(c_instance.pure_instance_variable1) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should be `int`
|
||||
reveal_type(c_instance.pure_instance_variable2) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should be `bytes`
|
||||
reveal_type(c_instance.pure_instance_variable3) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should be `Literal[True]` (or `bool`)
|
||||
reveal_type(c_instance.pure_instance_variable4) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should be `Literal["possibly set in __init__"]` (or `str`)
|
||||
# We probably don't want to emit a diagnostic for this being possibly undeclared/unbound.
|
||||
# mypy and pyright do not show an error here.
|
||||
reveal_type(c_instance.pure_instance_variable5) # revealed: @Todo(instance attributes)
|
||||
|
||||
c_instance.pure_instance_variable1 = "value set on instance"
|
||||
|
||||
# TODO: this should be an error (incompatible types in assignment)
|
||||
c_instance.pure_instance_variable2 = "incompatible"
|
||||
|
||||
# TODO: we already show an error here but the message might be improved?
|
||||
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
|
||||
# error: [unresolved-attribute] "Type `Literal[C]` has no attribute `pure_instance_variable1`"
|
||||
reveal_type(C.pure_instance_variable1) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error (pure instance variables cannot be accessed on the class)
|
||||
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
|
||||
C.pure_instance_variable1 = "overwritten on class"
|
||||
|
||||
# TODO: should ideally be `Literal["value set on instance"]`
|
||||
# (due to earlier assignment of the attribute from the global scope)
|
||||
reveal_type(c_instance.pure_instance_variable1) # revealed: @Todo(instance attributes)
|
||||
```
|
||||
|
||||
#### Variable declared in class body and declared/bound in `__init__`
|
||||
|
||||
The same rule applies even if the variable is *declared* (not bound!) in the class body: it is still
|
||||
a pure instance variable.
|
||||
|
||||
```py
|
||||
class C:
|
||||
pure_instance_variable: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pure_instance_variable = "value set in __init__"
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: we currently plan to emit a diagnostic here. Note that both mypy
|
||||
# and pyright show no error in this case! So we may reconsider this in
|
||||
# the future, if it turns out to produce too many false positives.
|
||||
reveal_type(C.pure_instance_variable) # revealed: str
|
||||
|
||||
# TODO: same as above. We plan to emit a diagnostic here, even if both mypy
|
||||
# and pyright allow this.
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
|
||||
# TODO: this should be an error (incompatible types in assignment)
|
||||
c_instance.pure_instance_variable = 1
|
||||
```
|
||||
|
||||
#### Variable only defined in unrelated method
|
||||
|
||||
We also recognize pure instance variables if they are defined in a method that is not `__init__`.
|
||||
|
||||
```py
|
||||
class C:
|
||||
def set_instance_variable(self) -> None:
|
||||
self.pure_instance_variable = "value set in method"
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# for a more realistic example, let's actually call the method
|
||||
c_instance.set_instance_variable()
|
||||
|
||||
# TODO: should be `Literal["value set in method"]` or `str`
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: We already show an error here, but the message might be improved?
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_instance_variable) # revealed: Unknown
|
||||
|
||||
# TODO: this should be an error
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
```
|
||||
|
||||
#### Variable declared in class body and not bound anywhere
|
||||
|
||||
If a variable is declared in the class body but not bound anywhere, we still consider it a pure
|
||||
instance variable and allow access to it via instances.
|
||||
|
||||
```py
|
||||
class C:
|
||||
pure_instance_variable: str
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# TODO: should be 'str'
|
||||
reveal_type(c_instance.pure_instance_variable) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: mypy and pyright do not show an error here, but we plan to emit a diagnostic.
|
||||
# The type could be changed to 'Unknown' if we decide to emit an error?
|
||||
reveal_type(C.pure_instance_variable) # revealed: str
|
||||
|
||||
# TODO: mypy and pyright do not show an error here, but we plan to emit one.
|
||||
C.pure_instance_variable = "overwritten on class"
|
||||
```
|
||||
|
||||
### Pure class variables (`ClassVar`)
|
||||
|
||||
#### Annotated with `ClassVar` type qualifier
|
||||
|
||||
Class variables annotated with the [`typing.ClassVar`] type qualifier are pure class variables. They
|
||||
cannot be overwritten on instances, but they can be accessed on instances.
|
||||
|
||||
For more details, see the [typing spec on `ClassVar`].
|
||||
|
||||
```py
|
||||
from typing import ClassVar
|
||||
|
||||
class C:
|
||||
pure_class_variable1: ClassVar[str] = "value in class body"
|
||||
pure_class_variable2: ClassVar = 1
|
||||
|
||||
reveal_type(C.pure_class_variable1) # revealed: str
|
||||
|
||||
# TODO: this should be `Literal[1]`, `int`, or maybe `Unknown | Literal[1]` / `Unknown | int`
|
||||
reveal_type(C.pure_class_variable2) # revealed: @Todo(Unsupported or invalid type in a type expression)
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# TODO: This should be `str`. It is okay to access a pure class variable on an instance.
|
||||
reveal_type(c_instance.pure_class_variable1) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should raise an error. It is not allowed to reassign a pure class variable on an instance.
|
||||
c_instance.pure_class_variable1 = "value set on instance"
|
||||
|
||||
C.pure_class_variable1 = "overwritten on class"
|
||||
|
||||
# TODO: should ideally be `Literal["overwritten on class"]`, but not a priority
|
||||
reveal_type(C.pure_class_variable1) # revealed: str
|
||||
|
||||
# TODO: should raise an error (incompatible types in assignment)
|
||||
C.pure_class_variable1 = 1
|
||||
|
||||
class Subclass(C):
|
||||
pure_class_variable1: ClassVar[str] = "overwritten on subclass"
|
||||
|
||||
reveal_type(Subclass.pure_class_variable1) # revealed: str
|
||||
```
|
||||
|
||||
#### Variable only mentioned in a class method
|
||||
|
||||
We also consider a class variable to be a pure class variable if it is only mentioned in a class
|
||||
method.
|
||||
|
||||
```py
|
||||
class C:
|
||||
@classmethod
|
||||
def class_method(cls):
|
||||
cls.pure_class_variable = "value set in class method"
|
||||
|
||||
# for a more realistic example, let's actually call the method
|
||||
C.class_method()
|
||||
|
||||
# TODO: We currently plan to support this and show no error here.
|
||||
# mypy shows an error here, pyright does not.
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_class_variable) # revealed: Unknown
|
||||
|
||||
C.pure_class_variable = "overwritten on class"
|
||||
|
||||
# TODO: should be `Literal["overwritten on class"]`
|
||||
# error: [unresolved-attribute]
|
||||
reveal_type(C.pure_class_variable) # revealed: Unknown
|
||||
|
||||
c_instance = C()
|
||||
# TODO: should be `Literal["overwritten on class"]` or `str`
|
||||
reveal_type(c_instance.pure_class_variable) # revealed: @Todo(instance attributes)
|
||||
|
||||
# TODO: should raise an error.
|
||||
c_instance.pure_class_variable = "value set on instance"
|
||||
```
|
||||
|
||||
### Instance variables with class-level default values
|
||||
|
||||
These are instance attributes, but the fact that we can see that they have a binding (not a
|
||||
declaration) in the class body means that reading the value from the class directly is also
|
||||
permitted. This is the only difference for these attributes as opposed to "pure" instance
|
||||
attributes.
|
||||
|
||||
#### Basic
|
||||
|
||||
```py
|
||||
class C:
|
||||
variable_with_class_default: str = "value in class body"
|
||||
|
||||
def instance_method(self):
|
||||
self.variable_with_class_default = "value set in instance method"
|
||||
|
||||
reveal_type(C.variable_with_class_default) # revealed: str
|
||||
|
||||
c_instance = C()
|
||||
|
||||
# TODO: should be `str`
|
||||
reveal_type(c_instance.variable_with_class_default) # revealed: @Todo(instance attributes)
|
||||
|
||||
c_instance.variable_with_class_default = "value set on instance"
|
||||
|
||||
reveal_type(C.variable_with_class_default) # revealed: str
|
||||
|
||||
# TODO: should ideally be Literal["value set on instance"], or still `str`
|
||||
reveal_type(c_instance.variable_with_class_default) # revealed: @Todo(instance attributes)
|
||||
|
||||
C.variable_with_class_default = "overwritten on class"
|
||||
|
||||
# TODO: should ideally be `Literal["overwritten on class"]`
|
||||
reveal_type(C.variable_with_class_default) # revealed: str
|
||||
|
||||
# TODO: should still be `Literal["value set on instance"]`
|
||||
reveal_type(c_instance.variable_with_class_default) # revealed: @Todo(instance attributes)
|
||||
```
|
||||
|
||||
## Union of attributes
|
||||
|
||||
```py
|
||||
|
@ -24,7 +282,9 @@ def _(flag: bool):
|
|||
reveal_type(C2.x) # revealed: Literal[3, 4]
|
||||
```
|
||||
|
||||
## Inherited attributes
|
||||
## Inherited class attributes
|
||||
|
||||
### Basic
|
||||
|
||||
```py
|
||||
class A:
|
||||
|
@ -36,7 +296,7 @@ class C(B): ...
|
|||
reveal_type(C.X) # revealed: Literal["foo"]
|
||||
```
|
||||
|
||||
## Inherited attributes (multiple inheritance)
|
||||
### Multiple inheritance
|
||||
|
||||
```py
|
||||
class O: ...
|
||||
|
@ -104,7 +364,7 @@ def _(flag: bool, flag1: bool, flag2: bool):
|
|||
reveal_type(C.x) # revealed: Literal[1, 2, 3]
|
||||
```
|
||||
|
||||
## Unions with all paths unbound
|
||||
### Unions with all paths unbound
|
||||
|
||||
If the symbol is unbound in all elements of the union, we detect that:
|
||||
|
||||
|
@ -158,7 +418,9 @@ class Foo: ...
|
|||
reveal_type(Foo.__class__) # revealed: Literal[type]
|
||||
```
|
||||
|
||||
## Function-literal attributes
|
||||
## Literal types
|
||||
|
||||
### Function-literal attributes
|
||||
|
||||
Most attribute accesses on function-literal types are delegated to `types.FunctionType`, since all
|
||||
functions are instances of that class:
|
||||
|
@ -179,7 +441,7 @@ reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions)
|
|||
reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
|
||||
```
|
||||
|
||||
## Int-literal attributes
|
||||
### Int-literal attributes
|
||||
|
||||
Most attribute accesses on int-literal types are delegated to `builtins.int`, since all literal
|
||||
integers are instances of that class:
|
||||
|
@ -196,7 +458,7 @@ reveal_type((2).numerator) # revealed: Literal[2]
|
|||
reveal_type((2).real) # revealed: Literal[2]
|
||||
```
|
||||
|
||||
## Literal `bool` attributes
|
||||
### Bool-literal attributes
|
||||
|
||||
Most attribute accesses on bool-literal types are delegated to `builtins.bool`, since all literal
|
||||
bols are instances of that class:
|
||||
|
@ -213,7 +475,7 @@ reveal_type(True.numerator) # revealed: Literal[1]
|
|||
reveal_type(False.real) # revealed: Literal[0]
|
||||
```
|
||||
|
||||
## Bytes-literal attributes
|
||||
### Bytes-literal attributes
|
||||
|
||||
All attribute access on literal `bytes` types is currently delegated to `buitins.bytes`:
|
||||
|
||||
|
@ -221,3 +483,12 @@ All attribute access on literal `bytes` types is currently delegated to `buitins
|
|||
reveal_type(b"foo".join) # revealed: @Todo(instance attributes)
|
||||
reveal_type(b"foo".endswith) # revealed: @Todo(instance attributes)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
Some of the tests in the *Class and instance variables* section draw inspiration from
|
||||
[pyright's documentation] on this topic.
|
||||
|
||||
[pyright's documentation]: https://microsoft.github.io/pyright/#/type-concepts-advanced?id=class-and-instance-variables
|
||||
[typing spec on `classvar`]: https://typing.readthedocs.io/en/latest/spec/class-compat.html#classvar
|
||||
[`typing.classvar`]: https://docs.python.org/3/library/typing.html#typing.ClassVar
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue