[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) 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 --> pyproject.toml:3:18
| |
2 | [tool.ty.environment] 2 | [tool.ty.environment]
@ -1179,6 +1180,8 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
import os import os
os.grantpt(1) # only available on unix, Python 3.13 or newer 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 | 3 |
4 | os.grantpt(1) # only available on unix, Python 3.13 or newer 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 --> ty.toml:3:18
| |
2 | [environment] 2 | [environment]
@ -1205,7 +1211,26 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
| |
info: rule `unresolved-attribute` is enabled by default 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 ----- ----- stderr -----
"#); "#);
@ -1225,6 +1250,8 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
import os import os
os.grantpt(1) # only available on unix, Python 3.13 or newer 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] 5 | # error: [unresolved-attribute]
6 | reveal_type(datetime.fakenotreal) # revealed: Unknown 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 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 /// *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 /// versions, we add a hint to the diagnostic that the user may have
/// misconfigured their Python version. /// 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( pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
db: &dyn Db, db: &dyn Db,
mut diagnostic: LintDiagnosticGuard, diagnostic: &mut Diagnostic,
full_submodule_name: &ModuleName, full_submodule_name: &ModuleName,
parent_module: Module, parent_module: Module,
) { ) -> bool {
let Some(search_path) = parent_module.search_path(db) else { let Some(search_path) = parent_module.search_path(db) else {
return; return false;
}; };
if !search_path.is_standard_library() { if !search_path.is_standard_library() {
return; return false;
} }
let program = Program::get(db); let program = Program::get(db);
let typeshed_versions = program.search_paths(db).typeshed_versions(); let typeshed_versions = program.search_paths(db).typeshed_versions();
let Some(version_range) = typeshed_versions.exact(full_submodule_name) else { let Some(version_range) = typeshed_versions.exact(full_submodule_name) else {
return; return false;
}; };
let python_version = program.python_version(db); let python_version = program.python_version(db);
if version_range.contains(python_version) { if version_range.contains(python_version) {
return; return false;
} }
diagnostic.info(format_args!( 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(), 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, /// 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( pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions(
db: &dyn Db, db: &dyn Db,
mut diagnostic: LintDiagnosticGuard, mut diagnostic: LintDiagnosticGuard,
value_type: &Type, value_type: Type,
attr: &str, attr: &str,
action: &str,
) { ) {
// Currently we limit this analysis to attributes of stdlib modules, // Currently we limit this analysis to attributes of stdlib modules,
// as this covers the most important cases while not being too noisy // 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 // so if this lookup succeeds then we know that this lookup *could* succeed with possible
// configuration changes. // configuration changes.
let symbol_table = place_table(db, global_scope(db, file)); 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; 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. // 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: determine what version they need to be on
// TODO: also mention the platform we're assuming // TODO: also mention the platform we're assuming
// TODO: determine what platform they need to be on // TODO: determine what platform they need to be on
add_inferred_python_version_hint_to_diagnostic( add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, action);
db,
&mut diagnostic,
&format!("accessing `{attr}`"),
);
} }

View File

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