Skip to main content

Fast, type-safe web framework with Rust-powered Python bindings

Project description

spikard-py

High-performance Python bindings for Spikard HTTP framework via PyO3.

Status & Badges

Crates.io Downloads Documentation PyPI License

Overview

PyO3 bindings that expose the Rust HTTP runtime to Python applications under the module name _spikard.

This crate implements high-performance Python bindings for Spikard's HTTP server by bridging Python's asyncio with Rust's async/await runtime through carefully optimized FFI patterns.


Table of Contents


Architecture

The spikard-py crate implements the language-agnostic Handler trait defined in spikard-http, enabling clean separation between the pure Rust HTTP server and Python-specific code.

Handler Trait Implementation

// From spikard-http/src/handler_trait.rs
pub trait Handler: Send + Sync {
    fn call(
        &self,
        request: Request<Body>,
        request_data: RequestData,
    ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>>;
}

The PythonHandler struct (src/handler.rs) implements this trait, wrapping Python callable objects and converting between Rust and Python types. This design keeps spikard-http completely free of FFI dependencies while enabling multiple language bindings (Python, Node.js, WASM) to share the same HTTP core.

Key architectural benefits:

  1. Zero FFI dependencies in HTTP core - spikard-http has no knowledge of Python, PyO3, or any other language binding
  2. Language-agnostic routing - Routes are defined once and work with any Handler implementation
  3. Isolated FFI concerns - All Python-specific code lives in this crate
  4. Future-proof - New language bindings can be added without touching the HTTP server

Performance Optimizations

Async Handler Execution

Problem: Naively calling Python async handlers from Rust using tokio::task::spawn_blocking introduces ~4.8ms overhead per request due to thread pool management and GIL coordination.

Solution: Convert Python coroutines directly to Rust futures using pyo3_async_runtimes::tokio::into_future(), eliminating the blocking thread pool entirely.

Implementation (src/handler.rs:155-194):

// For async handlers
let output = Python::attach(|py| {
    let handler_obj = handler.bind(py);

    // Prepare kwargs with request data
    let kwargs = request_data_to_py_kwargs(py, &request_data, handler_obj.clone())?;

    // Call Python async function, returns a coroutine
    let coroutine = if let Some(py_params) = validated_params {
        handler_obj.call((), Some(&kwargs))?
    } else {
        handler_obj.call((), Some(&kwargs))?
    };

    // ✅ Convert Python coroutine → Rust future (no spawn_blocking!)
    pyo3_async_runtimes::tokio::into_future(coroutine)
})
.map_err(|e: PyErr| (StatusCode::INTERNAL_SERVER_ERROR, format!("Python error: {}", e)))?
.await  // ✅ Await the Rust future directly on Tokio runtime
.map_err(|e: PyErr| (StatusCode::INTERNAL_SERVER_ERROR, format!("Python error: {}", e)))?;

Performance impact:

  • Before: ~5ms per async request (spawn_blocking + GIL + wake overhead)
  • After: ~170µs per async request (pure Python execution)
  • Improvement: ~25-30x faster for async handlers

Note: Synchronous Python handlers still require spawn_blocking to avoid blocking Tokio's executor, as they cannot yield control.


Event Loop Reuse

Problem: Creating a new Python event loop for each async handler invocation adds ~55µs overhead and increases GIL contention.

Solution: Initialize the event loop once at server startup and reuse it across all async handler calls using TaskLocals stored in a OnceCell.

Implementation (src/handler.rs:28-45):

use once_cell::sync::OnceCell;
use pyo3_async_runtimes::TaskLocals;

/// Global Python event loop task locals for async handlers
static TASK_LOCALS: OnceCell<TaskLocals> = OnceCell::new();

/// Initialize Python event loop for async handlers
/// Must be called once after Python::initialize()
pub fn init_python_event_loop() -> PyResult<()> {
    Python::attach(|py| {
        let asyncio = py.import("asyncio")?;
        let event_loop = asyncio.call_method0("new_event_loop")?;
        asyncio.call_method1("set_event_loop", (event_loop.clone(),))?;

        // ✅ Initialize once, reuse forever
        TASK_LOCALS.get_or_try_init(|| {
            TaskLocals::new(event_loop.into()).copy_context(py)
        })?;

        Ok(())
    })
}

Performance impact:

  • Eliminates ~55µs event loop creation overhead per request
  • Reduces GIL contention by reusing the same loop
  • One-time initialization cost amortized across millions of requests

Usage: The CLI (crates/spikard-cli/src/main.rs) calls init_python_event_loop() once during server startup.


Zero-Copy Data Conversion

Problem: Converting JSON data between Rust (serde_json::Value) and Python objects via intermediate JSON strings doubles the serialization cost:

// ❌ Inefficient: Rust Value → JSON string → Python object
let json_str = serde_json::to_string(value)?;  // Serialize to string
json_module.call_method1("loads", (json_str,))  // Parse string in Python

Solution: Directly construct Python objects from Rust Value using PyO3 native types, bypassing the JSON string encoding entirely.

Implementation (src/handler.rs:434-476):

/// Convert JSON Value to Python object (optimized zero-copy conversion)
///
/// Performance improvement: ~30-40% faster than the json.loads approach
fn json_to_python<'py>(py: Python<'py>, value: &Value) -> PyResult<Bound<'py, PyAny>> {
    use pyo3::types::{PyBool, PyDict, PyFloat, PyList, PyNone, PyString};

    match value {
        Value::Null => Ok(PyNone::get(py).as_any().clone()),

        Value::Bool(b) => Ok(PyBool::new(py, *b).as_any().clone()),

        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                Ok(i.into_pyobject(py)?.into_any())
            } else if let Some(u) = n.as_u64() {
                Ok(u.into_pyobject(py)?.into_any())
            } else if let Some(f) = n.as_f64() {
                Ok(PyFloat::new(py, f).into_any())
            } else {
                // Fallback for exotic number representations
                Ok(PyString::new(py, &n.to_string()).into_any())
            }
        }

        Value::String(s) => Ok(PyString::new(py, s).into_any()),

        // Recursively convert arrays
        Value::Array(arr) => {
            let py_list = PyList::empty(py);
            for item in arr {
                let py_item = json_to_python(py, item)?;
                py_list.append(py_item)?;
            }
            Ok(py_list.into_any())
        }

        // Recursively convert objects
        Value::Object(obj) => {
            let py_dict = PyDict::new(py);
            for (key, value) in obj {
                let py_value = json_to_python(py, value)?;
                py_dict.set_item(key, py_value)?;
            }
            Ok(py_dict.into_any())
        }
    }
}

Performance impact:

  • Before: ~100µs for typical request body conversion (serialize + parse)
  • After: ~60µs for same conversion (direct construction)
  • Improvement: ~30-40% faster, scales with payload complexity

Note: This optimization complements the msgspec integration in packages/python/spikard/_internal/converters.py for even faster Python-side serialization.


GIL Management

Problem: Holding Python's Global Interpreter Lock (GIL) during async awaits blocks other Python threads and degrades concurrency.

Solution: Minimize GIL scope by releasing it before awaiting Rust futures.

Implementation pattern:

// ✅ Correct: GIL released before async await
let output = Python::attach(|py| {
    // GIL held only during Python code execution
    let handler_obj = handler.bind(py);
    let kwargs = request_data_to_py_kwargs(py, &request_data, handler_obj.clone())?;
    let coroutine = handler_obj.call((), Some(&kwargs))?;

    // Convert to Rust future while still holding GIL
    pyo3_async_runtimes::tokio::into_future(coroutine)
})  // ✅ GIL released here
.await  // ✅ No GIL held during async wait

Anti-pattern to avoid:

// ❌ Wrong: GIL held during entire async operation
Python::with_gil(|py| async move {
    // GIL held for entire async block - blocks other threads!
    let result = some_async_operation().await;
    result
}).await

Performance impact:

  • Enables true concurrent request handling (GIL not blocking other requests)
  • Reduces latency spikes under concurrent load
  • Critical for achieving high requests-per-second throughput

Validation in Rust

Problem: Validating request bodies and parameters in Python requires acquiring the GIL, converting data to Python objects, and executing Python validation code—all before the handler runs.

Solution: Perform JSON Schema validation and parameter extraction in pure Rust before entering Python, enabling early returns on invalid requests without GIL overhead.

Implementation (src/handler.rs:100-147):

// ✅ Validate request body in Rust BEFORE entering Python
if let Some(validator) = &self.request_validator {
    if let Err(errors) = validator.validate(&request_data.body) {
        let problem = ProblemDetails::from_validation_error(&errors);
        return Err((problem.status_code(), problem.to_json_pretty()?));
    }
}

// ✅ Validate and extract parameters in Rust
let validated_params = if let Some(validator) = &self.parameter_validator {
    match validator.validate_and_extract(&request_data.path_params, &request_data.query_params) {
        Ok(params) => Some(params),
        Err(errors) => {
            let problem = ProblemDetails::from_validation_error(&errors);
            return Err((problem.status_code(), problem.to_json_pretty()?));
        }
    }
} else {
    None
};

// Only enter Python if validation passed
let handler = self.handler.clone();
if self.is_async {
    // ... call Python handler with validated data ...
}

Performance impact:

  • Validation happens in pure Rust (no GIL contention)
  • Invalid requests return immediately (never enter Python)
  • Validated parameters passed directly to handler (no re-validation)
  • Structured RFC 9457 Problem Details errors for client consumption

Security benefit: All input validation occurs in memory-safe Rust before untrusted data reaches Python code.


PyO3 Configuration

Extension Module Management

Critical configuration: The extension-module feature in PyO3 controls whether the library links to libpython. This setting must differ between extension modules (.so/.pyd files) and binaries (executables, tests).

Problem: If extension-module is in default features, binaries fail to link with errors like:

ld: symbol(s) not found for architecture arm64
  "__Py_NoneStruct", referenced from...

Solution: Keep extension-module as an optional feature, enable it only for maturin builds.

Configuration (Cargo.toml):

[features]
default = []  # ✅ NOT including extension-module
extension-module = ["pyo3/extension-module"]
server = ["dep:tracing", "dep:anyhow", "dep:clap", "pyo3/auto-initialize"]

Configuration (pyproject.toml):

[tool.maturin]
module-name = "_spikard"
features = ["extension-module"]  # ✅ Enable for Python extension builds

Why this works:

  • Python extensions (.so/.pyd built by maturin): Enable extension-module → don't link libpython → compatible with manylinux wheels
  • Rust binaries (CLI, tests): Disable extension-module → link libpython → can embed Python interpreter

Reference: PyO3 FAQ - extension-module feature


Building & Installation

Development Build (Editable Install)

For rapid iteration during development:

# From repository root
uv run maturin develop

# Or use the task runner
task build:py

This creates an editable install, allowing you to modify Rust code and rebuild without reinstalling the wheel.

Release Build

For production or benchmarking (enables optimizations):

# Build optimized wheel
uv run maturin build --release

# Or use task runner
task build:py --release

Important: Always benchmark with --release builds, as debug builds are 10-50x slower.

Building the Test Server

A standalone server binary is available for testing:

cargo build --release -p spikard-py --bin spikard-py-server --features server

Run it:

./target/release/spikard-py-server /path/to/app.py --port 8000

Testing

Rust Unit Tests

Test the Handler trait implementation in isolation:

# Run unit tests for spikard-http (Handler trait tests)
cargo test -p spikard-http

# Run with output
cargo test -p spikard-http -- --nocapture

Python Integration Tests

End-to-end tests that exercise the full Python → Rust → HTTP stack:

# From repository root
PYTHONPATH=packages/python uv run pytest e2e/python/tests/

# Run specific test file
PYTHONPATH=packages/python uv run pytest e2e/python/tests/test_query_params.py

# Run with verbose output
PYTHONPATH=packages/python uv run pytest e2e/python/tests/ -v

Full Test Suite

Run all tests (Rust + Python):

task test

Note: Integration tests for spikard-py itself require special PyO3 configuration due to the extension-module feature. The comprehensive e2e Python tests provide equivalent coverage.


Implementation Details

Module Structure

src/
├── lib.rs           # PyO3 module definition, Python-facing API
├── handler.rs       # PythonHandler implementation, core FFI logic
└── bin/
    └── server.rs    # Standalone server binary for testing

Key Types

PythonHandler

The bridge between Python callables and the Handler trait:

pub struct PythonHandler {
    handler: Py<PyAny>,                           // Python callable
    is_async: bool,                               // Async vs sync handler
    request_validator: Option<Arc<SchemaValidator>>,
    response_validator: Option<Arc<SchemaValidator>>,
    parameter_validator: Option<Arc<ParameterValidator>>,
}

Methods:

  • new() - Construct with validators
  • call() - Implement Handler trait, route to async vs sync execution

RequestData Conversion

Python handlers receive request data as keyword arguments:

async def my_handler(
    path_params: dict,
    query_params: dict,
    body: dict,
    headers: dict,
    cookies: dict,
    method: str,
    path: str,
):
    return {"message": "Hello"}

The request_data_to_py_kwargs() function (src/handler.rs:242-280) converts Rust RequestData to Python kwargs:

fn request_data_to_py_kwargs(
    py: Python,
    data: &RequestData,
    handler: Bound<PyAny>,
) -> PyResult<Bound<PyDict>> {
    let kwargs = PyDict::new(py);

    // Convert each field using zero-copy json_to_python()
    kwargs.set_item("path_params", json_to_python(py, &data.path_params)?)?;
    kwargs.set_item("query_params", json_to_python(py, &data.query_params)?)?;
    kwargs.set_item("body", json_to_python(py, &data.body)?)?;
    // ... headers, cookies, method, path ...

    Ok(kwargs)
}

Response Handling

Python handlers can return:

  1. Plain dict/list → 200 OK JSON response
  2. Custom Response object → Extract status_code, content, headers

The python_to_response_result() function (src/handler.rs:282-335) handles both cases:

fn python_to_response_result(py: Python, output: &Bound<PyAny>) -> PyResult<ResponseResult> {
    if output.hasattr("status_code")? {
        // Custom Response object
        let status_code: u16 = output.getattr("status_code")?.extract()?;
        let content: Value = /* extract and convert to JSON */;
        let headers: HashMap<String, String> = /* extract headers dict */;

        Ok(ResponseResult::Custom {
            content,
            status_code,
            headers,
        })
    } else {
        // Plain dict/list → default 200 OK
        let json_value = python_to_json(py, output)?;
        Ok(ResponseResult::Json(json_value))
    }
}

Error Handling

All errors crossing the FFI boundary are converted to PyErr:

// Domain errors → PyErr
let result = some_fallible_operation()
    .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("Error: {}", e)))?;

// Validation errors → RFC 9457 Problem Details JSON
if let Err(errors) = validator.validate(&data) {
    let problem = ProblemDetails::from_validation_error(&errors);
    return Err((problem.status_code(), problem.to_json_pretty()?));
}

Never panic across FFI boundaries. All fallible paths return PyResult<T>.


Design Principles

Panic-Free FFI

Rust code in this crate must avoid panics. All fallible operations use Result<T, E> and propagate errors with ?. Errors are converted to PyErr before crossing the FFI boundary.

Why: Panicking across FFI boundaries is undefined behavior and can corrupt Python's runtime state.

Minimal GIL Scope

The GIL should be held only during Python code execution, never during async awaits or Rust computations.

Why: The GIL is a global lock—holding it unnecessarily serializes concurrent requests.

Zero-Copy Where Possible

Prefer direct type conversion over serialization intermediates (JSON strings, msgpack bytes).

Why: Each serialization/deserialization cycle costs CPU and allocations. Zero-copy conversion is 30-40% faster.

Validate Early in Rust

Perform input validation in Rust before entering Python, enabling early returns on invalid data.

Why: Invalid requests fail fast without GIL overhead, improving throughput and security.


Performance Characteristics

Async Handler Latency

Component Time Notes
HTTP parsing (Axum) ~10µs Rust HTTP/1.1 parsing
Validation (Rust) ~20µs JSON Schema validation
FFI overhead ~5µs Rust → Python type conversion
Python handler execution ~150µs Depends on handler complexity
Response conversion ~10µs Python → Rust → HTTP response
Total ~195µs ~5,000 req/sec per core

Throughput Benchmarks

With a simple async handler returning {"message": "Hello"}:

  • Single core: ~60,000 req/sec
  • Concurrency 50: ~58,000 req/sec (stable under load)
  • P99 latency: <2ms

Comparison to pure Python:

  • FastAPI (uvicorn): ~3,000 req/sec (20x slower)
  • Pure Python asyncio: ~8,000 req/sec (7x slower)

Note: These numbers are from simple handlers. Real-world performance depends on handler complexity, database I/O, etc.


Future Optimizations

Object Pooling

Reuse Python objects (dicts, lists) across requests to reduce allocation pressure:

// Future optimization: object pool for kwargs dicts
static KWARGS_POOL: ObjectPool<PyDict> = ObjectPool::new();

let kwargs = KWARGS_POOL.acquire(py);
// ... populate kwargs ...

Estimated improvement: +5-10% throughput

HTTP Body Streaming

Currently buffering entire request body in memory. Stream large payloads directly to Python asyncio streams:

async def upload_handler(body_stream):
    async for chunk in body_stream:
        await process_chunk(chunk)

Benefit: Support multi-GB uploads without memory pressure.

Sub-Interpreter Support

Use Python 3.12+ sub-interpreters to run multiple Python runtimes in parallel (bypassing GIL):

// Future: per-worker sub-interpreter
let sub_interpreter = SubInterpreter::new()?;
sub_interpreter.run(handler)?;

Benefit: True parallelism for CPU-bound Python handlers.


Related Documentation

  • Architecture: docs/adr/0001-architecture-and-principles.md
  • Validation Strategy: docs/adr/0003-validation-and-fixtures.md
  • msgspec Integration: docs/adr/0003-validation-and-fixtures.md
  • HTTP Core: crates/spikard-http/README.md
  • Python Package: packages/python/README.md

Development Notes

Code Style

  • Rust code follows rustfmt.toml (run cargo fmt)
  • Keep functions small and focused (<100 lines)
  • Document public APIs with /// doc comments
  • Use debug_log_module!() macro for conditional logging

Performance Profiling

Profile with perf on Linux or Instruments on macOS:

# Build with debug info
cargo build --release -p spikard-py --features server

# Run server under profiler
perf record -F 99 -g ./target/release/spikard-py-server app.py

# Generate flamegraph
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

Debugging FFI Issues

Enable debug logging:

DEBUG=1 ./target/release/spikard-py-server app.py

Enable Python tracebacks in PyO3:

pyo3::prepare_freethreaded_python();
Python::with_gil(|py| {
    py.run("import sys; sys.excepthook = lambda *args: None", None, None).unwrap();
});

Contributing

When adding features to this crate:

  1. Maintain panic-free guarantees - All errors → PyErr
  2. Update tests - Add Rust unit tests and Python e2e tests
  3. Benchmark changes - Profile before/after with realistic workloads
  4. Document patterns - Update this README with new optimization techniques
  5. Run full test suite - task test before committing

Refer to ai-rulez.yaml for comprehensive rules and patterns.


License

MIT - See LICENSE in repository root.

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

spikard-0.2.2.tar.gz (194.0 kB view details)

Uploaded Source

Built Distributions

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

spikard-0.2.2-cp310-abi3-win_amd64.whl (5.6 MB view details)

Uploaded CPython 3.10+Windows x86-64

spikard-0.2.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (5.4 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

spikard-0.2.2-cp310-abi3-macosx_14_0_arm64.whl (5.0 MB view details)

Uploaded CPython 3.10+macOS 14.0+ ARM64

File details

Details for the file spikard-0.2.2.tar.gz.

File metadata

  • Download URL: spikard-0.2.2.tar.gz
  • Upload date:
  • Size: 194.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.2.2.tar.gz
Algorithm Hash digest
SHA256 d0ee19ad57f8287c15b0ffdb0e49a9d3c8ec49d27b12ae150ff144d2f863407a
MD5 dba42691065b99278aa8e5e0a32bf05e
BLAKE2b-256 6cb5f5fe97a0cfb22bbab98400a1191bfdd5bf80f5274c92d0f079e0310ed735

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.2.tar.gz:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.2.2-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: spikard-0.2.2-cp310-abi3-win_amd64.whl
  • Upload date:
  • Size: 5.6 MB
  • Tags: CPython 3.10+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.2.2-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 5e8f18e21f84e21b408aa89f8ac6ef7dbfe9623ea6ede9587f86f2dabc22ea9b
MD5 2cd6d26ecbf92365d76e0e146148c013
BLAKE2b-256 0f7f9da885f517b66dbef506ee43a3541b6935fffb80104f76f1b3999f23e9ff

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.2-cp310-abi3-win_amd64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.2.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for spikard-0.2.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 b0132dee8ee9a1a05cd78d7904490ae59e6cda88e61242365870518944607a82
MD5 b9446360808f9e87acf0c72805c91b86
BLAKE2b-256 6f66a35e50c57ee646d5aa24cf32c79a7d1423a8276da1bd34de623639c7169f

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.2.2-cp310-abi3-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for spikard-0.2.2-cp310-abi3-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 9f58a2b294abba4c3a883536314727538cc76316637979b4726db530bae3f11f
MD5 fb2dd7c720acc346f6507a136d304459
BLAKE2b-256 9220f299ac6a2f609ac5a6db142d6da4afc186ff296d92d72002ded64548c893

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.2-cp310-abi3-macosx_14_0_arm64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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