Skip to content

Commit 7a739d6

Browse files
authored
[ty] Custom concise diagnostic messages (#21498)
## Summary This PR proposes that we add a new `set_concise_message` functionality to our `Diagnostic` construction API. When used, the concise message that is otherwise auto-generated from the main diagnostic message and the primary annotation will be overwritten with the custom message. To understand why this is desirable, let's look at the `invalid-key` diagnostic. This is how I *want* the full diagnostic to look like: <img width="620" height="282" alt="image" src="https://github.com/user-attachments/assets/3bf70f52-9d9f-4817-bc16-fb0ebf7c2113" /> However, without the change in this PR, the concise message would have the following form: ``` error[invalid-key]: Unknown key "Age" for TypedDict `Person`: Unknown key "Age" - did you mean "age"? ``` This duplication is why the full `invalid-key` diagnostic used a main diagnostic message that is only "Invalid key for TypedDict `Person`", to make that bearable: ``` error[invalid-key] Invalid key for TypedDict `Person`: Unknown key "Age" - did you mean "age"? ``` This is still less than ideal, *and* we had to make the "full" diagnostic worse. With the new API here, we have to make no such compromises. We need to do slightly more work (provide one additional custom-designed message), but we get to keep the "full" diagnostic that we actually want, and we can make the concise message more terse and readable: ``` error[invalid-key] Unknown key "Age" for TypedDict `Person` - did you mean "age"? ``` Similar problems exist for other diagnostics as well (I really want this for #21476). In this PR, I only changed `invalid-key` and `type-assertion-failure`. The PR here is somewhat related to the discussion in astral-sh/ty#1418, but note that we are solving a problem that is unrelated to sub-diagnostics. ## Test Plan Updated tests
1 parent d5a95ec commit 7a739d6

12 files changed

+95
-56
lines changed

crates/ruff_db/src/diagnostic/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ impl Diagnostic {
6464
id,
6565
severity,
6666
message: message.into_diagnostic_message(),
67+
custom_concise_message: None,
6768
annotations: vec![],
6869
subs: vec![],
6970
fix: None,
@@ -213,6 +214,10 @@ impl Diagnostic {
213214
/// cases, just converting it to a string (or printing it) will do what
214215
/// you want.
215216
pub fn concise_message(&self) -> ConciseMessage<'_> {
217+
if let Some(custom_message) = &self.inner.custom_concise_message {
218+
return ConciseMessage::Custom(custom_message.as_str());
219+
}
220+
216221
let main = self.inner.message.as_str();
217222
let annotation = self
218223
.primary_annotation()
@@ -226,6 +231,15 @@ impl Diagnostic {
226231
}
227232
}
228233

234+
/// Set a custom message for the concise formatting of this diagnostic.
235+
///
236+
/// This overrides the default behavior of generating a concise message
237+
/// from the main diagnostic message and the primary annotation.
238+
pub fn set_concise_message(&mut self, message: impl IntoDiagnosticMessage) {
239+
Arc::make_mut(&mut self.inner).custom_concise_message =
240+
Some(message.into_diagnostic_message());
241+
}
242+
229243
/// Returns the severity of this diagnostic.
230244
///
231245
/// Note that this may be different than the severity of sub-diagnostics.
@@ -532,6 +546,7 @@ struct DiagnosticInner {
532546
id: DiagnosticId,
533547
severity: Severity,
534548
message: DiagnosticMessage,
549+
custom_concise_message: Option<DiagnosticMessage>,
535550
annotations: Vec<Annotation>,
536551
subs: Vec<SubDiagnostic>,
537552
fix: Option<Fix>,
@@ -1520,6 +1535,8 @@ pub enum ConciseMessage<'a> {
15201535
/// This indicates that the diagnostic is probably using the old
15211536
/// model.
15221537
Empty,
1538+
/// A custom concise message has been provided.
1539+
Custom(&'a str),
15231540
}
15241541

15251542
impl std::fmt::Display for ConciseMessage<'_> {
@@ -1535,6 +1552,9 @@ impl std::fmt::Display for ConciseMessage<'_> {
15351552
write!(f, "{main}: {annotation}")
15361553
}
15371554
ConciseMessage::Empty => Ok(()),
1555+
ConciseMessage::Custom(message) => {
1556+
write!(f, "{message}")
1557+
}
15381558
}
15391559
}
15401560
}

crates/ty_python_semantic/resources/mdtest/assignment/annotations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,12 @@ b: TD | None = f([{"x": 0}, {"x": 1}])
458458
reveal_type(b) # revealed: TD
459459

460460
# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor"
461-
# error: [invalid-key] "Invalid key for TypedDict `TD`: Unknown key "y""
461+
# error: [invalid-key] "Unknown key "y" for TypedDict `TD`"
462462
# error: [invalid-assignment] "Object of type `Unknown | dict[Unknown | str, Unknown | int]` is not assignable to `TD`"
463463
c: TD = f([{"y": 0}, {"x": 1}])
464464

465465
# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor"
466-
# error: [invalid-key] "Invalid key for TypedDict `TD`: Unknown key "y""
466+
# error: [invalid-key] "Unknown key "y" for TypedDict `TD`"
467467
# error: [invalid-assignment] "Object of type `Unknown | dict[Unknown | str, Unknown | int]` is not assignable to `TD | None`"
468468
c: TD | None = f([{"y": 0}, {"x": 1}])
469469
```

crates/ty_python_semantic/resources/mdtest/directives/assert_never.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def if_else_isinstance_error(obj: A | B):
8080
elif isinstance(obj, C):
8181
pass
8282
else:
83-
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
83+
# error: [type-assertion-failure] "Type `B & ~A & ~C` is not equivalent to `Never`"
8484
assert_never(obj)
8585

8686
def if_else_singletons_success(obj: Literal[1, "a"] | None):
@@ -101,7 +101,7 @@ def if_else_singletons_error(obj: Literal[1, "a"] | None):
101101
elif obj is None:
102102
pass
103103
else:
104-
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
104+
# error: [type-assertion-failure] "Type `Literal["a"]` is not equivalent to `Never`"
105105
assert_never(obj)
106106

107107
def match_singletons_success(obj: Literal[1, "a"] | None):
@@ -125,7 +125,9 @@ def match_singletons_error(obj: Literal[1, "a"] | None):
125125
pass
126126
case _ as obj:
127127
# TODO: We should emit an error here, but the message should
128-
# show the type `Literal["a"]` instead of `@Todo(…)`.
129-
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
128+
# show the type `Literal["a"]` instead of `@Todo(…)`. We only
129+
# assert on the first part of the message because the `@Todo`
130+
# message is not available in release mode builds.
131+
# error: [type-assertion-failure] "Type `@Todo"
130132
assert_never(obj)
131133
```

crates/ty_python_semantic/resources/mdtest/directives/assert_type.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ from typing_extensions import assert_type
4141

4242
# Subtype does not count
4343
def _(x: bool):
44-
assert_type(x, int) # error: [type-assertion-failure]
44+
assert_type(x, int) # error: [type-assertion-failure] "Type `int` does not match asserted type `bool`"
4545

4646
def _(a: type[int], b: type[Any]):
47-
assert_type(a, type[Any]) # error: [type-assertion-failure]
48-
assert_type(b, type[int]) # error: [type-assertion-failure]
47+
assert_type(a, type[Any]) # error: [type-assertion-failure] "Type `type[Any]` does not match asserted type `type[int]`"
48+
assert_type(b, type[int]) # error: [type-assertion-failure] "Type `type[int]` does not match asserted type `type[Any]`"
4949

5050
# The expression constructing the type is not taken into account
5151
def _(a: type[int]):

crates/ty_python_semantic/resources/mdtest/enums.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -901,7 +901,7 @@ def color_name_misses_one_variant(color: Color) -> str:
901901
elif color is Color.GREEN:
902902
return "Green"
903903
else:
904-
assert_never(color) # error: [type-assertion-failure] "Argument does not have asserted type `Never`"
904+
assert_never(color) # error: [type-assertion-failure] "Type `Literal[Color.BLUE]` is not equivalent to `Never`"
905905

906906
class Singleton(Enum):
907907
VALUE = 1
@@ -956,7 +956,7 @@ def color_name_misses_one_variant(color: Color) -> str:
956956
case Color.GREEN:
957957
return "Green"
958958
case _:
959-
assert_never(color) # error: [type-assertion-failure] "Argument does not have asserted type `Never`"
959+
assert_never(color) # error: [type-assertion-failure] "Type `Literal[Color.BLUE]` is not equivalent to `Never`"
960960

961961
class Singleton(Enum):
962962
VALUE = 1

crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Misspelled_key_for_`…_(7cf0fa634e2a2d59).snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
2424
# Diagnostics
2525

2626
```
27-
error[invalid-key]: Invalid key for TypedDict `Config`
27+
error[invalid-key]: Unknown key "Retries" for TypedDict `Config`
2828
--> src/mdtest_snippet.py:7:5
2929
|
3030
6 | def _(config: Config) -> None:
3131
7 | config["Retries"] = 30.0 # error: [invalid-key]
32-
| ------ ^^^^^^^^^ Unknown key "Retries" - did you mean "retries"?
32+
| ------ ^^^^^^^^^ Did you mean "retries"?
3333
| |
3434
| TypedDict `Config`
3535
|

crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_all_…_(1c685d9d10678263).snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
3030
# Diagnostics
3131

3232
```
33-
error[invalid-key]: Invalid key for TypedDict `Person`
33+
error[invalid-key]: Unknown key "surname" for TypedDict `Person`
3434
--> src/mdtest_snippet.py:13:5
3535
|
3636
11 | # error: [invalid-key]
3737
12 | # error: [invalid-key]
3838
13 | being["surname"] = "unknown"
39-
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
39+
| ----- ^^^^^^^^^ Did you mean "name"?
4040
| |
4141
| TypedDict `Person` in union type `Person | Animal`
4242
|
@@ -45,13 +45,13 @@ info: rule `invalid-key` is enabled by default
4545
```
4646

4747
```
48-
error[invalid-key]: Invalid key for TypedDict `Animal`
48+
error[invalid-key]: Unknown key "surname" for TypedDict `Animal`
4949
--> src/mdtest_snippet.py:13:5
5050
|
5151
11 | # error: [invalid-key]
5252
12 | # error: [invalid-key]
5353
13 | being["surname"] = "unknown"
54-
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
54+
| ----- ^^^^^^^^^ Did you mean "name"?
5555
| |
5656
| TypedDict `Animal` in union type `Person | Animal`
5757
|

crates/ty_python_semantic/resources/mdtest/snapshots/assignment_diagnosti…_-_Subscript_assignment…_-_Unknown_key_for_one_…_(b515711c0a451a86).snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
2828
# Diagnostics
2929

3030
```
31-
error[invalid-key]: Invalid key for TypedDict `Person`
31+
error[invalid-key]: Unknown key "legs" for TypedDict `Person`
3232
--> src/mdtest_snippet.py:11:5
3333
|
3434
10 | def _(being: Person | Animal) -> None:

crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
5757
# Diagnostics
5858

5959
```
60-
error[invalid-key]: Invalid key for TypedDict `Person`
60+
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
6161
--> src/mdtest_snippet.py:8:5
6262
|
6363
7 | def access_invalid_literal_string_key(person: Person):
6464
8 | person["naem"] # error: [invalid-key]
65-
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
65+
| ------ ^^^^^^ Did you mean "name"?
6666
| |
6767
| TypedDict `Person`
6868
9 |
@@ -73,7 +73,7 @@ info: rule `invalid-key` is enabled by default
7373
```
7474

7575
```
76-
error[invalid-key]: Invalid key for TypedDict `Person`
76+
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
7777
--> src/mdtest_snippet.py:13:5
7878
|
7979
12 | def access_invalid_key(person: Person):
@@ -130,12 +130,12 @@ info: rule `invalid-assignment` is enabled by default
130130
```
131131

132132
```
133-
error[invalid-key]: Invalid key for TypedDict `Person`
133+
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
134134
--> src/mdtest_snippet.py:22:5
135135
|
136136
21 | def write_to_non_existing_key(person: Person):
137137
22 | person["naem"] = "Alice" # error: [invalid-key]
138-
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
138+
| ------ ^^^^^^ Did you mean "name"?
139139
| |
140140
| TypedDict `Person`
141141
23 |
@@ -160,7 +160,7 @@ info: rule `invalid-key` is enabled by default
160160
```
161161

162162
```
163-
error[invalid-key]: Invalid key for TypedDict `Person`
163+
error[invalid-key]: Unknown key "unknown" for TypedDict `Person`
164164
--> src/mdtest_snippet.py:29:21
165165
|
166166
27 | def create_with_invalid_string_key():
@@ -178,7 +178,7 @@ info: rule `invalid-key` is enabled by default
178178
```
179179

180180
```
181-
error[invalid-key]: Invalid key for TypedDict `Person`
181+
error[invalid-key]: Unknown key "unknown" for TypedDict `Person`
182182
--> src/mdtest_snippet.py:32:11
183183
|
184184
31 | # error: [invalid-key]

0 commit comments

Comments
 (0)