Skip to content

[ty] Use RHS inferred type for bare Final symbols #19142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 96 additions & 16 deletions crates/ty_python_semantic/resources/mdtest/type_qualifiers/final.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure I intended to write FINAL_A: Final[int] = 1 here originally :-)

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
Expand Down
27 changes: 27 additions & 0 deletions crates/ty_python_semantic/src/place.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeQualifiers> {
match self {
PlaceAndQualifiers { place, qualifiers }
if (qualifiers.contains(TypeQualifiers::FINAL)
&& place
.ignore_possibly_unbound()
.is_some_and(|ty| ty.is_unknown())) =>
{
Some(*qualifiers)
}
_ => None,
}
}
Comment on lines +527 to +540
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that we would consider x to be "qualified with bare Final" here?

from ty_extensions import Unknown
from typing import Final

x: Final[Unknown]

I think that's okay if so; people just shouldn't do that ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, well spotted 😄. I agree that it's not as clean as it could be. The alternative is much more complicated as it requires us to return Option<Type> and TypeQualifiers instead of just Type and TypeQualifiers from infer_annotation_expression and related methods (such that we could return None for a bare Final or ClassVar), which has a lot of downstream consequences.


#[must_use]
pub(crate) fn map_type(
self,
Expand Down Expand Up @@ -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(
Expand Down
Loading