From 3ef310100a2ee845beccb9d7aac0facf09f095ec Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 27 Apr 2026 14:24:23 -0400 Subject: [PATCH] [ty] Detect invalid attribute overrides (#24767) ## Summary We now detect Liskov violations when a parent-child pair have attributes with a differing `ClassVar` status. In general, we interpret an attribute without a `ClassVar` annotation as an instance attribute, with the exception of cases like the following, where we "allow" the child to "inherit" the annotation to adhere to the conformance suite: ```python class ProtoB(Protocol): z: ClassVar[int] class ProtoBImpl(ProtoB): z = 0 ``` There are a few other tricky cases to consider. ### Methods Like Mypy and Pyright, we don't flag the following: ```py from typing import Any, Callable, ClassVar class Base: f: ClassVar[Callable[..., Any]] class Sub(Base): def f(self) -> int: return 1 ``` ### Descriptors Like Mypy and Pyright, we don't flag the following: ```python class Descriptor: def __get__(self, obj: object, owner: type[object]) -> int: return 1 class Base: attr = Descriptor() class Sub(Base): attr: int ``` ### Properties Like Mypy and Pyright, we _do_ flag the following, since it changes the class-vs.-instance contract: ```python from typing import ClassVar class Base: attr: ClassVar[int] class Sub(Base): @property def attr(self) -> int: # error: [invalid-attribute-override] return 1 ``` #### Final We don't flag the following, because it already has a dedicated diagnostic: ```python from typing import Final class Base: attr: Final[int] = 1 class Sub(Base): attr = 2 # error: [override-of-final-variable] ``` Closes https://github.com/astral-sh/ty/issues/3093. --- crates/ty/docs/rules.md | 259 ++++++----- .../resources/mdtest/liskov.md | 245 ++++++++++ .../src/types/diagnostic.rs | 36 ++ .../ty_python_semantic/src/types/overrides.rs | 433 +++++++++++++++--- ty.schema.json | 10 + 5 files changed, 821 insertions(+), 162 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index f355188133..8028c4057b 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -49,7 +49,7 @@ class Derived(Base): # Error: `Derived` does not implement `method` Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -90,7 +90,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -126,7 +126,7 @@ def _(x: int): Default level: error · Preview (since 0.0.16) · Related issues · -View source +View source @@ -175,7 +175,7 @@ Foo.method() # Error: cannot call abstract classmethod Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -199,7 +199,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -230,7 +230,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -262,7 +262,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -293,7 +293,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -325,7 +325,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -357,7 +357,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -385,7 +385,7 @@ type B = A Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -417,7 +417,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -444,7 +444,7 @@ old_func() # emits [deprecated] diagnostic Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -473,7 +473,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -500,7 +500,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -538,7 +538,7 @@ class A: # Crash at runtime Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -609,7 +609,7 @@ def foo() -> "intt\b": ... Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -641,7 +641,7 @@ def my_function() -> int: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -736,7 +736,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -766,7 +766,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -792,7 +792,7 @@ t[3] # IndexError: tuple index out of range Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -826,7 +826,7 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -915,7 +915,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -942,7 +942,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -970,7 +970,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -998,13 +998,54 @@ C().instance_var = 3 # okay C.instance_var = 3 # error: Cannot assign to instance variable ``` +## `invalid-attribute-override` + + +Default level: error · +Added in 0.0.33 · +Related issues · +View source + + + +**What it does** + +Detects attribute overrides that change whether an inherited attribute +is a class variable or an instance variable. + +This rule currently only covers class-variable and instance-variable +category changes. + +**Why is this bad?** + +Pure class variables and instance variables have different access and +assignment behavior. Overriding one with the other violates the +[Liskov Substitution Principle] ("LSP"), because code that is valid for +the superclass may no longer be valid for the subclass. + +**Example** + +```python +from typing import ClassVar + +class Base: + instance_attr: int + class_attr: ClassVar[int] + +class Sub(Base): + instance_attr: ClassVar[int] # error: [invalid-attribute-override] + class_attr: int # error: [invalid-attribute-override] +``` + +[Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle + ## `invalid-await` Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1040,7 +1081,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1064,7 +1105,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1091,7 +1132,7 @@ with 1: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1128,7 +1169,7 @@ class Foo(NamedTuple): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -1160,7 +1201,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1189,7 +1230,7 @@ a: str Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -1238,7 +1279,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1282,7 +1323,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1324,7 +1365,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1368,7 +1409,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1406,7 +1447,7 @@ class D(Generic[U, T]): ... Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1485,7 +1526,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1524,7 +1565,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -1585,7 +1626,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1620,7 +1661,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -1648,7 +1689,7 @@ match x: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1682,7 +1723,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1789,7 +1830,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1843,7 +1884,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: warn · Added in 0.0.31 · Related issues · -View source +View source @@ -1884,7 +1925,7 @@ admin[0] # "Alice" Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -1914,7 +1955,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1964,7 +2005,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1990,7 +2031,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2021,7 +2062,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2055,7 +2096,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2104,7 +2145,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2133,7 +2174,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2229,7 +2270,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2275,7 +2316,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -2302,7 +2343,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2349,7 +2390,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2379,7 +2420,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2409,7 +2450,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2443,7 +2484,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2477,7 +2518,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2508,7 +2549,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2555,7 +2596,7 @@ U = TypeVar('U', list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2587,7 +2628,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -2618,7 +2659,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2653,7 +2694,7 @@ def f(x: dict): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -2684,7 +2725,7 @@ class Foo(TypedDict): Default level: error · Added in 0.0.25 · Related issues · -View source +View source @@ -2715,7 +2756,7 @@ def gen() -> Iterator[int]: Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2770,7 +2811,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2813,7 +2854,7 @@ def g(arg: object): Default level: warn · Added in 0.0.30 · Related issues · -View source +View source @@ -2851,7 +2892,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2876,7 +2917,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2909,7 +2950,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2938,7 +2979,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.30 · Related issues · -View source +View source @@ -2971,7 +3012,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2997,7 +3038,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3021,7 +3062,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -3054,7 +3095,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -3087,7 +3128,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3114,7 +3155,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3141,7 +3182,7 @@ f(x=1) # Error raised here Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3174,7 +3215,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3206,7 +3247,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3243,7 +3284,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -3270,7 +3311,7 @@ html.parser # AttributeError: module 'html' has no attribute 'parser' Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3334,7 +3375,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3361,7 +3402,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -3393,7 +3434,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3427,7 +3468,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3457,7 +3498,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3486,7 +3527,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -3520,7 +3561,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3547,7 +3588,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3575,7 +3616,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3621,7 +3662,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3658,7 +3699,7 @@ class C(Generic[T]): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3682,7 +3723,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3709,7 +3750,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3737,7 +3778,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -3795,7 +3836,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3820,7 +3861,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3845,7 +3886,7 @@ print(x) # NameError: name 'x' is not defined Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -3884,7 +3925,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3921,7 +3962,7 @@ b1 < b2 < b1 # exception raised here Default level: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -3961,7 +4002,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3989,7 +4030,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: warn · Preview (since 0.0.21) · Related issues · -View source +View source @@ -4095,7 +4136,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -4158,7 +4199,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md index 624016cda4..32f3168156 100644 --- a/crates/ty_python_semantic/resources/mdtest/liskov.md +++ b/crates/ty_python_semantic/resources/mdtest/liskov.md @@ -362,6 +362,251 @@ class Sub23(Super4): def method(self, x, *args, y, **kwargs): ... ``` +## `ClassVar` and instance variables + +A pure class variable cannot override an inherited instance variable, and an instance variable +cannot override an inherited pure class variable. + +### Direct overrides + +An annotation without `ClassVar` declares an instance variable, even if the declaration also has a +class-level default value. An explicit `ClassVar` declaration is a pure class variable. Overriding +one with the other changes the places where the attribute is valid, so it violates Liskov +substitution: + +```py +from typing import ClassVar + +class Base: + instance_attr: int + instance_attr_with_default: int = 1 + class_attr: ClassVar[int] = 1 + +class Subclass(Base): + # error: [invalid-attribute-override] "class variable cannot override instance variable `Base.instance_attr`" + instance_attr: ClassVar[int] + + # error: [invalid-attribute-override] "class variable cannot override instance variable `Base.instance_attr_with_default`" + instance_attr_with_default: ClassVar[int] = 1 + + # error: [invalid-attribute-override] "instance variable cannot override class variable `Base.class_attr`" + class_attr: int + +class ValidSubclass(Base): + instance_attr: int + instance_attr_with_default: int = 1 + class_attr: ClassVar[int] = 1 +``` + +### Regular class-body assignments + +An unannotated class-body assignment is an instance variable with a class-level default. This means +it can replace another inherited instance-variable default. If it overrides an inherited `ClassVar`, +it inherits that declaration and remains a class variable. However, an explicit `ClassVar` cannot +override an inherited unannotated class-body assignment, because code using the base class can still +write that attribute through an instance: + +```py +from typing import ClassVar + +class Base: + instance_attr_with_default: int = 1 + class_attr: ClassVar[int] = 1 + +class RegularClassAttributeOverride(Base): + class_attr = 1 + +class AugmentedClassAttributeOverride(Base): + class_attr = 1 + class_attr += 1 + +class IntermediateClassAttributeOverride(Base): + class_attr = 1 + +class ExplicitClassVarOverrideAfterInheritedClassVar(IntermediateClassAttributeOverride): + class_attr: ClassVar[int] = 1 + +class RegularClassAttributeBase: + attr = 1 + +class ExplicitClassVarOverride(RegularClassAttributeBase): + # error: [invalid-attribute-override] "class variable cannot override instance variable `RegularClassAttributeBase.attr`" + attr: ClassVar[int] = 1 + +class ClassDefaultBase: + class_default: int = 1 + declared_instance: bool + +class ClassDefaultSubclass(ClassDefaultBase): + class_default = 2 + declared_instance = True +``` + +### Method definitions + +Method definitions create descriptors in the class body. They are not instance variable +declarations, so the class-variable vs. instance-variable override check does not apply to them: + +```py +from collections.abc import Callable +from typing import Any, ClassVar + +class ClassVarBase: + plain: ClassVar[Callable[..., Any]] + static: ClassVar[Callable[..., Any]] + class_: ClassVar[Callable[..., Any]] + non_callable: ClassVar[int] + +class MethodSubclass(ClassVarBase): + def plain(self, x: int) -> int: + return x + + @staticmethod + def static(x: int) -> int: + return x + + @classmethod + def class_(cls, x: int) -> int: + return x + + def non_callable(self) -> int: + return 1 + +class PropertyBase: + attr: ClassVar[int] + +class PropertySubclass(PropertyBase): + @property + def attr( # error: [invalid-attribute-override] "instance variable cannot override class variable `PropertyBase.attr`" + self, + ) -> int: + return 1 +``` + +### Repeated inherited conflicts + +If a parent class already made an invalid change from class variable to instance variable, a child +that keeps the parent's kind should not receive a duplicate diagnostic. The same applies in the +other direction: + +```py +from typing import ClassVar + +class GrandparentClassVar: + attr: ClassVar[int] + +class ParentInstance(GrandparentClassVar): + # error: [invalid-attribute-override] "instance variable cannot override class variable `GrandparentClassVar.attr`" + attr: int + +class ChildInstance(ParentInstance): + attr: int + +class GrandparentInstance: + attr: int + +class ParentClassVar(GrandparentInstance): + # error: [invalid-attribute-override] "class variable cannot override instance variable `GrandparentInstance.attr`" + attr: ClassVar[int] + +class ChildClassVar(ParentClassVar): + attr: ClassVar[int] +``` + +### Descriptors + +A descriptor can define different behavior when accessed on an instance. Because descriptor lookup +is neither a pure class variable nor a normal instance variable, overriding it with an instance +attribute is accepted: + +```py +from typing import ClassVar + +class Descriptor: + def __get__(self, instance: object, owner: type[object]) -> int: + return 1 + +class DescriptorBase: + descriptor_attr = Descriptor() + +class DescriptorOverride(DescriptorBase): + descriptor_attr: int + +class DescriptorAnnotationBase: + descriptor_attr: Descriptor + +class DescriptorAnnotationOverride(DescriptorAnnotationBase): + # error: [invalid-attribute-override] "class variable cannot override instance variable `DescriptorAnnotationBase.descriptor_attr`" + descriptor_attr: ClassVar[Descriptor] +``` + +### Multiple inheritance + +The subclass must satisfy every base class. It is not enough for the first base in the MRO to agree +with the subclass: an unrelated base that declares the same member as a pure class variable still +makes an instance-variable override invalid. + +```py +from typing import ClassVar + +class ClassVarBase: + attr: ClassVar[int] + +class InstanceBase: + attr: int + +class MultipleInheritanceSubclass(InstanceBase, ClassVarBase): + # error: [invalid-attribute-override] "instance variable cannot override class variable `ClassVarBase.attr`" + attr: int +``` + +### Dataclasses + +Dataclass fields are instance variables, even though they are usually declared in the class body. +`ClassVar` fields remain pure class variables and are excluded from dataclass instance fields: + +```py +from dataclasses import dataclass +from typing import ClassVar + +@dataclass +class DC6: + x: int + y: ClassVar[int] = 1 + +@dataclass +class DC7(DC6): + # error: [invalid-attribute-override] "class variable cannot override instance variable `DC6.x`" + x: ClassVar[int] + + # error: [invalid-attribute-override] "instance variable cannot override class variable `DC6.y`" + y: int +``` + +### Protocol implementations + +Regular class-body assignments can implement protocol instance attributes. The `ClassVar` case below +uses the same rule as normal classes: an unannotated class-body assignment over an inherited +`ClassVar` provides a value while preserving the inherited declaration. + +```py +from typing import ClassVar, Protocol + +class ProtocolBase(Protocol): + class_attr: ClassVar[int] + instance_attr: int + instance_attr_with_default: int = 1 + +class ProtocolImpl(ProtocolBase): + class_attr = 1 + instance_attr = 1 + instance_attr_with_default = 1 + +class ProtocolWithClassVarImpl(ProtocolBase): + class_attr = 0 + instance_attr = 0 +``` + ## The entire class hierarchy is checked If a child class's method definition is Liskov-compatible with the method definition on its parent diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 5670dd3241..9f92b704e9 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -159,6 +159,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&INVALID_TYPED_DICT_STATEMENT); registry.register_lint(&INVALID_TYPED_DICT_FIELD); registry.register_lint(&INVALID_TYPED_DICT_HEADER); + registry.register_lint(&INVALID_ATTRIBUTE_OVERRIDE); registry.register_lint(&INVALID_METHOD_OVERRIDE); registry.register_lint(&INVALID_EXPLICIT_OVERRIDE); registry.register_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD); @@ -3165,6 +3166,41 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Detects attribute overrides that change whether an inherited attribute + /// is a class variable or an instance variable. + /// + /// This rule currently only covers class-variable and instance-variable + /// category changes. + /// + /// ## Why is this bad? + /// Pure class variables and instance variables have different access and + /// assignment behavior. Overriding one with the other violates the + /// [Liskov Substitution Principle] ("LSP"), because code that is valid for + /// the superclass may no longer be valid for the subclass. + /// + /// ## Example + /// ```python + /// from typing import ClassVar + /// + /// class Base: + /// instance_attr: int + /// class_attr: ClassVar[int] + /// + /// class Sub(Base): + /// instance_attr: ClassVar[int] # error: [invalid-attribute-override] + /// class_attr: int # error: [invalid-attribute-override] + /// ``` + /// + /// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle + pub(crate) static INVALID_ATTRIBUTE_OVERRIDE = { + summary: "detects attribute overrides that change class-variable or instance-variable behavior", + status: LintStatus::stable("0.0.33"), + default_level: Level::Error, + } +} + declare_lint! { /// ## What it does /// Detects method overrides that violate the [Liskov Substitution Principle] ("LSP"). diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 4d33f26f4b..496a4b53b3 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -12,7 +12,7 @@ use rustc_hash::FxHashSet; use crate::{ Db, lint::LintId, - place::{DefinedPlace, Place}, + place::{DefinedPlace, Place, PlaceAndQualifiers, TypeOrigin}, types::{ CallableType, ClassBase, ClassLiteral, ClassType, KnownClass, Parameter, Parameters, Signature, StaticClassLiteral, Type, TypeContext, TypeQualifiers, @@ -21,10 +21,11 @@ use crate::{ constraints::ConstraintSetBuilder, context::InferContext, diagnostic::{ - INVALID_ASSIGNMENT, INVALID_DATACLASS, INVALID_EXPLICIT_OVERRIDE, - INVALID_METHOD_OVERRIDE, INVALID_NAMED_TUPLE, INVALID_NAMED_TUPLE_OVERRIDE, - OVERRIDE_OF_FINAL_METHOD, OVERRIDE_OF_FINAL_VARIABLE, report_invalid_method_override, - report_overridden_final_method, report_overridden_final_variable, + INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_OVERRIDE, INVALID_DATACLASS, + INVALID_EXPLICIT_OVERRIDE, INVALID_METHOD_OVERRIDE, INVALID_NAMED_TUPLE, + INVALID_NAMED_TUPLE_OVERRIDE, OVERRIDE_OF_FINAL_METHOD, OVERRIDE_OF_FINAL_VARIABLE, + report_invalid_method_override, report_overridden_final_method, + report_overridden_final_variable, }, enums::{EnumMetadata, enum_metadata}, function::{FunctionDecorators, FunctionType, KnownFunction}, @@ -132,41 +133,6 @@ fn check_class_declaration<'db>( class_scope: ScopeId<'db>, member: &MemberWithDefinition<'db>, ) { - /// Salsa-tracked query to check whether any of the definitions of a symbol - /// in a superclass scope are function definitions. - /// - /// We need to know this for compatibility with pyright and mypy, neither of which emit an error - /// on `C.f` here: - /// - /// ```python - /// from typing import final - /// - /// class A: - /// @final - /// def f(self) -> None: ... - /// - /// class B: - /// f = A.f - /// - /// class C(B): - /// def f(self) -> None: ... # no error here - /// ``` - /// - /// This is a Salsa-tracked query because it has to look at the AST node for the definition, - /// which might be in a different Python module. If this weren't a tracked query, we could - /// introduce cross-module dependencies and over-invalidation. - #[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] - fn is_function_definition<'db>( - db: &'db dyn Db, - scope: ScopeId<'db>, - symbol: ScopedSymbolId, - ) -> bool { - use_def_map(db, scope) - .end_of_scope_symbol_bindings(symbol) - .filter_map(|binding| binding.binding.definition()) - .any(|definition| definition.kind(db).is_function_def()) - } - let db = context.db(); let MemberWithDefinition { @@ -176,10 +142,11 @@ fn check_class_declaration<'db>( let instance_of_class = Type::instance(db, class); + let subclass_instance_member = instance_of_class.member(db, &member.name); let Place::Defined(DefinedPlace { ty: type_on_subclass_instance, .. - }) = instance_of_class.member(db, &member.name).place + }) = subclass_instance_member.place else { return; }; @@ -324,11 +291,13 @@ fn check_class_declaration<'db>( let mut overridden_final_method = None; let mut overridden_final_variable: Option<(ClassType<'db>, Option>)> = None; let is_private_member = is_mangled_private(member.name.as_str()); + let mut subclass_variable_kind: Option> = None; // Track the first superclass that defines this method (the "immediate parent" for this method). // We need this to check if parent itself already has an LSP violation with an ancestor. // If so, we shouldn't report the same violation for the child class. let mut immediate_parent_method: Option<(ClassType<'db>, Type<'db>)> = None; + let mut immediate_parent_variable_kind: Option<(ClassType<'db>, VariableKind)> = None; if !is_private_member { for class_base in class.iter_mro(db).skip(1) { @@ -382,12 +351,12 @@ fn check_class_declaration<'db>( .unwrap_or_default(); } + let superclass_instance_member = + Type::instance(db, superclass).member(db, &member.name); let Place::Defined(DefinedPlace { ty: superclass_type, .. - }) = Type::instance(db, superclass) - .member(db, &member.name) - .place + }) = superclass_instance_member.place else { // If not defined on any superclass, no point in continuing to walk up the MRO break; @@ -463,11 +432,75 @@ fn check_class_declaration<'db>( continue; } + if !configuration.check_liskov_violations() { + continue; + } + + if configuration.check_attribute_liskov_violations() { + if let Some(superclass_variable_kind) = + effective_superclass_variable_kind(db, superclass, member.name.clone()) + { + if immediate_parent_variable_kind.is_none() { + immediate_parent_variable_kind = + Some((superclass, superclass_variable_kind)); + } + + let subclass_kind = *subclass_variable_kind.get_or_insert_with(|| { + variable_kind( + db, + class.own_class_member(db, None, &member.name).inner, + subclass_instance_member, + ) + }); + + if let Some(subclass_kind) = subclass_kind + && subclass_kind != superclass_variable_kind + { + // An unannotated class-body assignment can inherit an overridden `ClassVar` + // declaration instead of introducing a conflicting instance variable. This + // also applies to augmented assignments after the initial class-body + // assignment, e.g. `epilog = "..."; epilog += "..."`. + if subclass_kind == VariableKind::Instance + && superclass_variable_kind == VariableKind::Class + && matches!( + first_reachable_definition.kind(db), + DefinitionKind::Assignment(_) + | DefinitionKind::AugmentedAssignment(_) + ) + { + continue; + } + + if let Some((immediate_parent, immediate_parent_kind)) = + immediate_parent_variable_kind + && immediate_parent != superclass + && immediate_parent.is_subclass_of(db, superclass) + && immediate_parent_kind != superclass_variable_kind + { + continue; + } + + let superclass_definition = superclass_symbol_id + .and_then(|id| symbol_definition(db, superclass_scope, id)); + report_invalid_attribute_override( + context, + &member.name, + *first_reachable_definition, + superclass, + superclass_definition, + subclass_kind, + superclass_variable_kind, + ); + liskov_diagnostic_emitted = true; + continue; + } + } + } + if !configuration.check_method_liskov_violations() { continue; } - // TODO: Check Liskov on non-methods too let Type::FunctionLiteral(subclass_function) = member.ty else { continue; }; @@ -590,6 +623,287 @@ fn check_class_declaration<'db>( } } +/// Whether an attribute declaration is a class variable or an instance variable. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +enum VariableKind { + /// A variable annotated with `ClassVar`. + Class, + /// An instance variable, including an unannotated class-body assignment. + Instance, +} + +impl VariableKind { + /// Returns the wording used for this variable kind in diagnostics. + const fn description(self) -> &'static str { + match self { + VariableKind::Class => "class variable", + VariableKind::Instance => "instance variable", + } + } +} + +/// Returns the variable kind for a superclass member. +fn superclass_variable_kind<'db>( + db: &'db dyn Db, + superclass_scope: ScopeId<'db>, + superclass_symbol_id: Option, + class_member: PlaceAndQualifiers<'db>, + instance_member: PlaceAndQualifiers<'db>, +) -> Option { + // Method definitions and properties are not instance-variable declarations. Check the symbol + // definition before class/instance member lookup can erase that distinction. For example, + // resolving an abstract `@property def f(self) -> int` through instance-member lookup would + // make it look like an instance variable of type `int`, causing this rule to report + // `f: ClassVar[int]` as an invalid attribute override even though the superclass member is not + // an instance-attribute declaration. + if superclass_symbol_id.is_some_and(|id| is_function_definition(db, superclass_scope, id)) { + return None; + } + + // Final attributes have their own override rule and diagnostic. Treating them as class + // variables here would report both diagnostics for the same override. + if class_member.qualifiers.contains(TypeQualifiers::FINAL) { + return None; + } + + variable_kind(db, class_member, instance_member) +} + +/// Returns the variable kind for a superclass member, preserving inherited `ClassVar` declarations +/// through unannotated class-body assignments. +/// +/// For example, `Intermediate.x = 1` inherits the pure-class-variable declaration from `Base`, so +/// `Sub.x: ClassVar[int]` should not be reported as overriding an instance variable: +/// +/// ```python +/// from typing import ClassVar +/// +/// class Base: +/// x: ClassVar[int] +/// +/// class Intermediate(Base): +/// x = 1 +/// +/// class Sub(Intermediate): +/// x: ClassVar[int] = 2 +/// ``` +#[allow(clippy::needless_pass_by_value)] +#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +fn effective_superclass_variable_kind<'db>( + db: &'db dyn Db, + superclass: ClassType<'db>, + name: Name, +) -> Option { + let inherited_variable_kind = || { + superclass + .iter_mro(db) + .skip(1) + .filter_map(ClassBase::into_class) + .find_map(|base| effective_superclass_variable_kind(db, base, name.clone())) + }; + + let (superclass_literal, superclass_specialization) = superclass.static_class_literal(db)?; + let superclass_scope = superclass_literal.body_scope(db); + let superclass_symbol_table = place_table(db, superclass_scope); + let superclass_symbol_id = superclass_symbol_table.symbol_id(&name); + + let has_own_member = if let Some(id) = superclass_symbol_id { + let superclass_symbol = superclass_symbol_table.symbol(id); + superclass_symbol.is_bound() || superclass_symbol.is_declared() + } else { + superclass_literal + .own_synthesized_member(db, superclass_specialization, None, &name) + .is_some() + }; + + if has_own_member { + let superclass_variable_kind = superclass_variable_kind( + db, + superclass_scope, + superclass_symbol_id, + superclass.own_class_member(db, None, &name).inner, + Type::instance(db, superclass).member(db, &name), + ); + + if superclass_variable_kind == Some(VariableKind::Instance) + && superclass_symbol_id.is_some_and(|id| { + symbol_definition(db, superclass_scope, id).is_some_and(|definition| { + matches!( + definition.kind(db), + DefinitionKind::Assignment(_) | DefinitionKind::AugmentedAssignment(_) + ) + }) + }) + && inherited_variable_kind() == Some(VariableKind::Class) + { + return Some(VariableKind::Class); + } + + if superclass_variable_kind.is_some() { + return superclass_variable_kind; + } + } + + inherited_variable_kind() +} + +/// Salsa-tracked query to check whether any of the definitions of a symbol +/// in a superclass scope are function definitions. +/// +/// We need to know this for compatibility with pyright and mypy, neither of which emit an error +/// on `C.f` here: +/// +/// ```python +/// from typing import final +/// +/// class A: +/// @final +/// def f(self) -> None: ... +/// +/// class B: +/// f = A.f +/// +/// class C(B): +/// def f(self) -> None: ... # no error here +/// ``` +/// +/// This is a Salsa-tracked query because it has to look at the AST node for the definition, +/// which might be in a different Python module. If this weren't a tracked query, we could +/// introduce cross-module dependencies and over-invalidation. +#[salsa::tracked(heap_size=ruff_memory_usage::heap_size)] +fn is_function_definition<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + symbol: ScopedSymbolId, +) -> bool { + use_def_map(db, scope) + .end_of_scope_symbol_bindings(symbol) + .filter_map(|binding| binding.binding.definition()) + .any(|definition| definition.kind(db).is_function_def()) +} + +/// Returns the variable kind for an attribute if it should participate in `ClassVar` override checks. +fn variable_kind<'db>( + db: &'db dyn Db, + class_member: PlaceAndQualifiers<'db>, + instance_member: PlaceAndQualifiers<'db>, +) -> Option { + if class_member.is_class_var() || instance_member.is_class_var() { + return Some(VariableKind::Class); + } + + // A `Final` attribute behaves like a class variable, but final overrides are diagnosed by + // `override-of-final-variable` instead of this rule. + if class_member.qualifiers.contains(TypeQualifiers::FINAL) { + return None; + } + + // A method definition is a descriptor in the class body, not an instance variable declaration, + // even though instance lookup binds it as a method. It should therefore not participate in the + // class-variable vs. instance-variable declaration check. For example, `Sub.f` here is a + // descriptor stored on the class, not an instance attribute: + // + // ```python + // class Base: + // f: ClassVar[int] + // + // class Sub(Base): + // def f(self) -> int: ... + // ``` + if matches!( + class_member.place, + Place::Defined(DefinedPlace { + ty: Type::FunctionLiteral(_), + .. + }) + ) { + return None; + } + + // Descriptor values are not normal instance variables: lookup calls `__get__`, so the value + // exposed through an instance can differ from the value stored on the class. For example, + // `attr = property(lambda self: 1)` installs a descriptor value, so `C().attr` exposes the + // getter return type instead of the `property` object. By contrast, `attr: Descriptor` only + // annotates an instance attribute; the annotated type having `__get__` does not make `C.attr` + // a descriptor value. + if let Place::Defined(DefinedPlace { + ty: class_member_ty, + origin: TypeOrigin::Inferred, + .. + }) = class_member.place + && class_member_ty + .class_member(db, "__get__".into()) + .place + .ignore_possibly_undefined() + .is_some() + { + return None; + } + + Some(VariableKind::Instance) +} + +/// Returns the definition to use as the secondary annotation for an overridden symbol. +fn symbol_definition<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + symbol: ScopedSymbolId, +) -> Option> { + use_def_map(db, scope) + .end_of_scope_symbol_declarations(symbol) + .find_map(|declaration| declaration.declaration.definition()) + .or_else(|| { + use_def_map(db, scope) + .end_of_scope_symbol_bindings(symbol) + .find_map(|binding| binding.binding.definition()) + }) +} + +/// Reports an invalid override between a class variable and an instance variable. +fn report_invalid_attribute_override<'db>( + context: &InferContext<'db, '_>, + member: &Name, + subclass_definition: Definition<'db>, + superclass: ClassType<'db>, + superclass_definition: Option>, + subclass_kind: VariableKind, + superclass_kind: VariableKind, +) { + let db = context.db(); + + let Some(builder) = context.report_lint( + &INVALID_ATTRIBUTE_OVERRIDE, + subclass_definition.focus_range(db, context.module()), + ) else { + return; + }; + + let superclass_name = superclass.name(db); + let superclass_member = format!("{superclass_name}.{member}"); + let subclass_kind = subclass_kind.description(); + let superclass_kind = superclass_kind.description(); + + let mut diagnostic = + builder.into_diagnostic(format_args!("Invalid override of attribute `{member}`")); + diagnostic.set_primary_message(format_args!( + "{subclass_kind} cannot override {superclass_kind} `{superclass_member}`" + )); + diagnostic.info("This violates the Liskov Substitution Principle"); + + if let Some(superclass_definition) = superclass_definition + && superclass_definition.file(db) == context.file() + { + diagnostic.annotate( + Annotation::secondary( + context.span(superclass_definition.focus_range(db, context.module())), + ) + .message(format_args!( + "{superclass_kind} `{superclass_member}` declared here" + )), + ); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(super) enum MethodKind<'db> { Synthesized(CodeGeneratorKind<'db>), @@ -600,15 +914,16 @@ pub(super) enum MethodKind<'db> { bitflags! { /// Bitflags representing which override-related rules have been enabled. #[derive(Default, Debug, Copy, Clone)] - struct OverrideRulesConfig: u8 { + struct OverrideRulesConfig: u16 { const LISKOV_METHODS = 1 << 0; - const EXPLICIT_OVERRIDE = 1 << 1; - const FINAL_METHOD_OVERRIDDEN = 1 << 2; - const INVALID_NAMED_TUPLE = 1 << 3; - const NAMED_TUPLE_FIELD_OVERRIDE = 1 << 4; - const INVALID_DATACLASS = 1 << 5; - const FINAL_VARIABLE_OVERRIDDEN = 1 << 6; - const INVALID_ENUM_VALUE = 1 << 7; + const LISKOV_ATTRIBUTES = 1 << 1; + const EXPLICIT_OVERRIDE = 1 << 2; + const FINAL_METHOD_OVERRIDDEN = 1 << 3; + const INVALID_NAMED_TUPLE = 1 << 4; + const NAMED_TUPLE_FIELD_OVERRIDE = 1 << 5; + const INVALID_DATACLASS = 1 << 6; + const FINAL_VARIABLE_OVERRIDDEN = 1 << 7; + const INVALID_ENUM_VALUE = 1 << 8; } } @@ -622,6 +937,9 @@ impl From<&InferContext<'_, '_>> for OverrideRulesConfig { if rule_selection.is_enabled(LintId::of(&INVALID_METHOD_OVERRIDE)) { config |= OverrideRulesConfig::LISKOV_METHODS; } + if rule_selection.is_enabled(LintId::of(&INVALID_ATTRIBUTE_OVERRIDE)) { + config |= OverrideRulesConfig::LISKOV_ATTRIBUTES; + } if rule_selection.is_enabled(LintId::of(&INVALID_EXPLICIT_OVERRIDE)) { config |= OverrideRulesConfig::EXPLICIT_OVERRIDE; } @@ -657,6 +975,15 @@ impl OverrideRulesConfig { self.contains(OverrideRulesConfig::LISKOV_METHODS) } + const fn check_attribute_liskov_violations(self) -> bool { + self.contains(OverrideRulesConfig::LISKOV_ATTRIBUTES) + } + + const fn check_liskov_violations(self) -> bool { + self.contains(OverrideRulesConfig::LISKOV_METHODS) + || self.contains(OverrideRulesConfig::LISKOV_ATTRIBUTES) + } + const fn check_final_method_overridden(self) -> bool { self.contains(OverrideRulesConfig::FINAL_METHOD_OVERRIDDEN) } diff --git a/ty.schema.json b/ty.schema.json index 85319d93a3..d3db477b94 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -620,6 +620,16 @@ } ] }, + "invalid-attribute-override": { + "title": "detects attribute overrides that change class-variable or instance-variable behavior", + "description": "## What it does\nDetects attribute overrides that change whether an inherited attribute\nis a class variable or an instance variable.\n\nThis rule currently only covers class-variable and instance-variable\ncategory changes.\n\n## Why is this bad?\nPure class variables and instance variables have different access and\nassignment behavior. Overriding one with the other violates the\n[Liskov Substitution Principle] (\"LSP\"), because code that is valid for\nthe superclass may no longer be valid for the subclass.\n\n## Example\n```python\nfrom typing import ClassVar\n\nclass Base:\n instance_attr: int\n class_attr: ClassVar[int]\n\nclass Sub(Base):\n instance_attr: ClassVar[int] # error: [invalid-attribute-override]\n class_attr: int # error: [invalid-attribute-override]\n```\n\n[Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "invalid-await": { "title": "detects awaiting on types that don't support it", "description": "## What it does\nChecks for `await` being used with types that are not [Awaitable].\n\n## Why is this bad?\nSuch expressions will lead to `TypeError` being raised at runtime.\n\n## Examples\n```python\nimport asyncio\n\nclass InvalidAwait:\n def __await__(self) -> int:\n return 5\n\nasync def main() -> None:\n await InvalidAwait() # error: [invalid-await]\n await 42 # error: [invalid-await]\n\nasyncio.run(main())\n```\n\n[Awaitable]: https://docs.python.org/3/library/collections.abc.html#collections.abc.Awaitable",