doeff
Write your Python logic once. Choose later how it talks to the world.
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.pytakes 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
- Documentation: start here: every guide, in reading order
- Tutorial: Getting started
- Testing an LLM agent:
examples/llm_agent.pymakes the model call an effect: an OpenAI-compatible client answers it in production, canned answers in its tests. It needs onlypip install doeff, and its tests pytest besides. - Testing an HTTP API client:
examples/github_repocalls the GitHub API through the HTTP handler ofdoeff[http]; its tests answer with a fake handler. Run them withpip install "doeff[http]" pytest, thenpytest examples/github_repo(no network). - Why effects?: Effects vs. dependency injection and plain
async - Questions: FAQ: dependency injection, asyncio, speed, types, Hy, production use
- Built-in effects and handlers: doeff-core-effects
- Writing programs in Hy: doeff-hy
- Command line:
doeff run
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)— returnsOk(value)orErr(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 likeyield Fetch(url), but gives the answer the typeEffectBase[str]declares:pageis astr, whereyieldgivesAny.Generator[Fetch | Summarize, Any, str]lists the effectsdigestmay yield and its result. Yielding another effect indigestis an error, andrunof the program is astr.typed_resumechecks the handler's answer against the effect:42for aFetchis an error.- mypy needs the annotations. It does not infer the types of a function without them: to mypy, an
unannotated
@dofunction takes and returnsAny, 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--transformstill work but emit deprecation warnings. Use--hyfor 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.
Related packages
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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| doeff-0.7.0.tar.gz | 86.9 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.