diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md index d50277c09b808..da1398b1ab60f 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md @@ -3,44 +3,124 @@ [`typing.Final`] is a type qualifier that is used to indicate that a symbol may not be reassigned in any scope. Final names declared in class scopes cannot be overridden in subclasses. -## Basic +## Basic type inference + +### `Final` with type + +Declared symbols that are additionally qualified with `Final` use the declared type when accessed +from another scope. Local uses of the symbol will use the inferred type, which may be more specific: `mod.py`: ```py from typing import Final, Annotated -FINAL_A: int = 1 +FINAL_A: Final[int] = 1 FINAL_B: Annotated[Final[int], "the annotation for FINAL_B"] = 1 FINAL_C: Final[Annotated[int, "the annotation for FINAL_C"]] = 1 -FINAL_D: Final = 1 -FINAL_E: "Final[int]" = 1 +FINAL_D: "Final[int]" = 1 +FINAL_F: Final[int] +FINAL_F = 1 reveal_type(FINAL_A) # revealed: Literal[1] reveal_type(FINAL_B) # revealed: Literal[1] reveal_type(FINAL_C) # revealed: Literal[1] reveal_type(FINAL_D) # revealed: Literal[1] -reveal_type(FINAL_E) # revealed: Literal[1] +reveal_type(FINAL_D) # revealed: Literal[1] -# TODO: All of these should be errors: -FINAL_A = 2 -FINAL_B = 2 -FINAL_C = 2 -FINAL_D = 2 -FINAL_E = 2 +def nonlocal_uses(): + reveal_type(FINAL_A) # revealed: int + reveal_type(FINAL_B) # revealed: int + reveal_type(FINAL_C) # revealed: int + reveal_type(FINAL_D) # revealed: int + reveal_type(FINAL_F) # revealed: int ``` -Public types: +Imported types: ```py -from mod import FINAL_A, FINAL_B, FINAL_C, FINAL_D, FINAL_E +from mod import FINAL_A, FINAL_B, FINAL_C, FINAL_D, FINAL_F -# TODO: All of these should be Literal[1] reveal_type(FINAL_A) # revealed: int reveal_type(FINAL_B) # revealed: int reveal_type(FINAL_C) # revealed: int -reveal_type(FINAL_D) # revealed: Unknown -reveal_type(FINAL_E) # revealed: int +reveal_type(FINAL_D) # revealed: int +reveal_type(FINAL_F) # revealed: int +``` + +### `Final` without a type + +When a symbol is qualified with `Final` but no type is specified, the type is inferred from the +right-hand side of the assignment. We do not union the inferred type with `Unknown`, because the +symbol cannot be modified: + +`mod.py`: + +```py +from typing import Final + +FINAL_A: Final = 1 + +reveal_type(FINAL_A) # revealed: Literal[1] + +def nonlocal_uses(): + reveal_type(FINAL_A) # revealed: Literal[1] +``` + +`main.py`: + +```py +from mod import FINAL_A + +reveal_type(FINAL_A) # revealed: Literal[1] +``` + +### In class definitions + +```py +from typing import Final + +class C: + FINAL_A: Final[int] = 1 + FINAL_B: Final = 1 + + def __init__(self): + self.FINAL_C: Final[int] = 1 + self.FINAL_D: Final = 1 + +reveal_type(C.FINAL_A) # revealed: int +reveal_type(C.FINAL_B) # revealed: Literal[1] + +reveal_type(C().FINAL_A) # revealed: int +reveal_type(C().FINAL_B) # revealed: Literal[1] +reveal_type(C().FINAL_C) # revealed: int +# TODO: this should be `Literal[1]` +reveal_type(C().FINAL_D) # revealed: Unknown +``` + +## Not modifiable + +Symbols qualified with `Final` cannot be reassigned, and attempting to do so will result in an +error: + +```py +from typing import Final, Annotated + +FINAL_A: Final[int] = 1 +FINAL_B: Annotated[Final[int], "the annotation for FINAL_B"] = 1 +FINAL_C: Final[Annotated[int, "the annotation for FINAL_C"]] = 1 +FINAL_D: "Final[int]" = 1 +FINAL_E: Final[int] +FINAL_E = 1 +FINAL_F: Final = 1 + +# TODO: all of these should be errors +FINAL_A = 2 +FINAL_B = 2 +FINAL_C = 2 +FINAL_D = 2 +FINAL_E = 2 +FINAL_F = 2 ``` ## Too many arguments diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 97cb7c367399b..dcb6300221738 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -524,6 +524,21 @@ impl<'db> PlaceAndQualifiers<'db> { self.qualifiers.contains(TypeQualifiers::CLASS_VAR) } + /// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type. + pub(crate) fn is_bare_final(&self) -> Option { + match self { + PlaceAndQualifiers { place, qualifiers } + if (qualifiers.contains(TypeQualifiers::FINAL) + && place + .ignore_possibly_unbound() + .is_some_and(|ty| ty.is_unknown())) => + { + Some(*qualifiers) + } + _ => None, + } + } + #[must_use] pub(crate) fn map_type( self, @@ -645,6 +660,18 @@ fn place_by_id<'db>( ConsideredDefinitions::AllReachable => use_def.all_reachable_bindings(place_id), }; + // If a symbol is undeclared, but qualified with `typing.Final`, we use the right-hand side + // inferred type, without unioning with `Unknown`, because it can not be modified. + if let Some(qualifiers) = declared + .as_ref() + .ok() + .and_then(PlaceAndQualifiers::is_bare_final) + { + let bindings = all_considered_bindings(); + return place_from_bindings_impl(db, bindings, requires_explicit_reexport) + .with_qualifiers(qualifiers); + } + match declared { // Place is declared, trust the declared type Ok(