Metadata-Version: 2.4
Name: plexspaces
Version: 0.1.2
Summary: PlexSpaces Python SDK - Build actors with minimal boilerplate
Author: PlexSpaces Contributors
License: AGPL-3.0-or-later
Project-URL: Homepage, https://github.com/plexspaces/plexspaces
Project-URL: Documentation, https://docs.plexspaces.io
Project-URL: Repository, https://github.com/plexspaces/plexspaces
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: proto
Requires-Dist: betterproto>=2.0.0b7; extra == "proto"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Requires-Dist: componentize-py>=0.12; extra == "dev"
Requires-Dist: betterproto>=2.0.0b7; extra == "dev"
Requires-Dist: grpcio-tools>=1.50; extra == "dev"

# PlexSpaces Python SDK

Build PlexSpaces actors with minimal boilerplate, inspired by [Ray's `@ray.remote`](https://docs.ray.io/en/latest/ray-core/api/doc/ray.remote.html).

## Installation

```bash
# 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!)

```python
# 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

```bash
plexspaces-py build bank_account.py -o bank_account_actor.wasm
```

### 3. Deploy

```bash
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

```python
@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:

```python
@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.

```python
@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`:

```python
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:

```python
@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](../../examples/python/apps/migrating_wasmcloud/README.md) for details.

## Migration from Legacy Examples

If you have existing actors using the WIT interface directly:

### Before (Legacy)
```python
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)
```python
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

```python
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

```python
# 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

```python
# 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.

```python
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

```bash
# 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
```

```bash
# 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
