From 7155a62e5c3e465d926f36835352dd48cdbdf5d2 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 16 Oct 2025 10:07:33 -0400 Subject: [PATCH] [ty] Add version hint for failed stdlib attribute accesses (#20909) This is the ultra-minimal implementation of * https://github.com/astral-sh/ty/issues/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. --- crates/ty/docs/rules.md | 132 +++++++++--------- crates/ty/tests/cli/python_environment.rs | 23 ++- .../resources/mdtest/attributes.md | 22 +++ ...ributes_of_standa…_(49ba2c9016d64653).snap | 51 +++++++ .../src/semantic_index/place.rs | 3 +- .../src/types/diagnostic.rs | 53 ++++++- .../src/types/infer/builder.rs | 4 +- 7 files changed, 215 insertions(+), 73 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 8ae4ba1da5..7687ea2a7c 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -217,7 +217,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -329,7 +329,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -359,7 +359,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -474,7 +474,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -599,7 +599,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -650,7 +650,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -679,7 +679,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -723,7 +723,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -756,7 +756,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -787,7 +787,7 @@ alice["height"] # KeyError: 'height' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -822,7 +822,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -856,7 +856,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -888,7 +888,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -938,7 +938,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -964,7 +964,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -998,7 +998,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1047,7 +1047,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1072,7 +1072,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1130,7 +1130,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1157,7 +1157,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1187,7 +1187,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1217,7 +1217,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1251,7 +1251,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1285,7 +1285,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1320,7 +1320,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1345,7 +1345,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1378,7 +1378,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1407,7 +1407,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1431,7 +1431,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1457,7 +1457,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1484,7 +1484,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1542,7 +1542,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1572,7 +1572,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1601,7 +1601,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1628,7 +1628,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1656,7 +1656,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1702,7 +1702,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1729,7 +1729,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1757,7 +1757,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1782,7 +1782,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1807,7 +1807,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1844,7 +1844,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1872,7 +1872,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1897,7 +1897,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1938,7 +1938,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -1995,7 +1995,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2023,7 +2023,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2055,7 +2055,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2087,7 +2087,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2114,7 +2114,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2169,7 +2169,7 @@ a = 20 / 0 # ty: ignore[division-by-zero] Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2227,7 +2227,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2266,7 +2266,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2329,7 +2329,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2353,7 +2353,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 0a200c3c26..4168da8e4c 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -26,7 +26,7 @@ fn config_override_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- @@ -37,12 +37,19 @@ fn config_override_python_version() -> anyhow::Result<()> { 5 | print(sys.last_exc) | ^^^^^^^^^^^^ | + info: Python 3.11 was assumed when accessing `last_exc` + --> pyproject.toml:3:18 + | + 2 | [tool.ty.environment] + 3 | python-version = "3.11" + | ^^^^^^ Python 3.11 assumed due to this configuration setting + | info: rule `unresolved-attribute` is enabled by default Found 1 diagnostic ----- stderr ----- - "###); + "#); assert_cmd_snapshot!(case.command().arg("--python-version").arg("3.12"), @r###" success: true @@ -951,7 +958,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { ), ])?; - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- @@ -963,12 +970,20 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { 4 | os.grantpt(1) # only available on unix, Python 3.13 or newer | ^^^^^^^^^^ | + info: Python 3.10 was assumed when accessing `grantpt` + --> 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-attribute` is enabled by default Found 1 diagnostic ----- stderr ----- - "###); + "#); // Use default (which should be latest supported) let case = CliTest::with_files([ diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 496f796c7a..3c910ee77e 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2558,6 +2558,28 @@ class C: reveal_type(C().x) # revealed: Unknown | tuple[Divergent, Literal[1]] ``` +## Attributes of standard library modules that aren't yet defined + +For attributes of stdlib modules that exist in future versions, we can give better diagnostics. + + + +```toml +[environment] +python-version = "3.10" +``` + +`main.py`: + +```py +import datetime + +# error: [unresolved-attribute] +reveal_type(datetime.UTC) # revealed: Unknown +# error: [unresolved-attribute] +reveal_type(datetime.fakenotreal) # revealed: Unknown +``` + ## References Some of the tests in the *Class and instance variables* section draw inspiration from diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap new file mode 100644 index 0000000000..b5171a701c --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Attributes_of_standa…_(49ba2c9016d64653).snap @@ -0,0 +1,51 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: attributes.md - Attributes - Attributes of standard library modules that aren't yet defined +mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md +--- + +# Python source files + +## main.py + +``` +1 | import datetime +2 | +3 | # error: [unresolved-attribute] +4 | reveal_type(datetime.UTC) # revealed: Unknown +5 | # error: [unresolved-attribute] +6 | reveal_type(datetime.fakenotreal) # revealed: Unknown +``` + +# Diagnostics + +``` +error[unresolved-attribute]: Type `` has no attribute `UTC` + --> src/main.py:4:13 + | +3 | # error: [unresolved-attribute] +4 | reveal_type(datetime.UTC) # revealed: Unknown + | ^^^^^^^^^^^^ +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: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `` has no attribute `fakenotreal` + --> src/main.py:6:13 + | +4 | reveal_type(datetime.UTC) # revealed: Unknown +5 | # error: [unresolved-attribute] +6 | reveal_type(datetime.fakenotreal) # revealed: Unknown + | ^^^^^^^^^^^^^^^^^^^^ + | +info: rule `unresolved-attribute` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/semantic_index/place.rs b/crates/ty_python_semantic/src/semantic_index/place.rs index 38e3d92816..04bea97626 100644 --- a/crates/ty_python_semantic/src/semantic_index/place.rs +++ b/crates/ty_python_semantic/src/semantic_index/place.rs @@ -181,7 +181,8 @@ impl PlaceTable { } /// Looks up a symbol by its name and returns a reference to it, if it exists. - #[cfg(test)] + /// + /// This should only be used in diagnostics and tests. pub(crate) fn symbol_by_name(&self, name: &str) -> Option<&Symbol> { self.symbols.symbol_id(name).map(|id| self.symbol(id)) } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index cb1b5b6a0d..8c836c0038 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -8,6 +8,7 @@ use super::{ use crate::lint::{Level, LintRegistryBuilder, LintStatus}; use crate::semantic_index::definition::{Definition, DefinitionKind}; use crate::semantic_index::place::{PlaceTable, ScopedPlaceId}; +use crate::semantic_index::{global_scope, place_table}; use crate::suppression::FileSuppressionId; use crate::types::call::CallError; use crate::types::class::{DisjointBase, DisjointBaseKind, Field}; @@ -29,7 +30,7 @@ use crate::{ use itertools::Itertools; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; use ruff_python_ast::name::Name; -use ruff_python_ast::{self as ast, AnyNodeRef}; +use ruff_python_ast::{self as ast, AnyNodeRef, Identifier}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::fmt::Formatter; @@ -3140,6 +3141,56 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions( add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules"); } +/// This function receives an unresolved `foo.bar` attribute access, +/// where `foo` can be resolved to have a type but that type does not +/// have a `bar` attribute. +/// +/// If the type of `foo` has a definition that originates in the +/// standard library and `foo.bar` *does* exist as an attribute on *other* +/// Python versions, we add a hint to the diagnostic that the user may have +/// misconfigured their Python version. +pub(super) fn hint_if_stdlib_attribute_exists_on_other_versions( + db: &dyn Db, + mut diagnostic: LintDiagnosticGuard, + value_type: &Type, + attr: &Identifier, +) { + // Currently we limit this analysis to attributes of stdlib modules, + // as this covers the most important cases while not being too noisy + // about basic typos or special types like `super(C, self)` + let Type::ModuleLiteral(module_ty) = value_type else { + return; + }; + let module = module_ty.module(db); + let Some(file) = module.file(db) else { + return; + }; + let Some(search_path) = module.search_path(db) else { + return; + }; + if !search_path.is_standard_library() { + return; + } + + // We populate place_table entries for stdlib items across all known versions and platforms, + // 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() { + return; + } + + // 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.id), + ); +} + /// Suggest a name from `existing_names` that is similar to `wrong_name`. fn did_you_mean, T: AsRef>( existing_names: impl Iterator, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 4b2c7cdc44..a38c9e4462 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -59,6 +59,7 @@ use crate::types::diagnostic::{ IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, + hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict, report_duplicate_bases, report_implicit_return_type, report_index_out_of_bounds, @@ -7497,13 +7498,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ), ); } else { - builder.into_diagnostic( + let diagnostic = builder.into_diagnostic( format_args!( "Type `{}` has no attribute `{}`", value_type.display(db), attr.id ), ); + hint_if_stdlib_attribute_exists_on_other_versions(db, diagnostic, &value_type, attr); } } }