diff --git a/crates/red_knot_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md b/crates/red_knot_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md index 0a61157d62..7065406d60 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md +++ b/crates/red_knot_python_semantic/resources/mdtest/annotations/unsupported_type_qualifiers.md @@ -6,14 +6,11 @@ Several type qualifiers are unsupported by red-knot currently. However, we also false-positive errors if you use one in an annotation: ```py -from typing_extensions import Final, ClassVar, Required, NotRequired, ReadOnly, TypedDict +from typing_extensions import Final, Required, NotRequired, ReadOnly, TypedDict X: Final = 42 Y: Final[int] = 42 -class Foo: - A: ClassVar[int] = 42 - # TODO: `TypedDict` is actually valid as a base # error: [invalid-base] class Bar(TypedDict): diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md index c79cff9509..b1db9e28d3 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md @@ -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 diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 194ce6c634..17bf3caabb 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -5346,8 +5346,8 @@ impl<'db> TypeInferenceBuilder<'db> { todo_type!("`NotRequired[]` type qualifier") } KnownInstanceType::ClassVar => { - self.infer_type_expression(arguments_slice); - todo_type!("`ClassVar[]` type qualifier") + let ty = self.infer_type_expression(arguments_slice); + ty } KnownInstanceType::Final => { self.infer_type_expression(arguments_slice);