Async/Await is a Plague: Part 1 Roots
▲ 68 r/Python

Async/Await is a Plague: Part 1 Roots

This is the first part of a multi-part series exploring why async/await might not be the best concurrency pattern for most use cases, and what alternative models you should consider instead. Using Python for our practical examples, this opening post digs into the roots of async/await, guiding you through building a custom event loop from scratch using generators.

https://theblog.info/posts/asyncawait-is-a-plague-part-1-roots

Note: This is Part 1 of a multi-part series. Instead of diving straight into why async/await can be problematic, this post explores the original motivations behind the pattern. Understanding how it works under the hood will provide the essential context for the issues we'll discuss in upcoming parts.

u/EntryNo8040 — 7 days ago
▲ 33 r/compsci+2 crossposts

GitHub - kamalfarahani/katharos: A functional programming and concurrency library for Python: algebraic abstractions (Semigroups, Monoids, Functors, Applicatives, Monads), immutable data types, and message-passing concurrency, all designed as composable, type-safe values.

I have been building Katharos, a functional programming library for Python that recently grew a message-passing concurrency layer. I wanted to share it and get some feedback.

The whole library is built around one idea: model errors, effects, and concurrent communication as composable, type-safe values rather than as control flow that jumps around your program. The interesting part (to me, at least) is that the concurrency layer follows the exact same idea, so receiving from a channel gives you a Result. "The channel is closed" becomes a value you handle, not an exception you remember to catch.

The functional core

Optional values without scattered None checks, using do-notation that short-circuits on Nothing:

from katharos.types import Maybe
from katharos.syntax_sugar import do, DoBlock

@do(Maybe)
def lookup_discount(user_id: int) -> DoBlock[Maybe, float]:
    user    = yield find_user(user_id)
    account = yield find_account(user)
    return account.discount   # Just(0.15) or Nothing()

Errors as values, chained with |, so a failure short-circuits the rest automatically:

from katharos.types import Result

def process(raw: str) -> Result[Exception, int]:
    return parse_int(raw) | validate_positive

And Result.catch turns a function that raises into one that returns a Result, while keeping the original traceback so you can still find the line that failed:

from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

parse_int("42")   # Success(42)
parse_int("??")   # Failure(ValueError("invalid literal for int() with base 10: '??'"))

There is also ImmutableList, NonEmptyList, IO, Lazy, numeric monoids, and the usual algebraic abstractions (Functor, Applicative, Monad, Semigroup, Monoid) if you want to build your own types.

The new part: CSP concurrency

This is what I have been working on lately. Katharos now has Go-style CSP (Communicating Sequential Processes): launch work concurrently with go, talk over typed channels, and receive values as a Result.

from katharos.concurrency.csp import csp

ch = csp.Channel[int](capacity=1)

csp.go(ch.send, 42)     # run work concurrently, like Go's `go f(x)`

ch.recv()               # Success(42)

ch.close()
ch.recv()               # Failure(ChannelClosedError(...)): closure is a value, not a raise

Used as a context manager, go becomes a structured-concurrency scope that joins everything spawned inside it before the block exits, so concurrent work cannot leak out of the block:

with csp.go:                 # scope waits for all work launched inside
    csp.go(worker, 1)
    csp.go(worker, 2)
# both workers have finished here

There is also a select for waiting on whichever of several channels is ready first, with non-blocking polls and timeouts:

from katharos.concurrency.csp import csp, recv, select

choice = select(recv(results), recv(cancel), timeout=1.0)
if choice.is_timeout:
    ...
else:
    print(choice.index, choice.value.unwrap())

The concurrency model sits on a swappable backend (standard threads by default), so the same code could run on a green-thread backend later. An actor model is planned next, built on the same backend abstraction and the same Result-valued style.

Why I think the "channel returns a Result" thing is nice

In most channel APIs, a closed channel or a timeout shows up as a sentinel, a second return value, or an exception. In Katharos it is just a typed value: Success(v), Failure(ChannelClosedError), or Failure(ChannelTimeoutError). You pattern-match it the same way you handle any other Result, and the type tells you it can happen. The error-handling discipline you use in the rest of your code carries straight over to concurrency.

Links

I would love feedback on the API, the concurrency design, or whether the Result-everywhere approach feels natural or noisy to you in practice. Thanks for reading.

github.com
u/EntryNo8040 — 9 days ago

Katharos: a functional programming and concurrency library for Python where errors, effects, and channel hand-offs are all composable values

I have been building Katharos, a functional programming library for Python that recently grew a message-passing concurrency layer. I wanted to share it and get some feedback.

The whole library is built around one idea: model errors, effects, and concurrent communication as composable, type-safe values rather than as control flow that jumps around your program. The interesting part (to me, at least) is that the concurrency layer follows the exact same idea, so receiving from a channel gives you a Result. "The channel is closed" becomes a value you handle, not an exception you remember to catch.

The functional core

Optional values without scattered None checks, using do-notation that short-circuits on Nothing:

from katharos.types import Maybe
from katharos.syntax_sugar import do, DoBlock

@do(Maybe)
def lookup_discount(user_id: int) -> DoBlock[Maybe, float]:
    user    = yield find_user(user_id)
    account = yield find_account(user)
    return account.discount   # Just(0.15) or Nothing()

Errors as values, chained with |, so a failure short-circuits the rest automatically:

from katharos.types import Result

def process(raw: str) -> Result[Exception, int]:
    return parse_int(raw) | validate_positive

And Result.catch turns a function that raises into one that returns a Result, while keeping the original traceback so you can still find the line that failed:

from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

parse_int("42")   # Success(42)
parse_int("??")   # Failure(ValueError("invalid literal for int() with base 10: '??'"))

There is also ImmutableList, NonEmptyList, IO, Lazy, numeric monoids, and the usual algebraic abstractions (Functor, Applicative, Monad, Semigroup, Monoid) if you want to build your own types.

The new part: CSP concurrency

This is what I have been working on lately. Katharos now has Go-style CSP (Communicating Sequential Processes): launch work concurrently with go, talk over typed channels, and receive values as a Result.

from katharos.concurrency.csp import csp

ch = csp.Channel[int](capacity=1)

csp.go(ch.send, 42)     # run work concurrently, like Go's `go f(x)`

ch.recv()               # Success(42)

ch.close()
ch.recv()               # Failure(ChannelClosedError(...)): closure is a value, not a raise

Used as a context manager, go becomes a structured-concurrency scope that joins everything spawned inside it before the block exits, so concurrent work cannot leak out of the block:

with csp.go:                 # scope waits for all work launched inside
    csp.go(worker, 1)
    csp.go(worker, 2)
# both workers have finished here

There is also a select for waiting on whichever of several channels is ready first, with non-blocking polls and timeouts:

from katharos.concurrency.csp import csp, recv, select

choice = select(recv(results), recv(cancel), timeout=1.0)
if choice.is_timeout:
    ...
else:
    print(choice.index, choice.value.unwrap())

The concurrency model sits on a swappable backend (standard threads by default), so the same code could run on a green-thread backend later. An actor model is planned next, built on the same backend abstraction and the same Result-valued style.

Why I think the "channel returns a Result" thing is nice

In most channel APIs, a closed channel or a timeout shows up as a sentinel, a second return value, or an exception. In Katharos it is just a typed value: Success(v), Failure(ChannelClosedError), or Failure(ChannelTimeoutError). You pattern-match it the same way you handle any other Result, and the type tells you it can happen. The error-handling discipline you use in the rest of your code carries straight over to concurrency.

Links

I would love feedback on the API, the concurrency design, or whether the Result-everywhere approach feels natural or noisy to you in practice. Thanks for reading.

reddit.com
u/EntryNo8040 — 10 days ago

Katharos: a functional programming and concurrency library for Python where errors, effects, and channel hand-offs are all composable values

I have been building Katharos, a functional programming library for Python that recently grew a message-passing concurrency layer. I wanted to share it and get some feedback.

The whole library is built around one idea: model errors, effects, and concurrent communication as composable, type-safe values rather than as control flow that jumps around your program. The interesting part (to me, at least) is that the concurrency layer follows the exact same idea, so receiving from a channel gives you a Result. "The channel is closed" becomes a value you handle, not an exception you remember to catch.

The functional core

Optional values without scattered None checks, using do-notation that short-circuits on Nothing:

from katharos.types import Maybe
from katharos.syntax_sugar import do, DoBlock

@do(Maybe)
def lookup_discount(user_id: int) -> DoBlock[Maybe, float]:
    user    = yield find_user(user_id)
    account = yield find_account(user)
    return account.discount   # Just(0.15) or Nothing()

Errors as values, chained with |, so a failure short-circuits the rest automatically:

from katharos.types import Result

def process(raw: str) -> Result[Exception, int]:
    return parse_int(raw) | validate_positive

And Result.catch turns a function that raises into one that returns a Result, while keeping the original traceback so you can still find the line that failed:

from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

parse_int("42")   # Success(42)
parse_int("??")   # Failure(ValueError("invalid literal for int() with base 10: '??'"))

There is also ImmutableList, NonEmptyList, IO, Lazy, numeric monoids, and the usual algebraic abstractions (Functor, Applicative, Monad, Semigroup, Monoid) if you want to build your own types.

The new part: CSP concurrency

This is what I have been working on lately. Katharos now has Go-style CSP (Communicating Sequential Processes): launch work concurrently with go, talk over typed channels, and receive values as a Result.

from katharos.concurrency.csp import csp

ch = csp.Channel[int](capacity=1)

csp.go(ch.send, 42)     # run work concurrently, like Go's `go f(x)`

ch.recv()               # Success(42)

ch.close()
ch.recv()               # Failure(ChannelClosedError(...)): closure is a value, not a raise

Used as a context manager, go becomes a structured-concurrency scope that joins everything spawned inside it before the block exits, so concurrent work cannot leak out of the block:

with csp.go:                 # scope waits for all work launched inside
    csp.go(worker, 1)
    csp.go(worker, 2)
# both workers have finished here

There is also a select for waiting on whichever of several channels is ready first, with non-blocking polls and timeouts:

from katharos.concurrency.csp import csp, recv, select

choice = select(recv(results), recv(cancel), timeout=1.0)
if choice.is_timeout:
    ...
else:
    print(choice.index, choice.value.unwrap())

The concurrency model sits on a swappable backend (standard threads by default), so the same code could run on a green-thread backend later. An actor model is planned next, built on the same backend abstraction and the same Result-valued style.

Why I think the "channel returns a Result" thing is nice

In most channel APIs, a closed channel or a timeout shows up as a sentinel, a second return value, or an exception. In Katharos it is just a typed value: Success(v), Failure(ChannelClosedError), or Failure(ChannelTimeoutError). You pattern-match it the same way you handle any other Result, and the type tells you it can happen. The error-handling discipline you use in the rest of your code carries straight over to concurrency.

Links

I would love feedback on the API, the concurrency design, or whether the Result-everywhere approach feels natural or noisy to you in practice. Thanks for reading.

reddit.com
u/EntryNo8040 — 10 days ago

Katharos: a functional programming and concurrency library for Python where errors, effects, and channel hand-offs are all composable values

Hi great devs,

I have been building Katharos, a functional programming library for Python that recently grew a message-passing concurrency layer. I wanted to share it and get some feedback.

The whole library is built around one idea: model errors, effects, and concurrent communication as composable, type-safe values rather than as control flow that jumps around your program. The interesting part (to me, at least) is that the concurrency layer follows the exact same idea, so receiving from a channel gives you a Result. "The channel is closed" becomes a value you handle, not an exception you remember to catch.

The functional core

Optional values without scattered None checks, using do-notation that short-circuits on Nothing:

from katharos.types import Maybe
from katharos.syntax_sugar import do, DoBlock

@do(Maybe)
def lookup_discount(user_id: int) -> DoBlock[Maybe, float]:
    user    = yield find_user(user_id)
    account = yield find_account(user)
    return account.discount   # Just(0.15) or Nothing()

Errors as values, chained with |, so a failure short-circuits the rest automatically:

from katharos.types import Result

def process(raw: str) -> Result[Exception, int]:
    return parse_int(raw) | validate_positive

And Result.catch turns a function that raises into one that returns a Result, while keeping the original traceback so you can still find the line that failed:

from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

parse_int("42")   # Success(42)
parse_int("??")   # Failure(ValueError("invalid literal for int() with base 10: '??'"))

There is also ImmutableList, NonEmptyList, IO, Lazy, numeric monoids, and the usual algebraic abstractions (Functor, Applicative, Monad, Semigroup, Monoid) if you want to build your own types.

The new part: CSP concurrency

This is what I have been working on lately. Katharos now has Go-style CSP (Communicating Sequential Processes): launch work concurrently with go, talk over typed channels, and receive values as a Result.

from katharos.concurrency.csp import csp

ch = csp.Channel[int](capacity=1)

csp.go(ch.send, 42)     # run work concurrently, like Go's `go f(x)`

ch.recv()               # Success(42)

ch.close()
ch.recv()               # Failure(ChannelClosedError(...)): closure is a value, not a raise

Used as a context manager, go becomes a structured-concurrency scope that joins everything spawned inside it before the block exits, so concurrent work cannot leak out of the block:

with csp.go:                 # scope waits for all work launched inside
    csp.go(worker, 1)
    csp.go(worker, 2)
# both workers have finished here

There is also a select for waiting on whichever of several channels is ready first, with non-blocking polls and timeouts:

from katharos.concurrency.csp import csp, recv, select

choice = select(recv(results), recv(cancel), timeout=1.0)
if choice.is_timeout:
    ...
else:
    print(choice.index, choice.value.unwrap())

The concurrency model sits on a swappable backend (standard threads by default), so the same code could run on a green-thread backend later. An actor model is planned next, built on the same backend abstraction and the same Result-valued style.

Why I think the "channel returns a Result" thing is nice

In most channel APIs, a closed channel or a timeout shows up as a sentinel, a second return value, or an exception. In Katharos it is just a typed value: Success(v), Failure(ChannelClosedError), or Failure(ChannelTimeoutError). You pattern-match it the same way you handle any other Result, and the type tells you it can happen. The error-handling discipline you use in the rest of your code carries straight over to concurrency.

Links

I would love feedback on the API, the concurrency design, or whether the Result-everywhere approach feels natural or noisy to you in practice. Thanks for reading.

reddit.com
u/EntryNo8040 — 10 days ago
▲ 32 r/haskell

Bringing rigorous Type Classes (Functor, Applicative, Monad) to Python: Introducing Katharos

If you come from Haskell or Rust and have to write Python for ML/AI work, you know the pain: if x is None everywhere, exceptions that silently swallow errors, no ? operator, no HKTs, no sealed types. I got tired of it and built a library to close that gap.

Katharos is a zero-dependency Python library that gives you Maybe, Either/Result, IO, the list monad, Semigroup, Monoid, Functor, Applicative, and Monad — all fully typed and passing pyright strict mode.

https://github.com/kamalfarahani/katharos


The Engineering Challenge

The hard part is that Python has no HKTs and no sealed keyword (as of 3.13). There's no way to say Functor f or write :: f a -> (a -> b) -> f b generically. The workaround is structural gymnastics: a two-parameter generic class hierarchy (Functor[F, A], Applicative[App, A], Monad[M, A]) plus @final on concrete types to prevent unsafe subclassing. It's not pretty internally, but the external API stays clean.


Operator Mapping

If you already think in Haskell or Rust, here's the translation table:

Katharos Haskell Rust
m | f m >>= f m.and_then(f)
v ** wrapped_f wrapped_f <*> v
a >> b a >> b
a @ b a <> b
@do(M) decorator do { ... }

Examples

1. Maybe[A] — Haskell's Maybe a / Rust's Option<T>

No more if x is None chains. Short-circuits automatically on Nothing.

from katharos.types import Maybe

def safe_div(x: float) -> Maybe[float]:
    return Maybe[float].Nothing() if x == 0 else Maybe[float].Just(10.0 / x)

def safe_sqrt(x: float) -> Maybe[float]:
    return Maybe[float].Nothing() if x < 0 else Maybe[float].Just(x ** 0.5)

# | is >>=
Maybe[float].Just(4.0)  | safe_div | safe_sqrt  # Just(1.5811...)
Maybe[float].Just(0.0)  | safe_div | safe_sqrt  # Nothing()  — short-circuits at safe_div
Maybe[float].Just(-1.0) | safe_div | safe_sqrt  # Nothing()  — short-circuits at safe_sqrt

# fmap for pure transformations
Maybe[int].Just(5).fmap(lambda x: x * 2)  # Just(10)
Maybe[int].Nothing().fmap(lambda x: x * 2)  # Nothing()

2. Result[E, A] — Haskell's Either e a / Rust's Result<T, E>

Errors as values. The | chain (>>=) stops at the first Failure, exactly like Rust's ?.

from katharos.types import Result

def parse_int(s: str) -> Result[ValueError, int]:
    try:
        return Result[ValueError, int].Success(int(s))
    except ValueError as e:
        return Result[ValueError, int].Failure(e)

def validate_positive(n: int) -> Result[ValueError, int]:
    if n > 0:
        return Result[ValueError, int].Success(n)

    else:
        return Result[ValueError, int].Failure(ValueError(f"{n} is not positive"))

parse_int("42")  | validate_positive  # Success(42)
parse_int("abc") | validate_positive  # Failure(ValueError("invalid literal..."))
parse_int("-5")  | validate_positive  # Failure(ValueError("-5 is not positive"))

# fmap only runs on the success path
parse_int("42").fmap(lambda n: n * 2)  # Success(84)

3. do-notation — Python do blocks, exactly like Haskell

The @do(M) decorator desugars yield into >>= chains. Each yield unwraps the value; short-circuits on Nothing/Failure. The final return is lifted via M.pure(...).

from katharos.syntax_sugar import do, DoBlock
from katharos.types import Maybe, Result

# Maybe — like Haskell:
#   userScore uid = do
#     name  <- lookupUser uid
#     score <- lookupScore name
#     return (name ++ ": " ++ show score)

def lookup_user(uid: int) -> Maybe[str]:
    db = {1: "alice", 2: "bob"}
    return Maybe[str].Just(db[uid]) if uid in db else Maybe[str].Nothing()

def lookup_score(name: str) -> Maybe[int]:
    scores = {"alice": 95, "bob": 87}
    return Maybe[int].Just(scores[name]) if name in scores else Maybe[int].Nothing()

@do(Maybe)
def user_score(uid: int) -> DoBlock[str]:
    name: str  = yield lookup_user(uid)
    score: int = yield lookup_score(name)
    return f"{name}: {score}"

user_score(1)   # Just(alice: 95)
user_score(99)  # Nothing()  — short-circuits at lookup_user

# Result — equivalent of Rust's ? in a pipeline

def parse_positive(x: int) -> Result[ValueError, int]:
    return Result[ValueError, int].Success(x) if x > 0 else Result[ValueError, int].Failure(ValueError(f"{x} is not positive"))

@do(Result)
def compute() -> DoBlock[int]:
    x: int = yield parse_positive(5)
    y: int = yield parse_positive(3)
    return x + y

compute()  # Success(8)

4. ImmutableList[T] — the list monad, non-determinism included

ImmutableList is a full Monad + Monoid. Bind (|) is concatMap. The do-notation gives you Haskell list comprehensions.

from katharos.types import ImmutableList
from katharos.syntax_sugar import do, DoBlock

# concatMap / flatMap
ImmutableList([1, 2, 3]) | (lambda x: ImmutableList([x, -x]))
# ImmutableList([1, -1, 2, -2, 3, -3])

# do-notation = list comprehension
# In Haskell: [(color, size) | color <- ["red","blue"], size <- ["S","M","L"]]
@do(ImmutableList)
def variants() -> DoBlock[tuple]:
    color: str = yield ImmutableList(["red", "blue"])
    size: str  = yield ImmutableList(["S", "M", "L"])
    return (color, size)

variants()
# ImmutableList([
#   ('red','S'), ('red','M'), ('red','L'),
#   ('blue','S'), ('blue','M'), ('blue','L')
# ])

# Monoid: @ is <>
ImmutableList([1, 2]) @ ImmutableList([3, 4])  # ImmutableList([1, 2, 3, 4])
ImmutableList.identity()                        # ImmutableList([])  — mempty

5. Semigroup / Monoid@ is <>

Sum, Product, and NonEmptyList are all Semigroup/Monoid instances. F.sigma is fold1 / sconcat over a NonEmptyList.

from katharos.types import NonEmptyList
from katharos.types.monoid import Sum, Product
from katharos.functools import F

# @ is <>
Sum[int](3) @ Sum[int](4) @ Sum[int](5)        # Sum(12)
Product[int](2) @ Product[int](3) @ Product[int](4)  # Product(24)

# identity() is mempty
Sum[int].identity()      # Sum(0)
Product[int].identity()  # Product(1)

# F.sigma is fold1 / sconcat — requires NonEmptyList (no empty-list footgun)
values = NonEmptyList(Sum[int](1), [Sum[int](2), Sum[int](3), Sum[int](4)])
F.sigma(values)  # Sum(10)

# NonEmptyList itself is a Semigroup (no Monoid — no empty case)
nel1 = NonEmptyList(1, [2, 3])
nel2 = NonEmptyList(4, [5, 6])
nel1 @ nel2  # NonEmptyList([1, 2, 3, 4, 5, 6])

Docs

Full docs at https://katharos.readthedocs.io. If this scratches an itch for you, a star on the repo goes a long way.

https://github.com/kamalfarahani/katharos

u/EntryNo8040 — 1 month ago

Bringing rigorous Type Classes (Functor, Applicative, Monad) to Python: Introducing Katharos

If you come from Haskell or Rust and have to write Python for ML/AI work, you know the pain: if x is None everywhere, exceptions that silently swallow errors, no ? operator, no HKTs, no sealed types. I got tired of it and built a library to close that gap.

Katharos is a zero-dependency Python library that gives you Maybe, Either/Result, IO, the list monad, Semigroup, Monoid, Functor, Applicative, and Monad — all fully typed and passing pyright strict mode.

https://github.com/kamalfarahani/katharos


The Engineering Challenge

The hard part is that Python has no HKTs and no sealed keyword (as of 3.13). There's no way to say Functor f or write :: f a -> (a -> b) -> f b generically. The workaround is structural gymnastics: a two-parameter generic class hierarchy (Functor[F, A], Applicative[App, A], Monad[M, A]) plus @final on concrete types to prevent unsafe subclassing. It's not pretty internally, but the external API stays clean.


Operator Mapping

If you already think in Haskell or Rust, here's the translation table:

Katharos Haskell Rust
m | f m >>= f m.and_then(f)
v ** wrapped_f wrapped_f <*> v
a >> b a >> b
a @ b a <> b
@do(M) decorator do { ... }

Examples

1. Maybe[A] — Haskell's Maybe a / Rust's Option<T>

No more if x is None chains. Short-circuits automatically on Nothing.

from katharos.types import Maybe

def safe_div(x: float) -> Maybe[float]:
    return Maybe[float].Nothing() if x == 0 else Maybe[float].Just(10.0 / x)

def safe_sqrt(x: float) -> Maybe[float]:
    return Maybe[float].Nothing() if x < 0 else Maybe[float].Just(x ** 0.5)

# | is >>=
Maybe[float].Just(4.0)  | safe_div | safe_sqrt  # Just(1.5811...)
Maybe[float].Just(0.0)  | safe_div | safe_sqrt  # Nothing()  — short-circuits at safe_div
Maybe[float].Just(-1.0) | safe_div | safe_sqrt  # Nothing()  — short-circuits at safe_sqrt

# fmap for pure transformations
Maybe[int].Just(5).fmap(lambda x: x * 2)  # Just(10)
Maybe[int].Nothing().fmap(lambda x: x * 2)  # Nothing()

2. Result[E, A] — Haskell's Either e a / Rust's Result<T, E>

Errors as values. The | chain (>>=) stops at the first Failure, exactly like Rust's ?.

from katharos.types import Result

def parse_int(s: str) -> Result[ValueError, int]:
    try:
        return Result[ValueError, int].Success(int(s))
    except ValueError as e:
        return Result[ValueError, int].Failure(e)

def validate_positive(n: int) -> Result[ValueError, int]:
    if n > 0:
        return Result[ValueError, int].Success(n)

    else:
        return Result[ValueError, int].Failure(ValueError(f"{n} is not positive"))

parse_int("42")  | validate_positive  # Success(42)
parse_int("abc") | validate_positive  # Failure(ValueError("invalid literal..."))
parse_int("-5")  | validate_positive  # Failure(ValueError("-5 is not positive"))

# fmap only runs on the success path
parse_int("42").fmap(lambda n: n * 2)  # Success(84)

3. do-notation — Python do blocks, exactly like Haskell

The @do(M) decorator desugars yield into >>= chains. Each yield unwraps the value; short-circuits on Nothing/Failure. The final return is lifted via M.pure(...).

from katharos.syntax_sugar import do, DoBlock
from katharos.types import Maybe, Result

# Maybe — like Haskell:
#   userScore uid = do
#     name  <- lookupUser uid
#     score <- lookupScore name
#     return (name ++ ": " ++ show score)

def lookup_user(uid: int) -> Maybe[str]:
    db = {1: "alice", 2: "bob"}
    return Maybe[str].Just(db[uid]) if uid in db else Maybe[str].Nothing()

def lookup_score(name: str) -> Maybe[int]:
    scores = {"alice": 95, "bob": 87}
    return Maybe[int].Just(scores[name]) if name in scores else Maybe[int].Nothing()

@do(Maybe)
def user_score(uid: int) -> DoBlock[str]:
    name: str  = yield lookup_user(uid)
    score: int = yield lookup_score(name)
    return f"{name}: {score}"

user_score(1)   # Just(alice: 95)
user_score(99)  # Nothing()  — short-circuits at lookup_user

# Result — equivalent of Rust's ? in a pipeline

def parse_positive(x: int) -> Result[ValueError, int]:
    return Result[ValueError, int].Success(x) if x > 0 else Result[ValueError, int].Failure(ValueError(f"{x} is not positive"))

@do(Result)
def compute() -> DoBlock[int]:
    x: int = yield parse_positive(5)
    y: int = yield parse_positive(3)
    return x + y

compute()  # Success(8)

4. ImmutableList[T] — the list monad, non-determinism included

ImmutableList is a full Monad + Monoid. Bind (|) is concatMap. The do-notation gives you Haskell list comprehensions.

from katharos.types import ImmutableList
from katharos.syntax_sugar import do, DoBlock

# concatMap / flatMap
ImmutableList([1, 2, 3]) | (lambda x: ImmutableList([x, -x]))
# ImmutableList([1, -1, 2, -2, 3, -3])

# do-notation = list comprehension
# In Haskell: [(color, size) | color <- ["red","blue"], size <- ["S","M","L"]]
@do(ImmutableList)
def variants() -> DoBlock[tuple]:
    color: str = yield ImmutableList(["red", "blue"])
    size: str  = yield ImmutableList(["S", "M", "L"])
    return (color, size)

variants()
# ImmutableList([
#   ('red','S'), ('red','M'), ('red','L'),
#   ('blue','S'), ('blue','M'), ('blue','L')
# ])

# Monoid: @ is <>
ImmutableList([1, 2]) @ ImmutableList([3, 4])  # ImmutableList([1, 2, 3, 4])
ImmutableList.identity()                        # ImmutableList([])  — mempty

5. Semigroup / Monoid@ is <>

Sum, Product, and NonEmptyList are all Semigroup/Monoid instances. F.sigma is fold1 / sconcat over a NonEmptyList.

from katharos.types import NonEmptyList
from katharos.types.monoid import Sum, Product
from katharos.functools import F

# @ is <>
Sum[int](3) @ Sum[int](4) @ Sum[int](5)        # Sum(12)
Product[int](2) @ Product[int](3) @ Product[int](4)  # Product(24)

# identity() is mempty
Sum[int].identity()      # Sum(0)
Product[int].identity()  # Product(1)

# F.sigma is fold1 / sconcat — requires NonEmptyList (no empty-list footgun)
values = NonEmptyList(Sum[int](1), [Sum[int](2), Sum[int](3), Sum[int](4)])
F.sigma(values)  # Sum(10)

# NonEmptyList itself is a Semigroup (no Monoid — no empty case)
nel1 = NonEmptyList(1, [2, 3])
nel2 = NonEmptyList(4, [5, 6])
nel1 @ nel2  # NonEmptyList([1, 2, 3, 4, 5, 6])

Docs

Full docs at https://katharos.readthedocs.io. If this scratches an itch for you, a star on the repo goes a long way.

https://github.com/kamalfarahani/katharos

u/EntryNo8040 — 1 month ago