[ty] emit diagnostics for method definitions and other invalid statements in TypedDict class bodies (#22351)
Fixes https://github.com/astral-sh/ty/issues/2277.
This commit is contained in:
@@ -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): ...
|
||||
|
||||
@@ -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 (`<name>: <type>`) 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
|
||||
|
||||
```
|
||||
@@ -2266,6 +2266,47 @@ def match_with_dict(u: Foo | Bar | dict):
|
||||
reveal_type(u) # revealed: Foo | (dict[Unknown, Unknown] & ~<TypedDict with items 'tag'>)
|
||||
```
|
||||
|
||||
## Only annotated declarations are allowed in the class body
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
`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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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 (`<name>: <type>`) are allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user