From c5eaba88b72547f77f2342fca6b0994542232e09 Mon Sep 17 00:00:00 2001 From: Sai Nane Date: Sat, 8 Mar 2025 18:01:57 +0000 Subject: [PATCH] Python type system experiments. Push the envelope as far as you can! ...and then give up and use cast(), because we don't have applicative functors or higher kinded types --- gears/connections.py | 4 +- gears/effect.py | 58 +++++++++---------------- gears/gear.py | 10 +++-- gears/wrap_aware_tuple.py | 90 +++++++++++++++++++++++++++++++++++++++ tests/test_signals.py | 4 +- 5 files changed, 121 insertions(+), 45 deletions(-) create mode 100644 gears/wrap_aware_tuple.py diff --git a/gears/connections.py b/gears/connections.py index e9269d7..cafc10a 100644 --- a/gears/connections.py +++ b/gears/connections.py @@ -1,4 +1,4 @@ -from typing import Any, Generic, Hashable, TypeVar, Callable +from typing import Any, Hashable, TypeVar, Callable from gears.effect import effect_of from gears.gear import Gear @@ -10,7 +10,7 @@ T = TypeVar("T", bound=Hashable) def connect( source: Gear[S], to_target_val: Callable[[S], T], - to_source: Callable[[S, T], S] | None = None, + to_source: Callable[[S, T], S], ) -> Gear[T]: target = Gear(to_target_val(source())) diff --git a/gears/effect.py b/gears/effect.py index 553bccf..f311dec 100644 --- a/gears/effect.py +++ b/gears/effect.py @@ -1,47 +1,31 @@ +from typing import Any, Callable, cast + from gears import Gear -from typing import Any, Generic, Hashable, TypeVar, Callable, TypeVarTuple +from .wrap_aware_tuple import WrapAwareTuple - -class Effect: - def __init__(self, fn: Callable[..., None], *gears: Gear): - self._fn = fn - self._gears = gears +class Effect[*Ts]: + def __init__(self, fn: Callable[[*Ts], None], gears: WrapAwareTuple[Gear, *Ts]): for gear in gears: gear.effects.append(self) + self._fn = fn + self._gears = gears - self.on_change(gears[0]()) + self.on_change() - def on_change(self, _value: Any): - self._fn(*(gear() for gear in self._gears)) + def on_change(self): + self._fn(*self._gears.unwrap(lambda x: x())) +def effect_of[*Ts, *G_Ts]( + *gears: Gear[Any] +) -> Callable[[Callable[[*Ts], None]], Effect]: + # No deduction, no tears, only cast now + ts_gears = cast(WrapAwareTuple[Gear, *Ts], WrapAwareTuple.prewrapped(gears)) + return effect_of_typechecked(ts_gears) -T0 = TypeVar("T0", bound=Hashable) -T1 = TypeVar("T1", bound=Hashable) -T2 = TypeVar("T2", bound=Hashable) - -# Ts = TypeVarTuple("Ts", bound=Hashable) - - -def effect_of(g0: Gear[T0]) -> Callable[[Callable[[T0], None]], Effect]: - def decorator(fn: Callable[[T0], None]) -> Effect: - return Effect(fn, g0) - - return decorator - - -def effect_of_2( - g0: Gear[T0], g1: Gear[T1] -) -> Callable[[Callable[[T0, T1], None]], Effect]: - def decorator(fn: Callable[[T0, T1], None]) -> Effect: - return Effect(fn, g0, g1) - - return decorator - - -def effect_of_3( - g0: Gear[T0], g1: Gear[T1], g2: Gear[T2] -) -> Callable[[Callable[[T0, T1, T2], None]], Effect]: - def decorator(fn: Callable[[T0, T1, T2], None]) -> Effect: - return Effect(fn, g0, g1, g2) +def effect_of_typechecked[*Ts]( + gears: WrapAwareTuple[Gear, *Ts] +) -> Callable[[Callable[[*Ts], None]], Effect]: + def decorator(fn: Callable[[*Ts], None]) -> Effect: + return Effect(fn, gears) return decorator diff --git a/gears/gear.py b/gears/gear.py index 408ad60..5ecee8b 100644 --- a/gears/gear.py +++ b/gears/gear.py @@ -1,12 +1,14 @@ -from typing import Any, Generic, Hashable, TypeVar, Callable +from typing import Any, Hashable, TypeVar, TYPE_CHECKING T = TypeVar("T", bound=Hashable) +if TYPE_CHECKING: + from gears.effect import Effect -class Gear(Generic[T]): +class Gear[T](): def __init__(self, value: T): self._value = value - self.effects = [] + self.effects: list['Effect'] = [] def get(self) -> T: return self._value @@ -19,4 +21,4 @@ class Gear(Generic[T]): return self._value = value for effect in self.effects: - effect.on_change(value) + effect.on_change() diff --git a/gears/wrap_aware_tuple.py b/gears/wrap_aware_tuple.py new file mode 100644 index 0000000..36eb1f2 --- /dev/null +++ b/gears/wrap_aware_tuple.py @@ -0,0 +1,90 @@ + +from typing import cast, Callable, reveal_type, TYPE_CHECKING + +# no-op "Wrap" type +type Identity[X] = X + +class WrapAwareTuple[Wrap, *Ts](tuple[Wrap, ...]): + ''' + Wrap is a wrapper type around each of the types in Ts, in order. + + For example: + + a: Wrapped[Wrap, int, str, float] = Wrapped(Wrap[int], Wrap[str], Wrap[float]) + + This lets us keep track of type transformations, starting from the Identity mapping. + ''' + + @staticmethod + def id[*NewTs](*args: *NewTs) -> 'WrapAwareTuple[Identity, *NewTs]': + ''' + Create a new WrapAwareTuple, using the Identity psuedo-wrapper. + ''' + return WrapAwareTuple[Identity, *NewTs](args) + + @staticmethod + def prewrapped[NewWrap, *NewTs](args: tuple[NewWrap, ...]) -> 'WrapAwareTuple[NewWrap, *NewTs]': + ''' + Create a new WrapAwareTuple, using the given wrapper type. + ''' + return WrapAwareTuple[NewWrap, *NewTs](args) + + def wrap[NewWrap](self, mapper: Callable[[Wrap], NewWrap]) -> 'WrapAwareTuple[NewWrap, *Ts]': + ''' + Wrap all existing elements with a new wrapping function. + + This function must unwrap the current wrapping type, if non-Identity, and apply a new one. + + To pop the wrapped value from a tuple wrapper: + >>> Wrapped[tuple, int](3).wrap[Identity](lambda x: x[0]) + + To wrap an unwrapped value into a tuple: + >>> Wrapped[Identity, int](3).wrap(tuple) + ''' + # pyright supports Union[*Ts], but we still can't do applicative functor things with it + return WrapAwareTuple[NewWrap, *Ts](mapper(v) for v in self) + + def unwrap(self, mapper: Callable[[Wrap], Identity]) -> tuple[*Ts]: + ''' + Unwrap all elements in the tuple, returning a normal tuple. + + This is the same as calling wrap with the appropriate wrapper, except we simplify the signature and + return type for this case. + ''' + return cast(tuple[*Ts], self.wrap(mapper)) + +def list_fmap[*Ts](t: tuple[*Ts]): + match t: + case (head, *tail): + return ([head], *list_fmap(tuple(tail))) + case _: + return () + + +if TYPE_CHECKING: + def _fmap_scratchpad(): + reveal_type(list_fmap((1,"s",3.0))) + # Type of "list_fmap((1, "s", 3))" is "tuple[list[*tuple[int, str, float]], *tuple[list[*tuple[Unknown, ...]] | Unknown, ...]] | tuple[()]" + # ...wanted to get "tuple[list[int], list[str], list[float]]"; RIP + + def _wrapaware_scratchpad(): + + atom = WrapAwareTuple.id(3) + reveal_type(atom) + # Type of "atom" is "Wrapped[Unknown, int]" + # (variable) atom: Wrapped[Identity[Unknown], int] + + compound_3 = WrapAwareTuple.id(3, "str", 9.0) + reveal_type(compound_3) + # Type of "compound_3" is "Wrapped[Unknown, int, str, float]" + # (variable) compound_3: Wrapped[Identity[Unknown], int, str, float] + + tupled_compound_3 = compound_3.wrap(tuple) + reveal_type(tupled_compound_3) + # Type of "tupled_compound_3" is "Wrapped[tuple[Unknown, ...], int, str, float]" + # (variable) tupled_compound_3: Wrapped[tuple[Identity[Unknown], ...], int, str, float] + + restored_compound_3 = tupled_compound_3.wrap(lambda x: x[0]) + reveal_type(restored_compound_3) + # Type of "restored_compound_3" is "Wrapped[Unknown, int, str, float]" + # (variable) restored_compound_3: Wrapped[Unknown, int, str, float] diff --git a/tests/test_signals.py b/tests/test_signals.py index 5b7af55..0aee19c 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -3,7 +3,7 @@ from typing import NamedTuple from unittest.mock import MagicMock from gears import Gear from gears.connections import connect -from gears.effect import effect_of, effect_of_2 +from gears.effect import effect_of def test_get_set(): @@ -35,7 +35,7 @@ def test_effect_of_2(): last_arg: tuple[str, int] | None = None - @effect_of_2(g1, g2) + @effect_of(g1, g2) def value_changed(v1: str, v2: int): nonlocal last_arg last_arg = (v1, v2)