feat: add Python (PyO3) and Ruby (Magnus) native bindings

Introduces three crates:
- quicnprotochat-bindings: shared Rust API returning structured data
  instead of printing to stdout; explicit per-call auth (no global
  OnceLock); QPCE state files fully interoperable with the CLI.
- quicnprotochat-python: PyO3 0.22 extension; GIL released during
  all blocking QUIC calls via py.allow_threads.
- quicnprotochat-ruby: Magnus 0.7 extension; Rakefile build task.

Core/proto crates referenced via git dep on the main repo.
This commit is contained in:
2026-02-22 18:56:27 +01:00
commit f511903a5d
11 changed files with 1523 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "quicnprotochat-python"
version = "0.1.0"
edition = "2021"
description = "Python bindings for quicnprotochat (PyO3 / maturin)."
license = "MIT"
[lib]
name = "quicnprotochat"
crate-type = ["cdylib"]
[dependencies]
quicnprotochat-bindings = { path = "../quicnprotochat-bindings" }
pyo3 = { version = "0.22", features = ["extension-module"] }

View File

@@ -0,0 +1,17 @@
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "quicnprotochat"
version = "0.1.0"
description = "Python bindings for quicnprotochat — QUIC + MLS + post-quantum E2E chat."
license = { text = "MIT" }
requires-python = ">=3.8"
readme = "README.md"
[tool.maturin]
# The Cargo manifest is one level up in the workspace; point maturin at it.
manifest-path = "Cargo.toml"
module-name = "quicnprotochat"
features = ["pyo3/extension-module"]

View File

@@ -0,0 +1,176 @@
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use quicnprotochat_bindings::Client;
fn to_py(e: impl std::fmt::Display) -> PyErr {
PyRuntimeError::new_err(e.to_string())
}
// ── Return types as Python classes ───────────────────────────────────────────
#[pyclass(get_all)]
struct WhoamiInfo {
identity_key: String,
fingerprint: String,
hybrid_key: bool,
has_group: bool,
}
#[pyclass(get_all)]
struct HealthInfo {
status: String,
rtt_ms: u64,
}
#[pyclass(get_all)]
struct ReceivedMsg {
plaintext: String,
}
// ── Client ────────────────────────────────────────────────────────────────────
#[pyclass(name = "Client")]
struct PyClient {
inner: Client,
}
#[pymethods]
impl PyClient {
/// Create a new client.
///
/// Args:
/// server: "host:port" of the quicnprotochat server.
/// ca_cert: Path to the server's TLS certificate (DER format).
/// server_name: TLS SNI name (must match the certificate).
/// state_path: Path where client state is stored.
/// access_token: Bearer token for server authentication.
/// state_password: Optional password to decrypt/encrypt the state file.
/// device_id: Optional device identifier included in auth headers.
#[new]
#[pyo3(signature = (server, ca_cert, server_name, state_path, access_token,
state_password=None, device_id=None))]
fn new(
server: &str,
ca_cert: &str,
server_name: &str,
state_path: &str,
access_token: &str,
state_password: Option<String>,
device_id: Option<String>,
) -> PyResult<Self> {
let inner = Client::new(
server,
ca_cert,
server_name,
state_path,
access_token,
state_password,
device_id,
)
.map_err(to_py)?;
Ok(PyClient { inner })
}
/// Return local identity info from the state file (no network call).
fn whoami(&self, py: Python<'_>) -> PyResult<WhoamiInfo> {
let info = py.allow_threads(|| self.inner.whoami()).map_err(to_py)?;
Ok(WhoamiInfo {
identity_key: info.identity_key,
fingerprint: info.fingerprint,
hybrid_key: info.hybrid_key,
has_group: info.has_group,
})
}
/// Probe server health. Returns HealthInfo with `status` and `rtt_ms`.
fn health(&self, py: Python<'_>) -> PyResult<HealthInfo> {
let info = py.allow_threads(|| self.inner.health()).map_err(to_py)?;
Ok(HealthInfo {
status: info.status,
rtt_ms: info.rtt_ms,
})
}
/// Register a new user account via OPAQUE password authentication.
fn register_user(&self, py: Python<'_>, username: &str, password: &str) -> PyResult<()> {
let (u, p) = (username.to_string(), password.to_string());
py.allow_threads(|| self.inner.register_user(&u, &p))
.map_err(to_py)
}
/// Log in via OPAQUE. Returns the session token as a hex string.
///
/// Construct a new Client with this token as `access_token` for
/// subsequent authenticated calls.
fn login(&self, py: Python<'_>, username: &str, password: &str) -> PyResult<String> {
let (u, p) = (username.to_string(), password.to_string());
py.allow_threads(|| self.inner.login(&u, &p))
.map_err(to_py)
}
/// Upload the stored identity's KeyPackage and hybrid key to the server.
/// Returns the KeyPackage fingerprint as a hex string.
fn register_state(&self, py: Python<'_>) -> PyResult<String> {
py.allow_threads(|| self.inner.register_state())
.map_err(to_py)
}
/// Returns True if the peer has uploaded a hybrid public key.
fn check_key(&self, py: Python<'_>, peer_hex: &str) -> PyResult<bool> {
let k = peer_hex.to_string();
py.allow_threads(|| self.inner.check_key(&k))
.map_err(to_py)
}
/// Create a new MLS group and persist state.
fn create_group(&self, py: Python<'_>, group_id: &str) -> PyResult<()> {
let g = group_id.to_string();
py.allow_threads(|| self.inner.create_group(&g))
.map_err(to_py)
}
/// Invite a peer into the current group (fetches their KeyPackage,
/// adds them, and enqueues a hybrid-encrypted Welcome).
fn invite(&self, py: Python<'_>, peer_hex: &str) -> PyResult<()> {
let k = peer_hex.to_string();
py.allow_threads(|| self.inner.invite(&k)).map_err(to_py)
}
/// Join a group by consuming a Welcome from the server queue.
fn join(&self, py: Python<'_>) -> PyResult<()> {
py.allow_threads(|| self.inner.join()).map_err(to_py)
}
/// Encrypt and send `text` to `peer_hex` via the Delivery Service.
fn send_message(&self, py: Python<'_>, peer_hex: &str, text: &str) -> PyResult<()> {
let (k, t) = (peer_hex.to_string(), text.to_string());
py.allow_threads(|| self.inner.send_message(&k, &t))
.map_err(to_py)
}
/// Poll for incoming messages. Returns a list of ReceivedMsg objects.
///
/// Args:
/// wait_ms: Long-poll timeout in milliseconds (0 = return immediately).
#[pyo3(signature = (wait_ms = 0))]
fn recv(&self, py: Python<'_>, wait_ms: u64) -> PyResult<Vec<ReceivedMsg>> {
let msgs = py
.allow_threads(|| self.inner.recv(wait_ms))
.map_err(to_py)?;
Ok(msgs
.into_iter()
.map(|m| ReceivedMsg { plaintext: m.plaintext })
.collect())
}
}
// ── Module ────────────────────────────────────────────────────────────────────
#[pymodule]
fn quicnprotochat(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyClient>()?;
m.add_class::<WhoamiInfo>()?;
m.add_class::<HealthInfo>()?;
m.add_class::<ReceivedMsg>()?;
Ok(())
}