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.0.tar.gz (194.1 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.0-py3-none-any.whl (56.1 kB view details)

Uploaded Python 3

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

Uploaded CPython 3.10+Windows x86-64

spikard-0.2.0-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.0-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.0.tar.gz.

File metadata

  • Download URL: spikard-0.2.0.tar.gz
  • Upload date:
  • Size: 194.1 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.0.tar.gz
Algorithm Hash digest
SHA256 7b72dc25788fea4bbd750de5747abfb931b0e26e51e64cc0da52520f411df36f
MD5 0daa2f724053cd29c4b8d7d7472ca8ff
BLAKE2b-256 93962e7547124b5188e54a92c40413e16d0cd0b08b389276aef44809e9f5074f

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.0.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.0-py3-none-any.whl.

File metadata

  • Download URL: spikard-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 56.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1380242ae50a4d0994132a11b222779d3e288e21931d98a84ab309c885c120d2
MD5 879a6cb2d6a96f8faf6ed71f5ada803a
BLAKE2b-256 7670b27deebb1ec09495100e98af35e7c8e49b20d72f635bf777a003c3682ba0

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.0-py3-none-any.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.0-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: spikard-0.2.0-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.0-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 6caf89968584c235eaaa819afadb5b80571e1c8011f4925b2167a1d50e1878e0
MD5 b9bbb880d88ff4977c7ef077bd332747
BLAKE2b-256 e5d055031815953217adf0282eecd9aaac4b5f2a5912b69b04726059284a4728

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.0-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.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for spikard-0.2.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 bf0ba5f012bee73a6ac22d0ff7fc180da0d44aee8001ea17a410247a353d68a6
MD5 31a6f9cae1d6586aba8d61abf7fd8cf8
BLAKE2b-256 1e8f8193e123cf6fe83a3922d54f421aed634f232f12b05b81641169b61d8da2

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.0-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.0-cp310-abi3-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for spikard-0.2.0-cp310-abi3-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 ff290658c3eb7c1a622f16e96cdcc979cd7296010f8cfbbaf82ab4e57ad70ce2
MD5 ea780695ff4c7451091a177443a094e7
BLAKE2b-256 bf56b0e794131882a87ee37fea63a63ab3b2c7f4f1d08cb20f6b2584bbb6632e

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.2.0-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