[ty] Better invalid-assignment diagnostics

This commit is contained in:
David Peter 2025-11-15 22:31:09 +01:00
parent fb5b8c3653
commit 1a6a8ac57d
10 changed files with 194 additions and 31 deletions

View file

@ -41,22 +41,24 @@ fn test_quiet_output() -> anyhow::Result<()> {
let case = CliTest::with_file("test.py", "x: int = 'foo'")?; let case = CliTest::with_file("test.py", "x: int = 'foo'")?;
// By default, we emit a diagnostic // By default, we emit a diagnostic
assert_cmd_snapshot!(case.command(), @r###" assert_cmd_snapshot!(case.command(), @r#"
success: false success: false
exit_code: 1 exit_code: 1
----- stdout ----- ----- stdout -----
error[invalid-assignment]: Object of type `Literal["foo"]` is not assignable to `int` error[invalid-assignment]: Object of type `Literal["foo"]` is not assignable to `int`
--> test.py:1:1 --> test.py:1:4
| |
1 | x: int = 'foo' 1 | x: int = 'foo'
| ^ | --- ^^^^^ Incompatible value of type `Literal["foo"]`
| |
| Declared type
| |
info: rule `invalid-assignment` is enabled by default info: rule `invalid-assignment` is enabled by default
Found 1 diagnostic Found 1 diagnostic
----- stderr ----- ----- stderr -----
"###); "#);
// With `quiet`, the diagnostic is not displayed, just the summary message // With `quiet`, the diagnostic is not displayed, just the summary message
assert_cmd_snapshot!(case.command().arg("--quiet"), @r" assert_cmd_snapshot!(case.command().arg("--quiet"), @r"

View file

@ -0,0 +1,28 @@
# Invalid assignment diagnostics
<!-- snapshot-diagnostics -->
## Annotated assignment
```py
x: int = "three" # error: [invalid-assignment]
```
## Unannotated assignment
```py
x: int
x = "three" # error: [invalid-assignment]
```
## Named expression
```py
x: int
(x := "three") # error: [invalid-assignment]
```
## Shadowing of classes and functions
See [shadowing.md](./shadowing.md).

View file

@ -0,0 +1,31 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Annotated assignment
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:1:4
|
1 | x: int = "three" # error: [invalid-assignment]
| --- ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type
|
info: rule `invalid-assignment` is enabled by default
```

View file

@ -0,0 +1,35 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Named expression
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int
2 |
3 | (x := "three") # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:3:2
|
1 | x: int
2 |
3 | (x := "three") # error: [invalid-assignment]
| - ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type `int`
|
info: rule `invalid-assignment` is enabled by default
```

View file

@ -0,0 +1,33 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Unannotated assignment
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int
2 | x = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:2:1
|
1 | x: int
2 | x = "three" # error: [invalid-assignment]
| - ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type `int`
|
info: rule `invalid-assignment` is enabled by default
```

View file

@ -26,7 +26,9 @@ error[invalid-assignment]: Implicit shadowing of class `C`
1 | class C: ... 1 | class C: ...
2 | 2 |
3 | C = 1 # error: [invalid-assignment] 3 | C = 1 # error: [invalid-assignment]
| ^ | - ^ Incompatible value of type `Literal[1]`
| |
| Declared type `<class 'C'>`
| |
info: Annotate to make it explicit if this is intentional info: Annotate to make it explicit if this is intentional
info: rule `invalid-assignment` is enabled by default info: rule `invalid-assignment` is enabled by default

View file

@ -26,7 +26,9 @@ error[invalid-assignment]: Implicit shadowing of function `f`
1 | def f(): ... 1 | def f(): ...
2 | 2 |
3 | f = 1 # error: [invalid-assignment] 3 | f = 1 # error: [invalid-assignment]
| ^ | - ^ Incompatible value of type `Literal[1]`
| |
| Declared type `def f() -> Unknown`
| |
info: Annotate to make it explicit if this is intentional info: Annotate to make it explicit if this is intentional
info: rule `invalid-assignment` is enabled by default info: rule `invalid-assignment` is enabled by default

View file

@ -59,10 +59,10 @@ In a non-stub file, there's no special treatment of ellipsis literals. An ellips
be assigned if `EllipsisType` is actually assignable to the annotated type. be assigned if `EllipsisType` is actually assignable to the annotated type.
```py ```py
# error: 7 [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`" # error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
def f(x: int = ...) -> None: ... def f(x: int = ...) -> None: ...
# error: 1 [invalid-assignment] "Object of type `EllipsisType` is not assignable to `int`" # error: [invalid-assignment] "Object of type `EllipsisType` is not assignable to `int`"
a: int = ... a: int = ...
b = ... b = ...
reveal_type(b) # revealed: EllipsisType reveal_type(b) # revealed: EllipsisType
@ -73,6 +73,6 @@ reveal_type(b) # revealed: EllipsisType
There is no special treatment of the builtin name `Ellipsis` in stubs, only of `...` literals. There is no special treatment of the builtin name `Ellipsis` in stubs, only of `...` literals.
```pyi ```pyi
# error: 7 [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`" # error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
def f(x: int = Ellipsis) -> None: ... def f(x: int = Ellipsis) -> None: ...
``` ```

View file

@ -2068,15 +2068,13 @@ pub(crate) fn is_invalid_typed_dict_literal(
&& matches!(source, AnyNodeRef::ExprDict(_)) && matches!(source, AnyNodeRef::ExprDict(_))
} }
fn report_invalid_assignment_with_message( fn report_invalid_assignment_with_message<'db, 'ctx: 'db>(
context: &InferContext, context: &'ctx InferContext,
node: AnyNodeRef, node: AnyNodeRef,
target_ty: Type, target_ty: Type<'db>,
message: std::fmt::Arguments, message: std::fmt::Arguments,
) { ) -> Option<LintDiagnosticGuard<'db, 'ctx>> {
let Some(builder) = context.report_lint(&INVALID_ASSIGNMENT, node) else { let builder = context.report_lint(&INVALID_ASSIGNMENT, node)?;
return;
};
match target_ty { match target_ty {
Type::ClassLiteral(class) => { Type::ClassLiteral(class) => {
let mut diag = builder.into_diagnostic(format_args!( let mut diag = builder.into_diagnostic(format_args!(
@ -2084,6 +2082,7 @@ fn report_invalid_assignment_with_message(
class.name(context.db()), class.name(context.db()),
)); ));
diag.info("Annotate to make it explicit if this is intentional"); diag.info("Annotate to make it explicit if this is intentional");
Some(diag)
} }
Type::FunctionLiteral(function) => { Type::FunctionLiteral(function) => {
let mut diag = builder.into_diagnostic(format_args!( let mut diag = builder.into_diagnostic(format_args!(
@ -2091,53 +2090,84 @@ fn report_invalid_assignment_with_message(
function.name(context.db()), function.name(context.db()),
)); ));
diag.info("Annotate to make it explicit if this is intentional"); diag.info("Annotate to make it explicit if this is intentional");
Some(diag)
} }
_ => { _ => {
builder.into_diagnostic(message); let diag = builder.into_diagnostic(message);
Some(diag)
} }
} }
} }
pub(super) fn report_invalid_assignment<'db>( pub(super) fn report_invalid_assignment<'db>(
context: &InferContext<'db, '_>, context: &InferContext<'db, '_>,
node: AnyNodeRef, target_node: AnyNodeRef,
definition: Definition<'db>, definition: Definition<'db>,
target_ty: Type, target_ty: Type,
mut source_ty: Type<'db>, mut value_ty: Type<'db>,
) { ) {
let value_expr = match definition.kind(context.db()) { let definition_kind = definition.kind(context.db());
let value_node = match definition_kind {
DefinitionKind::Assignment(def) => Some(def.value(context.module())), DefinitionKind::Assignment(def) => Some(def.value(context.module())),
DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()), DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()),
DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value), DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value),
_ => None, _ => None,
}; };
if let Some(value_expr) = value_expr if let Some(value_node) = value_node
&& is_invalid_typed_dict_literal(context.db(), target_ty, value_expr.into()) && is_invalid_typed_dict_literal(context.db(), target_ty, value_node.into())
{ {
return; return;
} }
let settings = let settings =
DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty); DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, value_ty);
if let Some(value_expr) = value_expr { if let Some(value_node) = value_node {
// Re-infer the RHS of the annotated assignment, ignoring the type context for more precise // Re-infer the RHS of the annotated assignment, ignoring the type context for more precise
// error messages. // error messages.
source_ty = value_ty =
infer_isolated_expression(context.db(), definition.scope(context.db()), value_expr); infer_isolated_expression(context.db(), definition.scope(context.db()), value_node);
} }
report_invalid_assignment_with_message( let Some(mut diag) = report_invalid_assignment_with_message(
context, context,
node, value_node.map(AnyNodeRef::from).unwrap_or(target_node),
target_ty, target_ty,
format_args!( format_args!(
"Object of type `{}` is not assignable to `{}`", "Object of type `{}` is not assignable to `{}`",
source_ty.display_with(context.db(), settings.clone()), value_ty.display_with(context.db(), settings.clone()),
target_ty.display_with(context.db(), settings) target_ty.display_with(context.db(), settings)
), ),
) else {
return;
};
if value_node.is_some() {
match definition_kind {
DefinitionKind::AnnotatedAssignment(assignment) => {
// For annotated assignments, just point to the annotation in the source code.
diag.annotate(
context
.secondary(assignment.annotation(context.module()))
.message("Declared type"),
); );
}
_ => {
// Otherwise, annotate the target with its declared type.
diag.annotate(context.secondary(target_node).message(format_args!(
"Declared type `{}`",
target_ty.display(context.db()),
)));
}
}
diag.set_primary_message(format_args!(
"Incompatible value of type `{}`",
value_ty.display(context.db()),
));
}
} }
pub(super) fn report_invalid_attribute_assignment( pub(super) fn report_invalid_attribute_assignment(

View file

@ -7744,7 +7744,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_expression(target, TypeContext::default()); self.infer_expression(target, TypeContext::default());
self.add_binding(named.into(), definition, |builder, tcx| { self.add_binding(named.target.as_ref().into(), definition, |builder, tcx| {
builder.infer_expression(value, tcx) builder.infer_expression(value, tcx)
}) })
} }