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:
14
crates/quicnprotochat-python/Cargo.toml
Normal file
14
crates/quicnprotochat-python/Cargo.toml
Normal 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"] }
|
||||
17
crates/quicnprotochat-python/pyproject.toml
Normal file
17
crates/quicnprotochat-python/pyproject.toml
Normal 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"]
|
||||
176
crates/quicnprotochat-python/src/lib.rs
Normal file
176
crates/quicnprotochat-python/src/lib.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user