Skip to main content

Rust-style exhaustive error and None handling for Python

Project description

Pyrethrin Logo

Pyrethrin

Rust and OCaml-style exhaustive error and None handling for Python

PyPI Version Python Version License

InstallationQuick StartAPI ReferenceDocumentationLicense


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 recieve 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

  • @raises decorator - Declare exceptions a function can throw
  • @returns_option decorator - Mark functions returning optional values
  • match() function - Exhaustive error handling for Result and Option types
  • Result type - Ok and Err for explicit success/failure
  • Option type - Some and Nothing for 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
})

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 UndeclaredExceptionError for 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) or Nothing()
  • Raises TypeError otherwise

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:

  • Ok handler is missing (for Result types)
  • Any declared exception handler is missing
  • Some or Nothing handler 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:

  1. Python Library - Runtime decorators, Result/Option types, AST extraction
  2. Pyrethrum - OCaml static analyzer for exhaustiveness checking (bundled as platform-specific binary)

When a decorated function is called:

  1. The decorator invokes static analysis on the caller's source file
  2. AST is extracted and converted to JSON
  3. JSON is passed to the Pyrethrum binary
  4. Pyrethrum checks exhaustiveness and returns diagnostics
  5. ExhaustivenessError is 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

pyrethrin-0.1.5.tar.gz (3.4 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyrethrin-0.1.5-py3-none-any.whl (1.9 MB view details)

Uploaded Python 3

File details

Details for the file pyrethrin-0.1.5.tar.gz.

File metadata

  • Download URL: pyrethrin-0.1.5.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

Hashes for pyrethrin-0.1.5.tar.gz
Algorithm Hash digest
SHA256 2e3f78c923fd8fcde7a3311f97b145c8afab1389fa837d61fe5dbacbc53fbc32
MD5 041b15ec95e6396c29711b320e0a9fcd
BLAKE2b-256 cb35d43cd7f3b61daba73bd6bd7978b4ffe430c3e15d3c1a0b638fc818086f74

See more details on using hashes here.

File details

Details for the file pyrethrin-0.1.5-py3-none-any.whl.

File metadata

  • Download URL: pyrethrin-0.1.5-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

Hashes for pyrethrin-0.1.5-py3-none-any.whl
Algorithm Hash digest
SHA256 b205b3413ba0dfeb864ef6aa0f26a1b8ba38aeafb9c1a857bf81cb2e355a0fe7
MD5 e56e84361c976b06490be71f6cdd90c6
BLAKE2b-256 9cbebcbf8601b8d88f79a5ff5f230607faa3c15ac1895f15595380760b890095

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page