crates/ty_python_semantic/resources/mdtest/cycle.md
Deferred annotations can result in cycles in resolving a function signature:
from __future__ import annotations
# error: [invalid-type-form]
def f(x: f):
pass
reveal_type(f) # revealed: def f(x: Unknown) -> Unknown
See: https://github.com/astral-sh/ty/issues/364
class Point:
def __init__(self, x: int = 0, y: int = 0) -> None:
self.x = x
self.y = y
def replace_with(self, other: "Point") -> None:
self.x, self.y = other.x, other.y
p = Point()
reveal_type(p.x) # revealed: int
reveal_type(p.y) # revealed: int
This is a regression test for https://github.com/astral-sh/ty/issues/3838.
while 1:
# error: [possibly-unresolved-reference]
# error: [possibly-unresolved-reference]
x = (*x, x)
while 1:
y = (y, *y)
This is a regression test for https://github.com/astral-sh/ty/issues/3851. Constructing a union during cycle recovery must not run redundancy checks between a literal and a protocol instance. Resolving the protocol interface can depend on the expression inference query that is already being recovered, which would introduce a new Salsa cycle.
[environment]
python-version = "3.14"
from typing import Protocol, runtime_checkable
_: Any
@property
def prop(self) -> A:
raise NotImplementedError
@runtime_checkable
class B(Protocol):
_: A
x = 5
while isinstance(x, B):
x = B() # error: [call-non-callable]
type(x)
x = 2
from typing import Any, assert_type
assert_type(prop, property)
if bool:
x = 5
while isinstance(x, B):
x = B() # error: [call-non-callable]
class A: ...
Once a recursively growing group of integer literals widens to int, later iterations must not
reintroduce individual literals. Otherwise, the inferred type continues changing and the cycle never
converges. This is a reduced regression test from SciPy's iterative sparse solvers.
def solve(maxiter, a, b, c, d, e):
iteration = 0
stop = 0
while iteration < maxiter:
iteration = iteration + 1
if iteration >= maxiter:
stop = 7
if a:
stop = 6
if b:
stop = 5
if c:
stop = 4
if d:
stop = 3
if e:
stop = 2
if stop > 0:
break
return stop
[environment]
python-version = "3.12" # typing.TypeAliasType
from typing import Union, TypeAliasType, Sequence, Mapping
A = list["A | None"]
def f(x: A):
# TODO: should be `list[A | None]`?
reveal_type(x) # revealed: list[Divergent]
# TODO: should be `A | None`?
reveal_type(x[0]) # revealed: Divergent
JSONPrimitive = Union[str, int, float, bool, None]
JSONValue = TypeAliasType("JSONValue", 'Union[JSONPrimitive, Sequence["JSONValue"], Mapping[str, "JSONValue"]]')
def _(x: JSONValue):
reveal_type(x) # revealed: Sequence[JSONValue] | int | float | None | Mapping[str, JSONValue]
from typing import Generic, TypeVar
B = TypeVar("B", bound="Base") # error: [missing-type-argument]
class Base(Generic[B]):
pass
This is a regression test for https://github.com/astral-sh/ty/issues/1402. When a parameter has a
default value that references the callable itself, we currently prevent infinite recursion by simply
falling back to Unknown for the type of the default value, which does not have any practical
impact except for the displayed type. We could also consider inferring Divergent when we encounter
too many layers of nesting (instead of just one), but that would require a type traversal which
could have performance implications. So for now, we mainly make sure not to panic or stack overflow
for these seeminly rare cases.
class C:
def f(self: "C"):
def inner_a(positional=self.a):
return
self.a = inner_a
# revealed: def inner_a(positional=...) -> Unknown
reveal_type(inner_a)
def inner_b(*, kw_only=self.b):
return
self.b = inner_b
# revealed: def inner_b(*, kw_only=...) -> Unknown
reveal_type(inner_b)
def inner_c(positional_only=self.c, /):
return
self.c = inner_c
# revealed: def inner_c(positional_only=..., /) -> Unknown
reveal_type(inner_c)
def inner_d(*, kw_only=self.d):
return
self.d = inner_d
# revealed: def inner_d(*, kw_only=...) -> Unknown
reveal_type(inner_d)
We do, however, still check assignability of the default value to the parameter type:
class D:
def f(self: "D"):
# error: [invalid-parameter-default] "Default value of type `(a: int = ...) -> Unknown` is not assignable to annotated parameter type `int`"
def inner_a(a: int = self.a): ...
self.a = inner_a
class C:
def f(self: "C"):
self.a = lambda positional=self.a: positional
self.b = lambda *, kw_only=self.b: kw_only
self.c = lambda positional_only=self.c, /: positional_only
self.d = lambda *, kw_only=self.d: kw_only
# revealed: (positional: Unknown = ...) -> Unknown | ((positional=...) -> Divergent)
reveal_type(self.a)
# revealed: (*, kw_only=...) -> Unknown | ((*, kw_only=...) -> Divergent)
reveal_type(self.b)
# revealed: (positional_only: Unknown = ..., /) -> Unknown | ((positional_only=..., /) -> Divergent)
reveal_type(self.c)
# revealed: (*, kw_only=...) -> Unknown | ((*, kw_only=...) -> Divergent)
reveal_type(self.d)
class Cyclic:
def __init__(self, data: str | dict): # error: [missing-type-argument]
self.data = data
def update(self):
if isinstance(self.data, str):
self.data = {"url": self.data}
# revealed: str | dict[Unknown, Unknown] | dict[str, str]
reveal_type(Cyclic("").data)
This is a regression test for https://github.com/astral-sh/ty/issues/3471.
from collections.abc import Callable
from typing import TypeVar
class A: ...
T = TypeVar("T")
U = TypeVar("U", bound=A)
C = Callable[[T, U], object]
def d() -> Callable[[C[U, A]], object]:
raise NotImplementedError
class B:
@d()
def m1(self, p):
pass
@d()
def m2(self, p):
self.__slots__ # error: [unresolved-attribute]
NamedTuple / NewTypeThis is a regression test for https://github.com/astral-sh/ty/issues/3485 and
https://github.com/astral-sh/ty/issues/3682. Type traversal during cycle recovery should not force
the lazy base of a NewType.
class C:
pass
def f():
pass
def g() -> T: # error: [unresolved-reference]
pass
g()
from typing import NamedTuple, NewType
X = NamedTuple("X", [("x", "X")]), None # error: [invalid-type-form]
list(X)
min(X)
T = f()
X = NewType("X", C)
The runtime callable returned by NewType also carries the lazy base and must use the same
cycle-safe traversal.
class C: ...
def f(): ...
def g() -> T: ...
g()
from typing import NamedTuple, NewType
X = NewType("X", C)
Y = NamedTuple("Y", [("a", "Y")]), X # error: [invalid-type-form]
min(Y)
T = f()
hasattrThis pattern used to panic with "too many cycle iterations".
class Cached:
def get(self) -> int:
return 0
@property
def metadata(self) -> int:
if not hasattr(self, "_metadata"):
self._metadata = self.get()
return self._metadata
reveal_type(Cached().metadata) # revealed: int
This example was minimized from
a real issue in robotframework.
It created
a complicated cycle with multiple cycle heads,
which also involved
a tricky Salsa behavior that comes up when a query oscillates between being a cycle head and not being one.
entry.py:
from derived import Derived
Derived.decorate
# revealed: bound method <class 'Derived'>.decorate[T](item_class: type[T]) -> type[T]
reveal_type(Derived.decorate)
derived.py:
from ty_extensions import reveal_mro
import bases
class Derived(bases.GenericBase["Foo", "Bar"]): ...
@Derived.decorate
class Foo(bases.Foo): ...
# revealed: <class 'Foo'>
reveal_type(Foo)
# revealed: (<class 'derived.Foo'>, <class 'bases.Foo'>, <class 'object'>)
reveal_mro(Foo)
@Derived.decorate
class Bar(bases.Bar): ...
# revealed: <class 'Bar'>
reveal_type(Bar)
# revealed: (<class 'derived.Bar'>, <class 'bases.Bar'>, <class 'object'>)
reveal_mro(Bar)
bases.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: <class 'GenericBase'>
reveal_type(GenericBase)
# revealed: (<class 'GenericBase[Unknown, Unknown]'>, typing.Generic, <class 'object'>)
reveal_mro(GenericBase)
# revealed: (<class 'GenericBase[Foo, Bar]'>, typing.Generic, <class 'object'>)
reveal_mro(GenericBase["Foo", "Bar"])
class Foo: ...
class Bar: ...