Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 27 additions & 28 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
```
Expand All @@ -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."""
...
```
Expand All @@ -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."""
...
```
Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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**:

Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 22 additions & 13 deletions oslash/do.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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], test: bool) -> Monad[tuple[()]]:
def guard[Result](m: type[_Guardable[Result]], test: bool) -> Result:
"""Monadic guard.

What it does::
Expand All @@ -27,18 +35,18 @@ def guard[T](m: Monad[T], test: bool) -> Monad[tuple[()]]:

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.
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::
Expand All @@ -51,12 +59,13 @@ 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)


def do(*lines: Monad[Any] | MonadicLet | Callable[[Any], Monad[Any]]) -> Monad[Any]:
# 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.

Syntax::
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
40 changes: 27 additions & 13 deletions oslash/either.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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

Expand Down Expand Up @@ -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
# =============
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 9 additions & 11 deletions oslash/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading