Skip to content

Commit 3079cc3

Browse files
committed
add version hint for failed stdlib accesses
1 parent a67e069 commit 3079cc3

File tree

7 files changed

+209
-73
lines changed

7 files changed

+209
-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: 46 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,50 @@ 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+
// Must be a stdlib module
3169+
let Some(search_path) = module.search_path(db) else {
3170+
return;
3171+
};
3172+
if !search_path.is_standard_library() {
3173+
return;
3174+
}
3175+
// We must be aware that this is a real symbol on *some* version
3176+
let symbol_table = place_table(db, global_scope(db, file));
3177+
if symbol_table.symbol_by_name(attr).is_none() {
3178+
return;
3179+
}
3180+
3181+
add_inferred_python_version_hint_to_diagnostic(
3182+
db,
3183+
&mut diagnostic,
3184+
&format!("accessing `{}`", attr.id),
3185+
);
3186+
}
3187+
31433188
/// Suggest a name from `existing_names` that is similar to `wrong_name`.
31443189
fn did_you_mean<S: AsRef<str>, T: AsRef<str>>(
31453190
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)