Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
name: Rust Tests
name: Unit Tests

on:
pull_request:
branches:
- main
push:
branches:
- main

jobs:
test:
rust-core-tests:
name: Rust Core Tests
runs-on: ubuntu-latest

steps:
Expand All @@ -19,3 +23,33 @@ jobs:
- name: Run cargo test in crates/capsule-core
working-directory: crates/capsule-core
run: cargo test --all --verbose

python-sdk-tests:
name: Python SDK Tests
runs-on: ubuntu-latest

steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install SDK dependencies
working-directory: crates/capsule-sdk/python
run: |
pip install -e .
pip install pytest

- name: Run SDK tests
working-directory: crates/capsule-sdk/python
run: python test_sdk.py

- name: Verify package builds
working-directory: crates/capsule-sdk/python
run: |
pip install build
python -m build
ls -lh dist/
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This document tracks the development status of Capsule. We follow a "Release Ear
**Goal:** Validating the core Host/Guest technology and Python interoperability.

- [x] **Core:** Rust Host capable of loading Wasm Components.
- [ ] **SDK (Python):** Basic `@task` decorator with JSON serialization.
- [x] **SDK (Python):** Basic `@task` decorator with JSON serialization.
- [ ] **CLI:** `capsule run main.py` with JIT compilation.
- [ ] **Limits:** Basic Fuel metering for CPU protection.

Expand Down
14 changes: 14 additions & 0 deletions crates/capsule-sdk/python/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
include src/capsule/py.typed

include README.md
include LICENSE

exclude *.pyc
exclude __pycache__
exclude .pytest_cache
exclude *.egg-info
exclude test_*.py
exclude *_test.py
exclude tests/

# Do NOT include wit_world (it's auto-generated)
49 changes: 49 additions & 0 deletions crates/capsule-sdk/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "capsule"
version = "0.1.0"
description = "Python SDK for building Capsule agent tasks with WebAssembly isolation"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "Apache-2.0"}
authors = [
{name = "Capsule Contributors"}
]
keywords = ["wasm", "webassembly", "agents", "isolation", "runtime"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
]

dependencies = [
"componentize-py>=0.19.3",
]

[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=22.0",
"mypy>=0.990",
]

[project.urls]
Homepage = "https://github.com/mavdol/capsule"
Repository = "https://github.com/mavdol/capsule"
Documentation = "https://github.com/mavdol/capsule/tree/main/crates/capsule-sdk"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
capsule = ["py.typed"]
6 changes: 6 additions & 0 deletions crates/capsule-sdk/python/src/capsule/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .decorator import task
from . import app
from .app import TaskRunner

exports = app.exports

98 changes: 98 additions & 0 deletions crates/capsule-sdk/python/src/capsule/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Python SDK Export Module for task-runner interface
#
# This module implements the `capsule:host/task-runner` export interface
# that the Wasm component must provide. When compiled with Rust core,
# this is the entry point host calls.

import json

_TASKS = {}


def register_task(name: str, func, config: dict = None):
"""
Register a task function by name with its configuration.

Args:
name: Task name
func: The task function
config: Task configuration (compute, ram, timeout, etc.)
"""
_TASKS[name] = {
"func": func,
"config": config or {}
}

def get_task(name: str):
"""Get a registered task by name"""
task_info = _TASKS.get(name)
if task_info:
return task_info["func"]
return None

def get_task_config(name: str):
"""Get the configuration for a registered task"""
task_info = _TASKS.get(name)
if task_info:
return task_info["config"]
return {}

class TaskRunner:
"""
Implementation of the capsule:host/task-runner interface.

This class is instantiated by capsule-core when the component is loaded.
The Rust host calls `run(args_json)` to execute a task.
"""
def run(self, args_json: str) -> str:
"""
Execute a task with the given arguments.

Args:
args_json: JSON string containing:
- task_name: Name of the task to run
- args: Positional arguments list
- kwargs: Keyword arguments dict

Returns:
JSON string with the task result or error

The host calls this function to execute a task within this Wasm instance.
This is the entry point from the Rust host into the Python code.
"""
try:
data = json.loads(args_json)
task_name = data.get("task_name", "main")
args = data.get("args", [])
kwargs = data.get("kwargs", {})

task_func = get_task(task_name)

if task_func is None and task_name != "main":
task_func = get_task("main")

if task_func is None and _TASKS:
first_task_name = next(iter(_TASKS.keys()))
task_func = get_task(first_task_name)

if task_func is None:
return json.dumps({
"error": f"No tasks found. Available: {list(_TASKS.keys())}"
})

result = task_func(*args, **kwargs)

if result is None:
return json.dumps({"result": None})

return json.dumps({"result": result})

except Exception as e:
import traceback
return json.dumps({
"error": str(e),
"traceback": traceback.format_exc()
})


exports = TaskRunner()
64 changes: 64 additions & 0 deletions crates/capsule-sdk/python/src/capsule/decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import json
import functools
from .host_api import call_host, IS_WASM
from . import app


def task(name=None, compute="MEDIUM", ram=None, timeout=None, max_retries=None, env_vars=None):
"""
Decorator to mark a function as a Capsule task.

Args:
name: Task name (defaults to function name)
compute: Compute level - "LOW", "MEDIUM", or "HIGH"
ram: RAM limit - e.g., "512MB", "2GB"
timeout: Timeout duration - e.g., "30s", "5m"
max_retries: Maximum number of retries (default: 1)
env_vars: Environment variables as dict

In WASM mode:
- The function is registered in the task registry with its config
- When called from within a task, it schedules a new isolated instance
- The host creates a new Wasm instance with resource limits

In non-WASM mode:
- The function executes directly
"""
def decorator(func):
task_name = name if name is not None else func.__name__

task_config = {
"name": task_name,
"compute": compute.upper() if compute else "MEDIUM",
}

if ram is not None:
task_config["ram"] = ram
if timeout is not None:
task_config["timeout"] = timeout
if max_retries is not None:
task_config["max_retries"] = max_retries
if env_vars is not None:
task_config["env_vars"] = list(env_vars.items()) if isinstance(env_vars, dict) else env_vars

@functools.wraps(func)
def wrapper(*args, **kwargs):
if not IS_WASM:
return func(*args, **kwargs)

args_data = list(args)
result_json = call_host(task_name, args_data, task_config)

try:
result = json.loads(result_json)
if "error" in result:
raise RuntimeError(f"Task {task_name} failed: {result['error']}")
return result.get("result")
except json.JSONDecodeError:
return result_json

app.register_task(task_name, func, task_config)

return wrapper

return decorator
53 changes: 53 additions & 0 deletions crates/capsule-sdk/python/src/capsule/host_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Capsule SDK - Host API Interface

This module provides the interface to call host functions from Python Wasm code.
When running in WASM mode, it imports the auto-generated bindings from capsule-core.
When running locally, it provides mock implementations for testing.
"""

import json

IS_WASM = False

# capsule-core generates bindings in wit_world.imports.api
try:
from wit_world.imports import api as host
IS_WASM = True
except ImportError:
IS_WASM = False
host = None

def call_host(name: str, args: list, config: dict) -> str:
"""
Call the host's schedule_task function to create a new isolated task instance.

This is the bridge between Python code and the Rust host runtime.

Args:
name: Task name to schedule
args: List of arguments to pass to the task
config: Task configuration dict containing:
- compute: "LOW", "MEDIUM", or "HIGH"
- ram: e.g., "512MB", "2GB"
- timeout: e.g., "30s", "5m"
- max_retries: int
- env_vars: list of (key, value) tuples

Returns:
JSON string with the task result

In WASM mode:
Calls the Rust host's schedule_task function via WIT bindings

In local mode:
Returns mocked result
"""
if IS_WASM and host is not None:
try:
result = host.schedule_task(name, json.dumps(args), json.dumps(config))
return result
except Exception as e:
return json.dumps({"error": f"Host call failed: {str(e)}"})
else:
return json.dumps({"result": f"mock_result_for_{name}"})
Empty file.
Loading