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); } } }