Skip to main content

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:

  1. State and handler returns: Use only JSON-serializable types: str, int, bool, list, dict. Avoid raw float in state or return values; use str for numbers if needed (the SDK sanitizes float→str at the boundary).
  2. No in-place mutation of state lists: Prefer replacing the list instead of list.append() then list.pop(0) in a loop (e.g. self.items = (list(self.items) + [new])[-max:] or build a new list and assign).
  3. Clear/reset: Use self.items = [] instead of self.items.clear().
  4. Return values: Handlers should return plain dicts; the generated wrapper runs _sanitize_payload_for_wasm before json.dumps so 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

plexspaces-0.1.7.tar.gz (256.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plexspaces-0.1.7-py3-none-any.whl (258.5 kB view details)

Uploaded Python 3

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

Hashes for plexspaces-0.1.7.tar.gz
Algorithm Hash digest
SHA256 c7889db3993145f1a2c898d60ab89818d03f888561a455788dba1b3e4c497841
MD5 a4671bb28137c8e4f2ee7907b68db13c
BLAKE2b-256 271ac89f3e39de4e39c724df58e1a939938b3f871befcb91ef50e03e39bc9647

See more details on using hashes here.

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

Hashes for plexspaces-0.1.7-py3-none-any.whl
Algorithm Hash digest
SHA256 cccfbdaa4c584065dbf07da45230fe069e91210e0ae739712e474dc58671cadc
MD5 8a98d838d8f4de48d693640b90620ea8
BLAKE2b-256 63d8da469e8553c2d59675e1bca93e3eba07747d3402196c24c459a86e830c27

See more details on using hashes here.

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