Make function side effects visible in return-type annotations, checked by a mypy plugin.
Project description
efflux
efflux makes a function's real side effects part of its signature — and keeps them honest.
from efflux import Effects, Raises, WritesDB, Logs
def charge(user_id: int) -> Effects[Receipt, Raises[PaymentError], WritesDB, Logs]:
...
That return type says exactly what charge does to the outside world: it can raise
PaymentError, it writes to the database, it logs. Yet to mypy and to your callers,
charge still returns Receipt — the effects ride along as metadata, nothing about
your code or its types changes. A companion checker then propagates effects across your
call graph, so a function that quietly starts writing to the DB — or a domain layer
that sneaks in IO — gets caught.
See what your code does. Enforce what it's allowed to do.
The problem
Python hides side effects. A signature like def settle(order: Order) -> Receipt tells
you the return type and nothing else:
- What can it raise? Python has no checked exceptions — you find out in production.
- Does it touch the database? The network? The filesystem? You have to read the body, and the bodies of everything it calls.
- Did your architecture erode? The "pure" domain function someone added a
requestscall to last quarter looks identical to the one that didn't.
Docstrings drift. Code review can't see three calls deep. The information you most want about a function — its blast radius — is exactly what the type system throws away.
efflux puts it back, without making you rewrite anything.
Quickstart
pip install efflux
Enable the mypy plugin (this is what makes Effects[...] type-check):
# pyproject.toml
[tool.mypy]
plugins = ["efflux.mypy_plugin"]
Annotate a boundary and let mypy and the checker keep it honest:
from efflux import Effects, WritesDB
def save_user(u: User) -> Effects[None, WritesDB]:
db.execute("insert into users ...") # the declared WritesDB
Then propagate effects across your whole package:
efflux path/to/your/package
If some caller of save_user doesn't declare WritesDB, efflux tells you — pointing at
the exact call that introduced it.
What you get
Non-invasive by design
Effects[T, *effects] erases to T. Callers see the real return type; refactoring tools,
overload resolution, and isinstance all behave exactly as before. You can adopt efflux
one function at a time and rip it out just as easily — it's metadata on an Annotated
type, not a wrapper around your values.
A practical effect vocabulary, with subsumption
Effects are just classes, arranged in a shallow hierarchy. Declaring a parent covers its children, so you choose how precise to be:
IO — any interaction with the outside world
├── Network
├── ReadsEnv
├── Filesystem → ReadsFS, WritesFS
└── Database → ReadsDB, WritesDB
Raises[E] — parameterized by the exception class
Logs Emits Random Clock MutatesGlobal Blocks
Declare Database and both ReadsDB and WritesDB are covered; declare IO and you've
covered the lot. Raises[ConnectionError] is covered by Raises[OSError]; bare Raises
covers anything.
Your own effects
An effect is a class — subclass Effect anywhere, including in your own package.
Inheritance gives you subsumption for free:
from efflux import Effect, WritesDB
class WritesKafka(Effect): ... # a brand-new effect
class WritesPostgres(WritesDB): ... # implies WritesDB
Effects at runtime
from efflux import effects_of
effects_of(charge)
# -> (Raises[PaymentError], WritesDB, Logs) — in declaration order
A checker that propagates across the call graph
efflux <path> infers each function's effects bottom-up — so you only annotate the
boundaries you care about — and reports any function that uses an effect it didn't
declare. It's gradual: only functions that carry an Effects[...] declaration are
enforced; everything else is inferred and propagated silently. Calls it can't see into are
treated as pure, and you can teach it about third-party functions:
[tool.efflux.external]
"requests.api.get" = ["Network"]
"time.time" = ["Clock"]
efflux already ships a built-in map for common stdlib and HTTP calls (open,
os.getenv, logging.*, time.*, random.*, socket, requests, httpx),
applied by default — your entries override it per callee, and --no-builtins
turns it off. Report every function's inferred effects instead of checking:
efflux --report path/to/your/package # human-readable
efflux --report --json path/to/package # machine-readable (also: efflux --json)
Run efflux as a pre-commit hook in your own repo:
# .pre-commit-config.yaml
- repo: https://github.com/tatulian/efflux
rev: v0.1.0
hooks:
- id: efflux
args: ["yourpackage"]
Enforce architectural boundaries — forbid an effect across a glob of modules (checks inferred effects, so it catches leaks even when undeclared):
[tool.efflux.boundaries]
"myapp.domain.*" = { forbid = ["IO"] } # the domain layer must stay pure
Adopt on an existing codebase with a baseline — grandfather current violations and fail only on new ones:
efflux mypkg --baseline efflux-baseline.json --update # write the baseline once
efflux mypkg --baseline efflux-baseline.json # then only new violations fail
Adopt fast — let efflux write the annotations for you (needs pip install 'efflux[fix]'):
efflux mypkg --fix # complete existing Effects[...] declarations
efflux mypkg --fix --unsafe # also wrap plain `-> T` return types (review the diff!)
Narrowing: contain effects on purpose
A try/except discharges the matching Raises:
def safe_parse(s: str) -> Effects[int]: # declares no Raises
try:
return parse(s) # parse may Raises[ValueError]
except ValueError:
return 0
efflux.allow(...) (or a # efflux: allow <Effect> comment) discharges any effect you
intentionally contain:
from efflux import allow
def warm_cache() -> Effects[int]:
with allow(WritesDB):
return _seed() # _seed writes to the DB on purpose
A worked example
A billing service that reads and writes the DB, calls a payment gateway over the network, can raise, and logs — all declared in one line:
from efflux import Effects, Raises, ReadsDB, WritesDB, Network, Logs
class PaymentError(Exception): ...
def charge(user_id: int, cents: int) -> Effects[
Receipt, Raises[PaymentError], ReadsDB, WritesDB, Network, Logs
]:
account = load_account(user_id) # ReadsDB
if account.balance < cents:
raise PaymentError("insufficient funds")
receipt = gateway.capture(cents) # Network
save_receipt(receipt) # WritesDB
log.info("charged user %s", user_id) # Logs
return receipt
Now enforce an architectural boundary — a domain rule that must stay pure:
def price_basket(basket: Basket) -> Effects[Money]: # declares: no effects
return apply_discounts(basket.subtotal())
If someone later makes apply_discounts read feature flags from the database, efflux fails:
pricing.py:12: error: function "pricing.price_basket" has undeclared effect
"ReadsDB" (introduced by call to "flags.is_enabled" at line 14)
The violation is caught at check time, with the exact call that broke the rule — not in a postmortem.
How it works
efflux is two independent layers that share one effect vocabulary:
- Visibility (
efflux/+ the mypy plugin) —Effects[T, *effects]is anAnnotatedtype; the plugin makes it type-check asTand validates the effects. Pure runtime, zero cost to import. - The checker (
efflux <path>) — a separate static analyzer that drives mypy's own build, walks the call graph, and reports undeclared effects, with subsumption and discharge.
Use the first layer alone as type-checked documentation, or add the checker when you want propagation enforced.
Built for AI-assisted development
Effect annotations are machine-readable contracts, which pays off twice when AI coding agents are in the loop:
- Legibility without reading bodies. An agent sees
-> Effects[Receipt, Raises[PaymentError], WritesDB]and knows the blast radius — no need to trace the implementation or its callees. - A verification oracle. After an agent edits code,
efflux <path>deterministically flags any new, undeclared effect it introduced — instant feedback that a change didn't quietly start touching the DB or the network. - Navigation by effect. "Everything that writes to the DB" or "what can this raise"
become
grep/effects_of()queries instead of archaeology.
efflux ships two Claude Code skills (in skills/)
to make this hands-on:
- efflux-annotate — cover an existing codebase with
Effects[...], using the checker as the oracle. - efflux-navigate — explore and audit a codebase by its declared effects.
efflux vs. the alternatives
| efflux | returns (dry-python) |
Java-style checked exceptions | docstrings / plain mypy | |
|---|---|---|---|---|
| Effects visible in the signature | ✅ | ✅ | errors only | informal / ❌ |
| Code & types stay unchanged (non-invasive) | ✅ | ❌ (wrap your values) | ❌ | ✅ / ✅ |
| Covers IO, DB, network… not just errors | ✅ | partial | ❌ | ❌ |
| Checked by tooling | ✅ mypy + checker | ✅ mypy plugin | ✅ compiler | ❌ |
| Propagates across the call graph | ✅ | via types | manual | ❌ |
| Gradual, opt-in adoption | ✅ | partial | ❌ all-or-nothing | n/a |
The short version: returns gives you typed effects but you wrap every value; checked
exceptions are all-or-nothing and only cover errors; docstrings aren't checked. efflux's bet
is non-invasive metadata + a gradual checker.
Install
pip install efflux
Not yet published to PyPI — until the first release, install from source:
uv pip install git+https://github.com/tatulian/efflux
The mypy plugin is required for Effects[...] to type-check:
# mypy.ini
[mypy]
plugins = efflux.mypy_plugin
# or pyproject.toml
[tool.mypy]
plugins = ["efflux.mypy_plugin"]
Effect reference
| Effect | Meaning |
|---|---|
IO |
umbrella for any outside-world interaction |
Network |
network access |
ReadsEnv |
reads environment variables |
Filesystem / ReadsFS / WritesFS |
filesystem access (read / write) |
Database / ReadsDB / WritesDB |
database access (read / write) |
Raises[E] |
can raise exception class E |
Logs |
emits log output |
Emits |
emits events / messages |
Random |
uses randomness |
Clock |
reads the clock |
MutatesGlobal |
mutates global / module state |
Blocks |
blocks the calling thread |
FAQ
Do I have to use the checker? No. The annotations + mypy plugin work on their own as
checked documentation. The efflux checker is an optional second layer for call-graph
enforcement.
What's the runtime cost? Effectively none. Effects[...] is an Annotated type; at
runtime it's metadata, and import efflux does not import mypy.
Is it all-or-nothing? No — adoption is gradual. Only functions you annotate with
Effects[...] are enforced; the rest are inferred and propagated silently.
Which versions? Python ≥ 3.10. The checker drives mypy internals and is exercised
against mypy 2.x (mypy>=2.1,<3).
Is it production-ready? It's early (0.1, beta) but usable today: the visibility layer is small and stable, and the checker has a thorough test suite. Pin your version and expect the API to still move.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file efflux-0.1.0.tar.gz.
File metadata
- Download URL: efflux-0.1.0.tar.gz
- Upload date:
- Size: 89.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13c02c2b23d89d4ce866a0cda2303ad26ca8257b3eb7410d47feeb2f15fed09a
|
|
| MD5 |
0d325b8a25e2d4585ab3fbee69538860
|
|
| BLAKE2b-256 |
cda16d5a295da3d67c49bd9709c8e90dafa26928c5778e19e33b8e4664243360
|
File details
Details for the file efflux-0.1.0-py3-none-any.whl.
File metadata
- Download URL: efflux-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
db8e58007dc7bab1811caecbe5b29bf466dc7ba11e23db12cee40f3744b46665
|
|
| MD5 |
eb34cd6f68677da366748bd6330f3f98
|
|
| BLAKE2b-256 |
ea22781871f655bd6f868db0d9b5371faff9af512b710d73a17f5ef06b838a79
|