Rust-style exhaustive error and None handling for Python
Project description
Pyrethrin
Rust and OCaml-style exhaustive error and None handling for Python
Installation • Quick Start • API Reference • Documentation • License
Pyrethrin brings compile-time safety guarantees to Python for two of the most common sources of runtime errors:
Python's flexibility is its greatest strength and its Achilles' heel. There are weird edge-case exceptions everywhere in your code-base, functions receive None in the most unexpected places, and you only discover these issues when your app crashes in production at 3 AM.
Languages like Rust and OCaml solved this with Result and Option types that make error handling explicit and exhaustive. Pyrethrin brings that same peace of mind to Python: if it compiles, it handles all the errors. Stop playing whack-a-mole with try/except blocks and if x is not None checks scattered throughout your codebase.
Exceptions - Declare what exceptions a function can raise with @raises, and the static analyzer ensures every caller handles all of them. No more except Exception or forgotten error paths.
None values - Mark functions that may return nothing with @returns_option, forcing callers to explicitly handle the Some and Nothing cases. No more AttributeError: 'NoneType' has no attribute crashes.
Table of Contents
- Features
- Installation
- Quick Start
- Shields
- API Reference
- Option Type
- Combining Decorators
- Async Support
- Pattern Matching
- Error Codes
- Testing
- Configuration
- Documentation
- License
Features
@raisesdecorator - Declare exceptions a function can throw@returns_optiondecorator - Mark functions returning optional valuesmatch()function - Exhaustive error handling for Result and Option typesResulttype -OkandErrfor explicit success/failureOptiontype -SomeandNothingfor optional values- Static analysis - Catches missing handlers before runtime
- Full async/await support - Works with async functions
Core Principle: You must use match() or native match-case to handle Result and Option types. There are no escape hatches like unwrap() - this is by design.
Installation
pip install pyrethrin
From source:
git clone https://github.com/4tyone/pyrethrin
cd pyrethrin
pip install -e .
Requirements:
- Python 3.11+
- No additional dependencies required (Pyrethrum binary is bundled)
Quick Start
1. Declare Exceptions
from pyrethrin import raises, match, Ok, Err
class UserNotFound(Exception):
pass
class InvalidUserId(Exception):
pass
@raises(UserNotFound, InvalidUserId)
def get_user(user_id: str) -> User:
if not user_id.isalnum():
raise InvalidUserId(f"Invalid ID: {user_id}")
user = db.find(user_id)
if user is None:
raise UserNotFound(user_id)
return user
2. Handle All Cases
def handle_request(user_id: str) -> Response:
return match(get_user, user_id)({
Ok: lambda user: Response(200, user.to_dict()),
UserNotFound: lambda e: Response(404, {"error": str(e)}),
InvalidUserId: lambda e: Response(400, {"error": str(e)}),
})
3. Or Use Native Pattern Matching
def handle_request(user_id: str) -> Response:
result = get_user(user_id)
match result:
case Ok(user):
return Response(200, user.to_dict())
case Err(UserNotFound() as e):
return Response(404, {"error": str(e)})
case Err(InvalidUserId() as e):
return Response(400, {"error": str(e)})
4. Missing Handlers? Static Analysis Catches It
# ERROR: Result not handled with match
def bad_handler(user_id: str):
result = get_user(user_id)
print(result) # ExhaustivenessError at runtime
# ERROR: Missing handler for InvalidUserId
match(get_user, user_id)({
Ok: lambda user: user,
UserNotFound: lambda e: None,
# Missing: InvalidUserId - caught by static analysis
})
Shields
New in 0.2.0: Pyrethrin now ships with pre-built "shields" for popular libraries. Shields are drop-in replacements that add explicit exception declarations to library functions, so you get the same exhaustive error handling guarantees for third-party code.
Available Shields
| Shield | Coverage | Description |
|---|---|---|
pyrethrin.shields.pandas |
File I/O, parsing, data manipulation | read_csv, read_excel, read_json, concat, merge, pivot, etc. |
pyrethrin.shields.numpy |
95%+ of numpy API | Array creation, math, linalg, FFT, random, I/O |
pyrethrin.shields.fastapi |
Core FastAPI | FastAPI, APIRouter, Request, Response, dependencies |
Usage
Replace your imports with the shielded version:
# Before - exceptions are implicit
import pandas as pd
df = pd.read_csv("data.csv") # Can raise OSError, ParserError, ValueError...
# After - exceptions are explicit and must be handled
from pyrethrin.shields import pandas as pd
from pyrethrin import match, Ok
result = match(pd.read_csv, "data.csv")({
Ok: lambda df: process(df),
OSError: lambda e: log_error("File not found", e),
pd.ParserError: lambda e: log_error("Invalid CSV", e),
ValueError: lambda e: log_error("Bad data", e),
})
Shields export everything from the original library, so you can use them as drop-in replacements. Only the functions that can fail are wrapped with @raises.
Exception Discovery with Arbor
Shield exception declarations are generated using Arbor, a static analysis tool that traverses Python call graphs to discover all possible exceptions. This ensures shields declare the actual exceptions that can occur, not just guesses.
API Reference
@raises(*exceptions)
Declares which exceptions a function can raise.
@raises(ValueError, KeyError)
def risky_function(x: str) -> int:
if not x:
raise ValueError("empty string")
return data[x] # may raise KeyError
Behavior:
- Returns
Ok(value)on success - Returns
Err(exception)for declared exceptions - Raises
UndeclaredExceptionErrorfor undeclared exceptions
@returns_option
Marks a function as returning an Option type.
@returns_option
def find_item(items: list, key: str) -> Option[Any]:
for item in items:
if item.key == key:
return Some(item)
return Nothing()
Requirements:
- Function must return
Some(value)orNothing() - Raises
TypeErrorotherwise
match(fn, *args, **kwargs)
Creates a match builder for exhaustive error handling.
# For @raises functions
result = match(risky_function, "key")({
Ok: lambda value: f"Got {value}",
ValueError: lambda e: f"Bad value: {e}",
KeyError: lambda e: f"Missing key: {e}",
})
# For @returns_option functions
result = match(find_item, items, "key")({
Some: lambda item: f"Found {item}",
Nothing: lambda: "Not found",
})
Raises ExhaustivenessError if:
Okhandler is missing (for Result types)- Any declared exception handler is missing
SomeorNothinghandler is missing (for Option types)
Ok and Err
Result types for success or failure.
from pyrethrin import Ok, Err
result: Ok[int] | Err[ValueError] = Ok(42)
result.value # 42
result.is_ok() # True
result.is_err() # False
error = Err(ValueError("oops"))
error.error # ValueError("oops")
error.is_ok() # False
error.is_err() # True
Some and Nothing
Option types for optional values.
from pyrethrin import Some, Nothing
option = Some(42)
option.value # 42
option.is_some() # True
option.is_nothing() # False
empty = Nothing()
empty.is_some() # False
empty.is_nothing() # True
Nothing() == Nothing() # True (all Nothing instances are equal)
Note: There is no unwrap() method. You must use pattern matching.
Option Type
For functions that may or may not return a value:
from pyrethrin import returns_option, match, Some, Nothing, Option
@returns_option
def find_user(user_id: str) -> Option[dict]:
user = db.get(user_id)
if user is None:
return Nothing()
return Some(user)
# Must handle both cases
result = match(find_user, "123")({
Some: lambda user: f"Found: {user['name']}",
Nothing: lambda: "User not found",
})
Combining Decorators
Sometimes you need both: a function that may return nothing (Option) AND may fail with an exception (Result). You can combine @raises and @returns_option for this.
Correct Order: @raises on top
from pyrethrin import raises, Ok, Err
from pyrethrin.decorators import returns_option
from pyrethrin.option import Some, Nothing, Option
class DatabaseError(Exception):
pass
class ValidationError(Exception):
pass
@raises(DatabaseError, ValidationError)
@returns_option
def find_product(product_id: str) -> Option[dict]:
"""
Find a product by ID.
Returns:
- Ok(Some(product)) - found
- Ok(Nothing()) - not found
- Err(DatabaseError) - connection failed
- Err(ValidationError) - invalid ID
"""
if not product_id.startswith("prod-"):
raise ValidationError(f"Invalid ID: {product_id}")
if product_id == "prod-error":
raise DatabaseError("Connection failed")
product = PRODUCTS.get(product_id)
if product is None:
return Nothing()
return Some(product)
The result type is Result[Option[T], E] - a nested type requiring two levels of handling.
Handling Nested Result[Option[T], E]
Nested pattern matching (explicit):
def get_product_price(product_id: str) -> str:
result = find_product(product_id)
match result:
case Ok(option_value):
match option_value:
case Some(product):
return f"${product['price']:.2f}"
case Nothing():
return "Product not found"
case Err(DatabaseError() as e):
return f"Database error: {e}"
case Err(ValidationError() as e):
return f"Invalid input: {e}"
Flat pattern matching (concise):
def get_product_price(product_id: str) -> str:
match find_product(product_id):
case Ok(Some(product)):
return f"${product['price']:.2f}"
case Ok(Nothing()):
return "Product not found"
case Err(DatabaseError() as e):
return f"Database error: {e}"
case Err(ValidationError() as e):
return f"Invalid input: {e}"
Wrong Order: @returns_option on top
# DON'T DO THIS - will raise TypeError at runtime
@returns_option
@raises(ValueError)
def wrong_order(x: int) -> int:
if x < 0:
raise ValueError("negative")
return x
wrong_order(5) # TypeError: returned Ok instead of Some or Nothing
The @raises decorator returns Ok/Err, but @returns_option expects Some/Nothing.
When to Use Combined Decorators
Use @raises + @returns_option when your function has two distinct failure modes:
| Scenario | Use |
|---|---|
| Value exists or doesn't | @returns_option alone |
| Operation can fail | @raises alone |
| Value may not exist AND operation can fail | @raises + @returns_option |
Example scenarios:
- Database lookup that may not find a record AND may have connection errors
- API call that may return no data AND may timeout
- File parsing that may have no matches AND may fail to read
See examples/combined_decorators_correct.py and examples/combined_decorators_missing_handlers.py for complete examples.
Async Support
Use @async_raises and async_match for async functions:
from pyrethrin import async_raises, async_match, Ok
@async_raises(ConnectionError, TimeoutError)
async def fetch_data(url: str) -> bytes:
async with session.get(url) as response:
return await response.read()
async def handle_fetch(url: str) -> str:
return await async_match(fetch_data, url)({
Ok: lambda data: data.decode(),
ConnectionError: lambda e: "Connection failed",
TimeoutError: lambda e: "Request timed out",
})
Pattern Matching (Python 3.10+)
Pyrethrin works with Python's structural pattern matching:
result = get_user("123")
match result:
case Ok(user):
return {"status": "ok", "user": user.to_dict()}
case Err(UserNotFound() as e):
return {"status": "error", "code": 404, "message": str(e)}
case Err(InvalidUserId() as e):
return {"status": "error", "code": 400, "message": str(e)}
The static analyzer verifies exhaustiveness for native match-case too.
Error Codes
| Code | Severity | Description |
|---|---|---|
| EXH001 | Error | Missing handlers for declared exceptions |
| EXH002 | Warning | Handlers for undeclared exceptions |
| EXH003 | Error | Missing Ok handler |
| EXH004 | Warning | Unknown function (no @raises signature) |
| EXH005 | Error | Missing Some handler |
| EXH006 | Error | Missing Nothing handler |
| EXH007 | Error | Result not handled with match |
| EXH008 | Error | Option not handled with match |
Exception Types
ExhaustivenessError
Raised when a match is not exhaustive.
from pyrethrin import ExhaustivenessError
try:
match(get_user, "123")({
Ok: lambda u: u,
# Missing exception handlers
})
except ExhaustivenessError as e:
print(e.func_name) # "get_user"
print(e.missing) # [UserNotFound, InvalidUserId]
UndeclaredExceptionError
Raised when a function raises an exception not in its @raises declaration.
from pyrethrin import UndeclaredExceptionError
@raises(ValueError)
def buggy():
raise KeyError("oops") # Not declared
try:
buggy()
except UndeclaredExceptionError as e:
print(e.fn) # "buggy"
print(e.got) # "KeyError"
print(e.declared) # ["ValueError"]
Testing
from pyrethrin import raises, match, Ok, Err
import pytest
@raises(ValueError)
def parse_int(s: str) -> int:
return int(s)
def test_parse_int_success():
result = parse_int("42")
assert isinstance(result, Ok)
assert result.value == 42
def test_parse_int_failure():
result = parse_int("not a number")
assert isinstance(result, Err)
assert isinstance(result.error, ValueError)
def test_exhaustive_handling():
result = match(parse_int, "42")({
Ok: lambda n: n * 2,
ValueError: lambda e: 0,
})
assert result == 84
Configuration
Environment Variables
| Variable | Description |
|---|---|
PYRETHRIN_DISABLE_STATIC_CHECK |
Set to 1 to disable static analysis (useful for production) |
Architecture
Pyrethrin consists of two components:
- Python Library - Runtime decorators, Result/Option types, AST extraction
- Pyrethrum - OCaml static analyzer for exhaustiveness checking (bundled as platform-specific binary)
When a decorated function is called:
- The decorator invokes static analysis on the caller's source file
- AST is extracted and converted to JSON
- JSON is passed to the Pyrethrum binary
- Pyrethrum checks exhaustiveness and returns diagnostics
ExhaustivenessErroris raised if violations are found
Results are cached per call site to avoid redundant analysis.
Contributing
Contributions are welcome! Please see our Contributing Guide.
# Setup development environment
git clone https://github.com/4tyone/pyrethrin
cd pyrethrin
pip install -e ".[dev]"
# Run tests
pytest
# Run linting
ruff check .
License
Apache-2.0
Project details
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 pyrethrin-0.2.0.tar.gz.
File metadata
- Download URL: pyrethrin-0.2.0.tar.gz
- Upload date:
- Size: 3.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
41c91769e7e4419aa69c6dc350cc51e8e34071b740aed57f79b4cfdab89c8307
|
|
| MD5 |
bd401b4f68549badb797d57a7f16151d
|
|
| BLAKE2b-256 |
63dbb3e3d1f3f2d246c07b9f2324b7697c9609ccc7318fcc99ffdeb98cc557ef
|
File details
Details for the file pyrethrin-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pyrethrin-0.2.0-py3-none-any.whl
- Upload date:
- Size: 1.9 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7ac07f05aa7cc5996269fd562d33cfb4496be1cd81625b836380ec6abfaec639
|
|
| MD5 |
bfa453beefc3969f1cc9280ed2be4b76
|
|
| BLAKE2b-256 |
f433f875747c89e86eb98d15505371421c15e23c8af0d734c7c4a0aad7b50293
|