Skip to content

Commit 574bff2

Browse files
committed
Nicer diagnostics
1 parent 8ed6771 commit 574bff2

File tree

8 files changed

+231
-62
lines changed

8 files changed

+231
-62
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ test-case = { workspace = true }
4848
memchr = { workspace = true }
4949
strum = { workspace = true }
5050
strum_macros = { workspace = true }
51+
strsim = "0.11.1"
5152

5253
[dev-dependencies]
5354
ruff_db = { workspace = true, features = ["testing", "os"] }
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: typed_dict.md - `TypedDict` - Diagnostics
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | from typing import TypedDict, Final
16+
2 |
17+
3 | class Person(TypedDict):
18+
4 | name: str
19+
5 | age: int | None
20+
6 |
21+
7 | def access_invalid_literal_string_key(person: Person):
22+
8 | person["naem"] # error: [invalid-key]
23+
9 |
24+
10 | NAME_KEY: Final = "naem"
25+
11 |
26+
12 | def access_invalid_key(person: Person):
27+
13 | person[NAME_KEY] # error: [invalid-key]
28+
14 |
29+
15 | def access_with_str_key(person: Person, str_key: str):
30+
16 | person[str_key] # error: [invalid-key]
31+
```
32+
33+
# Diagnostics
34+
35+
```
36+
error[invalid-key]: Invalid key access on TypedDict `Person`
37+
--> src/mdtest_snippet.py:8:5
38+
|
39+
7 | def access_invalid_literal_string_key(person: Person):
40+
8 | person["naem"] # error: [invalid-key]
41+
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
42+
| |
43+
| TypedDict `Person`
44+
9 |
45+
10 | NAME_KEY: Final = "naem"
46+
|
47+
info: rule `invalid-key` is enabled by default
48+
49+
```
50+
51+
```
52+
error[invalid-key]: Invalid key access on TypedDict `Person`
53+
--> src/mdtest_snippet.py:13:5
54+
|
55+
12 | def access_invalid_key(person: Person):
56+
13 | person[NAME_KEY] # error: [invalid-key]
57+
| ------ ^^^^^^^^ Unknown key "naem" - did you mean "name"?
58+
| |
59+
| TypedDict `Person`
60+
14 |
61+
15 | def access_with_str_key(person: Person, str_key: str):
62+
|
63+
info: rule `invalid-key` is enabled by default
64+
65+
```
66+
67+
```
68+
error[invalid-key]: TypedDict `Person` can not be indexed with a key of type `str`
69+
--> src/mdtest_snippet.py:16:12
70+
|
71+
15 | def access_with_str_key(person: Person, str_key: str):
72+
16 | person[str_key] # error: [invalid-key]
73+
| ^^^^^^^
74+
|
75+
info: rule `invalid-key` is enabled by default
76+
77+
```

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ reveal_type(alice["name"]) # revealed: Unknown
2626
# TODO: this should be `int | None`
2727
reveal_type(alice["age"]) # revealed: Unknown
2828

29-
# TODO: this should reveal `Never`, and it should emit an error
29+
# TODO: this should reveal `Unknown`, and it should emit an error
3030
reveal_type(alice["non_existing"]) # revealed: Unknown
3131
```
3232

@@ -38,7 +38,7 @@ bob = Person(name="Bob", age=25)
3838
reveal_type(bob["name"]) # revealed: str
3939
reveal_type(bob["age"]) # revealed: int | None
4040

41-
# error: [invalid-key] "The `Person` TypedDict does not define a key named 'non_existing'"
41+
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing""
4242
reveal_type(bob["non_existing"]) # revealed: Unknown
4343
```
4444

@@ -139,7 +139,7 @@ reveal_type(alice["name"]) # revealed: Unknown
139139
## Key-based access
140140

141141
```py
142-
from typing import TypedDict, Final, Literal
142+
from typing import TypedDict, Final, Literal, Any
143143

144144
class Person(TypedDict):
145145
name: str
@@ -148,7 +148,7 @@ class Person(TypedDict):
148148
NAME_FINAL: Final = "name"
149149
AGE_FINAL: Final[Literal["age"]] = "age"
150150

151-
def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age", "name"], unknown_key: str) -> None:
151+
def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age", "name"], str_key: str, unknown_key: Any) -> None:
152152
reveal_type(person["name"]) # revealed: str
153153
reveal_type(person["age"]) # revealed: int | None
154154

@@ -159,10 +159,13 @@ def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age",
159159

160160
reveal_type(person[union_of_keys]) # revealed: int | None | str
161161

162-
# error: [invalid-key] "The `Person` TypedDict does not define a key named 'non_existing'"
162+
# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing""
163163
reveal_type(person["non_existing"]) # revealed: Unknown
164164

165-
# error: [invalid-key] "The `Person` TypedDict can not be indexed with a key of type `str`"
165+
# error: [invalid-key] "TypedDict `Person` can not be indexed with a key of type `str`"
166+
reveal_type(person[str_key]) # revealed: Unknown
167+
168+
# No error here:
166169
reveal_type(person[unknown_key]) # revealed: Unknown
167170
```
168171

@@ -372,4 +375,29 @@ reveal_type(Message.__required_keys__) # revealed: @Todo(Support for `TypedDict
372375
msg.content
373376
```
374377

378+
## Diagnostics
379+
380+
<!-- snapshot-diagnostics -->
381+
382+
Snapshot tests for diagnostic messages including suggestions:
383+
384+
```py
385+
from typing import TypedDict, Final
386+
387+
class Person(TypedDict):
388+
name: str
389+
age: int | None
390+
391+
def access_invalid_literal_string_key(person: Person):
392+
person["naem"] # error: [invalid-key]
393+
394+
NAME_KEY: Final = "naem"
395+
396+
def access_invalid_key(person: Person):
397+
person[NAME_KEY] # error: [invalid-key]
398+
399+
def access_with_str_key(person: Person, str_key: str):
400+
person[str_key] # error: [invalid-key]
401+
```
402+
375403
[`typeddict`]: https://typing.python.org/en/latest/spec/typeddict.html

crates/ty_python_semantic/src/types.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use crate::semantic_index::scope::ScopeId;
3838
use crate::semantic_index::{imported_modules, place_table, semantic_index};
3939
use crate::suppression::check_suppressions;
4040
use crate::types::call::{Binding, Bindings, CallArguments, CallableBinding};
41+
use crate::types::class::{CodeGeneratorKind, Field, FxOrderMap};
4142
pub(crate) use crate::types::class_base::ClassBase;
4243
use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder};
4344
use crate::types::diagnostic::{INVALID_TYPE_FORM, UNSUPPORTED_BOOL_CONVERSION};
@@ -669,10 +670,6 @@ impl<'db> Type<'db> {
669670
matches!(self, Type::Dynamic(_))
670671
}
671672

672-
pub(crate) const fn is_typed_dict(&self) -> bool {
673-
matches!(self, Type::TypedDict(..))
674-
}
675-
676673
/// Returns the top materialization (or upper bound materialization) of this type, which is the
677674
/// most general form of the type that is fully static.
678675
#[must_use]
@@ -834,6 +831,17 @@ impl<'db> Type<'db> {
834831
.expect("Expected a Type::EnumLiteral variant")
835832
}
836833

834+
pub(crate) const fn is_typed_dict(&self) -> bool {
835+
matches!(self, Type::TypedDict(..))
836+
}
837+
838+
pub(crate) fn into_typed_dict(self) -> Option<TypedDictType<'db>> {
839+
match self {
840+
Type::TypedDict(typed_dict) => Some(typed_dict),
841+
_ => None,
842+
}
843+
}
844+
837845
/// Turn a class literal (`Type::ClassLiteral` or `Type::GenericAlias`) into a `ClassType`.
838846
/// Since a `ClassType` must be specialized, apply the default specialization to any
839847
/// unspecialized generic class literal.
@@ -8933,6 +8941,11 @@ impl<'db> TypedDictType<'db> {
89338941
Type::TypedDict(Self::new(db, defining_class))
89348942
}
89358943

8944+
pub(crate) fn items(self, db: &'db dyn Db) -> FxOrderMap<Name, Field<'db>> {
8945+
let (class_literal, specialization) = self.defining_class(db).class_literal(db);
8946+
class_literal.fields(db, specialization, CodeGeneratorKind::TypedDict)
8947+
}
8948+
89368949
pub(crate) fn apply_type_mapping<'a>(
89378950
self,
89388951
db: &'db dyn Db,

crates/ty_python_semantic/src/types/class.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ use ruff_python_ast::{self as ast, PythonVersion};
5656
use ruff_text_size::{Ranged, TextRange};
5757
use rustc_hash::{FxHashSet, FxHasher};
5858

59-
type FxOrderMap<K, V> = ordermap::map::OrderMap<K, V, BuildHasherDefault<FxHasher>>;
59+
pub(crate) type FxOrderMap<K, V> = ordermap::map::OrderMap<K, V, BuildHasherDefault<FxHasher>>;
6060

6161
fn explicit_bases_cycle_recover<'db>(
6262
_db: &'db dyn Db,
@@ -1097,11 +1097,11 @@ impl MethodDecorator {
10971097
}
10981098
}
10991099

1100-
/// Metadata regarding a dataclass field/attribute.
1100+
/// Metadata regarding a dataclass field/attribute or a `TypedDict` "item" / key-value pair.
11011101
#[derive(Debug, Clone, PartialEq, Eq)]
1102-
pub(crate) struct DataclassField<'db> {
1102+
pub(crate) struct Field<'db> {
11031103
/// The declared type of the field
1104-
pub(crate) field_ty: Type<'db>,
1104+
pub(crate) declared_ty: Type<'db>,
11051105

11061106
/// The type of the default value for this field
11071107
pub(crate) default_ty: Option<Type<'db>>,
@@ -1858,8 +1858,8 @@ impl<'db> ClassLiteral<'db> {
18581858
let mut kw_only_field_seen = false;
18591859
for (
18601860
field_name,
1861-
DataclassField {
1862-
mut field_ty,
1861+
Field {
1862+
declared_ty: mut field_ty,
18631863
mut default_ty,
18641864
init_only: _,
18651865
init,
@@ -2051,7 +2051,7 @@ impl<'db> ClassLiteral<'db> {
20512051
Parameter::positional_only(Some(Name::new_static("key")))
20522052
.with_annotated_type(key_type),
20532053
]),
2054-
Some(field.field_ty),
2054+
Some(field.declared_ty),
20552055
)
20562056
});
20572057

@@ -2154,7 +2154,7 @@ impl<'db> ClassLiteral<'db> {
21542154
db: &'db dyn Db,
21552155
specialization: Option<Specialization<'db>>,
21562156
field_policy: CodeGeneratorKind,
2157-
) -> FxOrderMap<Name, DataclassField<'db>> {
2157+
) -> FxOrderMap<Name, Field<'db>> {
21582158
if field_policy == CodeGeneratorKind::NamedTuple {
21592159
// NamedTuples do not allow multiple inheritance, so it is sufficient to enumerate the
21602160
// fields of this class only.
@@ -2201,7 +2201,7 @@ impl<'db> ClassLiteral<'db> {
22012201
self,
22022202
db: &'db dyn Db,
22032203
specialization: Option<Specialization<'db>>,
2204-
) -> FxOrderMap<Name, DataclassField<'db>> {
2204+
) -> FxOrderMap<Name, Field<'db>> {
22052205
let mut attributes = FxOrderMap::default();
22062206

22072207
let class_body_scope = self.body_scope(db);
@@ -2253,8 +2253,8 @@ impl<'db> ClassLiteral<'db> {
22532253

22542254
attributes.insert(
22552255
symbol.name().clone(),
2256-
DataclassField {
2257-
field_ty: attr_ty.apply_optional_specialization(db, specialization),
2256+
Field {
2257+
declared_ty: attr_ty.apply_optional_specialization(db, specialization),
22582258
default_ty,
22592259
init_only: attr.is_init_var(),
22602260
init,

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2617,3 +2617,28 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
26172617

26182618
add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules");
26192619
}
2620+
2621+
/// Suggest a name from `existing_names` that is similar to `wrong_name`.
2622+
pub(super) fn did_you_mean<S: AsRef<str>, T: AsRef<str>>(
2623+
existing_names: impl Iterator<Item = S>,
2624+
wrong_name: T,
2625+
) -> Option<String> {
2626+
if wrong_name.as_ref().len() < 3 {
2627+
return None;
2628+
}
2629+
2630+
existing_names
2631+
.map(|ref id| {
2632+
(
2633+
id.as_ref().to_string(),
2634+
strsim::damerau_levenshtein(
2635+
&id.as_ref().to_lowercase(),
2636+
&wrong_name.as_ref().to_lowercase(),
2637+
),
2638+
)
2639+
})
2640+
.min_by_key(|(_, dist)| *dist)
2641+
// Heuristic to filter out bad matches
2642+
.filter(|(id, dist)| id.len() >= 2 && *dist <= 3)
2643+
.map(|(id, _)| id)
2644+
}

0 commit comments

Comments
 (0)