PlexSpaces Python SDK - Build actors with minimal boilerplate
Project description
PlexSpaces Python SDK
Build PlexSpaces actors with minimal boilerplate, inspired by Ray's @ray.remote.
Installation
# Activate virtualenv
source ~/venv/bin/activate
# From source (development)
cd sdks/python
pip install -e ".[dev]"
# Required for building WASM
pip install componentize-py
Quick Start
1. Write Your Actor (No Boilerplate!)
# bank_account.py
from plexspaces import actor, state, handler
@actor
class BankAccount:
balance: int = state(default=0)
account_id: str = state(default="")
@handler("deposit")
def deposit(self, amount: int) -> dict:
self.balance += amount
return {"balance": self.balance}
@handler("withdraw")
def withdraw(self, amount: int) -> dict:
if amount > self.balance:
return {"error": "insufficient_funds"}
self.balance -= amount
return {"balance": self.balance}
@handler("balance", "get")
def get_balance(self) -> dict:
return {"balance": self.balance}
2. Build to WASM
plexspaces-py build bank_account.py -o bank_account_actor.wasm
3. Deploy
curl -X POST http://localhost:8094/api/v1/deploy \
-F "namespace=default" \
-F "actor_type=bank_account" \
-F "wasm=@bank_account_actor.wasm"
Proto-generated types (optional)
Workflow and common messages are defined in Protocol Buffers. After make proto or make proto-python at the repo root, typed classes appear under plexspaces/generated/ (betterproto). The plexspaces.workflow module now treats the proto-shaped RetryConfig object as the canonical SDK model while still accepting dict input for compatibility in existing call sites.
Install pip install -e ".[dev]" or pip install -e ".[proto]" to use betterproto locally. Regeneration uses local buf plugins; see make proto-install-deps in the root Makefile.
Features
State Persistence
Fields decorated with state() are automatically:
- Serialized in
get_state() - Restored in
set_state() - Persisted across actor restarts
@actor
class Counter:
count: int = state(default=0) # Immutable default
history: list = state(default_factory=list) # Mutable default
Message Handlers
Use @handler() to route messages to methods:
@actor
class Calculator:
@handler("add")
def add(self, a: int, b: int) -> dict:
return {"result": a + b}
@handler("sub", "subtract") # Multiple message types
def subtract(self, a: int, b: int) -> dict:
return {"result": a - b}
Ask vs Tell (GET vs POST)
The HTTP gateway uses explicit actor-runtime endpoints: GET on /api/v1/actors/{namespace}/{actor_type} and GET|POST|PUT on /ask route to AskReply (request-reply), while POST/PUT on /api/v1/actors/{namespace}/{actor_type} route to SendMessage (fire-and-forget). Design handlers accordingly:
- Read handlers (e.g.
count,get_readings): Use GET so the client receives the reply. Query params are passed as payload, e.g.?msg_type=readings&limit=10→{"msg_type": "readings", "limit": "10"}. - Write handlers (e.g.
ingest,clear): Use POST (tell); the client does not wait for a reply.
@actor
class SensorStream:
readings: list = state(default_factory=list)
@handler("ingest") # POST (tell) - fire-and-forget
def ingest(self, sensor_id: str = "", value: str = "0") -> dict:
self.readings.append({"sensor_id": sensor_id, "value": value})
return {"status": "ok"}
@handler("count", "call") # GET (ask) - client gets reply
def count(self) -> dict:
return {"reading_count": len(self.readings)}
@handler("readings") # GET (ask) - e.g. ?msg_type=readings&limit=10
def get_readings(self, limit: int = None) -> dict:
n = int(limit) if limit is not None else 100
return {"readings": self.readings[-n:], "count": len(self.readings[-n:])}
WIT/WASM compatibility (production-grade)
The SDK targets the plexspaces-actor WIT world: all data crosses the WASM boundary as JSON strings (no raw float/list/dict in WIT). To avoid traps in componentize-py:
- State and handler returns: Use only JSON-serializable types:
str,int,bool,list,dict. Avoid rawfloatin state or return values; usestrfor numbers if needed (the SDK sanitizes float→str at the boundary). - No in-place mutation of state lists: Prefer replacing the list instead of
list.append()thenlist.pop(0)in a loop (e.g.self.items = (list(self.items) + [new])[-max:]or build a new list and assign). - Clear/reset: Use
self.items = []instead ofself.items.clear(). - Return values: Handlers should return plain dicts; the generated wrapper runs
_sanitize_payload_for_wasmbeforejson.dumpsso floats become strings at the boundary.
The generated wrapper automatically sanitizes get_state, set_state, and handle payloads for the WASM boundary.
Host Functions
Access PlexSpaces capabilities via host:
from plexspaces import actor, handler, host
@actor
class ChatRoom:
@handler("send")
def send_message(self, text: str) -> dict:
# Log message
host.info(f"Sending: {text}")
# Broadcast to group
host.process_groups.publish("chat-room", {"text": text})
return {"status": "sent"}
API Reference
Decorators
| Decorator | Description |
|---|---|
@actor |
Define a PlexSpaces actor class (GenServer behavior) |
@actor(facets=["..."]) |
Actor with facet declaration (e.g., facets=["durability"]) |
@event_actor |
Event-handler actor (GenEvent behavior) |
@fsm_actor |
Finite state machine actor (GenStateMachine behavior) |
@fsm_actor(states=[...], initial="...") |
FSM actor with explicit state list and initial state |
@gen_server_actor |
Explicit GenServer actor (same as @actor) |
@workflow_actor |
Workflow/orchestration actor |
@run_handler |
Workflow run entrypoint |
@signal_handler("name") |
Named workflow signal handler |
@query_handler("name") |
Named workflow query handler |
@handler(*msg_types) |
Route messages to this method |
state(default=None, default_factory=None) |
Define persistent state field |
@init_handler |
Custom initialization handler |
Behavior Types
| Decorator | Behavior | Use Case | Pattern |
|---|---|---|---|
@actor |
GenServer | Request-reply actors (default) | Auto call |
@gen_server_actor |
GenServer | Explicit GenServer | Auto call |
@event_actor |
GenEvent | Fire-and-forget event handlers | cast |
@fsm_actor |
GenStateMachine | State machine workflows | Auto call |
@fsm_actor(states=[...], initial="...") |
GenStateMachine | FSM with documented states | Auto call |
@workflow_actor |
Workflow | Long-running orchestrations | Auto call |
GenServer Request-Reply Default: When using @actor or @gen_server_actor, handlers default to the call invocation automatically. You do not need to specify that in @handler().
Facets
Facets declare what capabilities an actor expects. For WASM actors:
@actor(facets=["durability"])
class DurableAccount:
balance: int = state(default=0)
| Facet | WASM Behavior |
|---|---|
durability |
Checkpoint-based persistence via WasmConfig.durability_enabled |
registry |
Service discovery via RegistryFacet in app-config |
Note: WASM durability uses checkpoint-based persistence (get_state/set_state), not the Rust DurabilityFacet. Enable via durability_enabled: true in release.yaml or WasmConfig.
Host Functions (Capabilities)
PlexSpaces provides comprehensive host functions for WASM actors, following wasmCloud-style capability-based design.
Messaging
| Function | Description | Example |
|---|---|---|
host.send(to, msg_type, payload) |
Send message to another actor (fire-and-forget) | host.send("actor-2", "ping", {"data": "hello"}) |
host.ask(to, msg_type, payload, timeout_ms) |
Send request and wait for response (request-reply) | response = host.ask("actor-2", "get_data", {}, 5000) |
host.self_id() |
Get own actor ID | my_id = host.self_id() |
Actor Lifecycle
| Function | Description | Example |
|---|---|---|
host.spawn(module_ref, actor_id, init_config_json) |
Spawn a new actor | id = host.spawn("calculator", "calc-1", "{}") |
host.stop(actor_id) |
Stop an actor gracefully | host.stop("calc-1") |
Actor Linking & Monitoring (Erlang/OTP patterns)
| Function | Description | Example |
|---|---|---|
host.link(actor_id) |
Bidirectional link (crash propagation) | host.link("worker-1") |
host.unlink(actor_id) |
Remove bidirectional link | host.unlink("worker-1") |
host.monitor(actor_id) |
Unidirectional monitor (exit notification) | ref = host.monitor("worker-1") |
host.demonitor(monitor_ref) |
Cancel monitor | host.demonitor(ref) |
Timers (Delayed Messaging)
| Function | Description | Example |
|---|---|---|
host.send_after(delay_ms, msg_type, payload) |
Send message to self after delay | timer_id = host.send_after(5000, "cleanup", {}) |
Logging & Time
| Function | Description | Example |
|---|---|---|
host.log(level, message) |
Log a message | host.log("info", "Processing") |
host.info(message) |
Log info message | host.info("Processing") |
host.debug(message) |
Log debug message | host.debug("Debug info") |
host.warn(message) |
Log warning message | host.warn("Warning") |
host.error(message) |
Log error message | host.error("Error occurred") |
host.now_ms() |
Get current timestamp (ms) | ts = host.now_ms() |
Key-Value Storage
| Function | Description | Example |
|---|---|---|
host.kv_get(key) |
Get value for key | value = host.kv_get("session:123") |
host.kv_put(key, value) |
Store value for key | host.kv_put("session:123", json.dumps(data)) |
host.kv_delete(key) |
Delete key | host.kv_delete("session:123") |
host.kv_list(prefix) |
List keys with prefix (returns JSON array) | keys = json.loads(host.kv_list("session:")) |
Backends: Redis, SQLite, DynamoDB, PostgreSQL, Memory
Blob Storage
| Function | Description | Example |
|---|---|---|
host.blob_upload(blob_id, data, content_type) |
Upload blob (data must be base64-encoded) | host.blob_upload("file-1", base64_data, "image/png") |
host.blob_download(blob_id) |
Download blob (returns base64) | data = host.blob_download("file-1") |
host.blob_delete(blob_id) |
Delete blob | host.blob_delete("file-1") |
host.blob_list(prefix) |
List blobs with prefix (returns JSON array) | blobs = json.loads(host.blob_list("files/")) |
Backends: S3, Azure Blob, GCP Storage, embedded object store (rustfs), Filesystem
ShardGroup / Application Metrics
| Function | Description | Example |
|---|---|---|
host.create_shard_group(request) |
Create a shard group using framework/proto field names | group = host.create_shard_group({"group_id": "g1", "actor_type": "worker", "shard_count": 4}) |
host.bulk_update_shard_group(request) |
Bulk update shard members | host.bulk_update_shard_group({"group_id": "g1", "updates": {"k1": {"op": "init"}}}) |
host.map_shard_group(request) |
Map a query across shards | resp = host.map_shard_group({"group_id": "g1", "query": {"op": "stats"}}) |
host.scatter_gather(request) |
Scatter/gather across shards | resp = host.scatter_gather({"group_id": "g1", "query": {"op": "compute"}, "aggregation": "concat"}) |
host.application_metrics_add(application_id, metrics) |
Merge node-local application metrics delta | host.application_metrics_add("heat-app", {"counter_metrics": {"worker_messages": 1}}) |
host.application_get_status(application_id, node_id) |
Get per-node application status and metrics | status = host.application_get_status("heat-app", "test-node-8093") |
These methods use the same actor-world WIT host surface as Rust WASM apps. The SDK wrapper is
thin: it serializes request/response JSON using the framework field names and delegates to the
underlying framework ActorService and application manager.
TupleSpace (Linda-style Coordination)
Preferred: host.ts — list-in, list-out; use None in patterns for wildcards.
| Function | Description | Example |
|---|---|---|
host.ts.write(tuple_list) |
Write tuple (list of JSON-serializable elements) | host.ts.write(["task", "worker-1", 123]) |
host.ts.read(pattern) |
Read one match (non-destructive). Returns list or None. | t = host.ts.read(["task", None, None]) |
host.ts.take(pattern) |
Take one match (destructive). Returns list or None. | t = host.ts.take(["task", None, None]); if t: ... |
host.ts.read_all(pattern) |
Read all matches (non-destructive). Returns list of lists. | all_ = host.ts.read_all(["task", None, None]) |
Low-level string API (when you need raw JSON): host.ts_write(tuple_json), host.ts_read(pattern_json), host.ts_take(pattern_json), host.ts_read_all(pattern_json).
Distributed Locks
| Function | Description | Example |
|---|---|---|
host.lock_acquire(tenant_id, namespace, holder_id, lock_name, lease_duration_secs, timeout_ms) |
Acquire lock (returns JSON with version) | lock = json.loads(host.lock_acquire("tenant", "ns", "actor-1", "resource", 30, 5000)) |
host.lock_renew(lock_id, tenant_id, namespace, holder_id, lock_version, lease_duration_secs) |
Renew lease on held lock | new_version = host.lock_renew("resource", "tenant", "ns", "actor-1", version, 30) |
host.lock_release(lock_id, tenant_id, namespace, holder_id, lock_version) |
Release lock | host.lock_release("resource", "tenant", "ns", "actor-1", version) |
Process Groups (Pub/Sub)
| Function | Description | Example |
|---|---|---|
host.process_groups.join(group) |
Join a process group | host.process_groups.join("chat-room") |
host.process_groups.leave(group) |
Leave a process group | host.process_groups.leave("chat-room") |
host.process_groups.members(group) |
Get group members (returns list) | members = host.process_groups.members("chat-room") |
host.process_groups.broadcast(group, msg_type, payload) |
Broadcast to all group members; msg_type is used for routing so payload can be data-only. | host.process_groups.broadcast("chat-room", "message", {"text": "hello"}) |
Capability Comparison: PlexSpaces vs wasmCloud
| Capability | wasmCloud | PlexSpaces | Status |
|---|---|---|---|
| HTTP Client | ✅ wasi:http |
⚠️ Simulated via host.ask() |
Partial |
| HTTP Server | ✅ wasi:http |
❌ Not available | Missing |
| KeyValue Storage | ✅ wasi:keyvalue |
✅ host.kv_* |
✅ Complete |
| Blob Storage | ✅ wasi:blobstore |
✅ host.blob_* |
✅ Complete |
| Messaging | ✅ wasmcloud:messaging |
✅ host.send/ask |
✅ Complete |
| Logging | ✅ wasi:logging |
✅ host.log |
✅ Complete |
| Process Groups | ❌ Not available | ✅ host.pg_* |
✅ Extra |
| Elastic pool | ❌ Not available | ✅ host.pool_checkout / host.pool_checkin / host.pool_get_metrics |
✅ Extra |
| TupleSpace | ❌ Not available | ✅ host.ts / host.ts_* |
✅ Extra |
| Distributed Locks | ❌ Not available | ✅ host.lock_* |
✅ Extra |
| Timers | ❌ Not available | ✅ host.send_after() |
✅ Extra |
Note: HTTP Client can be simulated by calling HTTP gateway actors via host.ask(). See Session Store example for details.
Migration from Legacy Examples
If you have existing actors using the WIT interface directly:
Before (Legacy)
from wit_world import exports
import json
class Actor(exports.Actor):
def init(self, config_json: str) -> str:
global _balance
_balance = 0
return ""
def handle(self, from_actor: str, msg_type: str, payload_json: str) -> str:
global _balance
data = json.loads(payload_json)
if msg_type == "deposit":
_balance += data["amount"]
return json.dumps({"balance": _balance})
# ... lots of boilerplate
def get_state(self) -> str:
return json.dumps({"balance": _balance})
def set_state(self, state_json: str) -> str:
global _balance
_balance = json.loads(state_json)["balance"]
return ""
After (SDK)
from plexspaces import actor, state, handler
@actor
class BankAccount:
balance: int = state(default=0)
@handler("deposit")
def deposit(self, amount: int) -> dict:
self.balance += amount
return {"balance": self.balance}
Tier 1 Ergonomics Helpers
Convenience wrappers available on the host singleton and via EventLog from plexspaces.host.
Process Groups
from plexspaces import host
# First member of a group (None if empty)
router = host.process_groups.first("svc:llm_router")
# Raises RuntimeError if empty
router = host.process_groups.first_or_raise("svc:llm_router")
KV JSON Helpers
# Write a JSON-serializable value
host.kv_put_json("task:1", {"seq": 1, "kind": "summarize"})
# Read (returns None if missing or corrupt JSON)
task = host.kv_get_json("task:1")
Metrics Helpers
# Increment one counter by 1 (errors swallowed)
host.incr_counter("my-app", "requests_processed")
# Increment multiple counters
host.incr_counters("my-app", {"cache_hits": 5, "cache_misses": 2})
EventLog
Monotonic append-only log backed by KV. Embed in actor state (JSON-serializable via state()). Multiple consumers track independent read cursors.
from plexspaces.host import EventLog
from plexspaces import actor, state, handler, host
@actor
class AuditActor:
log: EventLog = state(default_factory=EventLog)
@handler("record")
def record(self, event: dict) -> dict:
seq = self.log.append(host, "audit:", event)
return {"seq": seq}
@handler("poll")
def poll(self, consumer_id: str, limit: int = 50) -> dict:
events, cursor = self.log.poll(host, "audit:", consumer_id, limit=limit)
return {"events": events, "cursor": cursor}
Development
# Activate virtualenv (use project or shared venv)
source ~/venv/bin/activate
# Install in editable mode with dev deps
pip install -e ".[dev]"
# Run tests (this directory)
pytest
# Or from repository root: make test runs pytest here when VENV_PATH (default ~/venv) has pytest
# Build examples
cd examples
plexspaces-py build chat_room.py -v
Comparison with Other Frameworks
| Feature | PlexSpaces SDK | Ray | Temporal |
|---|---|---|---|
| Decorator | @actor |
@ray.remote |
@workflow.defn |
| State | state() |
Class attrs | N/A |
| Handlers | @handler() |
Methods | @activity.defn |
| Build | WASM | Python | Docker |
| Runtime | PlexSpaces | Ray Cluster | Temporal Server |
License
AGPL-3.0-or-later
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file plexspaces-0.1.7.tar.gz.
File metadata
- Download URL: plexspaces-0.1.7.tar.gz
- Upload date:
- Size: 256.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7889db3993145f1a2c898d60ab89818d03f888561a455788dba1b3e4c497841
|
|
| MD5 |
a4671bb28137c8e4f2ee7907b68db13c
|
|
| BLAKE2b-256 |
271ac89f3e39de4e39c724df58e1a939938b3f871befcb91ef50e03e39bc9647
|
File details
Details for the file plexspaces-0.1.7-py3-none-any.whl.
File metadata
- Download URL: plexspaces-0.1.7-py3-none-any.whl
- Upload date:
- Size: 258.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cccfbdaa4c584065dbf07da45230fe069e91210e0ae739712e474dc58671cadc
|
|
| MD5 |
8a98d838d8f4de48d693640b90620ea8
|
|
| BLAKE2b-256 |
63d8da469e8553c2d59675e1bca93e3eba07747d3402196c24c459a86e830c27
|