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