[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 https://github.com/astral-sh/ruff/pull/21476). In this PR, I only
changed `invalid-key` and `type-assertion-failure`.

The PR here is somewhat related to the discussion in
https://github.com/astral-sh/ty/issues/1418, but note that we are
solving a problem that is unrelated to sub-diagnostics.

## Test Plan

Updated tests
This commit is contained in:
David Peter 2025-11-18 09:35:40 +01:00 committed by GitHub
parent d5a95ec824
commit 7a739d6b76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 95 additions and 56 deletions

View File

@ -64,6 +64,7 @@ impl Diagnostic {
id,
severity,
message: message.into_diagnostic_message(),
custom_concise_message: None,
annotations: vec![],
subs: vec![],
fix: None,
@ -213,6 +214,10 @@ impl Diagnostic {
/// cases, just converting it to a string (or printing it) will do what
/// you want.
pub fn concise_message(&self) -> ConciseMessage<'_> {
if let Some(custom_message) = &self.inner.custom_concise_message {
return ConciseMessage::Custom(custom_message.as_str());
}
let main = self.inner.message.as_str();
let annotation = self
.primary_annotation()
@ -226,6 +231,15 @@ impl Diagnostic {
}
}
/// Set a custom message for the concise formatting of this diagnostic.
///
/// This overrides the default behavior of generating a concise message
/// from the main diagnostic message and the primary annotation.
pub fn set_concise_message(&mut self, message: impl IntoDiagnosticMessage) {
Arc::make_mut(&mut self.inner).custom_concise_message =
Some(message.into_diagnostic_message());
}
/// Returns the severity of this diagnostic.
///
/// Note that this may be different than the severity of sub-diagnostics.
@ -532,6 +546,7 @@ struct DiagnosticInner {
id: DiagnosticId,
severity: Severity,
message: DiagnosticMessage,
custom_concise_message: Option<DiagnosticMessage>,
annotations: Vec<Annotation>,
subs: Vec<SubDiagnostic>,
fix: Option<Fix>,
@ -1520,6 +1535,8 @@ pub enum ConciseMessage<'a> {
/// This indicates that the diagnostic is probably using the old
/// model.
Empty,
/// A custom concise message has been provided.
Custom(&'a str),
}
impl std::fmt::Display for ConciseMessage<'_> {
@ -1535,6 +1552,9 @@ impl std::fmt::Display for ConciseMessage<'_> {
write!(f, "{main}: {annotation}")
}
ConciseMessage::Empty => Ok(()),
ConciseMessage::Custom(message) => {
write!(f, "{message}")
}
}
}
}

View File

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

View File

@ -80,7 +80,7 @@ def if_else_isinstance_error(obj: A | B):
elif isinstance(obj, C):
pass
else:
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
# error: [type-assertion-failure] "Type `B & ~A & ~C` is not equivalent to `Never`"
assert_never(obj)
def if_else_singletons_success(obj: Literal[1, "a"] | None):
@ -101,7 +101,7 @@ def if_else_singletons_error(obj: Literal[1, "a"] | None):
elif obj is None:
pass
else:
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
# error: [type-assertion-failure] "Type `Literal["a"]` is not equivalent to `Never`"
assert_never(obj)
def match_singletons_success(obj: Literal[1, "a"] | None):
@ -125,7 +125,9 @@ def match_singletons_error(obj: Literal[1, "a"] | None):
pass
case _ as obj:
# TODO: We should emit an error here, but the message should
# show the type `Literal["a"]` instead of `@Todo(…)`.
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
# show the type `Literal["a"]` instead of `@Todo(…)`. We only
# assert on the first part of the message because the `@Todo`
# message is not available in release mode builds.
# error: [type-assertion-failure] "Type `@Todo"
assert_never(obj)
```

View File

@ -41,11 +41,11 @@ from typing_extensions import assert_type
# Subtype does not count
def _(x: bool):
assert_type(x, int) # error: [type-assertion-failure]
assert_type(x, int) # error: [type-assertion-failure] "Type `int` does not match asserted type `bool`"
def _(a: type[int], b: type[Any]):
assert_type(a, type[Any]) # error: [type-assertion-failure]
assert_type(b, type[int]) # error: [type-assertion-failure]
assert_type(a, type[Any]) # error: [type-assertion-failure] "Type `type[Any]` does not match asserted type `type[int]`"
assert_type(b, type[int]) # error: [type-assertion-failure] "Type `type[int]` does not match asserted type `type[Any]`"
# The expression constructing the type is not taken into account
def _(a: type[int]):

View File

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

View File

@ -24,12 +24,12 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Config`
error[invalid-key]: Unknown key "Retries" for TypedDict `Config`
--> src/mdtest_snippet.py:7:5
|
6 | def _(config: Config) -> None:
7 | config["Retries"] = 30.0 # error: [invalid-key]
| ------ ^^^^^^^^^ Unknown key "Retries" - did you mean "retries"?
| ------ ^^^^^^^^^ Did you mean "retries"?
| |
| TypedDict `Config`
|

View File

@ -30,13 +30,13 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "surname" for TypedDict `Person`
--> src/mdtest_snippet.py:13:5
|
11 | # error: [invalid-key]
12 | # error: [invalid-key]
13 | being["surname"] = "unknown"
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
| ----- ^^^^^^^^^ Did you mean "name"?
| |
| TypedDict `Person` in union type `Person | Animal`
|
@ -45,13 +45,13 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Animal`
error[invalid-key]: Unknown key "surname" for TypedDict `Animal`
--> src/mdtest_snippet.py:13:5
|
11 | # error: [invalid-key]
12 | # error: [invalid-key]
13 | being["surname"] = "unknown"
| ----- ^^^^^^^^^ Unknown key "surname" - did you mean "name"?
| ----- ^^^^^^^^^ Did you mean "name"?
| |
| TypedDict `Animal` in union type `Person | Animal`
|

View File

@ -28,7 +28,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "legs" for TypedDict `Person`
--> src/mdtest_snippet.py:11:5
|
10 | def _(being: Person | Animal) -> None:

View File

@ -57,12 +57,12 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
# Diagnostics
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
--> src/mdtest_snippet.py:8:5
|
7 | def access_invalid_literal_string_key(person: Person):
8 | person["naem"] # error: [invalid-key]
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
| ------ ^^^^^^ Did you mean "name"?
| |
| TypedDict `Person`
9 |
@ -73,7 +73,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
--> src/mdtest_snippet.py:13:5
|
12 | def access_invalid_key(person: Person):
@ -130,12 +130,12 @@ info: rule `invalid-assignment` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "naem" for TypedDict `Person`
--> src/mdtest_snippet.py:22:5
|
21 | def write_to_non_existing_key(person: Person):
22 | person["naem"] = "Alice" # error: [invalid-key]
| ------ ^^^^^^ Unknown key "naem" - did you mean "name"?
| ------ ^^^^^^ Did you mean "name"?
| |
| TypedDict `Person`
23 |
@ -160,7 +160,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "unknown" for TypedDict `Person`
--> src/mdtest_snippet.py:29:21
|
27 | def create_with_invalid_string_key():
@ -178,7 +178,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key for TypedDict `Person`
error[invalid-key]: Unknown key "unknown" for TypedDict `Person`
--> src/mdtest_snippet.py:32:11
|
31 | # error: [invalid-key]

View File

@ -29,7 +29,7 @@ alice: Person = {"name": "Alice", "age": 30}
reveal_type(alice["name"]) # revealed: str
reveal_type(alice["age"]) # revealed: int | None
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing""
# error: [invalid-key] "Unknown key "non_existing" for TypedDict `Person`"
reveal_type(alice["non_existing"]) # revealed: Unknown
```
@ -41,7 +41,7 @@ bob = Person(name="Bob", age=25)
reveal_type(bob["name"]) # revealed: str
reveal_type(bob["age"]) # revealed: int | None
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing""
# error: [invalid-key] " key "non_existing" for TypedDict `Person`"
reveal_type(bob["non_existing"]) # revealed: Unknown
```
@ -81,7 +81,7 @@ def _():
CAPITALIZED_NAME = "Name"
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "Name" - did you mean "name"?"
# error: [invalid-key] "Unknown key "Name" for TypedDict `Person` - did you mean "name"?"
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
dave: Person = {CAPITALIZED_NAME: "Dave", "age": 20}
@ -112,10 +112,10 @@ eve2b = Person(age=22)
reveal_type(eve2a) # revealed: Person
reveal_type(eve2b) # revealed: Person
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
eve3a: Person = {"name": "Eve", "age": 25, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
eve3b = Person(name="Eve", age=25, extra=True)
reveal_type(eve3a) # revealed: Person
@ -169,10 +169,10 @@ bob["name"] = None
Assignments to non-existing keys are disallowed:
```py
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
alice["extra"] = True
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
bob["extra"] = True
```
@ -197,10 +197,10 @@ alice: Person = {"inner": {"name": "Alice", "age": 30}}
reveal_type(alice["inner"]["name"]) # revealed: str
reveal_type(alice["inner"]["age"]) # revealed: int | None
# error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "non_existing""
# error: [invalid-key] "Unknown key "non_existing" for TypedDict `Inner`"
reveal_type(alice["inner"]["non_existing"]) # revealed: Unknown
# error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Inner`"
alice: Person = {"inner": {"name": "Alice", "age": 30, "extra": 1}}
```
@ -289,16 +289,16 @@ a_person = {"name": None, "age": 30}
All of these have an extra field that is not defined in the `TypedDict`:
```py
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
alice4: Person = {"name": "Alice", "age": 30, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
Person(name="Alice", age=30, extra=True)
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
Person({"name": "Alice", "age": 30, "extra": True})
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
# error: [invalid-argument-type]
accepts_person({"name": "Alice", "age": 30, "extra": True})
@ -307,10 +307,10 @@ accepts_person({"name": "Alice", "age": 30, "extra": True})
house.owner = {"name": "Alice", "age": 30, "extra": True}
a_person: Person
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
a_person = {"name": "Alice", "age": 30, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `Person`"
(a_person := {"name": "Alice", "age": 30, "extra": True})
```
@ -351,7 +351,7 @@ user2 = User({"name": "Bob"})
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `User`: value of type `None`"
user3 = User({"name": None, "age": 25})
# error: [invalid-key] "Invalid key for TypedDict `User`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `User`"
user4 = User({"name": "Charlie", "age": 30, "extra": True})
```
@ -388,7 +388,7 @@ invalid = OptionalPerson(name=123)
Extra fields are still not allowed, even with `total=False`:
```py
# error: [invalid-key] "Invalid key for TypedDict `OptionalPerson`: Unknown key "extra""
# error: [invalid-key] "Unknown key "extra" for TypedDict `OptionalPerson`"
invalid_extra = OptionalPerson(name="George", extra=True)
```
@ -550,7 +550,7 @@ def _(
reveal_type(person[union_of_keys]) # revealed: int | None | str
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing""
# error: [invalid-key] "Unknown key "non_existing" for TypedDict `Person`"
reveal_type(person["non_existing"]) # revealed: Unknown
# error: [invalid-key] "TypedDict `Person` can only be subscripted with a string literal key, got key of type `str`"
@ -563,7 +563,7 @@ def _(
# TODO: A type of `int | None | Unknown` might be better here. The `str` is mixed in
# because `Animal.__getitem__` can only return `str`.
# error: [invalid-key] "Invalid key for TypedDict `Animal`"
# error: [invalid-key] "Unknown key "age" for TypedDict `Animal`"
reveal_type(being["age"]) # revealed: int | None | str
```
@ -589,7 +589,7 @@ def _(person: Person):
person["name"] = "Alice"
person["age"] = 30
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "naem" - did you mean "name"?"
# error: [invalid-key] "Unknown key "naem" for TypedDict `Person` - did you mean "name"?"
person["naem"] = "Alice"
def _(person: Person):
@ -613,7 +613,7 @@ def _(being: Person | Animal):
# error: [invalid-assignment] "Invalid assignment to key "name" with declared type `str` on TypedDict `Animal`: value of type `Literal[1]`"
being["name"] = 1
# error: [invalid-key] "Invalid key for TypedDict `Animal`: Unknown key "surname" - did you mean "name"?"
# error: [invalid-key] "Unknown key "surname" for TypedDict `Animal` - did you mean "name"?"
being["surname"] = "unknown"
def _(centaur: Intersection[Person, Animal]):
@ -621,7 +621,7 @@ def _(centaur: Intersection[Person, Animal]):
centaur["age"] = 100
centaur["legs"] = 4
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "unknown""
# error: [invalid-key] "Unknown key "unknown" for TypedDict `Person`"
centaur["unknown"] = "value"
def _(person: Person, union_of_keys: Literal["name", "age"], unknown_value: Any):
@ -724,7 +724,7 @@ def _(p: Person) -> None:
reveal_type(p.setdefault("name", "Alice")) # revealed: str
reveal_type(p.setdefault("extra", "default")) # revealed: str
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extraz" - did you mean "extra"?"
# error: [invalid-key] "Unknown key "extraz" for TypedDict `Person` - did you mean "extra"?"
reveal_type(p.setdefault("extraz", "value")) # revealed: Unknown
```

View File

@ -3147,7 +3147,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
let typed_dict_name = typed_dict_ty.display(db);
let mut diagnostic = builder.into_diagnostic(format_args!(
"Invalid key for TypedDict `{typed_dict_name}`",
"Unknown key \"{key}\" for TypedDict `{typed_dict_name}`",
));
diagnostic.annotate(if let Some(full_object_ty) = full_object_ty {
@ -3167,15 +3167,21 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>(
});
let existing_keys = items.iter().map(|(name, _)| name.as_str());
diagnostic.set_primary_message(format!(
"Unknown key \"{key}\"{hint}",
hint = if let Some(suggestion) = did_you_mean(existing_keys, key) {
format!(" - did you mean \"{suggestion}\"?")
if let Some(suggestion) = did_you_mean(existing_keys, key) {
if key_node.is_expr_string_literal() {
diagnostic
.set_primary_message(format_args!("Did you mean \"{suggestion}\"?"));
} else {
String::new()
diagnostic.set_primary_message(format_args!(
"Unknown key \"{key}\" - did you mean \"{suggestion}\"?",
));
}
));
diagnostic.set_concise_message(format_args!(
"Unknown key \"{key}\" for TypedDict `{typed_dict_name}` - did you mean \"{suggestion}\"?",
));
} else {
diagnostic.set_primary_message(format_args!("Unknown key \"{key}\""));
}
}
_ => {
let mut diagnostic = builder.into_diagnostic(format_args!(

View File

@ -1495,6 +1495,12 @@ impl KnownFunction {
asserted_type = asserted_ty.display(db),
inferred_type = actual_ty.display(db),
));
diagnostic.set_concise_message(format_args!(
"Type `{}` does not match asserted type `{}`",
asserted_ty.display(db),
actual_ty.display(db),
));
}
}
@ -1520,6 +1526,11 @@ impl KnownFunction {
"`Never` and `{inferred_type}` are not equivalent types",
inferred_type = actual_ty.display(db),
));
diagnostic.set_concise_message(format_args!(
"Type `{}` is not equivalent to `Never`",
actual_ty.display(db),
));
}
}