From 922d964bcbeb91e68377061fcce7ce286e2be202 Mon Sep 17 00:00:00 2001 From: Jack O'Connor Date: Mon, 5 Jan 2026 11:28:04 -0800 Subject: [PATCH] [ty] emit diagnostics for method definitions and other invalid statements in `TypedDict` class bodies (#22351) Fixes https://github.com/astral-sh/ty/issues/2277. --- crates/ty/docs/rules.md | 183 ++++++++++-------- .../resources/mdtest/override.md | 4 +- ...y_annotated_decla…_(bef70731cae5b8af).snap | 103 ++++++++++ .../resources/mdtest/typed_dict.md | 41 ++++ .../src/types/diagnostic.rs | 26 +++ .../src/types/infer/builder.rs | 71 ++++++- ty.schema.json | 10 + 7 files changed, 354 insertions(+), 84 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Only_annotated_decla…_(bef70731cae5b8af).snap diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 8de896c0c6..ae4f3d5009 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -80,7 +80,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -104,7 +104,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 @@ -135,7 +135,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -167,7 +167,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -198,7 +198,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -230,7 +230,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -262,7 +262,7 @@ class B(A): ... Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -290,7 +290,7 @@ type B = A Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -317,7 +317,7 @@ old_func() # emits [deprecated] diagnostic Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -346,7 +346,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -373,7 +373,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -529,7 +529,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -559,7 +559,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -585,7 +585,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -674,7 +674,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -701,7 +701,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -729,7 +729,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -763,7 +763,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -799,7 +799,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -823,7 +823,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -850,7 +850,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -879,7 +879,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -923,7 +923,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -965,7 +965,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1009,7 +1009,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1077,7 +1077,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1116,7 +1116,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1151,7 +1151,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1185,7 +1185,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1292,7 +1292,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1346,7 +1346,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -1376,7 +1376,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 @@ -1426,7 +1426,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1452,7 +1452,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1483,7 +1483,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 @@ -1517,7 +1517,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 @@ -1566,7 +1566,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1591,7 +1591,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1687,7 +1687,7 @@ class C: ... Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1714,7 +1714,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 @@ -1761,7 +1761,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1791,7 +1791,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1821,7 +1821,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 @@ -1855,7 +1855,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1889,7 +1889,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1918,13 +1918,44 @@ T = TypeVar('T', bound=str) # valid bound TypeVar [type variables]: https://docs.python.org/3/library/typing.html#typing.TypeVar +## `invalid-typed-dict-statement` + + +Default level: error · +Added in 0.0.9 · +Related issues · +View source + + + +**What it does** + +Detects statements other than annotated declarations in `TypedDict` class bodies. + +**Why is this bad?** + +`TypedDict` class bodies aren't allowed to contain any other types of statements. For +example, method definitions and field values aren't allowed. None of these will be +available on "instances of the `TypedDict`" at runtime (as `dict` is the runtime class of +all "`TypedDict` instances"). + +**Example** + +```python +from typing import TypedDict + +class Foo(TypedDict): + def bar(self): # error: [invalid-typed-dict-statement] + pass +``` + ## `missing-argument` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1949,7 +1980,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 @@ -1982,7 +2013,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2011,7 +2042,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2037,7 +2068,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 @@ -2061,7 +2092,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 @@ -2094,7 +2125,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2121,7 +2152,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2148,7 +2179,7 @@ f(x=1) # Error raised here Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2176,7 +2207,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 @@ -2208,7 +2239,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 @@ -2245,7 +2276,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2309,7 +2340,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2336,7 +2367,7 @@ cast(int, f()) # Redundant Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2366,7 +2397,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 @@ -2395,7 +2426,7 @@ class B(A): ... # Error raised here Default level: error · Preview (since 0.0.1-alpha.30) · Related issues · -View source +View source @@ -2429,7 +2460,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2456,7 +2487,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2484,7 +2515,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2530,7 +2561,7 @@ class A: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2554,7 +2585,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 @@ -2581,7 +2612,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 @@ -2609,7 +2640,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 @@ -2667,7 +2698,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2692,7 +2723,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2717,7 +2748,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 @@ -2756,7 +2787,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2793,7 +2824,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2852,7 +2883,7 @@ a = 20 / 2 Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2915,7 +2946,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/override.md b/crates/ty_python_semantic/resources/mdtest/override.md index adc966aa28..0cf810c418 100644 --- a/crates/ty_python_semantic/resources/mdtest/override.md +++ b/crates/ty_python_semantic/resources/mdtest/override.md @@ -551,10 +551,8 @@ class MyNamedTupleChild(MyNamedTupleParent): class MyTypedDict(TypedDict): x: int + # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods" @override - # TODO: it's invalid to define a method on a `TypedDict` class, - # so we should emit a diagnostic here. - # It shouldn't be an `invalid-explicit-override` diagnostic, however. def copy(self) -> Self: ... class Grandparent(Any): ... diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Only_annotated_decla…_(bef70731cae5b8af).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Only_annotated_decla…_(bef70731cae5b8af).snap new file mode 100644 index 0000000000..ecee54b703 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Only_annotated_decla…_(bef70731cae5b8af).snap @@ -0,0 +1,103 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- + +--- +mdtest name: typed_dict.md - `TypedDict` - Only annotated declarations are allowed in the class body +mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing import TypedDict + 2 | + 3 | class Foo(TypedDict): + 4 | """docstring""" + 5 | + 6 | annotated_item: int + 7 | """attribute docstring""" + 8 | + 9 | pass +10 | +11 | # As a non-standard but common extension, we interpret `...` as equivalent to `pass`. +12 | ... +13 | +14 | class Bar(TypedDict): +15 | a: int +16 | # error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body" +17 | 42 +18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value" +19 | b: str = "hello" +20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods" +21 | def bar(self): ... +22 | class Baz(Bar): +23 | # error: [invalid-typed-dict-statement] +24 | def baz(self): +25 | pass +``` + +# Diagnostics + +``` +error[invalid-typed-dict-statement]: invalid statement in TypedDict class body + --> src/mdtest_snippet.py:17:5 + | +15 | a: int +16 | # error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body" +17 | 42 + | ^^ +18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value" +19 | b: str = "hello" + | +info: Only annotated declarations (`: `) are allowed. +info: rule `invalid-typed-dict-statement` is enabled by default + +``` + +``` +error[invalid-typed-dict-statement]: TypedDict item cannot have a value + --> src/mdtest_snippet.py:19:14 + | +17 | 42 +18 | # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value" +19 | b: str = "hello" + | ^^^^^^^ +20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods" +21 | def bar(self): ... + | +info: rule `invalid-typed-dict-statement` is enabled by default + +``` + +``` +error[invalid-typed-dict-statement]: TypedDict class cannot have methods + --> src/mdtest_snippet.py:21:5 + | +19 | b: str = "hello" +20 | # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods" +21 | def bar(self): ... + | ^^^^^^^^^^^^^^^^^^ +22 | class Baz(Bar): +23 | # error: [invalid-typed-dict-statement] + | +info: rule `invalid-typed-dict-statement` is enabled by default + +``` + +``` +error[invalid-typed-dict-statement]: TypedDict class cannot have methods + --> src/mdtest_snippet.py:24:5 + | +22 | class Baz(Bar): +23 | # error: [invalid-typed-dict-statement] +24 | / def baz(self): +25 | | pass + | |____________^ + | +info: rule `invalid-typed-dict-statement` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 7eddc51a62..a6dbce3426 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2266,6 +2266,47 @@ def match_with_dict(u: Foo | Bar | dict): reveal_type(u) # revealed: Foo | (dict[Unknown, Unknown] & ~) ``` +## Only annotated declarations are allowed in the class body + + + +`TypedDict` class bodies are very restricted in what kinds of statements they can contain. Besides +annotated items, the only allowed statements are docstrings and `pass`. Annotated items are are also +not allowed to have a value. + +```py +from typing import TypedDict + +class Foo(TypedDict): + """docstring""" + + annotated_item: int + """attribute docstring""" + + pass + + # As a non-standard but common extension, we interpret `...` as equivalent to `pass`. + ... + +class Bar(TypedDict): + a: int + # error: [invalid-typed-dict-statement] "invalid statement in TypedDict class body" + 42 + # error: [invalid-typed-dict-statement] "TypedDict item cannot have a value" + b: str = "hello" + # error: [invalid-typed-dict-statement] "TypedDict class cannot have methods" + def bar(self): ... +``` + +These rules are also enforced for `TypedDict` classes that don't directly inherit from `TypedDict`: + +```py +class Baz(Bar): + # error: [invalid-typed-dict-statement] + def baz(self): + pass +``` + [closed]: https://peps.python.org/pep-0728/#disallowing-extra-items-explicitly [subtyping section]: https://typing.python.org/en/latest/spec/typeddict.html#subtyping-between-typeddict-types [`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 0f1a23aae5..a924e03d4a 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -120,6 +120,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&REDUNDANT_CAST); registry.register_lint(&UNRESOLVED_GLOBAL); registry.register_lint(&MISSING_TYPED_DICT_KEY); + registry.register_lint(&INVALID_TYPED_DICT_STATEMENT); registry.register_lint(&INVALID_METHOD_OVERRIDE); registry.register_lint(&INVALID_EXPLICIT_OVERRIDE); registry.register_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD); @@ -2167,6 +2168,31 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Detects statements other than annotated declarations in `TypedDict` class bodies. + /// + /// ## Why is this bad? + /// `TypedDict` class bodies aren't allowed to contain any other types of statements. For + /// example, method definitions and field values aren't allowed. None of these will be + /// available on "instances of the `TypedDict`" at runtime (as `dict` is the runtime class of + /// all "`TypedDict` instances"). + /// + /// ## Example + /// ```python + /// from typing import TypedDict + /// + /// class Foo(TypedDict): + /// def bar(self): # error: [invalid-typed-dict-statement] + /// pass + /// ``` + pub(crate) static INVALID_TYPED_DICT_STATEMENT = { + summary: "detects invalid statements in `TypedDict` class bodies", + status: LintStatus::stable("0.0.9"), + 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/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 420286ca31..8b7b28d2cf 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -63,11 +63,11 @@ use crate::types::diagnostic::{ INVALID_LEGACY_TYPE_VARIABLE, INVALID_METACLASS, INVALID_NAMED_TUPLE, INVALID_NEWTYPE, INVALID_OVERLOAD, INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ARGUMENTS, INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, - INVALID_TYPE_VARIABLE_CONSTRAINTS, IncompatibleBases, NOT_SUBSCRIPTABLE, - POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, - SUBCLASS_OF_FINAL_CLASS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, - UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, - USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, + INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPED_DICT_STATEMENT, IncompatibleBases, + NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, + POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, + UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, + UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, report_cannot_delete_typed_dict_key, report_cannot_pop_required_field_on_typed_dict, @@ -1054,6 +1054,67 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(protocol) = class.into_protocol_class(self.db()) { protocol.validate_members(&self.context); } + + // (9) If it's a `TypedDict` class, check that it doesn't include any invalid + // statements: https://typing.python.org/en/latest/spec/typeddict.html#class-based-syntax + // + // The body of the class definition defines the items of the `TypedDict` type. It + // may also contain a docstring or pass statements (primarily to allow the creation + // of an empty `TypedDict`). No other statements are allowed, and type checkers + // should report an error if any are present. + if class.is_typed_dict(self.db()) { + for stmt in &class_node.body { + match stmt { + // Annotated assignments are allowed (that's the whole point), but they're + // not allowed to have a value. + ast::Stmt::AnnAssign(ann_assign) => { + if let Some(value) = &ann_assign.value { + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPED_DICT_STATEMENT, &**value) + { + builder.into_diagnostic(format_args!( + "TypedDict item cannot have a value" + )); + } + } + continue; + } + // Pass statements are allowed. + ast::Stmt::Pass(_) => continue, + ast::Stmt::Expr(expr) => { + // Docstrings are allowed. + if matches!(*expr.value, ast::Expr::StringLiteral(_)) { + continue; + } + // As a non-standard but common extension, we also interpret `...` as + // equivalent to `pass`. + if matches!(*expr.value, ast::Expr::EllipsisLiteral(_)) { + continue; + } + } + // Everything else is forbidden. + _ => {} + } + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPED_DICT_STATEMENT, stmt) + { + if matches!(stmt, ast::Stmt::FunctionDef(_)) { + builder.into_diagnostic(format_args!( + "TypedDict class cannot have methods" + )); + } else { + let mut diagnostic = builder.into_diagnostic(format_args!( + "invalid statement in TypedDict class body" + )); + diagnostic.info( + "Only annotated declarations (`: `) are allowed.", + ); + } + } + } + } } } diff --git a/ty.schema.json b/ty.schema.json index 50e0dee989..9b65d7e24e 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -856,6 +856,16 @@ } ] }, + "invalid-typed-dict-statement": { + "title": "detects invalid statements in `TypedDict` class bodies", + "description": "## What it does\nDetects statements other than annotated declarations in `TypedDict` class bodies.\n\n## Why is this bad?\n`TypedDict` class bodies aren't allowed to contain any other types of statements. For\nexample, method definitions and field values aren't allowed. None of these will be\navailable on \"instances of the `TypedDict`\" at runtime (as `dict` is the runtime class of\nall \"`TypedDict` instances\").\n\n## Example\n```python\nfrom typing import TypedDict\n\nclass Foo(TypedDict):\n def bar(self): # error: [invalid-typed-dict-statement]\n pass\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "missing-argument": { "title": "detects missing required arguments in a call", "description": "## What it does\nChecks for missing required arguments in a call.\n\n## Why is this bad?\nFailing to provide a required argument will raise a `TypeError` at runtime.\n\n## Examples\n```python\ndef func(x: int): ...\nfunc() # TypeError: func() missing 1 required positional argument: 'x'\n```",