Skip to content

Commit 7155a62

Browse files
authored
[ty] Add version hint for failed stdlib attribute accesses (#20909)
This is the ultra-minimal implementation of * astral-sh/ty#296 that was previously discussed as a good starting point. In particular we don't actually bother trying to figure out the exact python versions, but we still mention "hey btw for No Reason At All... you're on python 3.10" when you try to access something that has a definition rooted in the stdlib that we believe exists sometimes.
1 parent a67e069 commit 7155a62

File tree

7 files changed

+215
-73
lines changed

7 files changed

+215
-73
lines changed

crates/ty/docs/rules.md

Lines changed: 66 additions & 66 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty/tests/cli/python_environment.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ fn config_override_python_version() -> anyhow::Result<()> {
2626
),
2727
])?;
2828

29-
assert_cmd_snapshot!(case.command(), @r###"
29+
assert_cmd_snapshot!(case.command(), @r#"
3030
success: false
3131
exit_code: 1
3232
----- stdout -----
@@ -37,12 +37,19 @@ fn config_override_python_version() -> anyhow::Result<()> {
3737
5 | print(sys.last_exc)
3838
| ^^^^^^^^^^^^
3939
|
40+
info: Python 3.11 was assumed when accessing `last_exc`
41+
--> pyproject.toml:3:18
42+
|
43+
2 | [tool.ty.environment]
44+
3 | python-version = "3.11"
45+
| ^^^^^^ Python 3.11 assumed due to this configuration setting
46+
|
4047
info: rule `unresolved-attribute` is enabled by default
4148
4249
Found 1 diagnostic
4350
4451
----- stderr -----
45-
"###);
52+
"#);
4653

4754
assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r###"
4855
success: true
@@ -951,7 +958,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
951958
),
952959
])?;
953960

954-
assert_cmd_snapshot!(case.command(), @r###"
961+
assert_cmd_snapshot!(case.command(), @r#"
955962
success: false
956963
exit_code: 1
957964
----- stdout -----
@@ -963,12 +970,20 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
963970
4 | os.grantpt(1) # only available on unix, Python 3.13 or newer
964971
| ^^^^^^^^^^
965972
|
973+
info: Python 3.10 was assumed when accessing `grantpt`
974+
--> ty.toml:3:18
975+
|
976+
2 | [environment]
977+
3 | python-version = "3.10"
978+
| ^^^^^^ Python 3.10 assumed due to this configuration setting
979+
4 | python-platform = "linux"
980+
|
966981
info: rule `unresolved-attribute` is enabled by default
967982
968983
Found 1 diagnostic
969984
970985
----- stderr -----
971-
"###);
986+
"#);
972987

973988
// Use default (which should be latest supported)
974989
let case = CliTest::with_files([

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2558,6 +2558,28 @@ class C:
25582558
reveal_type(C().x) # revealed: Unknown | tuple[Divergent, Literal[1]]
25592559
```
25602560

2561+
## Attributes of standard library modules that aren't yet defined
2562+
2563+
For attributes of stdlib modules that exist in future versions, we can give better diagnostics.
2564+
2565+
<!-- snapshot-diagnostics -->
2566+
2567+
```toml
2568+
[environment]
2569+
python-version = "3.10"
2570+
```
2571+
2572+
`main.py`:
2573+
2574+
```py
2575+
import datetime
2576+
2577+
# error: [unresolved-attribute]
2578+
reveal_type(datetime.UTC) # revealed: Unknown
2579+
# error: [unresolved-attribute]
2580+
reveal_type(datetime.fakenotreal) # revealed: Unknown
2581+
```
2582+
25612583
## References
25622584

25632585
Some of the tests in the *Class and instance variables* section draw inspiration from
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: attributes.md - Attributes - Attributes of standard library modules that aren't yet defined
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md
8+
---
9+
10+
# Python source files
11+
12+
## main.py
13+
14+
```
15+
1 | import datetime
16+
2 |
17+
3 | # error: [unresolved-attribute]
18+
4 | reveal_type(datetime.UTC) # revealed: Unknown
19+
5 | # error: [unresolved-attribute]
20+
6 | reveal_type(datetime.fakenotreal) # revealed: Unknown
21+
```
22+
23+
# Diagnostics
24+
25+
```
26+
error[unresolved-attribute]: Type `<module 'datetime'>` has no attribute `UTC`
27+
--> src/main.py:4:13
28+
|
29+
3 | # error: [unresolved-attribute]
30+
4 | reveal_type(datetime.UTC) # revealed: Unknown
31+
| ^^^^^^^^^^^^
32+
5 | # error: [unresolved-attribute]
33+
6 | reveal_type(datetime.fakenotreal) # revealed: Unknown
34+
|
35+
info: Python 3.10 was assumed when accessing `UTC` because it was specified on the command line
36+
info: rule `unresolved-attribute` is enabled by default
37+
38+
```
39+
40+
```
41+
error[unresolved-attribute]: Type `<module 'datetime'>` has no attribute `fakenotreal`
42+
--> src/main.py:6:13
43+
|
44+
4 | reveal_type(datetime.UTC) # revealed: Unknown
45+
5 | # error: [unresolved-attribute]
46+
6 | reveal_type(datetime.fakenotreal) # revealed: Unknown
47+
| ^^^^^^^^^^^^^^^^^^^^
48+
|
49+
info: rule `unresolved-attribute` is enabled by default
50+
51+
```

crates/ty_python_semantic/src/semantic_index/place.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ impl PlaceTable {
181181
}
182182

183183
/// Looks up a symbol by its name and returns a reference to it, if it exists.
184-
#[cfg(test)]
184+
///
185+
/// This should only be used in diagnostics and tests.
185186
pub(crate) fn symbol_by_name(&self, name: &str) -> Option<&Symbol> {
186187
self.symbols.symbol_id(name).map(|id| self.symbol(id))
187188
}

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use super::{
88
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
99
use crate::semantic_index::definition::{Definition, DefinitionKind};
1010
use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
11+
use crate::semantic_index::{global_scope, place_table};
1112
use crate::suppression::FileSuppressionId;
1213
use crate::types::call::CallError;
1314
use crate::types::class::{DisjointBase, DisjointBaseKind, Field};
@@ -29,7 +30,7 @@ use crate::{
2930
use itertools::Itertools;
3031
use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity};
3132
use ruff_python_ast::name::Name;
32-
use ruff_python_ast::{self as ast, AnyNodeRef};
33+
use ruff_python_ast::{self as ast, AnyNodeRef, Identifier};
3334
use ruff_text_size::{Ranged, TextRange};
3435
use rustc_hash::FxHashSet;
3536
use std::fmt::Formatter;
@@ -3140,6 +3141,56 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions(
31403141
add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules");
31413142
}
31423143

3144+
/// This function receives an unresolved `foo.bar` attribute access,
3145+
/// where `foo` can be resolved to have a type but that type does not
3146+
/// have a `bar` attribute.
3147+
///
3148+
/// If the type of `foo` has a definition that originates in the
3149+
/// standard library and `foo.bar` *does* exist as an attribute on *other*
3150+
/// Python versions, we add a hint to the diagnostic that the user may have
3151+
/// misconfigured their Python version.
3152+
pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions(
3153+
db: &dyn Db,
3154+
mut diagnostic: LintDiagnosticGuard,
3155+
value_type: &Type,
3156+
attr: &Identifier,
3157+
) {
3158+
// Currently we limit this analysis to attributes of stdlib modules,
3159+
// as this covers the most important cases while not being too noisy
3160+
// about basic typos or special types like `super(C, self)`
3161+
let Type::ModuleLiteral(module_ty) = value_type else {
3162+
return;
3163+
};
3164+
let module = module_ty.module(db);
3165+
let Some(file) = module.file(db) else {
3166+
return;
3167+
};
3168+
let Some(search_path) = module.search_path(db) else {
3169+
return;
3170+
};
3171+
if !search_path.is_standard_library() {
3172+
return;
3173+
}
3174+
3175+
// We populate place_table entries for stdlib items across all known versions and platforms,
3176+
// so if this lookup succeeds then we know that this lookup *could* succeed with possible
3177+
// configuration changes.
3178+
let symbol_table = place_table(db, global_scope(db, file));
3179+
if symbol_table.symbol_by_name(attr).is_none() {
3180+
return;
3181+
}
3182+
3183+
// For now, we just mention the current version they're on, and hope that's enough of a nudge.
3184+
// TODO: determine what version they need to be on
3185+
// TODO: also mention the platform we're assuming
3186+
// TODO: determine what platform they need to be on
3187+
add_inferred_python_version_hint_to_diagnostic(
3188+
db,
3189+
&mut diagnostic,
3190+
&format!("accessing `{}`", attr.id),
3191+
);
3192+
}
3193+
31433194
/// Suggest a name from `existing_names` that is similar to `wrong_name`.
31443195
fn did_you_mean<S: AsRef<str>, T: AsRef<str>>(
31453196
existing_names: impl Iterator<Item = S>,

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ use crate::types::diagnostic::{
5959
IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT,
6060
SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL,
6161
UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY,
62+
hint_if_stdlib_attribute_exists_on_other_versions,
6263
hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation,
6364
report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict,
6465
report_duplicate_bases, report_implicit_return_type, report_index_out_of_bounds,
@@ -7497,13 +7498,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
74977498
),
74987499
);
74997500
} else {
7500-
builder.into_diagnostic(
7501+
let diagnostic = builder.into_diagnostic(
75017502
format_args!(
75027503
"Type `{}` has no attribute `{}`",
75037504
value_type.display(db),
75047505
attr.id
75057506
),
75067507
);
7508+
hint_if_stdlib_attribute_exists_on_other_versions(db, diagnostic, &value_type, attr);
75077509
}
75087510
}
75097511
}

0 commit comments

Comments
 (0)