diff --git a/crates/ty_python_semantic/resources/mdtest/cycle.md b/crates/ty_python_semantic/resources/mdtest/cycle.md index 8f5816fdc49539..695639f38de796 100644 --- a/crates/ty_python_semantic/resources/mdtest/cycle.md +++ b/crates/ty_python_semantic/resources/mdtest/cycle.md @@ -155,3 +155,73 @@ class Cyclic: # revealed: Unknown | str | dict[Unknown, Unknown] | dict[Unknown | str, Unknown | str] reveal_type(Cyclic("").data) ``` + +## Decorator defined on a base class with constrained typevars, accessed from a subclass with decorated generic parameters + +This example was minimized from +[a real issue in `robotframework`](https://github.com/astral-sh/ty/issues/2637#issuecomment-3807037935). +It created +[a complicated cycle with multiple cycle heads](https://gist.github.com/oconnor663/c996ed2cc97d172dd4b9a8d8207dc7ac), +which also involved +[a tricky Salsa behavior that comes up when a query oscillates between being a cycle head and not being one](https://gist.github.com/oconnor663/c2a7662e3d88048b691754da957121d1). + +`entry.py`: + +```py +from derived import Derived + +Derived.decorate +# revealed: bound method .decorate[T](item_class: type[T]) -> type[T] +reveal_type(Derived.decorate) +``` + +`derived.py`: + +```py +from ty_extensions import reveal_mro +import bases + +class Derived(bases.GenericBase["Foo", "Bar"]): ... + +@Derived.decorate +class Foo(bases.Foo): ... + +# revealed: +reveal_type(Foo) +# revealed: (, , ) +reveal_mro(Foo) + +@Derived.decorate +class Bar(bases.Bar): ... + +# revealed: +reveal_type(Bar) +# revealed: (, , ) +reveal_mro(Bar) +``` + +`bases.py`: + +```py +from typing import Generic, TypeVar, Type +from ty_extensions import reveal_mro + +T = TypeVar("T") +B1 = TypeVar("B1", bound="Foo") +B2 = TypeVar("B2", bound="Bar") + +class GenericBase(Generic[B1, B2]): + @classmethod + def decorate(cls, item_class: Type[T]) -> Type[T]: + return item_class + +# revealed: +reveal_type(GenericBase) +# revealed: (, typing.Generic, ) +reveal_mro(GenericBase) +# revealed: (, typing.Generic, ) +reveal_mro(GenericBase["Foo", "Bar"]) + +class Foo: ... +class Bar: ... +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index ca6a95a1dec998..621b1cdb9fa677 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -125,6 +125,43 @@ fn try_metaclass_cycle_initial<'db>( }) } +fn explicit_bases_cycle_initial<'db>( + db: &'db dyn Db, + id: salsa::Id, + literal: StaticClassLiteral<'db>, +) -> Box<[Type<'db>]> { + let module = parsed_module(db, literal.file(db)).load(db); + let class_stmt = literal.node(db, &module); + // Try to produce a list of `Divergent` types of the right length. However, if one or more of + // the bases is a starred expression, we don't know how many entries that will eventually + // expand to. + vec![Type::divergent(id); class_stmt.bases().len()].into_boxed_slice() +} + +fn explicit_bases_cycle_fn<'db>( + db: &'db dyn Db, + cycle: &salsa::Cycle, + previous: &[Type<'db>], + current: Box<[Type<'db>]>, + _literal: StaticClassLiteral<'db>, +) -> Box<[Type<'db>]> { + if previous.len() == current.len() { + // As long as the length of bases hasn't changed, use the same "monotonic widening" + // strategy that we use with most types, to avoid oscillations. + current + .iter() + .zip(previous.iter()) + .map(|(curr, prev)| curr.cycle_normalized(db, *prev, cycle)) + .collect() + } else { + // The length of bases has changed, presumably because we expanded a starred expression. We + // don't do "monotonic widening" here, because we don't want to make assumptions about + // which previous entries correspond to which current ones. An oscillation here would be + // unfortunate, but maybe only pathological programs can trigger such a thing. + current + } +} + #[expect(clippy::unnecessary_wraps)] fn dynamic_class_try_mro_cycle_initial<'db>( db: &'db dyn Db, @@ -2426,7 +2463,7 @@ impl<'db> StaticClassLiteral<'db> { /// /// Were this not a salsa query, then the calling query /// would depend on the class's AST and rerun for every change in that file. - #[salsa::tracked(returns(deref), cycle_initial=|_, _, _| Box::default(), heap_size=ruff_memory_usage::heap_size)] + #[salsa::tracked(returns(deref), cycle_initial=explicit_bases_cycle_initial, cycle_fn=explicit_bases_cycle_fn, heap_size=ruff_memory_usage::heap_size)] pub(super) fn explicit_bases(self, db: &'db dyn Db) -> Box<[Type<'db>]> { tracing::trace!( "StaticClassLiteral::explicit_bases_query: {}",