Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Do Notation

@do binds values from one sum-type family inside a generator. Each yield extracts a successful or present value. The first Err, Nothing, or Failure ends the block and becomes its result.

The following example parses two integers and skips the second parse if the first fails:

from pyrula import Either, do, Ok, Err
def parse_int(raw: str) -> Either[int, str]:
try:
return Ok(int(raw))
except ValueError:
return Err(f"not an int: {raw!r}")
@do
def total(a: str, b: str):
x = yield parse_int(a)
y = yield parse_int(b)
return x + y
total("2", "3") # Ok(5)
total("nope", "3") # Err("not an int: 'nope'")

Each yield unwraps an Ok and binds the value, or stops at the first Err and returns it. The body reads like straight-line code while keeping the error handling.

@do also accepts Option and Try. An Option block returns Nothing when a lookup is absent:

from pyrula import Option, Nothing
users = {"ada": {"email": "[email protected]"}}
@do
def lookup(uid: str):
user = yield Option.of(users.get(uid))
email = yield Option.of(user.get("email"))
return email
assert lookup("ada").get_or_else("") == "[email protected]"
assert lookup("missing") is Nothing

Each block must yield one family: Either, Option, or Try. Mixing families raises TypeError. Convert between families before yielding, or use separate blocks.

Validated is not supported. Use Validated.zip, sequence, or traverse for independent checks that accumulate errors. Use @do for dependent steps that must stop after an unsuccessful result.

@do wraps the generator’s returned value in its yielded family. It does not capture exceptions raised in the generator body, including a Try block. Wrap exception-raising operations with Try.apply before yielding them.

@do_async drives an async generator with the same single-family short-circuiting behavior. Python forbids return value in an async generator, so the result is the last successfully yielded value, wrapped in that family. End with a yield:

import asyncio
from pyrula import Either, Ok, Err, do_async
async def fetch_name(uid: str) -> Either[str, str]:
return Ok("Ada") if uid == "ada" else Err("unknown user")
@do_async
async def greeting(uid: str):
name = yield await fetch_name(uid)
yield Ok(f"Hello, {name}")
assert asyncio.run(greeting("ada")) == Ok("Hello, Ada")
assert asyncio.run(greeting("missing")) == Err("unknown user")

After establishing the Try family, @do_async captures Exception from the async body or a yielded awaitable as Failure. Exceptions outside that hierarchy propagate.

@do is Scala’s for-comprehension. x = yield e is x <- e and return is yield, with the first failure short-circuiting, exactly as a for { ... } yield over Either/Option/Try desugars to flatMap and map.