From eef38f97eb7cb2848995f2afc82506ec7ce3ef97 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 28 Oct 2026 23:44:06 +0100 Subject: [PATCH 1/4] fix: make public typing contracts accurate --- CLAUDE.md | 55 +++++++++++----------- README.md | 27 +++++++++++ oslash/do.py | 4 +- oslash/either.py | 40 ++++++++++------ oslash/identity.py | 20 ++++---- oslash/list.py | 40 +++++++++++----- oslash/maybe.py | 39 ++++++++++------ oslash/monadic.py | 2 +- oslash/py.typed | 0 oslash/reader.py | 25 ++++++---- oslash/typing/applicative.py | 14 ++---- oslash/typing/functor.py | 6 +-- oslash/typing/monad.py | 17 ++++--- oslash/util/apply.py | 27 +++++++++++ oslash/writer.py | 36 ++++++++++----- pyproject.toml | 2 +- tests/test_identity.py | 9 ++++ tests/test_writer.py | 12 +++++ tests/typing_contracts.py | 90 ++++++++++++++++++++++++++++++++++++ 19 files changed, 341 insertions(+), 124 deletions(-) create mode 100644 oslash/py.typed create mode 100644 oslash/util/apply.py create mode 100644 tests/typing_contracts.py diff --git a/CLAUDE.md b/CLAUDE.md index 0dc4e36..c92f88e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Located in [oslash/typing/](oslash/typing/), these define the "laws" of function ```python @runtime_checkable class Functor[T](Protocol): - def map[U](self, fn: Callable[[T], U]) -> Functor[U]: + def map(self, fn: Callable[[T], T], /) -> Functor[T]: """Transform values inside the container.""" ... ``` @@ -65,13 +65,13 @@ class Functor[T](Protocol): ```python @runtime_checkable -class Applicative[T](Protocol): - def apply[U](self, something: Applicative[Callable[[T], U]]) -> Applicative[U]: +class Applicative[T, Arg, Result](Protocol): + def apply(self, something: Arg, /) -> Result: """Apply a wrapped function to wrapped values.""" ... @classmethod - def pure(cls, value: T) -> Self: + def pure(cls, value: T, /) -> Applicative[T, Arg, Result]: """Wrap a pure value.""" ... ``` @@ -82,13 +82,13 @@ class Applicative[T](Protocol): ```python @runtime_checkable -class Monad[T](Protocol): - def bind[U](self, fn: Callable[[T], Monad[U]]) -> Monad[U]: +class Monad[T, Result](Protocol): + def bind(self, fn: Callable[[T], Result], /) -> Result: """Chain computations that produce wrapped values.""" ... @classmethod - def unit(cls, value: T) -> Self: + def unit(cls, value: T, /) -> Monad[T, Result]: """Wrap a pure value.""" ... ``` @@ -328,10 +328,10 @@ succ: Callable[[ChurchNumeral[T]], ChurchNumeral[T]] = ... ### [Kleisli Composition](oslash/monadic.py) ```python -def compose[A, B, C]( - f: Callable[[B], Monad[C]], - g: Callable[[A], Monad[B]] -) -> Callable[[A], Monad[C]]: +def compose[A, B, Result]( + f: Callable[[B], Result], + g: Callable[[A], Monad[B, Result]] +) -> Callable[[A], Result]: """Compose monadic functions.""" return lambda x: g(x).bind(f) ``` @@ -385,7 +385,9 @@ Continuation: TypeAlias = Callable[[T], R] # T, R must be defined elsewhere ### When `Any` is Acceptable -We minimize `Any` usage (<10 instances), but it's justified when: +Keep `Any` at dynamic implementation boundaries and expose precise public types. +The `apply` overloads, for example, describe unary and two-argument callables +while their shared implementation accepts both shapes. 1. **Higher-kinded types aren't supported**: @@ -394,21 +396,23 @@ We minimize `Any` usage (<10 instances), but it's justified when: type ListSelector[T] = Callable[[T, List[T]], Any] # Any: Return type varies ``` -2. **Partial application creates ambiguity**: +2. **Partial application across callable arities**: ```python - # Partially applied function has complex intermediate type - result = partial(func, arg1) # type: ignore # Partial: Complex type inference + # Public overloads return either U or Callable[[Next], U]. + # Only the shared implementation handles Callable[..., Any]. + result = apply_callable(func, arg1) ``` -3. **Protocol limitations**: +3. **Protocol implementation boundaries**: ```python - # Protocol can't express "returns same type as receiver" - def copy(self) -> Any: # Any: Protocol limitation, actual type is Self + # Keep a type-erased implementation behind precise overloads. + def apply(self: Identity[Callable[..., Any]], something: Identity[Any]) -> Identity[Any]: + ... ``` -**Always add inline comment** explaining why: +Document why `Any` is needed at its implementation boundary: ```python value: Any # Any: [Brief explanation of why static types insufficient] @@ -579,14 +583,9 @@ pythonVersion = "3.12" 3. **Protocol implementation**: ```python - # Ensure all protocol methods are implemented - class MyMonad[T](Monad[T]): - def bind[U](self, fn: Callable[[T], Monad[U]]) -> Monad[U]: - ... # Must implement - - @classmethod - def unit(cls, value: T) -> Self: - ... # Must implement + # Check the public structural contract from caller code. + value: Monad[int, Identity[str]] = Identity(1) + result: Identity[str] = value.bind(lambda x: Identity(str(x))) ``` ### Linting and Formatting @@ -842,7 +841,7 @@ OSlash uses **Protocol** instead of **ABC** for type definitions. Here's why: ```python @runtime_checkable class Functor[T](Protocol): - def map[U](self, fn: Callable[[T], U]) -> Functor[U]: ... + def map(self, fn: Callable[[T], T], /) -> Functor[T]: ... # Any class with a map method is a Functor - no inheritance needed class MyClass[T]: diff --git a/README.md b/README.md index cbbc597..defa19e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,33 @@ The project currently contains implementations for: - **State**, for stateful computations of stuff - **Cont**, for continuation of stuff +## Typing + +The concrete classes keep the wrapped result type through `map`, `bind`, and `apply`. +The structural protocols describe a particular operation and its result container: + +```python +from collections.abc import Callable +from oslash import Identity +from oslash.typing import Applicative, Monad + +monad: Monad[int, Identity[str]] = Identity(1) +result: Identity[str] = monad.bind(lambda value: Identity(str(value))) + +fn: Callable[[int], str] = str +applicative: Applicative[Callable[[int], str], Identity[int], Identity[str]] = Identity(fn) +applied: Identity[str] = applicative.apply(Identity(1)) +``` + +`Monad` now takes the input value type and the concrete bind result type. +`Applicative` takes the wrapped function type, argument container type, and result container type. +`Functor[T]` describes same-type mappings; concrete functors also support mappings to other types. + +`Writer.map` maps the value and keeps its log. Use `Writer.map_with_log` to transform both. +`Writer.unit(value)` uses a string log; for another log type, pass `empty_log` or use `Writer.create`. +`apply` has typed overloads for unary and two-argument functions; applying the first argument of a +two-argument function returns a wrapped one-argument function. + ## Monadic functions - **>>**, for sequencing monadic actions diff --git a/oslash/do.py b/oslash/do.py index 15fa714..74a66ba 100644 --- a/oslash/do.py +++ b/oslash/do.py @@ -18,7 +18,7 @@ # TODO: guard belongs where in OSlash? -def guard[T](m: Monad[T], test: bool) -> Monad[tuple[()]]: +def guard[T](m: Monad[T, Any], test: bool) -> Monad[tuple[()], Any]: """Monadic guard. What it does:: @@ -56,7 +56,7 @@ def let(**binding: Any) -> MonadicLet | None: # Any: Dynamic binding value return None -def do(*lines: Monad[Any] | MonadicLet | Callable[[Any], Monad[Any]]) -> Monad[Any]: +def do(*lines: Monad[Any, Any] | MonadicLet | Callable[[Any], Monad[Any, Any]]) -> Monad[Any, Any]: """Do-notation. Syntax:: diff --git a/oslash/either.py b/oslash/either.py index 0e9ec57..8af4d3d 100644 --- a/oslash/either.py +++ b/oslash/either.py @@ -9,10 +9,10 @@ from abc import abstractmethod from collections.abc import Callable -from functools import partial -from typing import Any, Self, cast +from typing import Any, Self, cast, overload from oslash.typing import Applicative, Functor, Monad +from oslash.util.apply import apply_callable class Either[T, E]: @@ -33,8 +33,18 @@ def pure(cls, value: T) -> Self: """Applicative pure operation.""" raise NotImplementedError + @overload @abstractmethod - def apply[U](self: Either[Callable[[T], U], E], something: Either[T, E]) -> Either[U, E]: + def apply[A, B](self: Either[Callable[[A], B], E], something: Either[A, E]) -> Either[B, E]: ... + + @overload + @abstractmethod + def apply[A, B, C]( + self: Either[Callable[[A, B], C], E], something: Either[A, E] + ) -> Either[Callable[[B], C], E]: ... + + @abstractmethod + def apply(self: Either[Callable[..., Any], E], something: Either[Any, E]) -> Either[Any, E]: """Applicative apply operation.""" raise NotImplementedError @@ -79,17 +89,15 @@ def pure(cls, value: T) -> Right[T, E]: """Wrap a value in Right.""" return cls(value) - def apply[U](self: Right[Callable[[T], U], E], something: Either[T, E]) -> Either[U, E]: - """Apply a wrapped function to a wrapped value.""" + @overload + def apply[A, B](self: Right[Callable[[A], B], E], something: Either[A, E]) -> Either[B, E]: ... - def mapper(other_value: T) -> U: - try: - return self._value(other_value) - except TypeError: - # Partial application for curried functions - return partial(self._value, other_value) # type: ignore + @overload + def apply[A, B, C](self: Right[Callable[[A, B], C], E], something: Either[A, E]) -> Either[Callable[[B], C], E]: ... - return something.map(mapper) + def apply(self: Right[Callable[..., Any], E], something: Either[Any, E]) -> Either[Any, E]: + """Apply a wrapped function, currying it when more arguments remain.""" + return something.map(lambda value: apply_callable(self._value, value)) # Monad Section # ============= @@ -148,7 +156,13 @@ def pure(cls, value: T) -> Either[T, E]: """Left cannot be pure, returns Right.""" return Right(value) - def apply[U](self: Left[Callable[[T], U], E], something: Either[T, E]) -> Either[U, E]: + @overload + def apply[A, B](self: Left[Callable[[A], B], E], something: Either[A, E]) -> Either[B, E]: ... + + @overload + def apply[A, B, C](self: Left[Callable[[A, B], C], E], something: Either[A, E]) -> Either[Callable[[B], C], E]: ... + + def apply(self: Left[Callable[..., Any], E], something: Either[Any, E]) -> Either[Any, E]: """Left values cannot apply.""" return Left(self._error) diff --git a/oslash/identity.py b/oslash/identity.py index 093d9c1..d88d735 100644 --- a/oslash/identity.py +++ b/oslash/identity.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial -from typing import Any, Self, cast +from typing import Any, Self, cast, overload from .typing import Applicative, Functor, Monad +from .util.apply import apply_callable class Identity[T]: @@ -38,17 +38,15 @@ def pure(cls, value: T) -> Self: """Wrap a value in the Identity context.""" return cls(value) - def apply[U](self: Identity[Callable[[T], U]], something: Identity[T]) -> Identity[U]: - """Apply a wrapped function to a wrapped value.""" + @overload + def apply[A, B](self: Identity[Callable[[A], B]], something: Identity[A]) -> Identity[B]: ... - def mapper(other_value: T) -> U: - try: - return self._value(other_value) - except TypeError: - # Partial application for curried functions - return partial(self._value, other_value) # type: ignore + @overload + def apply[A, B, C](self: Identity[Callable[[A, B], C]], something: Identity[A]) -> Identity[Callable[[B], C]]: ... - return something.map(mapper) + def apply(self: Identity[Callable[..., Any]], something: Identity[Any]) -> Identity[Any]: + """Apply a wrapped function, currying it when more arguments remain.""" + return something.map(lambda value: apply_callable(self._value, value)) def run(self) -> T: """Extract the value from the Identity.""" diff --git a/oslash/list.py b/oslash/list.py index 4d8ff29..b2438e6 100644 --- a/oslash/list.py +++ b/oslash/list.py @@ -8,10 +8,11 @@ from abc import abstractmethod from collections.abc import Callable, Iterable, Iterator, Sized -from functools import partial, reduce -from typing import Any, cast +from functools import reduce +from typing import Any, cast, overload from .typing import Applicative, Functor, Monad, Monoid +from .util.apply import apply_callable # Type alias for list selector function used in Church encoding # Returns either the head (T) or tail (List[T]) depending on which selector is passed @@ -101,8 +102,16 @@ def tail(self) -> List[T]: """Return tail of List.""" raise NotImplementedError + @overload @abstractmethod - def apply[U](self: List[Callable[[T], U]], something: List[T]) -> List[U]: + def apply[A, B](self: List[Callable[[A], B]], something: List[A]) -> List[B]: ... + + @overload + @abstractmethod + def apply[A, B, C](self: List[Callable[[A, B], C]], something: List[A]) -> List[Callable[[B], C]]: ... + + @abstractmethod + def apply(self: List[Callable[..., Any]], something: List[Any]) -> List[Any]: """Applicative apply operation.""" raise NotImplementedError @@ -190,18 +199,19 @@ def map[U](self, mapper: Callable[[T], U]) -> List[U]: """Map a function over a List.""" return self.tail().map(mapper).cons(mapper(self.head())) - def apply[U](self: Cons[Callable[[T], U]], something: List[T]) -> List[U]: + @overload + def apply[A, B](self: Cons[Callable[[A], B]], something: List[A]) -> List[B]: ... + + @overload + def apply[A, B, C](self: Cons[Callable[[A, B], C]], something: List[A]) -> List[Callable[[B], C]]: ... + + def apply(self: Cons[Callable[..., Any]], something: List[Any]) -> List[Any]: """Apply wrapped functions to wrapped values. Haskell: fs <*> xs = [f x | f <- fs, x <- xs] """ - try: - xs = [f(x) for f in self for x in something] - except TypeError: - # Partial application for curried functions - xs = [partial(f, x) for f in self for x in something] # type: ignore - - return List.from_iterable(xs) # type: ignore[return-value] + xs = [apply_callable(f, x) for f in self for x in something] + return List[Any].from_iterable(xs) def append(self, other: List[T]) -> List[T]: """Append other list to this list.""" @@ -322,7 +332,13 @@ def map[U](self, mapper: Callable[[T], U]) -> List[U]: """Map a function over an empty List.""" return Nil() - def apply[U](self: Nil[Callable[[T], U]], something: List[T]) -> List[U]: + @overload + def apply[A, B](self: Nil[Callable[[A], B]], something: List[A]) -> List[B]: ... + + @overload + def apply[A, B, C](self: Nil[Callable[[A, B], C]], something: List[A]) -> List[Callable[[B], C]]: ... + + def apply(self: Nil[Callable[..., Any]], something: List[Any]) -> List[Any]: """Apply empty list of functions.""" # fs <*> xs = [f x | f <- fs, x <- xs] # Empty list produces empty result diff --git a/oslash/maybe.py b/oslash/maybe.py index c2fa2db..db24437 100644 --- a/oslash/maybe.py +++ b/oslash/maybe.py @@ -8,10 +8,11 @@ from abc import abstractmethod from collections.abc import Callable -from functools import partial, reduce -from typing import Any, Self, cast +from functools import reduce +from typing import Any, Self, cast, overload from .typing import Applicative, Functor, Monad, Monoid +from .util.apply import apply_callable class Maybe[T]: @@ -45,8 +46,16 @@ def pure(cls, value: T) -> Self: """Applicative pure operation.""" raise NotImplementedError + @overload @abstractmethod - def apply[U](self: Maybe[Callable[[T], U]], something: Maybe[T]) -> Maybe[U]: + def apply[A, B](self: Maybe[Callable[[A], B]], something: Maybe[A]) -> Maybe[B]: ... + + @overload + @abstractmethod + def apply[A, B, C](self: Maybe[Callable[[A, B], C]], something: Maybe[A]) -> Maybe[Callable[[B], C]]: ... + + @abstractmethod + def apply(self: Maybe[Callable[..., Any]], something: Maybe[Any]) -> Maybe[Any]: """Applicative apply operation.""" raise NotImplementedError @@ -137,17 +146,15 @@ def pure[U](cls, value: U) -> Just[U]: """Wrap a value in Just.""" return Just[U](value) - def apply[U](self: Just[Callable[[T], U]], something: Maybe[T]) -> Maybe[U]: - """Apply a wrapped function to a wrapped value.""" + @overload + def apply[A, B](self: Just[Callable[[A], B]], something: Maybe[A]) -> Maybe[B]: ... - def mapper(other_value: T) -> U: - try: - return self._value(other_value) - except TypeError: - # Partial application for curried functions - return partial(self._value, other_value) # type: ignore + @overload + def apply[A, B, C](self: Just[Callable[[A, B], C]], something: Maybe[A]) -> Maybe[Callable[[B], C]]: ... - return something.map(mapper) + def apply(self: Just[Callable[..., Any]], something: Maybe[Any]) -> Maybe[Any]: + """Apply a wrapped function, currying it when more arguments remain.""" + return something.map(lambda value: apply_callable(self._value, value)) # Monad Section # ============= @@ -259,7 +266,13 @@ def pure(cls, value: T) -> Nothing[T]: """Nothing cannot be pure, returns Nothing.""" return cls() - def apply[U](self: Nothing[Callable[[T], U]], something: Maybe[T]) -> Maybe[U]: + @overload + def apply[A, B](self: Nothing[Callable[[A], B]], something: Maybe[A]) -> Maybe[B]: ... + + @overload + def apply[A, B, C](self: Nothing[Callable[[A, B], C]], something: Maybe[A]) -> Maybe[Callable[[B], C]]: ... + + def apply(self: Nothing[Callable[..., Any]], something: Maybe[Any]) -> Maybe[Any]: """Nothing <*> _ = Nothing""" return Nothing() diff --git a/oslash/monadic.py b/oslash/monadic.py index a87e504..cd2ceaf 100644 --- a/oslash/monadic.py +++ b/oslash/monadic.py @@ -12,7 +12,7 @@ from .typing import Monad -def compose[A, B, C](f: Callable[[B], Monad[C]], g: Callable[[A], Monad[B]]) -> Callable[[A], Monad[C]]: +def compose[A, B, Result](f: Callable[[B], Result], g: Callable[[A], Monad[B, Result]]) -> Callable[[A], Result]: r"""Monadic compose function. Right-to-left Kleisli composition of two monadic functions. diff --git a/oslash/py.typed b/oslash/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/oslash/reader.py b/oslash/reader.py index f04c012..3f7822a 100644 --- a/oslash/reader.py +++ b/oslash/reader.py @@ -7,9 +7,10 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial +from typing import Any, overload from .typing import Applicative, Functor, Monad +from .util.apply import apply_callable class Reader[Env, T]: @@ -64,7 +65,15 @@ def pure(cls, value: T) -> Reader[Env, T]: """Wrap a value in Reader context.""" return cls.unit(value) - def apply[U](self: Reader[Env, Callable[[T], U]], something: Reader[Env, T]) -> Reader[Env, U]: + @overload + def apply[A, B](self: Reader[Env, Callable[[A], B]], something: Reader[Env, A]) -> Reader[Env, B]: ... + + @overload + def apply[A, B, C]( + self: Reader[Env, Callable[[A, B], C]], something: Reader[Env, A] + ) -> Reader[Env, Callable[[B], C]]: ... + + def apply(self: Reader[Env, Callable[..., Any]], something: Reader[Env, Any]) -> Reader[Env, Any]: r"""(<*>) :: f (a -> b) -> f a -> f b. Haskell: f <*> g = \x -> f x (g x) @@ -75,14 +84,10 @@ def apply[U](self: Reader[Env, Callable[[T], U]], something: Reader[Env, T]) -> one (composes the two functions). """ - def comp(env: Env) -> U: - func: Callable[[T], U] = self.run(env) - value: T = something.run(env) - try: - return func(value) - except TypeError: - # Partial application for curried functions - return partial(func, value) # type: ignore + def comp(env: Env) -> Any: + func = self.run(env) + value = something.run(env) + return apply_callable(func, value) return Reader(comp) diff --git a/oslash/typing/applicative.py b/oslash/typing/applicative.py index 1519406..5868ac8 100644 --- a/oslash/typing/applicative.py +++ b/oslash/typing/applicative.py @@ -3,15 +3,11 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -if TYPE_CHECKING: - from typing import Self +from typing import Protocol, runtime_checkable @runtime_checkable -class Applicative[T](Protocol): +class Applicative[T, Arg, Result](Protocol): """Applicative. Applicative functors are functors with some extra properties. @@ -23,10 +19,10 @@ class Applicative[T](Protocol): """ @abstractmethod - def apply[U](self, something: Applicative[Callable[[T], U]]) -> Applicative[U]: + def apply(self, something: Arg, /) -> Result: """Apply wrapped callable. - Python: apply(self: Applicative[A], something: Applicative[Callable[[A], B]]) -> Applicative[B] + Python: apply(self: Applicative[Callable[[A], B], F[A], F[B]], something: F[A]) -> F[B] Haskell: (<*>) :: f (a -> b) -> f a -> f b. Apply (<*>) is a beefed up fmap. It takes a functor value that @@ -38,7 +34,7 @@ def apply[U](self, something: Applicative[Callable[[T], U]]) -> Applicative[U]: @classmethod @abstractmethod - def pure(cls, value: T) -> Self: + def pure(cls, value: T, /) -> Applicative[T, Arg, Result]: """Applicative functor constructor. Use pure if you're dealing with values in an applicative context diff --git a/oslash/typing/functor.py b/oslash/typing/functor.py index 5fd299c..868a52e 100644 --- a/oslash/typing/functor.py +++ b/oslash/typing/functor.py @@ -33,10 +33,10 @@ class Functor[T](Protocol): """ @abstractmethod - def map[U](self, fn: Callable[[T], U]) -> Functor[U]: + def map(self, fn: Callable[[T], T], /) -> Functor[T]: """Map a function over wrapped values. - Map knows how to apply functions to values that are wrapped in - a context. + This protocol describes mappings that preserve the wrapped value + type. Concrete functors also support mappings to a different type. """ ... diff --git a/oslash/typing/monad.py b/oslash/typing/monad.py index b130a19..e9baf9f 100644 --- a/oslash/typing/monad.py +++ b/oslash/typing/monad.py @@ -18,21 +18,18 @@ from abc import abstractmethod from collections.abc import Callable -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -if TYPE_CHECKING: - from typing import Self +from typing import Protocol, runtime_checkable @runtime_checkable -class Monad[T](Protocol): +class Monad[T, Result](Protocol): """Monad protocol""" @abstractmethod - def bind[U](self, fn: Callable[[T], Monad[U]]) -> Monad[U]: + def bind(self, fn: Callable[[T], Result], /) -> Result: """Monad bind method. - Python: bind(self: Monad[A], func: Callable[[A], Monad[B]]) -> Monad[B] + Python: bind(self: Monad[A, F[B]], func: Callable[[A], F[B]]) -> F[B] Haskell: (>>=) :: m a -> (a -> m b) -> m b This is the mother of all methods. It's hard to describe what it @@ -49,13 +46,15 @@ def bind[U](self, fn: Callable[[T], Monad[U]]) -> Monad[U]: The Monad doesn't specify what is happening, only that whatever is happening satisfies the laws of associativity and identity. - Returns a new Monad. + Result is the concrete container returned by the callback. Keeping it + explicit lets a type checker verify the monad family without requiring + higher-kinded type parameters. """ ... @classmethod @abstractmethod - def unit(cls, value: T) -> Self: + def unit(cls, value: T, /) -> Monad[T, Result]: """Wrap a value in a default context. Haskell: return :: a -> m a . diff --git a/oslash/util/apply.py b/oslash/util/apply.py new file mode 100644 index 0000000..87eb823 --- /dev/null +++ b/oslash/util/apply.py @@ -0,0 +1,27 @@ +"""Apply a callable or explicitly curry one missing argument.""" + +from collections.abc import Callable +from functools import partial +from inspect import signature +from typing import Any + + +def apply_callable(func: Callable[..., Any], value: object) -> Any: + """Apply a unary callable, or bind the first argument of a multi-arg callable. + + Inspect arity before calling so a TypeError raised inside a unary function + is not mistaken for a request to curry it. + """ + try: + return func(value) + except TypeError: + try: + callable_signature = signature(func) + except (TypeError, ValueError): + raise + + try: + callable_signature.bind(value) + except TypeError: + return partial(func, value) + raise diff --git a/oslash/writer.py b/oslash/writer.py index d794512..de15f78 100644 --- a/oslash/writer.py +++ b/oslash/writer.py @@ -7,10 +7,12 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, cast, overload from .typing import Functor, Monad, Monoid +_MISSING = object() + class Writer[T, Log]: """The writer monad. @@ -28,15 +30,17 @@ def __init__(self, value: T, log: Log) -> None: """ self._value: tuple[T, Log] = (value, log) - def map[U](self, func: Callable[[tuple[T, Log]], tuple[U, Log]]) -> Writer[U, Log]: + def map[U](self, func: Callable[[T], U]) -> Writer[U, Log]: """Map a function func over the Writer value. - Haskell: - fmap f m = Writer $ let (a, w) = runWriter m in (f a, w) + The log is preserved, as required by Functor.map. """ a, w = self.run() - b, w_ = func((a, w)) - return Writer(b, w_) + return Writer(func(a), w) + + def map_with_log[U](self, func: Callable[[tuple[T, Log]], tuple[U, Log]]) -> Writer[U, Log]: + """Transform the value and log together.""" + return Writer(*func(self.run())) def bind[U](self, func: Callable[[T], Writer[U, Log]]) -> Writer[U, Log]: """Flat is better than nested. @@ -52,16 +56,24 @@ def bind[U](self, func: Callable[[T], Writer[U, Log]]) -> Writer[U, Log]: # Any: Log type is generic and determined at runtime return Writer(b, w__) # type: ignore[arg-type] + @overload + @classmethod + def unit[U](cls, value: U) -> Writer[U, str]: ... + + @overload + @classmethod + def unit[U, EmptyLog](cls, value: U, *, empty_log: EmptyLog) -> Writer[U, EmptyLog]: ... + @classmethod - def unit(cls, value: T) -> Writer[T, Log]: + def unit[U](cls: type[Writer[Any, Any]], value: U, *, empty_log: object = _MISSING) -> Writer[U, Any]: """Wrap a single value in a Writer. - Use the factory method to create Writer classes that - use a different monoid than str, or use the constructor - directly. + The default log is an empty string. Supply empty_log for another + log type, or use a class created with create(). """ - # Log: Empty log is empty string by default - return Writer(value, log="") # type: ignore + # The default writer uses string logs; other log types must supply + # their own identity element (or use a class created with create()). + return cls(value, cast(Any, "" if empty_log is _MISSING else empty_log)) def run(self) -> tuple[T, Log]: """Extract value from Writer. diff --git a/pyproject.toml b/pyproject.toml index fed21dd..ff587f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ reportIncompatibleMethodOverride = "error" strictListInference = true strictDictionaryInference = true strictSetInference = true -include = ["oslash"] +include = ["oslash", "tests/typing_contracts.py"] exclude = ["**/__pycache__", "**/.venv", "**/node_modules"] [tool.pytest.ini_options] diff --git a/tests/test_identity.py b/tests/test_identity.py index 181de43..0e0955f 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -15,6 +15,8 @@ import unittest from collections.abc import Callable +import pytest + from oslash.identity import Identity from oslash.util import compose, fmap, identity @@ -46,6 +48,13 @@ def g(x: int) -> int: class TestIdentityApplicative(unittest.TestCase): + def test_apply_preserves_type_error_from_function_body(self) -> None: + def fail(value: int) -> int: + raise TypeError(f"bad value: {value}") + + with pytest.raises(TypeError, match="bad value: 1"): + Identity(fail).apply(Identity(1)) + def test_identity_applicative_law_functor(self) -> None: # pure f <*> x = fmap f x x = Identity.unit(42) diff --git a/tests/test_writer.py b/tests/test_writer.py index 1e35562..7bdc3cd 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -15,6 +15,18 @@ def test_writer_unitint_log(self) -> None: w: Any = IntWriter.unit(42) # type: ignore[attr-defined] assert w.run() == (42, 0) + def test_generic_writer_unit_uses_explicit_empty_log(self) -> None: + assert Writer[int, list[str]].unit(42, empty_log=[]).run() == (42, []) + assert Writer[int, None].unit(42, empty_log=None).run() == (42, None) + + def test_writer_map_preserves_log(self) -> None: + writer = Writer(42, ["before"]) + assert writer.map(str).run() == ("42", ["before"]) + assert writer.map_with_log(lambda pair: (str(pair[0]), [*pair[1], "after"])).run() == ( + "42", + ["before", "after"], + ) + def test_writer_monad_bind(self) -> None: entry: str = "Multiplied by 10" f: Callable[[int], StringWriter] = lambda x: StringWriter(x * 10, entry) diff --git a/tests/typing_contracts.py b/tests/typing_contracts.py new file mode 100644 index 0000000..32ef0f5 --- /dev/null +++ b/tests/typing_contracts.py @@ -0,0 +1,90 @@ +"""Caller-side type checks for public structural protocols. + +Pyright checks this file, while pytest does not collect it as a runtime test. +""" + +from collections.abc import Callable +from typing import assert_type + +from oslash import Either, Identity, Just, List, Maybe, Reader, Right, Writer, monadic_compose +from oslash.typing import Applicative, Functor, Monad + + +def check_functor_and_monad() -> None: + identity_functor: Functor[int] = Identity(1) + maybe_functor: Functor[int] = Just(1) + writer_functor: Functor[int] = Writer(1, "") + identity_functor.map(lambda value: value + 1) + maybe_functor.map(lambda value: value + 1) + writer_functor.map(lambda value: value + 1) + + identity_monad: Monad[int, Identity[str]] = Identity(1) + maybe_monad: Monad[int, Maybe[str]] = Just(1) + writer_monad: Monad[int, Writer[str, str]] = Writer(1, "") + + assert_type(identity_monad.bind(lambda value: Identity(str(value))), Identity[str]) + assert_type(maybe_monad.bind(lambda value: Just(str(value))), Maybe[str]) + assert_type(writer_monad.bind(lambda value: Writer(str(value), "")), Writer[str, str]) + + +def check_applicative() -> None: + fn: Callable[[int], str] = str + + identity_fn: Identity[Callable[[int], str]] = Identity(fn) + identity: Applicative[Callable[[int], str], Identity[int], Identity[str]] = identity_fn + assert_type(identity.apply(Identity(1)), Identity[str]) + + maybe_fn: Just[Callable[[int], str]] = Just(fn) + maybe: Applicative[Callable[[int], str], Maybe[int], Maybe[str]] = maybe_fn + assert_type(maybe.apply(Just(1)), Maybe[str]) + + either_fn: Right[Callable[[int], str], str] = Right(fn) + either: Applicative[Callable[[int], str], Either[int, str], Either[str, str]] = either_fn + assert_type(either.apply(Right(1)), Either[str, str]) + + reader_fn: Reader[str, Callable[[int], str]] = Reader(lambda _: fn) + reader: Applicative[Callable[[int], str], Reader[str, int], Reader[str, str]] = reader_fn + assert_type(reader.apply(Reader[str, int].unit(1)), Reader[str, str]) + + list_fn: List[Callable[[int], str]] = List[Callable[[int], str]].from_iterable([fn]) + list_applicative: Applicative[Callable[[int], str], List[int], List[str]] = list_fn + assert_type(list_applicative.apply(List[int].from_iterable([1])), List[str]) + + +def check_writer_unit() -> None: + empty_log: list[str] = [] + assert_type(Writer.unit(1), Writer[int, str]) + assert_type(Writer[int, list[str]].unit(1), Writer[int, str]) + assert_type(Writer[int, list[str]].unit(1, empty_log=empty_log), Writer[int, list[str]]) + + +def check_monadic_compose() -> None: + def first(value: int) -> Identity[int]: + return Identity(value + 1) + + def second(value: int) -> Identity[str]: + return Identity(str(value)) + + assert_type(monadic_compose(second, first)(1), Identity[str]) + + +def check_curried_apply() -> None: + fn: Callable[[int, int], int] = lambda left, right: left + right + + identity_fn: Identity[Callable[[int, int], int]] = Identity(fn) + assert_type(identity_fn.apply(Identity(1)).apply(Identity(2)), Identity[int]) + + maybe_fn: Just[Callable[[int, int], int]] = Just(fn) + assert_type(maybe_fn.apply(Just(1)).apply(Just(2)), Maybe[int]) + + either_fn: Right[Callable[[int, int], int], str] = Right(fn) + assert_type(either_fn.apply(Right(1)).apply(Right(2)), Either[int, str]) + + reader_fn: Reader[str, Callable[[int, int], int]] = Reader(lambda _: fn) + assert_type( + reader_fn.apply(Reader[str, int].unit(1)).apply(Reader[str, int].unit(2)), + Reader[str, int], + ) + + list_fn: List[Callable[[int, int], int]] = List[Callable[[int, int], int]].from_iterable([fn]) + assert_type(list_fn.apply(List[int].from_iterable([1])).apply(List[int].from_iterable([2])), List[int]) From 83e4087089964829a2699cc47c77bedd3d2a6367 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 28 Oct 2026 23:56:40 +0100 Subject: [PATCH 2/4] fix: curry built-ins without inspectable signatures --- oslash/util/apply.py | 5 +++-- tests/test_apply.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 tests/test_apply.py diff --git a/oslash/util/apply.py b/oslash/util/apply.py index 87eb823..baabcc5 100644 --- a/oslash/util/apply.py +++ b/oslash/util/apply.py @@ -9,7 +9,7 @@ def apply_callable(func: Callable[..., Any], value: object) -> Any: """Apply a unary callable, or bind the first argument of a multi-arg callable. - Inspect arity before calling so a TypeError raised inside a unary function + Inspect arity after a TypeError so one raised inside a unary function is not mistaken for a request to curry it. """ try: @@ -18,7 +18,8 @@ def apply_callable(func: Callable[..., Any], value: object) -> Any: try: callable_signature = signature(func) except (TypeError, ValueError): - raise + # Some built-ins accept more arguments but have no inspectable signature. + return partial(func, value) try: callable_signature.bind(value) diff --git a/tests/test_apply.py b/tests/test_apply.py new file mode 100644 index 0000000..714ef63 --- /dev/null +++ b/tests/test_apply.py @@ -0,0 +1,11 @@ +"""Regression tests for applicative application across contexts.""" + +from oslash import Identity, Just, List, Reader, Right + + +def test_apply_curries_builtin_without_signature() -> None: + assert Identity(getattr).apply(Identity(str)).apply(Identity("__name__")) == Identity("str") + assert Just(getattr).apply(Just(str)).apply(Just("__name__")) == Just("str") + assert Right(getattr).apply(Right(str)).apply(Right("__name__")) == Right("str") + assert List.unit(getattr).apply(List.unit(str)).apply(List.unit("__name__")) == List.unit("str") + assert Reader.pure(getattr).apply(Reader.pure(str)).apply(Reader.pure("__name__")).run(None) == "str" From eb1f37e166da1596591788f14e526fa2ade642f7 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 28 Oct 2026 23:58:11 +0100 Subject: [PATCH 3/4] fix: clear Ruff warnings in Church numerals --- oslash/util/numerals.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/oslash/util/numerals.py b/oslash/util/numerals.py index eb052a1..76cae6a 100644 --- a/oslash/util/numerals.py +++ b/oslash/util/numerals.py @@ -48,7 +48,7 @@ def make_pair[T](first: T) -> Callable[[T], Callable[[ChurchBoolean[T]], T]]: # Application def apply[T, U](func: Callable[[T], U]) -> Callable[[T], U]: """Apply a function to an argument.""" - return lambda arg: func(arg) + return func # Conditional @@ -87,7 +87,11 @@ def and_[T](x: ChurchBoolean[T]) -> Callable[[ChurchBoolean[T]], ChurchBoolean[T def or_[T](x: ChurchBoolean[T]) -> Callable[[ChurchBoolean[T]], ChurchBoolean[T]]: """Logical OR.""" - return lambda y: x(true)(y) # type: ignore[arg-type,return-value] + + def choose(y: ChurchBoolean[T]) -> ChurchBoolean[T]: + return x(true)(y) # type: ignore[arg-type,return-value] + + return choose # Church numerals @@ -104,7 +108,7 @@ def zero[T](f: Callable[[T], T]) -> Callable[[T], T]: def one[T](f: Callable[[T], T]) -> Callable[[T], T]: """Church numeral one.""" - return lambda x: f(x) + return f # Derived numerals From 8ffed9219f37a21282127e19d1d2968f628082c1 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 29 Oct 2026 00:05:52 +0100 Subject: [PATCH 4/4] fix: narrow dynamic do notation types --- oslash/do.py | 33 +++++++++++++++++++++------------ tests/typing_contracts.py | 7 ++++++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/oslash/do.py b/oslash/do.py index 74a66ba..8c35da8 100644 --- a/oslash/do.py +++ b/oslash/do.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, NamedTuple +from typing import Any, NamedTuple, Protocol from .typing import Monad from .util import Unit @@ -17,8 +17,16 @@ # borrowing Python's for run-of-the-mill names. +class _Guardable[Result](Protocol): + @classmethod + def pure(cls, value: tuple[()], /) -> Result: ... + + @classmethod + def empty(cls) -> Result: ... + + # TODO: guard belongs where in OSlash? -def guard[T](m: Monad[T, Any], test: bool) -> Monad[tuple[()], Any]: +def guard[Result](m: type[_Guardable[Result]], test: bool) -> Result: """Monadic guard. What it does:: @@ -27,7 +35,7 @@ def guard[T](m: Monad[T, Any], test: bool) -> Monad[tuple[()], Any]: https://en.wikibooks.org/wiki/Haskell/Alternative_and_MonadPlus#guard """ - return m.pure(Unit) if test else m.empty() # type: ignore[return-value,attr-defined] + return m.pure(Unit) if test else m.empty() # The kwargs syntax forces name to be a valid Python identifier. @@ -35,10 +43,10 @@ class MonadicLet(NamedTuple): """Represents a monadic let binding.""" name: str - value: Any # Any: Can be Monad or Callable returning Monad + value: object -def let(**binding: Any) -> MonadicLet | None: # Any: Dynamic binding value +def let(**binding: object) -> MonadicLet: """``<-`` for Python. Haskell:: @@ -51,11 +59,12 @@ def let(**binding: Any) -> MonadicLet | None: # Any: Dynamic binding value """ if len(binding) != 1: raise ValueError(f"Expected exactly one binding, got {len(binding):d} with values {binding}") - for k, v in binding.items(): - return MonadicLet(k, v) - return None + name, value = next(iter(binding.items())) + return MonadicLet(name, value) +# The generated environment has arbitrary named attributes, and the result +# depends on the final line of a variadic sequence. def do(*lines: Monad[Any, Any] | MonadicLet | Callable[[Any], Monad[Any, Any]]) -> Monad[Any, Any]: """Do-notation. @@ -138,7 +147,7 @@ class Env: def __init__(self) -> None: self.names: set[str] = set() - def assign(self, k: str, v: Any) -> None: # Any: Dynamic attribute values + def assign(self, k: str, v: object) -> None: self.names.add(k) setattr(self, k, v) @@ -153,21 +162,21 @@ def close_over(self, free_vars: set[str]) -> None: # stuff used inside the eval e = Env() - def begin(*exprs: Any) -> Any: # Any: args eagerly evaluated by Python + def begin(*exprs: object) -> object: # begin(e1, e2, ..., en): # perform side effects e1, e2, ..., e[n-1], return the value of en. return exprs[-1] all_code = "" names: set[str] = set() # names seen so far (working line by line, so textually!) - bodies: list[Any] = [] # Any: Can be Monad or Callable + bodies: list[object] = [] begin_is_open = False for j, item in enumerate(lines): is_first = j == 0 is_last = j == len(lines) - 1 name: str | None - body: Any # Any: Can be Monad or Callable + body: object if isinstance(item, MonadicLet): name, body = item else: diff --git a/tests/typing_contracts.py b/tests/typing_contracts.py index 32ef0f5..f51772a 100644 --- a/tests/typing_contracts.py +++ b/tests/typing_contracts.py @@ -6,7 +6,7 @@ from collections.abc import Callable from typing import assert_type -from oslash import Either, Identity, Just, List, Maybe, Reader, Right, Writer, monadic_compose +from oslash import Either, Identity, Just, List, Maybe, Reader, Right, Writer, guard, monadic_compose from oslash.typing import Applicative, Functor, Monad @@ -68,6 +68,11 @@ def second(value: int) -> Identity[str]: assert_type(monadic_compose(second, first)(1), Identity[str]) +def check_guard() -> None: + assert_type(guard(List, True), List[tuple[()]]) + assert_type(guard(Just, False), Maybe[tuple[()]]) + + def check_curried_apply() -> None: fn: Callable[[int, int], int] = lambda left, right: left + right