[ty] Add subdiagnostic hint if the user wrote `X = Any` rather than `X: Any` (#21777)

This commit is contained in:
Alex Waygood 2025-12-03 20:42:21 +00:00 committed by GitHub
parent 45ac30a4d7
commit 8ebecb2a88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 185 additions and 0 deletions

View File

@ -0,0 +1,26 @@
# Diagnostics for invalid attribute access on special forms
<!-- snapshot-diagnostics -->
```py
from typing_extensions import Any, Final, LiteralString, Self
X = Any
class Foo:
X: Final = LiteralString
a: int
b: Self
class Bar:
def __init__(self):
self.y: Final = LiteralString
X.foo # error: [unresolved-attribute]
X.aaaaooooooo # error: [unresolved-attribute]
Foo.X.startswith # error: [unresolved-attribute]
Foo.Bar().y.startswith # error: [unresolved-attribute]
# TODO: false positive (just testing the diagnostic in the meantime)
Foo().b.a # error: [unresolved-attribute]
```

View File

@ -0,0 +1,114 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: special_form_attributes.md - Diagnostics for invalid attribute access on special forms
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/special_form_attributes.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing_extensions import Any, Final, LiteralString, Self
2 |
3 | X = Any
4 |
5 | class Foo:
6 | X: Final = LiteralString
7 | a: int
8 | b: Self
9 |
10 | class Bar:
11 | def __init__(self):
12 | self.y: Final = LiteralString
13 |
14 | X.foo # error: [unresolved-attribute]
15 | X.aaaaooooooo # error: [unresolved-attribute]
16 | Foo.X.startswith # error: [unresolved-attribute]
17 | Foo.Bar().y.startswith # error: [unresolved-attribute]
18 |
19 | # TODO: false positive (just testing the diagnostic in the meantime)
20 | Foo().b.a # error: [unresolved-attribute]
```
# Diagnostics
```
error[unresolved-attribute]: Special form `typing.Any` has no attribute `foo`
--> src/mdtest_snippet.py:14:1
|
12 | self.y: Final = LiteralString
13 |
14 | X.foo # error: [unresolved-attribute]
| ^^^^^
15 | X.aaaaooooooo # error: [unresolved-attribute]
16 | Foo.X.startswith # error: [unresolved-attribute]
|
help: Objects with type `Any` have a `foo` attribute, but the symbol `typing.Any` does not itself inhabit the type `Any`
help: This error may indicate that `X` was defined as `X = typing.Any` when `X: typing.Any` was intended
info: rule `unresolved-attribute` is enabled by default
```
```
error[unresolved-attribute]: Special form `typing.Any` has no attribute `aaaaooooooo`
--> src/mdtest_snippet.py:15:1
|
14 | X.foo # error: [unresolved-attribute]
15 | X.aaaaooooooo # error: [unresolved-attribute]
| ^^^^^^^^^^^^^
16 | Foo.X.startswith # error: [unresolved-attribute]
17 | Foo.Bar().y.startswith # error: [unresolved-attribute]
|
help: Objects with type `Any` have an `aaaaooooooo` attribute, but the symbol `typing.Any` does not itself inhabit the type `Any`
help: This error may indicate that `X` was defined as `X = typing.Any` when `X: typing.Any` was intended
info: rule `unresolved-attribute` is enabled by default
```
```
error[unresolved-attribute]: Special form `typing.LiteralString` has no attribute `startswith`
--> src/mdtest_snippet.py:16:1
|
14 | X.foo # error: [unresolved-attribute]
15 | X.aaaaooooooo # error: [unresolved-attribute]
16 | Foo.X.startswith # error: [unresolved-attribute]
| ^^^^^^^^^^^^^^^^
17 | Foo.Bar().y.startswith # error: [unresolved-attribute]
|
help: Objects with type `LiteralString` have a `startswith` attribute, but the symbol `typing.LiteralString` does not itself inhabit the type `LiteralString`
help: This error may indicate that `Foo.X` was defined as `Foo.X = typing.LiteralString` when `Foo.X: typing.LiteralString` was intended
info: rule `unresolved-attribute` is enabled by default
```
```
error[unresolved-attribute]: Special form `typing.LiteralString` has no attribute `startswith`
--> src/mdtest_snippet.py:17:1
|
15 | X.aaaaooooooo # error: [unresolved-attribute]
16 | Foo.X.startswith # error: [unresolved-attribute]
17 | Foo.Bar().y.startswith # error: [unresolved-attribute]
| ^^^^^^^^^^^^^^^^^^^^^^
18 |
19 | # TODO: false positive (just testing the diagnostic in the meantime)
|
help: Objects with type `LiteralString` have a `startswith` attribute, but the symbol `typing.LiteralString` does not itself inhabit the type `LiteralString`
info: rule `unresolved-attribute` is enabled by default
```
```
error[unresolved-attribute]: Special form `typing.Self` has no attribute `a`
--> src/mdtest_snippet.py:20:1
|
19 | # TODO: false positive (just testing the diagnostic in the meantime)
20 | Foo().b.a # error: [unresolved-attribute]
| ^^^^^^^^^
|
info: rule `unresolved-attribute` is enabled by default
```

View File

@ -4,6 +4,7 @@ use itertools::{Either, EitherOrBoth, Itertools};
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span};
use ruff_db::files::File;
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
use ruff_db::source::source_text;
use ruff_python_ast::visitor::{Visitor, walk_expr};
use ruff_python_ast::{
self as ast, AnyNodeRef, ExprContext, HasNodeIndex, NodeIndex, PythonVersion,
@ -9111,6 +9112,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
/// Infer the type of a [`ast::ExprAttribute`] expression, assuming a load context.
fn infer_attribute_load(&mut self, attribute: &ast::ExprAttribute) -> Type<'db> {
fn is_dotted_name(attribute: &ast::Expr) -> bool {
match attribute {
ast::Expr::Name(_) => true,
ast::Expr::Attribute(ast::ExprAttribute { value, .. }) => is_dotted_name(value),
_ => false,
}
}
let ast::ExprAttribute { value, attr, .. } = attribute;
let value_type = self.infer_maybe_standalone_expression(value, TypeContext::default());
@ -9204,6 +9213,42 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
if let Type::SpecialForm(special_form) = value_type {
if let Some(builder) =
self.context.report_lint(&UNRESOLVED_ATTRIBUTE, attribute)
{
let mut diag = builder.into_diagnostic(format_args!(
"Special form `{special_form}` has no attribute `{attr_name}`",
));
if let Ok(defined_type) = value_type.in_type_expression(
db,
self.scope(),
self.typevar_binding_context,
) && !defined_type.member(db, attr_name).place.is_undefined()
{
diag.help(format_args!(
"Objects with type `{ty}` have a{maybe_n} `{attr_name}` attribute, but the symbol \
`{special_form}` does not itself inhabit the type `{ty}`",
maybe_n = if attr_name.starts_with(['a', 'e', 'i', 'o', 'u']) {
"n"
} else {
""
},
ty = defined_type.display(self.db())
));
if is_dotted_name(value) {
let source = &source_text(self.db(), self.file())[value.range()];
diag.help(format_args!(
"This error may indicate that `{source}` was defined as \
`{source} = {special_form}` when `{source}: {special_form}` \
was intended"
));
}
}
}
return fallback();
}
let Some(builder) = self.context.report_lint(&UNRESOLVED_ATTRIBUTE, attribute)
else {
return fallback();