feat: python bindings (#7)

Python bindings allowing us to interact with the library in a python REPL


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
This commit is contained in:
zacheryasc 2026-02-06 12:47:51 +00:00
parent e2c4f55941
commit 65614e14d3
11 changed files with 822 additions and 17 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target /target
.vscode/ .vscode/
.venv

106
Cargo.lock generated
View file

@ -203,12 +203,27 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.17" version = "0.4.17"
@ -257,6 +272,15 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -306,6 +330,12 @@ dependencies = [
"plotters-backend", "plotters-backend",
] ]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -315,6 +345,69 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pyo3"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@ -439,6 +532,7 @@ dependencies = [
"crossbeam-queue", "crossbeam-queue",
"crossbeam-utils", "crossbeam-utils",
"getrandom", "getrandom",
"pyo3",
] ]
[[package]] [[package]]
@ -452,6 +546,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tinytemplate" name = "tinytemplate"
version = "1.2.1" version = "1.2.1"
@ -468,6 +568,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View file

@ -11,12 +11,13 @@ crate-type = ["cdylib", "rlib"]
default = ["getrandom"] default = ["getrandom"]
getrandom = ["dep:getrandom"] getrandom = ["dep:getrandom"]
no_random = [] # compile without access to a source of randomness no_random = [] # compile without access to a source of randomness
stress = [] # Enable stress tests python = ["dep:pyo3"]
[dependencies] [dependencies]
getrandom = { version = "0.2", optional = true } getrandom = { version = "0.2", optional = true }
crossbeam-queue = "0.3.12" crossbeam-queue = "0.3.12"
crossbeam-utils = "0.8.21" crossbeam-utils = "0.8.21"
pyo3 = { version = "0.23", features = ["extension-module"], optional = true }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }

37
examples/hello_async.py Normal file
View file

@ -0,0 +1,37 @@
"""Async hello world: runtime runs in background threads, driven from asyncio."""
import asyncio
from swactor import Runtime, RuntimeConfig
async def recv(inbox, timeout=1.0):
"""Poll an inbox until a message arrives."""
while timeout > 0:
msg = inbox.try_recv()
if msg is not None:
return msg
await asyncio.sleep(0.01)
timeout -= 0.01
return None
async def main():
rt = Runtime(RuntimeConfig(num_threads=2))
def echo(ctx, msg):
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
addr = rt.spawn(echo)
inbox = rt.inbox()
handle = rt.run()
for name in ["alice", "bob", "charlie"]:
handle.send(addr, {"name": name, "reply_to": inbox.addr})
reply = await recv(inbox)
print(reply)
handle.shutdown()
handle.join()
asyncio.run(main())

View file

@ -0,0 +1,13 @@
"""Hello world: spawn an echo actor, send a message, get it back."""
from swactor import Runtime
def echo(ctx, msg):
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
rt = Runtime()
addr = rt.spawn(echo)
inbox = rt.inbox()
rt.send(addr, {"name": "world", "reply_to": inbox.addr})
rt.tick()
print(inbox.try_recv())

11
pyproject.toml Normal file
View file

@ -0,0 +1,11 @@
[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"
[project]
name = "swactor"
version = "0.1.0"
requires-python = ">=3.9"
[tool.maturin]
features = ["python"]

View file

@ -11,6 +11,15 @@ pub mod config;
pub mod runtime; pub mod runtime;
#[cfg(feature = "python")]
mod python;
#[cfg(feature = "python")]
#[pyo3::pymodule]
fn swactor(m: &pyo3::Bound<'_, pyo3::types::PyModule>) -> pyo3::PyResult<()> {
python::register(m)
}
#[cfg(feature = "getrandom")] #[cfg(feature = "getrandom")]
pub(crate) fn get_random(buf: &mut [u8]) { pub(crate) fn get_random(buf: &mut [u8]) {
getrandom::getrandom(buf).unwrap() getrandom::getrandom(buf).unwrap()

455
src/python.rs Normal file
View file

@ -0,0 +1,455 @@
use std::any::Any;
use std::cell::RefCell;
use pyo3::prelude::*;
use pyo3::types::PyModule;
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor};
use crate::config::{BackoffPolicy, RuntimeConfig};
use crate::runtime::{Ctx, Inbox, Runtime, RuntimeHandle};
use crate::worker::Mailbox;
use crate::Error;
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
/// Newtype around `PyObject` that implements `Clone + Send + Sync`.
///
/// `Py<PyAny>` in pyo3 0.23 doesn't implement `Clone` by default.
/// We implement it by acquiring the GIL to bump the refcount.
/// `Send + Sync` are safe because `Py<T>` is a reference-counted
/// pointer to a Python object protected by the GIL.
#[derive(Debug)]
struct PyMsg(PyObject);
impl Clone for PyMsg {
fn clone(&self) -> Self {
Python::with_gil(|py| PyMsg(self.0.clone_ref(py)))
}
}
// Safety: Py<PyAny> is Send + Sync — access is serialized by the GIL.
unsafe impl Send for PyMsg {}
unsafe impl Sync for PyMsg {}
impl PyMsg {
fn into_inner(self) -> PyObject {
self.0
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn to_py_err(e: Error) -> PyErr {
pyo3::exceptions::PyRuntimeError::new_err(e.to_string())
}
// ─── PyActorAddress ──────────────────────────────────────────────────────────
#[pyclass(name = "ActorAddress")]
#[derive(Clone)]
pub struct PyActorAddress {
inner: ActorAddress,
}
#[pymethods]
impl PyActorAddress {
fn hex(&self) -> String {
self.inner
.0
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn to_bytes(&self) -> Vec<u8> {
self.inner.0.to_vec()
}
fn __repr__(&self) -> String {
let hex = self.hex();
format!("ActorAddress({hex})")
}
fn __eq__(&self, other: &PyActorAddress) -> bool {
self.inner == other.inner
}
fn __hash__(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.inner.hash(&mut hasher);
hasher.finish()
}
}
impl From<ActorAddress> for PyActorAddress {
fn from(inner: ActorAddress) -> Self {
Self { inner }
}
}
// ─── Effects / PyCtx ─────────────────────────────────────────────────────────
enum Effect {
Send {
addr: ActorAddress,
msg: PyObject,
},
Spawn {
addr: ActorAddress,
handler: PyObject,
},
}
#[pyclass(name = "Ctx", unsendable)]
pub struct PyCtx {
self_addr: ActorAddress,
effects: RefCell<Vec<Effect>>,
}
impl PyCtx {
fn new(self_addr: ActorAddress) -> Self {
Self {
self_addr,
effects: RefCell::new(Vec::new()),
}
}
fn take_effects(&self) -> Vec<Effect> {
self.effects.borrow_mut().drain(..).collect()
}
}
#[pymethods]
impl PyCtx {
#[getter]
fn self_addr(&self) -> PyActorAddress {
PyActorAddress::from(self.self_addr)
}
fn send(&self, addr: &PyActorAddress, msg: PyObject) {
self.effects.borrow_mut().push(Effect::Send {
addr: addr.inner,
msg,
});
}
fn spawn(&self, handler: PyObject) -> PyActorAddress {
let addr = ActorAddress::new_random();
self.effects.borrow_mut().push(Effect::Spawn {
addr,
handler,
});
PyActorAddress::from(addr)
}
}
// ─── PyActor ─────────────────────────────────────────────────────────────────
struct PyActor {
handler: PyObject,
}
impl PyActor {
fn new(handler: PyObject) -> Self {
Self { handler }
}
}
impl ActorInterface for PyActor {
type Incoming = PyMsg;
type Response = PyMsg;
fn handle(&mut self, ctx: &Ctx, msg: Self::Incoming) {
let py_ctx = PyCtx::new(ctx.self_addr());
let call_result = Python::with_gil(|py| {
let ctx_bound = Bound::new(py, py_ctx)?;
self.handler
.call1(py, (&ctx_bound, msg.into_inner()))?;
let ctx_ref = ctx_bound.borrow();
Ok::<Vec<Effect>, PyErr>(ctx_ref.take_effects())
});
match call_result {
Ok(effects) => {
for effect in effects {
match effect {
Effect::Send { addr, msg } => {
let _ = ctx.raw_inner().send_via_queue(
addr,
Box::new(PyMsg(msg)) as Box<dyn Any + Send>,
);
}
Effect::Spawn { addr, handler } => {
let waterlevel = ctx.raw_inner().mailbox_waterlevel();
let actor = PyActor::new(handler);
let actor = Actor::new(addr, Mailbox::new(waterlevel), actor);
let boxed: Box<dyn AnyActor> = Box::new(actor);
let _ = ctx.raw_inner().spawn_any(addr, boxed);
}
}
}
}
Err(e) => {
Python::with_gil(|py| {
e.print(py);
});
}
}
}
}
// ─── PyInbox ─────────────────────────────────────────────────────────────────
#[pyclass(name = "Inbox")]
pub struct PyInbox {
inner: Inbox<PyMsg>,
}
#[pymethods]
impl PyInbox {
#[getter]
fn addr(&self) -> PyActorAddress {
PyActorAddress::from(*self.inner.addr())
}
fn try_recv(&self) -> Option<PyObject> {
self.inner.try_recv().map(|m| m.into_inner())
}
}
// ─── PyRuntimeConfig ─────────────────────────────────────────────────────────
#[pyclass(name = "RuntimeConfig")]
#[derive(Clone)]
pub struct PyRuntimeConfig {
#[pyo3(get, set)]
num_threads: usize,
#[pyo3(get, set)]
max_actors: usize,
#[pyo3(get, set)]
actor_max_messages: usize,
#[pyo3(get, set)]
mailbox_waterlevel: usize,
#[pyo3(get, set)]
spin_threshold: u32,
#[pyo3(get, set)]
yield_threshold: u32,
#[pyo3(get, set)]
sleep_increment_us: u64,
#[pyo3(get, set)]
sleep_max_us: u64,
}
#[pymethods]
impl PyRuntimeConfig {
#[new]
#[pyo3(signature = (
*,
num_threads = 1,
max_actors = 1_000,
actor_max_messages = 1_000,
mailbox_waterlevel = 10,
spin_threshold = 64,
yield_threshold = 256,
sleep_increment_us = 50,
sleep_max_us = 1_000,
))]
fn new(
num_threads: usize,
max_actors: usize,
actor_max_messages: usize,
mailbox_waterlevel: usize,
spin_threshold: u32,
yield_threshold: u32,
sleep_increment_us: u64,
sleep_max_us: u64,
) -> Self {
Self {
num_threads,
max_actors,
actor_max_messages,
mailbox_waterlevel,
spin_threshold,
yield_threshold,
sleep_increment_us,
sleep_max_us,
}
}
}
impl From<PyRuntimeConfig> for RuntimeConfig {
fn from(py: PyRuntimeConfig) -> Self {
RuntimeConfig {
num_threads: py.num_threads,
max_actors: py.max_actors,
actor_max_messages: py.actor_max_messages,
mailbox_waterlevel: py.mailbox_waterlevel,
backoff_policy: BackoffPolicy {
spin_threshold: py.spin_threshold,
yield_threshold: py.yield_threshold,
sleep_increment_us: py.sleep_increment_us,
sleep_max_us: py.sleep_max_us,
},
}
}
}
// ─── PyRuntime ───────────────────────────────────────────────────────────────
#[pyclass(name = "Runtime")]
pub struct PyRuntime {
inner: Option<Runtime>,
}
#[pymethods]
impl PyRuntime {
#[new]
#[pyo3(signature = (config=None))]
fn new(config: Option<PyRuntimeConfig>) -> Self {
let config: RuntimeConfig = match config {
Some(c) => c.into(),
None => RuntimeConfig::default(),
};
Self {
inner: Some(Runtime::new(config)),
}
}
fn spawn(&self, handler: PyObject) -> PyResult<PyActorAddress> {
let rt = self
.inner
.as_ref()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
let actor = PyActor::new(handler);
let addr = rt.spawn(actor).map_err(to_py_err)?;
Ok(PyActorAddress::from(addr))
}
fn send(&self, addr: &PyActorAddress, msg: PyObject) -> PyResult<()> {
let rt = self
.inner
.as_ref()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
rt.send_to(addr.inner, PyMsg(msg)).map_err(to_py_err)
}
fn inbox(&self) -> PyResult<PyInbox> {
let rt = self
.inner
.as_ref()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
let inbox: Inbox<PyMsg> = rt.new_inbox().map_err(to_py_err)?;
Ok(PyInbox { inner: inbox })
}
fn tick(&self) -> PyResult<()> {
let rt = self
.inner
.as_ref()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
rt.tick();
Ok(())
}
fn run(&mut self, py: Python<'_>) -> PyResult<PyRuntimeHandle> {
let rt = self
.inner
.take()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
let handle = py.allow_threads(|| rt.run().map_err(to_py_err))?;
Ok(PyRuntimeHandle {
inner: Some(handle),
})
}
fn shutdown(&self) -> PyResult<()> {
let rt = self
.inner
.as_ref()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Runtime consumed by run()"))?;
rt.shutdown();
Ok(())
}
}
// ─── PyRuntimeHandle ─────────────────────────────────────────────────────────
#[pyclass(name = "RuntimeHandle")]
pub struct PyRuntimeHandle {
inner: Option<RuntimeHandle>,
}
#[pymethods]
impl PyRuntimeHandle {
fn spawn(&self, handler: PyObject) -> PyResult<PyActorAddress> {
let handle = self
.inner
.as_ref()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err("RuntimeHandle consumed by join()")
})?;
let actor = PyActor::new(handler);
let addr = handle.runtime.spawn(actor).map_err(to_py_err)?;
Ok(PyActorAddress::from(addr))
}
fn send(&self, addr: &PyActorAddress, msg: PyObject) -> PyResult<()> {
let handle = self
.inner
.as_ref()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err("RuntimeHandle consumed by join()")
})?;
handle
.runtime
.send_to(addr.inner, PyMsg(msg))
.map_err(to_py_err)
}
fn inbox(&self) -> PyResult<PyInbox> {
let handle = self
.inner
.as_ref()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err("RuntimeHandle consumed by join()")
})?;
let inbox: Inbox<PyMsg> = handle.runtime.new_inbox().map_err(to_py_err)?;
Ok(PyInbox { inner: inbox })
}
fn shutdown(&self) -> PyResult<()> {
let handle = self
.inner
.as_ref()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err("RuntimeHandle consumed by join()")
})?;
handle.shutdown();
Ok(())
}
fn join(&mut self, py: Python<'_>) -> PyResult<()> {
let handle = self
.inner
.take()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err("RuntimeHandle consumed by join()")
})?;
py.allow_threads(|| handle.join());
Ok(())
}
}
// ─── Module registration ─────────────────────────────────────────────────────
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyActorAddress>()?;
m.add_class::<PyCtx>()?;
m.add_class::<PyInbox>()?;
m.add_class::<PyRuntimeConfig>()?;
m.add_class::<PyRuntime>()?;
m.add_class::<PyRuntimeHandle>()?;
Ok(())
}

View file

@ -64,6 +64,11 @@ impl<'a> Ctx<'a> {
Self { inner, self_addr } Self { inner, self_addr }
} }
#[cfg(feature = "python")]
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
self.inner
}
/// Returns the address of the actor currently being ticked. /// Returns the address of the actor currently being ticked.
pub fn self_addr(&self) -> ActorAddress { pub fn self_addr(&self) -> ActorAddress {
self.self_addr self.self_addr
@ -233,25 +238,22 @@ impl Runtime {
} }
} }
/// Spawn worker threads and start processing, returning a set of handles and /// Spawn worker threads and start processing, returning a handle
/// a Runtime object to interface with. /// to interact with the runtime and join the threads later.
/// ///
/// ### WARN: /// Works in both single-threaded and multi-threaded configurations.
/// ##### Returns an error if the configuration is set as single threaded /// In single-threaded mode, one background thread is spawned.
/// `config.num_threads == 1`
pub fn run(mut self) -> Result<RuntimeHandle, Error> { pub fn run(mut self) -> Result<RuntimeHandle, Error> {
if self.config.num_threads < 2 {
return Err(Error::from(
"Runtime error: cannot call `Runtime::run()` from a single-threaded context.",
));
}
self.is_running.store(true, Ordering::Release); self.is_running.store(true, Ordering::Release);
let workers = self let mut workers: Vec<Worker> = Vec::new();
.pending_workers
.take() if let Some(w) = self.single_worker.take() {
.expect("Workers must be present for multi-threaded runtime"); workers.push(w.into_inner());
}
if let Some(ws) = self.pending_workers.take() {
workers.extend(ws);
}
let rt = Arc::new(self); let rt = Arc::new(self);
let mut handles: Vec<JoinHandle<()>> = Vec::with_capacity(workers.len()); let mut handles: Vec<JoinHandle<()>> = Vec::with_capacity(workers.len());

162
tests/test_python.py Normal file
View file

@ -0,0 +1,162 @@
"""Tests for swactor Python bindings."""
import unittest
from swactor import Runtime, RuntimeConfig, ActorAddress
class TestActorAddress(unittest.TestCase):
def test_repr(self):
rt = Runtime()
addr = rt.spawn(lambda ctx, msg: None)
r = repr(addr)
self.assertTrue(r.startswith("ActorAddress("))
self.assertTrue(r.endswith(")"))
# hex string should be 64 chars (32 bytes)
hex_part = r[len("ActorAddress("):-1]
self.assertEqual(len(hex_part), 64)
def test_hex(self):
rt = Runtime()
addr = rt.spawn(lambda ctx, msg: None)
self.assertEqual(len(addr.hex()), 64)
def test_to_bytes(self):
rt = Runtime()
addr = rt.spawn(lambda ctx, msg: None)
self.assertEqual(len(addr.to_bytes()), 32)
def test_equality(self):
rt = Runtime()
addr = rt.spawn(lambda ctx, msg: None)
# Same address object should be equal to itself
self.assertEqual(addr, addr)
def test_hashable(self):
rt = Runtime()
addr1 = rt.spawn(lambda ctx, msg: None)
addr2 = rt.spawn(lambda ctx, msg: None)
s = {addr1, addr2}
self.assertEqual(len(s), 2)
s.add(addr1)
self.assertEqual(len(s), 2)
class TestRuntimeConfig(unittest.TestCase):
def test_defaults(self):
cfg = RuntimeConfig()
self.assertEqual(cfg.num_threads, 1)
self.assertEqual(cfg.max_actors, 1000)
self.assertEqual(cfg.actor_max_messages, 1000)
self.assertEqual(cfg.mailbox_waterlevel, 10)
self.assertEqual(cfg.spin_threshold, 64)
self.assertEqual(cfg.yield_threshold, 256)
self.assertEqual(cfg.sleep_increment_us, 50)
self.assertEqual(cfg.sleep_max_us, 1000)
def test_custom(self):
cfg = RuntimeConfig(num_threads=4, max_actors=500)
self.assertEqual(cfg.num_threads, 4)
self.assertEqual(cfg.max_actors, 500)
class TestSingleThreaded(unittest.TestCase):
def test_echo(self):
"""Spawn an echo actor, send a message, tick, and recv."""
rt = Runtime()
def echo(ctx, msg):
ctx.send(msg["reply_to"], msg["payload"])
addr = rt.spawn(echo)
inbox = rt.inbox()
rt.send(addr, {"payload": "hello", "reply_to": inbox.addr})
rt.tick()
result = inbox.try_recv()
self.assertEqual(result, "hello")
def test_spawn_from_handler(self):
"""Actor spawns a child and forwards work to it."""
rt = Runtime()
def child(ctx, msg):
ctx.send(msg["reply_to"], "from_child")
def parent(ctx, msg):
c = ctx.spawn(child)
ctx.send(c, {"reply_to": msg["reply_to"]})
addr = rt.spawn(parent)
inbox = rt.inbox()
rt.send(addr, {"reply_to": inbox.addr})
# First tick: parent runs, spawns child, sends to child
rt.tick()
# Second tick: child runs, sends to inbox
rt.tick()
result = inbox.try_recv()
self.assertEqual(result, "from_child")
def test_stateful_actor(self):
"""Callable class maintains state across messages."""
rt = Runtime()
class Counter:
def __init__(self):
self.n = 0
def __call__(self, ctx, msg):
self.n += 1
ctx.send(msg["reply_to"], self.n)
addr = rt.spawn(Counter())
inbox = rt.inbox()
rt.send(addr, {"reply_to": inbox.addr})
rt.send(addr, {"reply_to": inbox.addr})
rt.tick()
self.assertEqual(inbox.try_recv(), 1)
self.assertEqual(inbox.try_recv(), 2)
def test_no_message_returns_none(self):
rt = Runtime()
inbox = rt.inbox()
self.assertIsNone(inbox.try_recv())
class TestMultiThreaded(unittest.TestCase):
def test_run_shutdown_join(self):
"""Multi-threaded runtime can spawn, send, and receive."""
import time
rt = Runtime(RuntimeConfig(num_threads=2))
def echo(ctx, msg):
ctx.send(msg["reply_to"], msg["payload"])
addr = rt.spawn(echo)
inbox = rt.inbox()
handle = rt.run()
handle.send(addr, {"payload": "mt_hello", "reply_to": inbox.addr})
# Poll for result
result = None
for _ in range(100):
result = inbox.try_recv()
if result is not None:
break
time.sleep(0.01)
self.assertEqual(result, "mt_hello")
handle.shutdown()
handle.join()
def test_run_consumes_runtime(self):
"""After run(), tick() should raise."""
rt = Runtime(RuntimeConfig(num_threads=2))
handle = rt.run()
with self.assertRaises(RuntimeError):
rt.tick()
handle.shutdown()
handle.join()
if __name__ == "__main__":
unittest.main()

8
uv.lock Normal file
View file

@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.9"
[[package]]
name = "swactor"
version = "0.1.0"
source = { editable = "." }