Skip to main content

doeff

Write your Python logic once. Choose later how it talks to the world.

PyPI Python License: MIT

A terminal session: pip install doeff, then the same digest program runs under a fake handler and under a live HTTP handler, and only the answer changes

doeff brings algebraic effects to Python. Your functions yield requests such as "fetch this URL" or "summarize this text" instead of doing the I/O themselves. Handlers decide what each request means: real I/O in production, fakes in tests, recorded responses when you debug. Swap a handler, not your code.

pip install doeff          # Python 3.11+: the core (doeff, doeff-vm, doeff-core-effects)
pip install "doeff[hy]"    # + Hy and the handlers written in Hy (files, SQL, processes, cache, …)
pip install "doeff[http]"  # + the HTTP client handlers (Hy and httpx)
pip install "doeff[all]"   # both: what `pip install doeff` installed in 0.5.0

Then run python -m doeff demo to watch the example below answer from a fake handler, then over real HTTP.

Documentation: start here, with install, a first program, the FAQ, why effects, examples and the API, in that order.

A 30-second example

from dataclasses import dataclass
from doeff import EffectBase, Pass, Resume, do, handler, run

@dataclass(frozen=True)
class Fetch(EffectBase):       # a request your code can make
    url: str

@dataclass(frozen=True)
class Summarize(EffectBase):
    text: str

@do
def digest(url):               # your logic: no I/O inside
    page = yield Fetch(url)
    return (yield Summarize(page))

@do
def fake_world(effect, k):     # a handler: answers the requests it knows
    if isinstance(effect, Fetch):
        return (yield Resume(k, "<h1>Hello</h1>"))
    if isinstance(effect, Summarize):
        return (yield Resume(k, f"{len(effect.text)} chars"))
    yield Pass(effect, k)      # anything else goes to the next handler

print(run(handler(fake_world)(digest("https://example.com"))))  # 14 chars

To go live, replace fake_world with handlers that call your HTTP client and your model. digest does not change, and the fake stays as your test. examples/llm_agent.py does this for an LLM agent, with pytest tests that need no API key. examples/github_repo does it for a REST API: it calls GitHub through the HTTP handler of doeff[http], and its tests answer with a fake handler, without the network.

Why doeff?

You want to… Plain Python doeff
Test code that does I/O mock.patch("app.requests.get") for every import path Install a fake handler. Nothing is patched
Add logging, retries or a cost cap One wrapper per client One handler that sees every effect
Run work in parallel Rewrite the path with async or threads Spawn, Gather and Race in the same program
  • Batteries included. Production and test handlers ship for files, HTTP (record / replay), SQL (in-memory SQLite for tests), subprocesses, randomness, caching and metrics.
  • Plain Python. Programs are generator functions; no new syntax. Optional Hy macros live in doeff-hy.
  • Rust VM. Effects are dispatched by a VM written in Rust, with one-shot continuations.

Why not dependency injection or plain async? Why effects? compares effects with both, example by example, and says when DI or plain Python is the simpler choice.

One handler, every effect

A handler can watch every request without touching digest. Add this below the example above:

seen: list[object] = []

@do
def trace(effect, k):          # sees every request first, then passes it on
    seen.append(effect)
    return (yield Pass(effect, k))

print(run(handler(fake_world)(handler(trace)(digest("https://example.com")))))  # 14 chars
print(seen)  # [Fetch(url='https://example.com'), Summarize(text='<h1>Hello</h1>')]

Before and after: fakes in a test

welcome loads a user from the database and sends a welcome mail. Its test must reach neither.

Before: plain Python with mock.patch. The test patches each dependency by the name app looks it up under. Forget one patch and the test still passes, after sending a real mail:

# app.py
from myapp.db import load_user
from myapp.mail import send_mail

def welcome(user_id):
    user = load_user(user_id)
    body = f"Welcome, {user.name}!"
    send_mail(user.email, body)
    return body

# test_app.py
from unittest import mock
import app
from myapp.db import User

def test_welcome():
    with mock.patch("app.load_user", return_value=User("Alice", "alice@example.com")):
        assert app.welcome(1) == "Welcome, Alice!"
    # Passes. app.send_mail was never patched, so the test sent a real mail.

After: doeff. welcome yields its requests. The test answers them with one fake handler per dependency, and nothing is patched:

from dataclasses import dataclass
from doeff import EffectBase, Resume, do, handler, run

@dataclass(frozen=True)
class User:
    name: str
    email: str

@dataclass(frozen=True)
class LoadUser(EffectBase):
    user_id: int

@dataclass(frozen=True)
class SendMail(EffectBase):
    to: str
    body: str

@do
def welcome(user_id):
    user = yield LoadUser(user_id)
    body = f"Welcome, {user.name}!"
    yield SendMail(user.email, body)
    return body

sent: list[str] = []

@do
def fake_db(effect: LoadUser, k):      # the annotation picks the effects it answers
    return (yield Resume(k, User("Alice", "alice@example.com")))

@do
def fake_mail(effect: SendMail, k):    # keeps the mail instead of sending it
    sent.append(effect.to)
    return (yield Resume(k, None))

print(run(handler(fake_db)(handler(fake_mail)(welcome(1)))))  # Welcome, Alice!
print(sent)  # ['alice@example.com']

Forget fake_mail and nothing is sent: no handler answers SendMail, so the run stops and names it. Add this below the example above:

try:
    run(handler(fake_db)(welcome(1)))  # fake_mail left out
except Exception as error:
    print(type(error).__name__)  # UnhandledEffect

In production, welcome stays as it is and handlers that call your database and mail server take the place of the fakes. The doeff version is longer: each request is a class, and welcome is a generator. What the extra lines buy is a test that cannot reach production by accident.

The same test, written in other styles, with one fake left out:

Style The logic is The test swaps in fakes by Forget one fake and
Plain Python, mock.patch plain functions patching each name where the code looks it up the real function runs
Plain Python, dependencies as arguments functions that take their dependencies passing fake functions a TypeError if the test builds its own set; the real function runs if the test starts from production's set
effect 1.1.0 generators that yield Effect(...) listing the expected effects and their answers in order an AssertionError (Performer not found)
effectful 0.4.0 plain functions that call @defop operations a dict of handlers no error: the call returns a symbolic term instead of a value
doeff generators that yield effects installing one handler per effect an UnhandledEffect error

A doeff handler without an effect annotation sees every effect: without : LoadUser, fake_db would answer SendMail too, and the test without fake_mail would pass.

Performance

An effect costs about 3 to 5 µs. A network request or a model call takes milliseconds, so in I/O code the cost does not show. In a tight numeric loop it does: keep such a loop in plain Python and yield effects around it.

One effect round trip: perform an effect and get the handler's answer back (µs of CPU per effect, 2,000 effects per sample):

Implementation Median p90 × a function call
Plain Python: a function call 0.13 0.20 1
Plain Python: a generator and a driver loop 0.38 0.61 3
doeff: plain function handler that returns Resume 3.0 4.5 23
doeff: @do handler and Resume 4.3 6.0 34
doeff: built-in reader (Ask) 4.9 6.7 38
effect 1.1.0: @do and a sync performer 12.7 14.6 99
effectful 0.4.0: @defop and handler 57.9 62.3 452

Many tasks: spawn 1,000 tasks and gather them, and 1,000 tasks that all wait on one promise and wake at once (µs of CPU per task):

Implementation Spawn and gather: median p90 Wait on one promise: median p90
Plain Python: round-robin generator tasks 1.3 1.9 – –
Plain Python: asyncio 11.4 13.7 15.5 18.6
effect 1.1.0: parallel() (runs the effects one by one) 13.9 16.1 – –
doeff: scheduled() (the Rust scheduler, the default) 16.6 20.7 25.5 44.7
doeff: scheduled(..., implementation="python") 151 167 205 223

A dash marks a case the implementation has no promise for. effectful has no scheduler, so it is not in this table. pip install doeff installs 0.7.0, whose scheduled() runs the Rust scheduler by default (it ran the Python one up to 0.6.0). Select the Python one (the last row) with DOEFF_SCHEDULER=python or scheduled(program, implementation="python").

How the numbers were measured:

  • Machine: an Intel Core i9-7980XE (18 cores, 2.6 GHz) under Linux, with CPython 3.14.7, on 2026-10-17 at commit d40d0ab.
  • Method: benchmarks/compare_libraries.py takes 50 samples of each case and reports the median and the 90th percentile of the process CPU time. Each sample runs every case once, in a new random order. Each case checks its result, so no implementation can win by skipping work. The script has two more scenarios: nested calls, and handlers that an effect passes on its way.
  • Load: the machine was shared with other work. The random order spreads the load over every case alike, so compare the rows with each other more than with your own numbers.
  • Free-threaded build: two runs on CPython 3.14.3t gave numbers of the same size: doeff medians of 3.2 to 5.8 µs per effect, and with scheduled() 17.5 to 19.5 µs per spawned task and 29 to 32 µs per waiting task.

To run the benchmark in a checkout after make sync (there uv run uses the free-threaded CPython 3.14.3t):

uv run --with effect==1.1.0 --with effectful==0.4.0 python benchmarks/compare_libraries.py --runs 50

Next steps

Runtime API

Entrypoint Signature Use case
run run(doexpr) Execute a DoExpr program to completion

run() takes a single DoExpr (program node) and returns the result value directly. Handlers are composed explicitly by calling a Program -> Program handler installer. Use scheduled(prog) to wrap with the scheduler for concurrency effects.

Handler Composition

Handlers are installed by direct call. Stack multiple handlers by wrapping the program:

from doeff import do, run
from doeff_core_effects import Ask
from doeff_core_effects.handlers import reader

@do
def prog():
    return (yield Ask("greeting"))

result = run(reader(env={"greeting": "hello"})(prog()))
print(result)  # hello

Reusable custom handlers should expose the same Program -> Program shape. In Hy, defhandler creates that shape directly:

(require doeff-hy.handle [defhandler])
(require doeff-hy.macros [do! <-])
(import doeff [Ask run])

(defhandler ask-env
  (Ask [key]
    (resume (get {"greeting" "hello"} key))))

(setv greet
  (do!
    (<- greeting (Ask "greeting"))
    greeting))

(print (run (ask-env greet)))  ; hello

Hy comes with the extra hy: pip install "doeff[hy]". Save the example as greet.hy and run it with hy greet.hy. Macros such as do!, <- and defhandler are brought in with require, never with import.

Using a Hy module from Python

Python reads a .hy file only after import hy has run, so put import hy before the import of a Hy module:

(require doeff-hy.macros [do! <-])
(import doeff [Ask])

(setv greet
  (do!
    (<- greeting (Ask "greeting"))
    greeting))
import hy  # first: registers Hy's import hook, so Python can find greet.hy
from doeff import run
from doeff_core_effects.handlers import reader
from greet import greet

print(run(reader(env={"greeting": "hello"})(greet)))  # hello

Common Hy mistakes and the line that fixes each

You see Why Fix
NameError: name 'hyx_doXexclamation_markX' is not defined (or name 'defk' is not defined) A macro was used without a require, so Hy compiled it as a call to an undefined function. doeff prints a HyMacroUseWarning with the line to add just before the traceback. Add (require doeff-hy.macros [do! <-])
ImportError: cannot import name 'defhandler' from 'doeff_hy.handle' A macro was brought in with import. Write (require doeff-hy.handle [defhandler]) instead of (import doeff-hy.handle [defhandler])
ModuleNotFoundError: No module named 'greet' with a greet.hy next to the script Python does not read .hy files until import hy has run. Add import hy above from greet import greet

Effect Surface

Core effects (from doeff_core_effects):

  • Reader: Ask(key), Local(env, program)
  • State: Get(key), Put(key, value)
  • Writer: Tell(message), slog(msg, **kwargs)
  • Error: Try(program) — returns Ok(value) or Err(error)
  • Observe: Listen(program, types=...) — collect effects during execution

Scheduler effects (from doeff_core_effects.scheduler):

  • Concurrency: Spawn(program), Wait(task), Gather(*tasks), Race(*tasks)
  • Promises: CreatePromise(), CompletePromise(p, v), FailPromise(p, e)
  • External: CreateExternalPromise() — bridge external threads
  • Semaphores: CreateSemaphore(n), AcquireSemaphore(s), ReleaseSemaphore(s)

Handlers

Built-in handlers (from doeff_core_effects.handlers):

Handler Installer Effects handled
Reader reader(env={...}) Ask
Lazy Ask lazy_ask(env={...}) Ask, Local (with caching)
State state(initial={...}) Get, Put
Writer writer Tell / WriterTellEffect
Try try_handler Try
Slog slog_handler Slog
Local local_handler Local
Listen listen_handler Listen
Await await_handler() Await
Scheduler scheduled(prog) (from doeff_core_effects.scheduler) Spawn, Wait, Gather, Race, etc.

writer keeps its log in state: install state() outside it, and read the log with yield writer_log() (from doeff_core_effects.handlers).

Scheduler and Concurrency

from doeff import do, run
from doeff_core_effects import Tell
from doeff_core_effects.handlers import state, writer
from doeff_core_effects.scheduler import scheduled, Spawn, Gather

@do
def worker(label):
    yield Tell(f"working: {label}")
    return label

@do
def main():
    t1 = yield Spawn(worker("a"))
    t2 = yield Spawn(worker("b"))
    results = yield Gather(t1, t2)
    return results

# writer keeps its log in state, so install state() outside writer.
result = run(scheduled(state()(writer(main()))))
print(result)  # ['a', 'b']

Type checking

The examples above leave out type annotations and use plain yield, so pyright and mypy see Any where their values flow. Here is the 30-second example with types:

from collections.abc import Generator
from dataclasses import dataclass
from typing import Any

from doeff import EffectBase, K, Resume, do, handler, run, typed_resume

@dataclass(frozen=True)
class Fetch(EffectBase[str]):  # [str]: the type a handler answers with
    url: str

@dataclass(frozen=True)
class Summarize(EffectBase[str]):
    text: str

@do
def digest(url: str) -> Generator[Fetch | Summarize, Any, str]:
    page = yield from Fetch(url)  # page: str (plain yield gives Any)
    return (yield from Summarize(page))

@do
def fake_world(effect: Fetch | Summarize, k: K) -> Generator[Resume, Any, Any]:
    if isinstance(effect, Fetch):
        return (yield typed_resume(effect, k, "<h1>Hello</h1>"))  # must be a str
    return (yield typed_resume(effect, k, f"{len(effect.text)} chars"))

summary = run(handler(fake_world)(digest("https://example.com")))  # summary: str
print(summary)  # 14 chars
  • yield from Fetch(url) runs exactly like yield Fetch(url), but gives the answer the type EffectBase[str] declares: page is a str, where yield gives Any.
  • Generator[Fetch | Summarize, Any, str] lists the effects digest may yield and its result. Yielding another effect in digest is an error, and run of the program is a str.
  • typed_resume checks the handler's answer against the effect: 42 for a Fetch is an error.
  • mypy needs the annotations. It does not infer the types of a function without them: to mypy, an unannotated @do function takes and returns Any, and its body is not checked. pyright infers part of the types without annotations, and reads the same annotations.

Static typing covers the scheduler's effects, handlers and what stays Any.

CLI

# Run a program with auto-discovered interpreter
doeff run --program myapp.module.program

# Inline Python code
doeff run -c 'return 42'

# Inline Hy code (preferred — compose handlers inline)
doeff run --hy '(import myapp [p]) (import doeff-core-effects [lazy-ask]) ((lazy-ask :env myapp.env_dict) p)'

# JSON output
doeff run --program myapp.program --format json

Note: Legacy flags --interpreter, --env, --set, --apply, and --transform still work but emit deprecation warnings. Use --hy for inline handler composition instead.

The CLI supports automatic interpreter/environment discovery via doeff-indexer. See docs/14-cli-auto-discovery.md for marker syntax and hierarchy rules.

The add-on packages (LLM clients, agents, workflows and more) live in packages/.

doeff-agents on PyPI (0.1.1 and 0.1.2) was built for doeff 0.4 and does not work with doeff 0.5 or later — importing its handlers fails with ImportError: cannot import name 'WithHandler' from 'doeff'. 0.1.2 is the same code as 0.1.1 and declares doeff<0.5, so pip install doeff-agents installs it together with the doeff 0.4 line; it cannot share an environment with doeff 0.5 or later. The doeff-agents in this repository (packages/doeff-agents, with its CLI) is not published to PyPI.

Repository map

The paths for users come first. The rest are for people who work on doeff itself.

Path For What is inside
doeff/, packages/ users the library and its add-on packages
docs/index.md users start here: guides and API notes
examples/ users runnable examples
ide-plugins/ users editor plugins for PyCharm, VS Code and Neovim
docs/adr/ contributors design decisions written as executable tests
tests/, scripts/, tools/, benchmarks/ contributors the test suite, development tools and benchmarks
docs/design/, docs/design-checks/ maintainers development records; not user documentation
docs/history/ maintainers older planning notes
publications/, images/ maintainers drafts of articles about doeff and their images

docs/README.md tells the user guides in docs/ apart from the development records.

Contributing

Questions, bug reports and feature ideas are welcome as issues. To send a change, pick a good first issue; setting up the development environment, running the tests and opening a pull request are described in CONTRIBUTING.md.

License

MIT License. See LICENSE.

Metadata

Release files for doeff 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for doeff 0.7.0
File Size Uploaded Provenance
doeff-0.7.0.tar.gz 86.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for doeff 0.7.0
File Interpreter ABI Platform
doeff-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 158.5 kB

Release files / doeff-0.7.0.tar.gz

Download URL doeff-0.7.0.tar.gz
Size 86.9 kB
Tags Source
SHA-256 checksum
How to use checksums
ae5bd62aa4db1324c47c1fdf0b9db08962e2f948b3ce70a2c91ab235db0e1b68
BLAKE2b-256 checksum
How to use checksums
98e8c4a4807807e185e24c3976b3b18e871c9c6f20630386ed89ce7a7049668d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / doeff-0.7.0-py3-none-any.whl

Download URL doeff-0.7.0-py3-none-any.whl
Size 71.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f6f459f4e00daeedc1012634d163c03fba933316fd848412e9eb9ac210e167e4
BLAKE2b-256 checksum
How to use checksums
f905243a04ee5b3b84d1142536284ded57b2c4919d3bee2037a0d03d72b469da
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Security for doeff 0.7.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page