[ty] Improve diagnostic when a user tries to access a function attribute on a Callable type (#22182)

## Summary

Other type checkers allow you to access all `FunctionType` attributes on
any object with a `Callable` type. ty does not, because this is
demonstrably unsound, but this is often a source of confusion for users.
And there were lots of diagnostics in the ecosystem report for
https://github.com/astral-sh/ruff/pull/22145 that were complaining that
"Object of type `(...) -> Unknown` has no attribute `__name__`", for
example.

The discrepancy between what ty does here and what other type checkers
do is discussed a bit in https://github.com/astral-sh/ty/issues/1495.
You can see that there have been lots of issues closed as duplicates of
that issue; we should probably also add an FAQ entry for it.

Anyway, this PR adds a subdiagnostic to help users out when they hit
this diagnostic. Unfortunately something I did meant that rustfmt
increased the indentation of the whole of this huge closure, so this PR
is best reviewed with the "No whitespace" option selected for viewing
the diff.

## Test Plan

Snapshot added
This commit is contained in:
Alex Waygood
2025-12-24 20:47:11 +00:00
committed by GitHub
parent 768c5a2285
commit f9afcc400c
3 changed files with 229 additions and 133 deletions

View File

@@ -2775,6 +2775,23 @@ reveal_type(foo.bar) # revealed: Unknown
reveal_type(baz.bar) # revealed: Unknown
```
## Diagnostic for function attribute accessed on `Callable` type
<!-- snapshot-diagnostics -->
```toml
[environment]
python-version = "3.14"
```
```py
from typing import Callable
def f(x: Callable):
x.__name__ # error: [unresolved-attribute]
x.__annotate__ # error: [unresolved-attribute]
```
## References
Some of the tests in the *Class and instance variables* section draw inspiration from

View File

@@ -0,0 +1,50 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: attributes.md - Attributes - Diagnostic for function attribute accessed on `Callable` type
mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md
---
# Python source files
## mdtest_snippet.py
```
1 | from typing import Callable
2 |
3 | def f(x: Callable):
4 | x.__name__ # error: [unresolved-attribute]
5 | x.__annotate__ # error: [unresolved-attribute]
```
# Diagnostics
```
error[unresolved-attribute]: Object of type `(...) -> Unknown` has no attribute `__name__`
--> src/mdtest_snippet.py:4:5
|
3 | def f(x: Callable):
4 | x.__name__ # error: [unresolved-attribute]
| ^^^^^^^^^^
5 | x.__annotate__ # error: [unresolved-attribute]
|
help: Function objects have a `__name__` attribute, but not all callable objects are functions
info: rule `unresolved-attribute` is enabled by default
```
```
error[unresolved-attribute]: Object of type `(...) -> Unknown` has no attribute `__annotate__`
--> src/mdtest_snippet.py:5:5
|
3 | def f(x: Callable):
4 | x.__name__ # error: [unresolved-attribute]
5 | x.__annotate__ # error: [unresolved-attribute]
| ^^^^^^^^^^^^^^
|
help: Function objects have an `__annotate__` attribute, but not all callable objects are functions
info: rule `unresolved-attribute` is enabled by default
```

View File

@@ -9846,154 +9846,183 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
});
let attr_name = &attr.id;
let resolved_type = fallback_place.unwrap_with_diagnostic(db, |lookup_err| match lookup_err {
LookupError::Undefined(_) => {
let fallback = || {
TypeAndQualifiers::new(
Type::unknown(),
TypeOrigin::Inferred,
TypeQualifiers::empty(),
)
};
let resolved_type =
fallback_place.unwrap_with_diagnostic(db, |lookup_err| match lookup_err {
LookupError::Undefined(_) => {
let fallback = || {
TypeAndQualifiers::new(
Type::unknown(),
TypeOrigin::Inferred,
TypeQualifiers::empty(),
)
};
if !self.is_reachable(attribute) {
return fallback();
}
let bound_on_instance = match value_type {
Type::ClassLiteral(class) => {
!class.instance_member(db, None, attr).is_undefined()
if !self.is_reachable(attribute) {
return fallback();
}
Type::SubclassOf(subclass_of @ SubclassOfType { .. }) => {
match subclass_of.subclass_of() {
SubclassOfInner::Class(class) => {
!class.instance_member(db, attr).is_undefined()
}
SubclassOfInner::Dynamic(_) => unreachable!(
"Attribute lookup on a dynamic `SubclassOf` type \
let bound_on_instance = match value_type {
Type::ClassLiteral(class) => {
!class.instance_member(db, None, attr).is_undefined()
}
Type::SubclassOf(subclass_of @ SubclassOfType { .. }) => {
match subclass_of.subclass_of() {
SubclassOfInner::Class(class) => {
!class.instance_member(db, attr).is_undefined()
}
SubclassOfInner::Dynamic(_) => unreachable!(
"Attribute lookup on a dynamic `SubclassOf` type \
should always return a bound symbol"
),
SubclassOfInner::TypeVar(_) => false,
}
}
_ => false,
};
if let Type::ModuleLiteral(module) = value_type {
let module = module.module(db);
let module_name = module.name(db);
if module.kind(db).is_package()
&& let Some(relative_submodule) = ModuleName::new(attr_name)
{
let mut maybe_submodule_name = module_name.clone();
maybe_submodule_name.extend(&relative_submodule);
if resolve_module(db, self.file(), &maybe_submodule_name).is_some() {
if let Some(builder) = self
.context
.report_lint(&POSSIBLY_MISSING_ATTRIBUTE, attribute)
{
let mut diag = builder.into_diagnostic(format_args!(
"Submodule `{attr_name}` may not be available as an attribute \
on module `{module_name}`"
));
diag.help(format_args!(
"Consider explicitly importing `{maybe_submodule_name}`"
));
),
SubclassOfInner::TypeVar(_) => false,
}
return fallback();
}
}
}
_ => false,
};
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()
if let Type::ModuleLiteral(module) = value_type {
let module = module.module(db);
let module_name = module.name(db);
if module.kind(db).is_package()
&& let Some(relative_submodule) = ModuleName::new(attr_name)
{
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"
));
let mut maybe_submodule_name = module_name.clone();
maybe_submodule_name.extend(&relative_submodule);
if resolve_module(db, self.file(), &maybe_submodule_name).is_some() {
if let Some(builder) = self
.context
.report_lint(&POSSIBLY_MISSING_ATTRIBUTE, attribute)
{
let mut diag = builder.into_diagnostic(format_args!(
"Submodule `{attr_name}` may not be available as an \
attribute on module `{module_name}`"
));
diag.help(format_args!(
"Consider explicitly importing `{maybe_submodule_name}`"
));
}
return fallback();
}
}
}
return fallback();
}
let Some(builder) = self.context.report_lint(&UNRESOLVED_ATTRIBUTE, attribute)
else {
return fallback();
};
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();
}
if bound_on_instance {
builder.into_diagnostic(format_args!(
"Attribute `{attr_name}` can only be accessed on instances, \
let Some(builder) = self.context.report_lint(&UNRESOLVED_ATTRIBUTE, attribute)
else {
return fallback();
};
if bound_on_instance {
builder.into_diagnostic(format_args!(
"Attribute `{attr_name}` can only be accessed on instances, \
not on the class object `{}` itself.",
value_type.display(db)
));
return fallback();
value_type.display(db)
));
return fallback();
}
let mut diagnostic = match value_type {
Type::ModuleLiteral(module) => builder.into_diagnostic(format_args!(
"Module `{module_name}` has no member `{attr_name}`",
module_name = module.module(db).name(db),
)),
Type::ClassLiteral(class) => builder.into_diagnostic(format_args!(
"Class `{}` has no attribute `{attr_name}`",
class.name(db),
)),
Type::GenericAlias(alias) => builder.into_diagnostic(format_args!(
"Class `{}` has no attribute `{attr_name}`",
alias.display(db),
)),
Type::FunctionLiteral(function) => builder.into_diagnostic(format_args!(
"Function `{}` has no attribute `{attr_name}`",
function.name(db),
)),
_ => builder.into_diagnostic(format_args!(
"Object of type `{}` has no attribute `{attr_name}`",
value_type.display(db),
)),
};
if value_type.is_callable_type()
&& KnownClass::FunctionType
.to_instance(db)
.member(db, attr_name)
.place
.is_definitely_bound()
{
diagnostic.help(format_args!(
"Function objects have a{maybe_n} `{attr_name}` attribute, \
but not all callable objects are functions",
maybe_n = if attr_name
.trim_start_matches('_')
.starts_with(['a', 'e', 'i', 'o', 'u'])
{
"n"
} else {
""
},
));
} else {
hint_if_stdlib_attribute_exists_on_other_versions(
db,
diagnostic,
value_type,
attr_name,
&format!("resolving the `{attr_name}` attribute"),
);
}
fallback()
}
LookupError::PossiblyUndefined(type_when_bound) => {
report_possibly_missing_attribute(
&self.context,
attribute,
&attr.id,
value_type,
);
let diagnostic = match value_type {
Type::ModuleLiteral(module) => builder.into_diagnostic(format_args!(
"Module `{module_name}` has no member `{attr_name}`",
module_name = module.module(db).name(db),
)),
Type::ClassLiteral(class) => builder.into_diagnostic(format_args!(
"Class `{}` has no attribute `{attr_name}`",
class.name(db),
)),
Type::GenericAlias(alias) => builder.into_diagnostic(format_args!(
"Class `{}` has no attribute `{attr_name}`",
alias.display(db),
)),
Type::FunctionLiteral(function) => builder.into_diagnostic(format_args!(
"Function `{}` has no attribute `{attr_name}`",
function.name(db),
)),
_ => builder.into_diagnostic(format_args!(
"Object of type `{}` has no attribute `{attr_name}`",
value_type.display(db),
)),
};
hint_if_stdlib_attribute_exists_on_other_versions(
db,
diagnostic,
value_type,
attr_name,
&format!("resolving the `{attr_name}` attribute"),
);
fallback()
}
LookupError::PossiblyUndefined(type_when_bound) => {
report_possibly_missing_attribute(&self.context, attribute, &attr.id, value_type);
type_when_bound
}
});
type_when_bound
}
});
let resolved_type = resolved_type.inner_type();