[ty] Add hint about resolved Python version when a user attempts to import a member added on a newer version (#21615)

## Summary

Fixes https://github.com/astral-sh/ty/issues/1620. #20909 added hints if
you do something like this and your Python version is set to 3.10 or
lower:

```py
import typing

typing.LiteralString
```

And we also have hints if you try to do something like this and your
Python version is set too low:

```py
from stdlib_module import new_submodule
```

But we don't currently have any subdiagnostic hint if you do something
like _this_ and your Python version is set too low:

```py
from typing import LiteralString
```

This PR adds that hint!

## Test Plan

snapshots

---------

Co-authored-by: Aria Desires <aria.desires@gmail.com>
This commit is contained in:
Alex Waygood 2025-11-24 15:12:01 +00:00 committed by GitHub
parent f317a71682
commit a57e291311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 72 additions and 24 deletions

View File

@ -37,7 +37,8 @@ fn config_override_python_version() -> anyhow::Result<()> {
5 | print(sys.last_exc)
| ^^^^^^^^^^^^
|
info: Python 3.11 was assumed when accessing `last_exc`
info: The member may be available on other Python versions or platforms
info: Python 3.11 was assumed when resolving the `last_exc` attribute
--> pyproject.toml:3:18
|
2 | [tool.ty.environment]
@ -1179,6 +1180,8 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
import os
os.grantpt(1) # only available on unix, Python 3.13 or newer
from typing import LiteralString # added in Python 3.11
"#,
),
])?;
@ -1194,8 +1197,11 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
3 |
4 | os.grantpt(1) # only available on unix, Python 3.13 or newer
| ^^^^^^^^^^
5 |
6 | from typing import LiteralString # added in Python 3.11
|
info: Python 3.10 was assumed when accessing `grantpt`
info: The member may be available on other Python versions or platforms
info: Python 3.10 was assumed when resolving the `grantpt` attribute
--> ty.toml:3:18
|
2 | [environment]
@ -1205,7 +1211,26 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
|
info: rule `unresolved-attribute` is enabled by default
Found 1 diagnostic
error[unresolved-import]: Module `typing` has no member `LiteralString`
--> main.py:6:20
|
4 | os.grantpt(1) # only available on unix, Python 3.13 or newer
5 |
6 | from typing import LiteralString # added in Python 3.11
| ^^^^^^^^^^^^^
|
info: The member may be available on other Python versions or platforms
info: Python 3.10 was assumed when resolving imports
--> ty.toml:3:18
|
2 | [environment]
3 | python-version = "3.10"
| ^^^^^^ Python 3.10 assumed due to this configuration setting
4 | python-platform = "linux"
|
info: rule `unresolved-import` is enabled by default
Found 2 diagnostics
----- stderr -----
"#);
@ -1225,6 +1250,8 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
import os
os.grantpt(1) # only available on unix, Python 3.13 or newer
from typing import LiteralString # added in Python 3.11
"#,
),
])?;

View File

@ -32,7 +32,8 @@ error[unresolved-attribute]: Module `datetime` has no member `UTC`
5 | # error: [unresolved-attribute]
6 | reveal_type(datetime.fakenotreal) # revealed: Unknown
|
info: Python 3.10 was assumed when accessing `UTC` because it was specified on the command line
info: The member may be available on other Python versions or platforms
info: Python 3.10 was assumed when resolving the `UTC` attribute because it was specified on the command line
info: rule `unresolved-attribute` is enabled by default
```

View File

@ -3630,30 +3630,32 @@ pub(super) fn report_invalid_method_override<'db>(
/// *does* exist as a submodule in the standard library on *other* Python
/// versions, we add a hint to the diagnostic that the user may have
/// misconfigured their Python version.
///
/// The function returns `true` if a hint was added, `false` otherwise.
pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
db: &dyn Db,
mut diagnostic: LintDiagnosticGuard,
diagnostic: &mut Diagnostic,
full_submodule_name: &ModuleName,
parent_module: Module,
) {
) -> bool {
let Some(search_path) = parent_module.search_path(db) else {
return;
return false;
};
if !search_path.is_standard_library() {
return;
return false;
}
let program = Program::get(db);
let typeshed_versions = program.search_paths(db).typeshed_versions();
let Some(version_range) = typeshed_versions.exact(full_submodule_name) else {
return;
return false;
};
let python_version = program.python_version(db);
if version_range.contains(python_version) {
return;
return false;
}
diagnostic.info(format_args!(
@ -3667,7 +3669,9 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
version_range = version_range.diagnostic_display(),
));
add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules");
add_inferred_python_version_hint_to_diagnostic(db, diagnostic, "resolving modules");
true
}
/// This function receives an unresolved `foo.bar` attribute access,
@ -3681,8 +3685,9 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions(
db: &dyn Db,
mut diagnostic: LintDiagnosticGuard,
value_type: &Type,
value_type: Type,
attr: &str,
action: &str,
) {
// Currently we limit this analysis to attributes of stdlib modules,
// as this covers the most important cases while not being too noisy
@ -3705,17 +3710,19 @@ pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions(
// so if this lookup succeeds then we know that this lookup *could* succeed with possible
// configuration changes.
let symbol_table = place_table(db, global_scope(db, file));
if symbol_table.symbol_by_name(attr).is_none() {
let Some(symbol) = symbol_table.symbol_by_name(attr) else {
return;
};
if !symbol.is_bound() {
return;
}
diagnostic.info("The member may be available on other Python versions or platforms");
// For now, we just mention the current version they're on, and hope that's enough of a nudge.
// TODO: determine what version they need to be on
// TODO: also mention the platform we're assuming
// TODO: determine what platform they need to be on
add_inferred_python_version_hint_to_diagnostic(
db,
&mut diagnostic,
&format!("accessing `{attr}`"),
);
add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, action);
}

View File

@ -6240,18 +6240,30 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
return;
};
let diagnostic = builder.into_diagnostic(format_args!(
let mut diagnostic = builder.into_diagnostic(format_args!(
"Module `{module_name}` has no member `{name}`"
));
let mut submodule_hint_added = false;
if let Some(full_submodule_name) = full_submodule_name {
hint_if_stdlib_submodule_exists_on_other_versions(
submodule_hint_added = hint_if_stdlib_submodule_exists_on_other_versions(
self.db(),
diagnostic,
&mut diagnostic,
&full_submodule_name,
module,
);
}
if !submodule_hint_added {
hint_if_stdlib_attribute_exists_on_other_versions(
self.db(),
diagnostic,
module_ty,
name,
"resolving imports",
);
}
}
/// Infer the implicit local definition `x = <module 'whatever.thispackage.x'>` that
@ -6335,13 +6347,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
return;
};
let diagnostic = builder.into_diagnostic(format_args!(
let mut diagnostic = builder.into_diagnostic(format_args!(
"Module `{thispackage_name}` has no submodule `{final_part}`"
));
hint_if_stdlib_submodule_exists_on_other_versions(
self.db(),
diagnostic,
&mut diagnostic,
&full_submodule_name,
module,
);
@ -9131,8 +9143,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
hint_if_stdlib_attribute_exists_on_other_versions(
db,
diagnostic,
&value_type,
value_type,
attr_name,
&format!("resolving the `{attr_name}` attribute"),
);
fallback()