[ty] Bare ClassVar annotations (#15768)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

It was recently clarified in the [typing
spec](https://typing.python.org/en/latest/spec/class-compat.html#classvar)
that bare `ClassVar` annotations are allowed. For annotated assignments
with a right hand side value, the spec requires type checkers to infer
the type as something "to which [the] value is assignable". For a value
of `2`, the spec suggests `int`, `Literal[2]`, or `Any` as examples.
Here, we choose `Unknown | Literal[2]` instead, conforming with out
usual treatment of attribute types.

closes https://github.com/astral-sh/ty/issues/211
This commit is contained in:
David Peter 2025-07-07 15:04:27 +02:00 committed by GitHub
parent 4aaf32476a
commit e7fb3684e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 47 additions and 8 deletions

View file

@ -689,16 +689,14 @@ class C:
reveal_type(C.pure_class_variable1) # revealed: str
# TODO: Should be `Unknown | Literal[1]`.
reveal_type(C.pure_class_variable2) # revealed: Unknown
reveal_type(C.pure_class_variable2) # revealed: Unknown | Literal[1]
c_instance = C()
# It is okay to access a pure class variable on an instance.
reveal_type(c_instance.pure_class_variable1) # revealed: str
# TODO: Should be `Unknown | Literal[1]`.
reveal_type(c_instance.pure_class_variable2) # revealed: Unknown
reveal_type(c_instance.pure_class_variable2) # revealed: Unknown | Literal[1]
# error: [invalid-attribute-access] "Cannot assign to ClassVar `pure_class_variable1` from an instance of type `C`"
c_instance.pure_class_variable1 = "value set on instance"
@ -714,6 +712,24 @@ class Subclass(C):
reveal_type(Subclass.pure_class_variable1) # revealed: str
```
If a class variable is additionally qualified as `Final`, we do not union with `Unknown` for bare
`ClassVar`s:
```py
from typing import Final
class D:
final1: Final[ClassVar] = 1
final2: ClassVar[Final] = 1
final3: ClassVar[Final[int]] = 1
final4: Final[ClassVar[int]] = 1
reveal_type(D.final1) # revealed: Literal[1]
reveal_type(D.final2) # revealed: Literal[1]
reveal_type(D.final3) # revealed: int
reveal_type(D.final4) # revealed: int
```
#### Variable only mentioned in a class method
We also consider a class variable to be a pure class variable if it is only mentioned in a class

View file

@ -21,8 +21,7 @@ class C:
reveal_type(C.a) # revealed: int
reveal_type(C.b) # revealed: int
reveal_type(C.c) # revealed: int
# TODO: should be Unknown | Literal[1]
reveal_type(C.d) # revealed: Unknown
reveal_type(C.d) # revealed: Unknown | Literal[1]
reveal_type(C.e) # revealed: int
c = C()

View file

@ -9,8 +9,8 @@ use crate::semantic_index::{
};
use crate::semantic_index::{DeclarationWithConstraint, global_scope, use_def_map};
use crate::types::{
KnownClass, Truthiness, Type, TypeAndQualifiers, TypeQualifiers, UnionBuilder, UnionType,
binding_type, declaration_type, todo_type,
DynamicType, KnownClass, Truthiness, Type, TypeAndQualifiers, TypeQualifiers, UnionBuilder,
UnionType, binding_type, declaration_type, todo_type,
};
use crate::{Db, FxOrderSet, KnownModule, Program, resolve_module};
@ -672,6 +672,30 @@ fn place_by_id<'db>(
.with_qualifiers(qualifiers);
}
// Handle bare `ClassVar` annotations by falling back to the union of `Unknown` and the
// inferred type.
match declared {
Ok(PlaceAndQualifiers {
place: Place::Type(Type::Dynamic(DynamicType::Unknown), declaredness),
qualifiers,
}) if qualifiers.contains(TypeQualifiers::CLASS_VAR) => {
let bindings = all_considered_bindings();
match place_from_bindings_impl(db, bindings, requires_explicit_reexport) {
Place::Type(inferred, boundness) => {
return Place::Type(
UnionType::from_elements(db, [Type::unknown(), inferred]),
boundness,
)
.with_qualifiers(qualifiers);
}
Place::Unbound => {
return Place::Type(Type::unknown(), declaredness).with_qualifiers(qualifiers);
}
}
}
_ => {}
}
match declared {
// Place is declared, trust the declared type
Ok(