Skip to content

Commit d851708

Browse files
authored
[ty] Improve robustness of various type-qualifier-related checks (#24251)
1 parent aecb587 commit d851708

File tree

7 files changed

+205
-57
lines changed

7 files changed

+205
-57
lines changed

crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,18 +333,18 @@ python-version = "3.12"
333333
```
334334

335335
```py
336-
from typing import ClassVar
336+
from typing import ClassVar, TypedDict
337337
from ty_extensions import reveal_mro
338338

339-
# error: [invalid-type-form] "`ClassVar` annotations are only allowed in class-body scopes"
339+
# error: [invalid-type-form] "`ClassVar` is only allowed in class bodies"
340340
x: ClassVar[int] = 1
341341

342342
class C:
343343
def __init__(self) -> None:
344344
# error: [invalid-type-form] "`ClassVar` annotations are not allowed for non-name targets"
345345
self.x: ClassVar[int] = 1
346346

347-
# error: [invalid-type-form] "`ClassVar` annotations are only allowed in class-body scopes"
347+
# error: [invalid-type-form] "`ClassVar` is only allowed in class bodies"
348348
y: ClassVar[int] = 1
349349

350350
# error: [invalid-type-form] "Type qualifier `typing.ClassVar` is not allowed in parameter annotations"
@@ -369,6 +369,12 @@ class Foo(ClassVar[tuple[int]]): ...
369369
# TODO: Show `Unknown` instead of `@Todo` type in the MRO; or ignore `ClassVar` and show the MRO as if `ClassVar` was not there
370370
# revealed: (<class 'Foo'>, @Todo(Inference of subscript on special form), <class 'object'>)
371371
reveal_mro(Foo)
372+
373+
class Foo(TypedDict):
374+
# error: [invalid-type-form] "`ClassVar` is not allowed in TypedDict fields"
375+
x: ClassVar[int]
376+
# error: [invalid-type-form] "`ClassVar` is not allowed in TypedDict fields"
377+
y: ClassVar
372378
```
373379

374380
[`typing.classvar`]: https://docs.python.org/3/library/typing.html#typing.ClassVar

crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ python-version = "3.12"
662662
```
663663

664664
```py
665-
from typing import Final, ClassVar, Annotated
665+
from typing import Final, ClassVar, Annotated, TypedDict
666666
from ty_extensions import reveal_mro
667667

668668
LEGAL_A: Final[int] = 1
@@ -703,6 +703,18 @@ class Foo(Final[tuple[int]]): ...
703703
# TODO: Show `Unknown` instead of `@Todo` type in the MRO; or ignore `Final` and show the MRO as if `Final` was not there
704704
# revealed: (<class 'Foo'>, @Todo(Inference of subscript on special form), <class 'object'>)
705705
reveal_mro(Foo)
706+
707+
class Foo(TypedDict):
708+
# error: [invalid-type-form] "`Final` is not allowed in TypedDict fields"
709+
# error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
710+
a: Final[int] = 42
711+
# error: [invalid-type-form] "`Final` is not allowed in TypedDict fields"
712+
# error: [invalid-typed-dict-statement] "TypedDict item cannot have a value"
713+
b: Final = 56
714+
# error: [invalid-type-form] "`Final` is not allowed in TypedDict fields"
715+
c: Final[int]
716+
# error: [invalid-type-form] "`Final` is not allowed in TypedDict fields"
717+
d: Final
706718
```
707719

708720
### Attribute assignment outside `__init__`

crates/ty_python_semantic/resources/mdtest/type_qualifiers/initvar.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,10 @@ class AlsoWrong:
144144
`InitVar` annotations are not allowed outside of dataclass attribute annotations:
145145

146146
```py
147+
from typing import TypedDict
147148
from dataclasses import InitVar, dataclass
148149

149-
# error: [invalid-type-form] "`InitVar` annotations are only allowed in class-body scopes"
150+
# error: [invalid-type-form] "`InitVar` is only allowed in dataclass fields"
150151
x: InitVar[int] = 1
151152

152153
# error: [invalid-type-form] "Type qualifier `dataclasses.InitVar` is not allowed in parameter annotations"
@@ -158,7 +159,11 @@ def g() -> InitVar[int]:
158159
return 1
159160

160161
class C:
161-
# TODO: this would ideally be an error
162+
# error: [invalid-type-form] "`InitVar` is only allowed in dataclass fields"
163+
x: InitVar[int]
164+
165+
class D(TypedDict):
166+
# error: [invalid-type-form] "`InitVar` is not allowed in TypedDict fields"
162167
x: InitVar[int]
163168

164169
@dataclass

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2772,9 +2772,9 @@ from typing import TypedDict
27722772
x: TypedDict = {"name": "Alice"}
27732773
```
27742774

2775-
### `ReadOnly`, `Required` and `NotRequired` not allowed in parameter annotations
2775+
### `ReadOnly`, `Required` and `NotRequired` not allowed in parameter annotations or return annotations
27762776

2777-
```py
2777+
```pyi
27782778
from typing_extensions import Required, NotRequired, ReadOnly
27792779

27802780
def bad(
@@ -2785,29 +2785,62 @@ def bad(
27852785
# error: [invalid-type-form] "Type qualifier `typing.ReadOnly` is not allowed in parameter annotations"
27862786
c: ReadOnly[int],
27872787
): ...
2788+
2789+
# error: [invalid-type-form] "Type qualifier `typing.Required` is not allowed in return type annotations"
2790+
def bad2() -> Required[int]: ...
2791+
2792+
# error: [invalid-type-form] "Type qualifier `typing.NotRequired` is not allowed in return type annotations"
2793+
def bad2() -> NotRequired[int]: ...
2794+
2795+
# error: [invalid-type-form] "Type qualifier `typing.ReadOnly` is not allowed in return type annotations"
2796+
def bad2() -> ReadOnly[int]: ...
2797+
```
2798+
2799+
### `Required`, `NotRequired` and `ReadOnly` require exactly one argument
2800+
2801+
```py
2802+
from typing_extensions import TypedDict, ReadOnly, Required, NotRequired
2803+
2804+
class Foo(TypedDict):
2805+
a: Required # error: [invalid-type-form] "`Required` may not be used without a type argument"
2806+
b: Required[()] # error: [invalid-type-form] "Type qualifier `typing.Required` expected exactly 1 argument, got 0"
2807+
c: Required[int, str] # error: [invalid-type-form] "Type qualifier `typing.Required` expected exactly 1 argument, got 2"
2808+
d: NotRequired # error: [invalid-type-form] "`NotRequired` may not be used without a type argument"
2809+
e: NotRequired[()] # error: [invalid-type-form] "Type qualifier `typing.NotRequired` expected exactly 1 argument, got 0"
2810+
# error: [invalid-type-form] "Type qualifier `typing.NotRequired` expected exactly 1 argument, got 2"
2811+
f: NotRequired[int, str]
2812+
g: ReadOnly # error: [invalid-type-form] "`ReadOnly` may not be used without a type argument"
2813+
h: ReadOnly[()] # error: [invalid-type-form] "Type qualifier `typing.ReadOnly` expected exactly 1 argument, got 0"
2814+
i: ReadOnly[int, str] # error: [invalid-type-form] "Type qualifier `typing.ReadOnly` expected exactly 1 argument, got 2"
27882815
```
27892816

2790-
### `Required` and `NotRequired` not allowed outside `TypedDict`
2817+
### `Required`, `NotRequired` and `ReadOnly` are not allowed outside `TypedDict`
27912818

27922819
```py
2793-
from typing_extensions import Required, NotRequired, TypedDict
2820+
from typing_extensions import Required, NotRequired, TypedDict, ReadOnly
27942821

27952822
# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
27962823
x: Required[int]
27972824
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
27982825
y: NotRequired[str]
2826+
# error: [invalid-type-form] "`ReadOnly` is only allowed in TypedDict fields"
2827+
z: ReadOnly[str]
27992828

28002829
class MyClass:
28012830
# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
28022831
x: Required[int]
28032832
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
28042833
y: NotRequired[str]
2834+
# error: [invalid-type-form] "`ReadOnly` is only allowed in TypedDict fields"
2835+
z: ReadOnly[str]
28052836

28062837
def f():
28072838
# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields"
28082839
x: Required[int] = 1
28092840
# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields"
28102841
y: NotRequired[str] = ""
2842+
# error: [invalid-type-form] "`ReadOnly` is only allowed in TypedDict fields"
2843+
z: ReadOnly[str]
28112844

28122845
# fine
28132846
MyFunctionalTypedDict = TypedDict("MyFunctionalTypedDict", {"not-an-identifier": Required[int]})

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 109 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use ruff_python_stdlib::typing::as_pep_585_generic;
1818
use ruff_text_size::{Ranged, TextRange};
1919
use rustc_hash::{FxHashMap, FxHashSet};
2020
use smallvec::SmallVec;
21+
use strum::IntoEnumIterator;
2122
use ty_module_resolver::{KnownModule, ModuleName, resolve_module};
2223

2324
use super::deferred;
@@ -100,6 +101,7 @@ use crate::types::mro::DynamicMroErrorKind;
100101
use crate::types::newtype::NewType;
101102
use crate::types::set_theoretic::RecursivelyDefined;
102103
use crate::types::signatures::CallableSignature;
104+
use crate::types::special_form::TypeQualifier;
103105
use crate::types::subclass_of::SubclassOfInner;
104106
use crate::types::tuple::{Tuple, TupleLength, TupleSpecBuilder, TupleType};
105107
use crate::types::type_alias::{ManualPEP695TypeAliasType, PEP695TypeAliasType};
@@ -3864,8 +3866,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
38643866
);
38653867

38663868
if !annotated.qualifiers.is_empty() {
3867-
for qualifier in [TypeQualifiers::CLASS_VAR, TypeQualifiers::INIT_VAR] {
3868-
if annotated.qualifiers.contains(qualifier)
3869+
for qualifier in TypeQualifier::iter() {
3870+
if !qualifier.is_valid_for_non_name_targets()
3871+
&& annotated
3872+
.qualifiers
3873+
.contains(TypeQualifiers::from(qualifier))
38693874
&& let Some(builder) = self
38703875
.context
38713876
.report_lint(&INVALID_TYPE_FORM, annotation.as_ref())
@@ -4141,45 +4146,117 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
41414146
}
41424147

41434148
if !declared.qualifiers.is_empty() {
4144-
let current_scope_id = self.scope().file_scope_id(self.db());
4145-
let current_scope = self.index.scope(current_scope_id);
4146-
if current_scope.kind() != ScopeKind::Class {
4147-
for qualifier in [TypeQualifiers::CLASS_VAR, TypeQualifiers::INIT_VAR] {
4148-
if declared.qualifiers.contains(qualifier)
4149-
&& let Some(builder) =
4150-
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4151-
{
4152-
builder.into_diagnostic(format_args!(
4153-
"`{name}` annotations are only allowed in class-body scopes",
4154-
name = qualifier.name()
4155-
));
4149+
for qualifier in TypeQualifier::iter() {
4150+
if !declared
4151+
.qualifiers
4152+
.contains(TypeQualifiers::from(qualifier))
4153+
{
4154+
continue;
4155+
}
4156+
let current_scope_id = self.scope().file_scope_id(self.db());
4157+
4158+
if self.index.scope(current_scope_id).kind() != ScopeKind::Class {
4159+
match qualifier {
4160+
TypeQualifier::Final => {}
4161+
TypeQualifier::ClassVar => {
4162+
if let Some(builder) =
4163+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4164+
{
4165+
builder
4166+
.into_diagnostic("`ClassVar` is only allowed in class bodies");
4167+
}
4168+
}
4169+
TypeQualifier::InitVar => {
4170+
if let Some(builder) =
4171+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4172+
{
4173+
builder.into_diagnostic(
4174+
"`InitVar` is only allowed in dataclass fields",
4175+
);
4176+
}
4177+
}
4178+
TypeQualifier::NotRequired
4179+
| TypeQualifier::ReadOnly
4180+
| TypeQualifier::Required => {
4181+
if let Some(builder) =
4182+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4183+
{
4184+
builder.into_diagnostic(format_args!(
4185+
"`{name}` is only allowed in TypedDict fields",
4186+
name = qualifier.name()
4187+
));
4188+
}
4189+
}
41564190
}
4191+
4192+
continue;
41574193
}
4158-
}
41594194

4160-
// `Required`, `NotRequired`, and `ReadOnly` are only valid inside TypedDict classes.
4161-
if declared.qualifiers.intersects(
4162-
TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED | TypeQualifiers::READ_ONLY,
4163-
) {
4164-
let in_typed_dict = current_scope.kind() == ScopeKind::Class
4165-
&& nearest_enclosing_class(self.db(), self.index, self.scope())
4166-
.is_some_and(|class| class.is_typed_dict(self.db()));
4167-
if !in_typed_dict {
4168-
for qualifier in [
4169-
TypeQualifiers::REQUIRED,
4170-
TypeQualifiers::NOT_REQUIRED,
4171-
TypeQualifiers::READ_ONLY,
4172-
] {
4173-
if declared.qualifiers.contains(qualifier)
4174-
&& let Some(builder) =
4195+
let nearest_enclosing_class =
4196+
nearest_enclosing_class(self.db(), self.index, self.scope());
4197+
let class_kind = nearest_enclosing_class.and_then(|class| {
4198+
CodeGeneratorKind::from_class(self.db(), ClassLiteral::Static(class), None)
4199+
});
4200+
4201+
match class_kind {
4202+
Some(CodeGeneratorKind::TypedDict) => match qualifier {
4203+
TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => {
4204+
let Some(builder) =
41754205
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4176-
{
4206+
else {
4207+
continue;
4208+
};
4209+
builder.into_diagnostic(format_args!(
4210+
"`{name}` is not allowed in TypedDict fields",
4211+
name = qualifier.name()
4212+
));
4213+
}
4214+
TypeQualifier::NotRequired
4215+
| TypeQualifier::ReadOnly
4216+
| TypeQualifier::Required => {}
4217+
},
4218+
Some(CodeGeneratorKind::DataclassLike(_)) => match qualifier {
4219+
TypeQualifier::NotRequired
4220+
| TypeQualifier::ReadOnly
4221+
| TypeQualifier::Required => {
4222+
let Some(builder) =
4223+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4224+
else {
4225+
continue;
4226+
};
4227+
builder.into_diagnostic(format_args!(
4228+
"`{name}` is not allowed in dataclass fields",
4229+
name = qualifier.name()
4230+
));
4231+
}
4232+
TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => {
4233+
}
4234+
},
4235+
Some(CodeGeneratorKind::NamedTuple) | None => match qualifier {
4236+
TypeQualifier::NotRequired
4237+
| TypeQualifier::Required
4238+
| TypeQualifier::ReadOnly => {
4239+
let Some(builder) =
4240+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4241+
else {
4242+
continue;
4243+
};
41774244
builder.into_diagnostic(format_args!(
41784245
"`{name}` is only allowed in TypedDict fields",
41794246
name = qualifier.name()
41804247
));
41814248
}
4182-
}
4249+
TypeQualifier::InitVar => {
4250+
let Some(builder) =
4251+
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4252+
else {
4253+
continue;
4254+
};
4255+
builder
4256+
.into_diagnostic("`InitVar` is only allowed in dataclass fields");
4257+
}
4258+
TypeQualifier::ClassVar | TypeQualifier::Final => {}
4259+
},
41834260
}
41844261
}
41854262
}

crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,25 +85,30 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
8585
) -> TypeAndQualifiers<'db> {
8686
let special_case = match ty {
8787
Type::SpecialForm(special_form) => match special_form {
88-
SpecialFormType::TypeQualifier(TypeQualifier::InitVar) => {
89-
if let Some(builder) =
90-
builder.context.report_lint(&INVALID_TYPE_FORM, annotation)
91-
{
92-
builder.into_diagnostic(
93-
"`InitVar` may not be used without a type argument",
94-
);
88+
SpecialFormType::TypeQualifier(qualifier) => {
89+
match qualifier {
90+
TypeQualifier::InitVar
91+
| TypeQualifier::ReadOnly
92+
| TypeQualifier::NotRequired
93+
| TypeQualifier::Required => {
94+
if let Some(builder) =
95+
builder.context.report_lint(&INVALID_TYPE_FORM, annotation)
96+
{
97+
builder.into_diagnostic(format_args!(
98+
"`{}` may not be used without a type argument",
99+
qualifier.name(),
100+
));
101+
}
102+
}
103+
TypeQualifier::ClassVar | TypeQualifier::Final => {}
95104
}
105+
96106
Some(TypeAndQualifiers::new(
97107
Type::unknown(),
98108
TypeOrigin::Declared,
99-
TypeQualifiers::INIT_VAR,
109+
TypeQualifiers::from(qualifier),
100110
))
101111
}
102-
SpecialFormType::TypeQualifier(qualifier) => Some(TypeAndQualifiers::new(
103-
Type::unknown(),
104-
TypeOrigin::Declared,
105-
TypeQualifiers::from(qualifier),
106-
)),
107112
SpecialFormType::TypeAlias if pep_613_policy == PEP613Policy::Allowed => {
108113
Some(TypeAndQualifiers::declared(ty))
109114
}

0 commit comments

Comments
 (0)