[ty] Generate the top and bottom materialization of a type (#18594)

## Summary

This is to support https://github.com/astral-sh/ruff/pull/18607.

This PR adds support for generating the top materialization (or upper
bound materialization) and the bottom materialization (or lower bound
materialization) of a type. This is the most general and the most
specific form of the type which is fully static, respectively.
    
More concretely, `T'`, the top materialization of `T`, is the type `T`
with all occurrences
of dynamic type (`Any`, `Unknown`, `@Todo`) replaced as follows:

- In covariant position, it's replaced with `object`
- In contravariant position, it's replaced with `Never`
- In invariant position, it's replaced with an unresolved type variable

(For an invariant position, it should actually be replaced with an
existential type, but this is not currently representable in our type
system, so we use an unresolved type variable for now instead.)

The bottom materialization is implemented in the same way, except we
start out in "contravariant" position.

## Test Plan

Add test cases for various types.
This commit is contained in:
Dhruv Manilawala 2025-06-12 12:06:16 +05:30 committed by GitHub
parent f74527f4e9
commit ef4108af2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 798 additions and 3 deletions

View file

@ -615,6 +615,120 @@ impl<'db> Type<'db> {
matches!(self, Type::Dynamic(_))
}
/// Returns the top materialization (or upper bound materialization) of this type, which is the
/// most general form of the type that is fully static.
#[must_use]
pub(crate) fn top_materialization(&self, db: &'db dyn Db) -> Type<'db> {
self.materialize(db, TypeVarVariance::Covariant)
}
/// Returns the bottom materialization (or lower bound materialization) of this type, which is
/// the most specific form of the type that is fully static.
#[must_use]
pub(crate) fn bottom_materialization(&self, db: &'db dyn Db) -> Type<'db> {
self.materialize(db, TypeVarVariance::Contravariant)
}
/// Returns the materialization of this type depending on the given `variance`.
///
/// More concretely, `T'`, the materialization of `T`, is the type `T` with all occurrences of
/// the dynamic types (`Any`, `Unknown`, `Todo`) replaced as follows:
///
/// - In covariant position, it's replaced with `object`
/// - In contravariant position, it's replaced with `Never`
/// - In invariant position, it's replaced with an unresolved type variable
fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Type<'db> {
match self {
Type::Dynamic(_) => match variance {
// TODO: For an invariant position, e.g. `list[Any]`, it should be replaced with an
// existential type representing "all lists, containing any type." We currently
// represent this by replacing `Any` in invariant position with an unresolved type
// variable.
TypeVarVariance::Invariant => Type::TypeVar(TypeVarInstance::new(
db,
Name::new_static("T_all"),
None,
None,
variance,
None,
TypeVarKind::Pep695,
)),
TypeVarVariance::Covariant => Type::object(db),
TypeVarVariance::Contravariant => Type::Never,
TypeVarVariance::Bivariant => unreachable!(),
},
Type::Never
| Type::WrapperDescriptor(_)
| Type::MethodWrapper(_)
| Type::DataclassDecorator(_)
| Type::DataclassTransformer(_)
| Type::ModuleLiteral(_)
| Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::StringLiteral(_)
| Type::LiteralString
| Type::BytesLiteral(_)
| Type::SpecialForm(_)
| Type::KnownInstance(_)
| Type::AlwaysFalsy
| Type::AlwaysTruthy
| Type::PropertyInstance(_)
| Type::ClassLiteral(_)
| Type::BoundSuper(_) => *self,
Type::FunctionLiteral(_) | Type::BoundMethod(_) => {
// TODO: Subtyping between function / methods with a callable accounts for the
// signature (parameters and return type), so we might need to do something here
*self
}
Type::NominalInstance(nominal_instance_type) => {
Type::NominalInstance(nominal_instance_type.materialize(db, variance))
}
Type::GenericAlias(generic_alias) => {
Type::GenericAlias(generic_alias.materialize(db, variance))
}
Type::Callable(callable_type) => {
Type::Callable(callable_type.materialize(db, variance))
}
Type::SubclassOf(subclass_of_type) => subclass_of_type.materialize(db, variance),
Type::ProtocolInstance(protocol_instance_type) => {
// TODO: Add tests for this once subtyping/assignability is implemented for
// protocols. It _might_ require changing the logic here because:
//
// > Subtyping for protocol instances involves taking account of the fact that
// > read-only property members, and method members, on protocols act covariantly;
// > write-only property members act contravariantly; and read/write attribute
// > members on protocols act invariantly
Type::ProtocolInstance(protocol_instance_type.materialize(db, variance))
}
Type::Union(union_type) => union_type.map(db, |ty| ty.materialize(db, variance)),
Type::Intersection(intersection_type) => IntersectionBuilder::new(db)
.positive_elements(
intersection_type
.positive(db)
.iter()
.map(|ty| ty.materialize(db, variance)),
)
.negative_elements(
intersection_type
.negative(db)
.iter()
.map(|ty| ty.materialize(db, variance.flip())),
)
.build(),
Type::Tuple(tuple_type) => TupleType::from_elements(
db,
tuple_type
.elements(db)
.iter()
.map(|ty| ty.materialize(db, variance)),
),
Type::TypeVar(type_var) => Type::TypeVar(type_var.materialize(db, variance)),
}
}
/// Replace references to the class `class` with a self-reference marker. This is currently
/// used for recursive protocols, but could probably be extended to self-referential type-
/// aliases and similar.
@ -3634,6 +3748,21 @@ impl<'db> Type<'db> {
)
.into(),
Some(KnownFunction::TopMaterialization | KnownFunction::BottomMaterialization) => {
Binding::single(
self,
Signature::new(
Parameters::new([Parameter::positional_only(Some(Name::new_static(
"type",
)))
.type_form()
.with_annotated_type(Type::any())]),
Some(Type::any()),
),
)
.into()
}
Some(KnownFunction::AssertType) => Binding::single(
self,
Signature::new(
@ -5984,6 +6113,19 @@ impl<'db> TypeVarInstance<'db> {
self.kind(db),
)
}
fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self::new(
db,
self.name(db),
self.definition(db),
self.bound_or_constraints(db)
.map(|b| b.materialize(db, variance)),
self.variance(db),
self.default_ty(db),
self.kind(db),
)
}
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update)]
@ -5994,6 +6136,20 @@ pub enum TypeVarVariance {
Bivariant,
}
impl TypeVarVariance {
/// Flips the polarity of the variance.
///
/// Covariant becomes contravariant, contravariant becomes covariant, others remain unchanged.
pub(crate) const fn flip(self) -> Self {
match self {
TypeVarVariance::Invariant => TypeVarVariance::Invariant,
TypeVarVariance::Covariant => TypeVarVariance::Contravariant,
TypeVarVariance::Contravariant => TypeVarVariance::Covariant,
TypeVarVariance::Bivariant => TypeVarVariance::Bivariant,
}
}
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, salsa::Update)]
pub enum TypeVarBoundOrConstraints<'db> {
UpperBound(Type<'db>),
@ -6011,6 +6167,25 @@ impl<'db> TypeVarBoundOrConstraints<'db> {
}
}
}
fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
match self {
TypeVarBoundOrConstraints::UpperBound(bound) => {
TypeVarBoundOrConstraints::UpperBound(bound.materialize(db, variance))
}
TypeVarBoundOrConstraints::Constraints(constraints) => {
TypeVarBoundOrConstraints::Constraints(UnionType::new(
db,
constraints
.elements(db)
.iter()
.map(|ty| ty.materialize(db, variance))
.collect::<Vec<_>>()
.into_boxed_slice(),
))
}
}
}
}
/// Error returned if a type is not (or may not be) a context manager.
@ -7012,6 +7187,14 @@ impl<'db> CallableType<'db> {
))
}
fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
CallableType::new(
db,
self.signatures(db).materialize(db, variance),
self.is_function_like(db),
)
}
/// Create a callable type which represents a fully-static "bottom" callable.
///
/// Specifically, this represents a callable type with a single signature:

View file

@ -675,6 +675,18 @@ impl<'db> Bindings<'db> {
}
}
Some(KnownFunction::TopMaterialization) => {
if let [Some(ty)] = overload.parameter_types() {
overload.set_return_type(ty.top_materialization(db));
}
}
Some(KnownFunction::BottomMaterialization) => {
if let [Some(ty)] = overload.parameter_types() {
overload.set_return_type(ty.bottom_materialization(db));
}
}
Some(KnownFunction::Len) => {
if let [Some(first_arg)] = overload.parameter_types() {
if let Some(len_ty) = first_arg.len(db) {

View file

@ -1,6 +1,7 @@
use std::hash::BuildHasherDefault;
use std::sync::{LazyLock, Mutex};
use super::TypeVarVariance;
use super::{
IntersectionBuilder, MemberLookupPolicy, Mro, MroError, MroIterator, SpecialFormType,
SubclassOfType, Truthiness, Type, TypeQualifiers, class_base::ClassBase, infer_expression_type,
@ -173,6 +174,14 @@ impl<'db> GenericAlias<'db> {
Self::new(db, self.origin(db), self.specialization(db).normalized(db))
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self::new(
db,
self.origin(db),
self.specialization(db).materialize(db, variance),
)
}
pub(crate) fn definition(self, db: &'db dyn Db) -> Definition<'db> {
self.origin(db).definition(db)
}
@ -223,6 +232,13 @@ impl<'db> ClassType<'db> {
}
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
match self {
Self::NonGeneric(_) => self,
Self::Generic(generic) => Self::Generic(generic.materialize(db, variance)),
}
}
pub(super) fn has_pep_695_type_params(self, db: &'db dyn Db) -> bool {
match self {
Self::NonGeneric(class) => class.has_pep_695_type_params(db),

View file

@ -890,6 +890,10 @@ pub enum KnownFunction {
DunderAllNames,
/// `ty_extensions.all_members`
AllMembers,
/// `ty_extensions.top_materialization`
TopMaterialization,
/// `ty_extensions.bottom_materialization`
BottomMaterialization,
}
impl KnownFunction {
@ -947,6 +951,8 @@ impl KnownFunction {
| Self::IsSingleValued
| Self::IsSingleton
| Self::IsSubtypeOf
| Self::TopMaterialization
| Self::BottomMaterialization
| Self::GenericContext
| Self::DunderAllNames
| Self::StaticAssert
@ -1007,6 +1013,8 @@ pub(crate) mod tests {
| KnownFunction::IsAssignableTo
| KnownFunction::IsEquivalentTo
| KnownFunction::IsGradualEquivalentTo
| KnownFunction::TopMaterialization
| KnownFunction::BottomMaterialization
| KnownFunction::AllMembers => KnownModule::TyExtensions,
};

View file

@ -358,6 +358,25 @@ impl<'db> Specialization<'db> {
Self::new(db, self.generic_context(db), types)
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
let types: Box<[_]> = self
.generic_context(db)
.variables(db)
.into_iter()
.zip(self.types(db))
.map(|(typevar, vartype)| {
let variance = match typevar.variance(db) {
TypeVarVariance::Invariant => TypeVarVariance::Invariant,
TypeVarVariance::Covariant => variance,
TypeVarVariance::Contravariant => variance.flip(),
TypeVarVariance::Bivariant => unreachable!(),
};
vartype.materialize(db, variance)
})
.collect();
Specialization::new(db, self.generic_context(db), types)
}
pub(crate) fn has_relation_to(
self,
db: &'db dyn Db,

View file

@ -3,7 +3,7 @@
use std::marker::PhantomData;
use super::protocol_class::ProtocolInterface;
use super::{ClassType, KnownClass, SubclassOfType, Type};
use super::{ClassType, KnownClass, SubclassOfType, Type, TypeVarVariance};
use crate::place::{Boundness, Place, PlaceAndQualifiers};
use crate::types::{ClassLiteral, DynamicType, TypeMapping, TypeRelation, TypeVarInstance};
use crate::{Db, FxOrderSet};
@ -80,6 +80,10 @@ impl<'db> NominalInstanceType<'db> {
Self::from_class(self.class.normalized(db))
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self::from_class(self.class.materialize(db, variance))
}
pub(super) fn has_relation_to(
self,
db: &'db dyn Db,
@ -314,6 +318,16 @@ impl<'db> ProtocolInstanceType<'db> {
}
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
match self.inner {
// TODO: This should also materialize via `class.materialize(db, variance)`
Protocol::FromClass(class) => Self::from_class(class),
Protocol::Synthesized(synthesized) => {
Self::synthesized(synthesized.materialize(db, variance))
}
}
}
pub(super) fn apply_type_mapping<'a>(
self,
db: &'db dyn Db,
@ -370,7 +384,7 @@ impl<'db> Protocol<'db> {
mod synthesized_protocol {
use crate::types::protocol_class::ProtocolInterface;
use crate::types::{TypeMapping, TypeVarInstance};
use crate::types::{TypeMapping, TypeVarInstance, TypeVarVariance};
use crate::{Db, FxOrderSet};
/// A "synthesized" protocol type that is dissociated from a class definition in source code.
@ -390,6 +404,10 @@ mod synthesized_protocol {
Self(interface.normalized(db))
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self(self.0.materialize(db, variance))
}
pub(super) fn apply_type_mapping<'a>(
self,
db: &'db dyn Db,

View file

@ -303,4 +303,20 @@ mod flaky {
negation_reverses_subtype_order, db,
forall types s, t. s.is_subtype_of(db, t) => t.negate(db).is_subtype_of(db, s.negate(db))
);
// Both the top and bottom materialization tests are flaky in part due to various failures that
// it discovers in the current implementation of assignability of the types.
// TODO: Create a issue with some example failures to keep track of it
// `T'`, the top materialization of `T`, should be assignable to `T`.
type_property_test!(
top_materialization_of_type_is_assignable_to_type, db,
forall types t. t.top_materialization(db).is_assignable_to(db, t)
);
// Similarly, `T'`, the bottom materialization of `T`, should also be assignable to `T`.
type_property_test!(
bottom_materialization_of_type_is_assigneble_to_type, db,
forall types t. t.bottom_materialization(db).is_assignable_to(db, t)
);
}

View file

@ -13,6 +13,8 @@ use crate::{
{Db, FxOrderSet},
};
use super::TypeVarVariance;
impl<'db> ClassLiteral<'db> {
/// Returns `Some` if this is a protocol class, `None` otherwise.
pub(super) fn into_protocol_class(self, db: &'db dyn Db) -> Option<ProtocolClassLiteral<'db>> {
@ -177,6 +179,28 @@ impl<'db> ProtocolInterface<'db> {
}
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
match self {
Self::Members(members) => Self::Members(ProtocolInterfaceMembers::new(
db,
members
.inner(db)
.iter()
.map(|(name, data)| {
(
name.clone(),
ProtocolMemberData {
ty: data.ty.materialize(db, variance),
qualifiers: data.qualifiers,
},
)
})
.collect::<BTreeMap<_, _>>(),
)),
Self::SelfReference => Self::SelfReference,
}
}
pub(super) fn specialized_and_normalized<'a>(
self,
db: &'db dyn Db,

View file

@ -15,7 +15,7 @@ use std::{collections::HashMap, slice::Iter};
use itertools::EitherOrBoth;
use smallvec::{SmallVec, smallvec};
use super::{DynamicType, Type, definition_expression_type};
use super::{DynamicType, Type, TypeVarVariance, definition_expression_type};
use crate::semantic_index::definition::Definition;
use crate::types::generics::GenericContext;
use crate::types::{ClassLiteral, TypeMapping, TypeRelation, TypeVarInstance, todo_type};
@ -53,6 +53,14 @@ impl<'db> CallableSignature<'db> {
self.overloads.iter()
}
pub(super) fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self::from_overloads(
self.overloads
.iter()
.map(|signature| signature.materialize(db, variance)),
)
}
pub(crate) fn normalized(&self, db: &'db dyn Db) -> Self {
Self::from_overloads(
self.overloads
@ -353,6 +361,20 @@ impl<'db> Signature<'db> {
Self::new(Parameters::object(db), Some(Type::Never))
}
fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self {
generic_context: self.generic_context,
inherited_generic_context: self.inherited_generic_context,
// Parameters are at contravariant position, so the variance is flipped.
parameters: self.parameters.materialize(db, variance.flip()),
return_ty: Some(
self.return_ty
.unwrap_or(Type::unknown())
.materialize(db, variance),
),
}
}
pub(crate) fn normalized(&self, db: &'db dyn Db) -> Self {
Self {
generic_context: self.generic_context.map(|ctx| ctx.normalized(db)),
@ -984,6 +1006,17 @@ impl<'db> Parameters<'db> {
}
}
fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
if self.is_gradual {
Parameters::object(db)
} else {
Parameters::new(
self.iter()
.map(|parameter| parameter.materialize(db, variance)),
)
}
}
pub(crate) fn as_slice(&self) -> &[Parameter<'db>] {
self.value.as_slice()
}
@ -1304,6 +1337,18 @@ impl<'db> Parameter<'db> {
self
}
fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self {
annotated_type: Some(
self.annotated_type
.unwrap_or(Type::unknown())
.materialize(db, variance),
),
kind: self.kind.clone(),
form: self.form,
}
}
fn apply_type_mapping<'a>(&self, db: &'db dyn Db, type_mapping: &TypeMapping<'a, 'db>) -> Self {
Self {
annotated_type: self

View file

@ -1,3 +1,5 @@
use ruff_python_ast::name::Name;
use crate::place::PlaceAndQualifiers;
use crate::types::{
ClassType, DynamicType, KnownClass, MemberLookupPolicy, Type, TypeMapping, TypeRelation,
@ -5,6 +7,8 @@ use crate::types::{
};
use crate::{Db, FxOrderSet};
use super::{TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance};
/// A type that represents `type[C]`, i.e. the class object `C` and class objects that are subclasses of `C`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)]
pub struct SubclassOfType<'db> {
@ -73,6 +77,32 @@ impl<'db> SubclassOfType<'db> {
!self.is_dynamic()
}
pub(super) fn materialize(self, db: &'db dyn Db, variance: TypeVarVariance) -> Type<'db> {
match self.subclass_of {
SubclassOfInner::Dynamic(_) => match variance {
TypeVarVariance::Covariant => KnownClass::Type.to_instance(db),
TypeVarVariance::Contravariant => Type::Never,
TypeVarVariance::Invariant => {
// We need to materialize this to `type[T]` but that isn't representable so
// we instead use a type variable with an upper bound of `type`.
Type::TypeVar(TypeVarInstance::new(
db,
Name::new_static("T_all"),
None,
Some(TypeVarBoundOrConstraints::UpperBound(
KnownClass::Type.to_instance(db),
)),
variance,
None,
TypeVarKind::Pep695,
))
}
TypeVarVariance::Bivariant => unreachable!(),
},
SubclassOfInner::Class(_) => Type::SubclassOf(self),
}
}
pub(super) fn apply_type_mapping<'a>(
self,
db: &'db dyn Db,