[ty] Don't include already-bound legacy typevars in function generic context (#19558)
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 / mkdocs (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 / 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

We now correctly exclude legacy typevars from enclosing scopes when
constructing the generic context for a generic function.

more detail:

A function is generic if it refers to legacy typevars in its signature:

```py
from typing import TypeVar

T = TypeVar("T")

def f(t: T) -> T:
    return t
```

Generic functions are allowed to appear inside of other generic
contexts. When they do, they can refer to the typevars of those
enclosing generic contexts, and that should not rebind the typevar:

```py
from typing import TypeVar, Generic

T = TypeVar("T")
U = TypeVar("U")

class C(Generic[T]):
    @staticmethod
    def method(t: T, u: U) -> None: ...

# revealed: def method(t: int, u: U) -> None
reveal_type(C[int].method)
```

This substitution was already being performed correctly, but we were
also still including the enclosing legacy typevars in the method's own
generic context, which can be seen via `ty_extensions.generic_context`
(which has been updated to work on generic functions and methods):

```py
from ty_extensions import generic_context

# before: tuple[T, U]
# after: tuple[U]
reveal_type(generic_context(C[int].method))
```

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Douglas Creager 2025-07-25 18:14:19 -04:00 committed by GitHub
parent 72fdb7d439
commit e867830848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 145 additions and 18 deletions

View file

@ -433,17 +433,31 @@ the typevars of the enclosing generic class, and introduce new (distinct) typeva
scope for the method. scope for the method.
```py ```py
from ty_extensions import generic_context
from typing import Generic, TypeVar from typing import Generic, TypeVar
T = TypeVar("T") T = TypeVar("T")
U = TypeVar("U") U = TypeVar("U")
class C(Generic[T]): class C(Generic[T]):
def method(self, u: U) -> U: def method(self, u: int) -> int:
return u return u
def generic_method(self, t: T, u: U) -> U:
return u
reveal_type(generic_context(C)) # revealed: tuple[T]
reveal_type(generic_context(C.method)) # revealed: None
reveal_type(generic_context(C.generic_method)) # revealed: tuple[U]
reveal_type(generic_context(C[int])) # revealed: None
reveal_type(generic_context(C[int].method)) # revealed: None
reveal_type(generic_context(C[int].generic_method)) # revealed: tuple[U]
c: C[int] = C[int]() c: C[int] = C[int]()
reveal_type(c.method("string")) # revealed: Literal["string"] reveal_type(c.generic_method(1, "string")) # revealed: Literal["string"]
reveal_type(generic_context(c)) # revealed: None
reveal_type(generic_context(c.method)) # revealed: None
reveal_type(generic_context(c.generic_method)) # revealed: tuple[U]
``` ```
## Specializations propagate ## Specializations propagate

View file

@ -392,8 +392,13 @@ the typevars of the enclosing generic class, and introduce new (distinct) typeva
scope for the method. scope for the method.
```py ```py
from ty_extensions import generic_context
class C[T]: class C[T]:
def method[U](self, u: U) -> U: def method(self, u: int) -> int:
return u
def generic_method[U](self, t: T, u: U) -> U:
return u return u
# error: [unresolved-reference] # error: [unresolved-reference]
def cannot_use_outside_of_method(self, u: U): ... def cannot_use_outside_of_method(self, u: U): ...
@ -401,8 +406,18 @@ class C[T]:
# TODO: error # TODO: error
def cannot_shadow_class_typevar[T](self, t: T): ... def cannot_shadow_class_typevar[T](self, t: T): ...
reveal_type(generic_context(C)) # revealed: tuple[T]
reveal_type(generic_context(C.method)) # revealed: None
reveal_type(generic_context(C.generic_method)) # revealed: tuple[U]
reveal_type(generic_context(C[int])) # revealed: None
reveal_type(generic_context(C[int].method)) # revealed: None
reveal_type(generic_context(C[int].generic_method)) # revealed: tuple[U]
c: C[int] = C[int]() c: C[int] = C[int]()
reveal_type(c.method("string")) # revealed: Literal["string"] reveal_type(c.generic_method(1, "string")) # revealed: Literal["string"]
reveal_type(generic_context(c)) # revealed: None
reveal_type(generic_context(c.method)) # revealed: None
reveal_type(generic_context(c.generic_method)) # revealed: tuple[U]
``` ```
## Specializations propagate ## Specializations propagate

View file

@ -141,10 +141,22 @@ class Legacy(Generic[T]):
def m(self, x: T, y: S) -> S: def m(self, x: T, y: S) -> S:
return y return y
legacy: Legacy[int] = Legacy() legacy: Legacy[int] = Legacy[int]()
reveal_type(legacy.m(1, "string")) # revealed: Literal["string"] reveal_type(legacy.m(1, "string")) # revealed: Literal["string"]
``` ```
The class typevar in the method signature does not bind a _new_ instance of the typevar; it was
already solved and specialized when the class was specialized:
```py
from ty_extensions import generic_context
legacy.m("string", None) # error: [invalid-argument-type]
reveal_type(legacy.m) # revealed: bound method Legacy[int].m(x: int, y: S) -> S
reveal_type(generic_context(Legacy)) # revealed: tuple[T]
reveal_type(generic_context(legacy.m)) # revealed: tuple[S]
```
With PEP 695 syntax, it is clearer that the method uses a separate typevar: With PEP 695 syntax, it is clearer that the method uses a separate typevar:
```py ```py

View file

@ -623,19 +623,38 @@ impl<'db> Bindings<'db> {
Some(KnownFunction::GenericContext) => { Some(KnownFunction::GenericContext) => {
if let [Some(ty)] = overload.parameter_types() { if let [Some(ty)] = overload.parameter_types() {
let function_generic_context = |function: FunctionType<'db>| {
let union = UnionType::from_elements(
db,
function
.signature(db)
.overloads
.iter()
.filter_map(|signature| signature.generic_context)
.map(|generic_context| generic_context.as_tuple(db)),
);
if union.is_never() {
Type::none(db)
} else {
union
}
};
// TODO: Handle generic functions, and unions/intersections of // TODO: Handle generic functions, and unions/intersections of
// generic types // generic types
overload.set_return_type(match ty { overload.set_return_type(match ty {
Type::ClassLiteral(class) => match class.generic_context(db) { Type::ClassLiteral(class) => class
Some(generic_context) => TupleType::from_elements( .generic_context(db)
db, .map(|generic_context| generic_context.as_tuple(db))
generic_context .unwrap_or_else(|| Type::none(db)),
.variables(db)
.iter() Type::FunctionLiteral(function) => {
.map(|typevar| Type::TypeVar(*typevar)), function_generic_context(*function)
), }
None => Type::none(db),
}, Type::BoundMethod(bound_method) => {
function_generic_context(bound_method.function(db))
}
_ => Type::none(db), _ => Type::none(db),
}); });

View file

@ -340,6 +340,7 @@ impl<'db> OverloadLiteral<'db> {
let index = semantic_index(db, scope.file(db)); let index = semantic_index(db, scope.file(db));
GenericContext::from_type_params(db, index, type_params) GenericContext::from_type_params(db, index, type_params)
}); });
Signature::from_function( Signature::from_function(
db, db,
generic_context, generic_context,

View file

@ -1,9 +1,12 @@
use std::borrow::Cow; use std::borrow::Cow;
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
use ruff_python_ast as ast; use ruff_python_ast as ast;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use crate::semantic_index::SemanticIndex; use crate::semantic_index::definition::Definition;
use crate::semantic_index::scope::{FileScopeId, NodeWithScopeKind};
use crate::semantic_index::{SemanticIndex, semantic_index};
use crate::types::class::ClassType; use crate::types::class::ClassType;
use crate::types::class_base::ClassBase; use crate::types::class_base::ClassBase;
use crate::types::instance::{NominalInstanceType, Protocol, ProtocolInstanceType}; use crate::types::instance::{NominalInstanceType, Protocol, ProtocolInstanceType};
@ -11,10 +14,51 @@ use crate::types::signatures::{Parameter, Parameters, Signature};
use crate::types::tuple::{TupleSpec, TupleType}; use crate::types::tuple::{TupleSpec, TupleType};
use crate::types::{ use crate::types::{
KnownInstanceType, Type, TypeMapping, TypeRelation, TypeTransformer, TypeVarBoundOrConstraints, KnownInstanceType, Type, TypeMapping, TypeRelation, TypeTransformer, TypeVarBoundOrConstraints,
TypeVarInstance, TypeVarVariance, UnionType, declaration_type, TypeVarInstance, TypeVarVariance, UnionType, binding_type, declaration_type,
}; };
use crate::{Db, FxOrderSet}; use crate::{Db, FxOrderSet};
/// Returns an iterator of any generic context introduced by the given scope or any enclosing
/// scope.
fn enclosing_generic_contexts<'db>(
db: &'db dyn Db,
module: &ParsedModuleRef,
index: &SemanticIndex<'db>,
scope: FileScopeId,
) -> impl Iterator<Item = GenericContext<'db>> {
index
.ancestor_scopes(scope)
.filter_map(|(_, ancestor_scope)| match ancestor_scope.node() {
NodeWithScopeKind::Class(class) => {
binding_type(db, index.expect_single_definition(class.node(module)))
.into_class_literal()?
.generic_context(db)
}
NodeWithScopeKind::Function(function) => {
binding_type(db, index.expect_single_definition(function.node(module)))
.into_function_literal()?
.signature(db)
.iter()
.last()
.expect("function should have at least one overload")
.generic_context
}
_ => None,
})
}
/// Returns the legacy typevars that have been bound in the given scope or any enclosing scope.
fn bound_legacy_typevars<'db>(
db: &'db dyn Db,
module: &ParsedModuleRef,
index: &'db SemanticIndex<'db>,
scope: FileScopeId,
) -> impl Iterator<Item = TypeVarInstance<'db>> {
enclosing_generic_contexts(db, module, index, scope)
.flat_map(|generic_context| generic_context.variables(db).iter().copied())
.filter(|typevar| typevar.is_legacy(db))
}
/// A list of formal type variables for a generic function, class, or type alias. /// A list of formal type variables for a generic function, class, or type alias.
/// ///
/// TODO: Handle nested generic contexts better, with actual parent links to the lexically /// TODO: Handle nested generic contexts better, with actual parent links to the lexically
@ -82,9 +126,11 @@ impl<'db> GenericContext<'db> {
/// list. /// list.
pub(crate) fn from_function_params( pub(crate) fn from_function_params(
db: &'db dyn Db, db: &'db dyn Db,
definition: Definition<'db>,
parameters: &Parameters<'db>, parameters: &Parameters<'db>,
return_type: Option<Type<'db>>, return_type: Option<Type<'db>>,
) -> Option<Self> { ) -> Option<Self> {
// Find all of the legacy typevars mentioned in the function signature.
let mut variables = FxOrderSet::default(); let mut variables = FxOrderSet::default();
for param in parameters { for param in parameters {
if let Some(ty) = param.annotated_type() { if let Some(ty) = param.annotated_type() {
@ -97,6 +143,16 @@ impl<'db> GenericContext<'db> {
if let Some(ty) = return_type { if let Some(ty) = return_type {
ty.find_legacy_typevars(db, &mut variables); ty.find_legacy_typevars(db, &mut variables);
} }
// Then remove any that were bound in enclosing scopes.
let file = definition.file(db);
let module = parsed_module(db, file).load(db);
let index = semantic_index(db, file);
let containing_scope = definition.file_scope(db);
for typevar in bound_legacy_typevars(db, &module, index, containing_scope) {
variables.remove(&typevar);
}
if variables.is_empty() { if variables.is_empty() {
return None; return None;
} }
@ -171,6 +227,16 @@ impl<'db> GenericContext<'db> {
self.specialize(db, types.into()) self.specialize(db, types.into())
} }
/// Returns a tuple type of the typevars introduced by this generic context.
pub(crate) fn as_tuple(self, db: &'db dyn Db) -> Type<'db> {
TupleType::from_elements(
db,
self.variables(db)
.iter()
.map(|typevar| Type::TypeVar(*typevar)),
)
}
pub(crate) fn is_subset_of(self, db: &'db dyn Db, other: GenericContext<'db>) -> bool { pub(crate) fn is_subset_of(self, db: &'db dyn Db, other: GenericContext<'db>) -> bool {
self.variables(db).is_subset(other.variables(db)) self.variables(db).is_subset(other.variables(db))
} }

View file

@ -331,7 +331,7 @@ impl<'db> Signature<'db> {
} }
}); });
let legacy_generic_context = let legacy_generic_context =
GenericContext::from_function_params(db, &parameters, return_ty); GenericContext::from_function_params(db, definition, &parameters, return_ty);
if generic_context.is_some() && legacy_generic_context.is_some() { if generic_context.is_some() && legacy_generic_context.is_some() {
// TODO: Raise a diagnostic! // TODO: Raise a diagnostic!