From 0e55529edb9bccfc034d877cb3e71bff6ee35e66 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 17:01:24 +0100 Subject: [PATCH 01/10] Main sdk python files --- crates/capsule-sdk/python/pyproject.toml | 49 +++++++++ .../python/src/capsule/__init__.py | 7 ++ crates/capsule-sdk/python/src/capsule/app.py | 100 ++++++++++++++++++ .../python/src/capsule/decorator.py | 64 +++++++++++ .../python/src/capsule/host_api.py | 54 ++++++++++ 5 files changed, 274 insertions(+) create mode 100644 crates/capsule-sdk/python/pyproject.toml create mode 100644 crates/capsule-sdk/python/src/capsule/__init__.py create mode 100644 crates/capsule-sdk/python/src/capsule/app.py create mode 100644 crates/capsule-sdk/python/src/capsule/decorator.py create mode 100644 crates/capsule-sdk/python/src/capsule/host_api.py diff --git a/crates/capsule-sdk/python/pyproject.toml b/crates/capsule-sdk/python/pyproject.toml new file mode 100644 index 0000000..662563d --- /dev/null +++ b/crates/capsule-sdk/python/pyproject.toml @@ -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.9.2", +] + +[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"] diff --git a/crates/capsule-sdk/python/src/capsule/__init__.py b/crates/capsule-sdk/python/src/capsule/__init__.py new file mode 100644 index 0000000..f2c376b --- /dev/null +++ b/crates/capsule-sdk/python/src/capsule/__init__.py @@ -0,0 +1,7 @@ +from .decorator import task +from . import app +from .app import TaskRunner + +# Export the TaskRunner instance for componentize-py +exports = app.exports + diff --git a/crates/capsule-sdk/python/src/capsule/app.py b/crates/capsule-sdk/python/src/capsule/app.py new file mode 100644 index 0000000..43e68b0 --- /dev/null +++ b/crates/capsule-sdk/python/src/capsule/app.py @@ -0,0 +1,100 @@ +# 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 componentize-py, +# this will be the entry point that the Rust 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 componentize-py 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() + }) + + +# Export the TaskRunner class for componentize-py +# The binding generator will look for this +exports = TaskRunner() diff --git a/crates/capsule-sdk/python/src/capsule/decorator.py b/crates/capsule-sdk/python/src/capsule/decorator.py new file mode 100644 index 0000000..bf46f67 --- /dev/null +++ b/crates/capsule-sdk/python/src/capsule/decorator.py @@ -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 diff --git a/crates/capsule-sdk/python/src/capsule/host_api.py b/crates/capsule-sdk/python/src/capsule/host_api.py new file mode 100644 index 0000000..e4f3f88 --- /dev/null +++ b/crates/capsule-sdk/python/src/capsule/host_api.py @@ -0,0 +1,54 @@ +""" +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 componentize-py. +When running locally, it provides mock implementations for testing. +""" + +import json + +IS_WASM = False + +# componentize-py generates bindings in wit_world.imports.api +# The module name is based on the WIT world name "capsule-agent" → "wit_world" +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}"}) From 8dc11049c426f1c86db20cb908ca313f6a3eb631 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 17:12:37 +0100 Subject: [PATCH 02/10] Add manifest.in and py.typed to enable type checking --- crates/capsule-sdk/python/MANIFEST.in | 11 +++++++++++ crates/capsule-sdk/python/src/capsule/py.typed | 0 2 files changed, 11 insertions(+) create mode 100644 crates/capsule-sdk/python/MANIFEST.in create mode 100644 crates/capsule-sdk/python/src/capsule/py.typed diff --git a/crates/capsule-sdk/python/MANIFEST.in b/crates/capsule-sdk/python/MANIFEST.in new file mode 100644 index 0000000..c39a936 --- /dev/null +++ b/crates/capsule-sdk/python/MANIFEST.in @@ -0,0 +1,11 @@ +include src/capsule/py.typed + +include README.md +include LICENSE + +exclude *.pyc +exclude __pycache__ +exclude .pytest_cache +exclude *.egg-info + +# Do NOT include wit_world (it's auto-generated) diff --git a/crates/capsule-sdk/python/src/capsule/py.typed b/crates/capsule-sdk/python/src/capsule/py.typed new file mode 100644 index 0000000..e69de29 From 55a3de3ab7713a279dfc9f024d274463341b60ed Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 17:26:08 +0100 Subject: [PATCH 03/10] improve comments --- crates/capsule-sdk/python/src/capsule/__init__.py | 1 - crates/capsule-sdk/python/src/capsule/app.py | 8 +++----- crates/capsule-sdk/python/src/capsule/host_api.py | 5 ++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/capsule-sdk/python/src/capsule/__init__.py b/crates/capsule-sdk/python/src/capsule/__init__.py index f2c376b..d28fad1 100644 --- a/crates/capsule-sdk/python/src/capsule/__init__.py +++ b/crates/capsule-sdk/python/src/capsule/__init__.py @@ -2,6 +2,5 @@ from . import app from .app import TaskRunner -# Export the TaskRunner instance for componentize-py exports = app.exports diff --git a/crates/capsule-sdk/python/src/capsule/app.py b/crates/capsule-sdk/python/src/capsule/app.py index 43e68b0..203d8de 100644 --- a/crates/capsule-sdk/python/src/capsule/app.py +++ b/crates/capsule-sdk/python/src/capsule/app.py @@ -1,8 +1,8 @@ # 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 componentize-py, -# this will be the entry point that the Rust host calls. +# that the Wasm component must provide. When compiled with Rust core, +# this is the entry point host calls. import json @@ -41,7 +41,7 @@ class TaskRunner: """ Implementation of the capsule:host/task-runner interface. - This class is instantiated by componentize-py when the component is loaded. + 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: @@ -95,6 +95,4 @@ def run(self, args_json: str) -> str: }) -# Export the TaskRunner class for componentize-py -# The binding generator will look for this exports = TaskRunner() diff --git a/crates/capsule-sdk/python/src/capsule/host_api.py b/crates/capsule-sdk/python/src/capsule/host_api.py index e4f3f88..eb82667 100644 --- a/crates/capsule-sdk/python/src/capsule/host_api.py +++ b/crates/capsule-sdk/python/src/capsule/host_api.py @@ -2,7 +2,7 @@ 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 componentize-py. +When running in WASM mode, it imports the auto-generated bindings from capsule-core. When running locally, it provides mock implementations for testing. """ @@ -10,8 +10,7 @@ IS_WASM = False -# componentize-py generates bindings in wit_world.imports.api -# The module name is based on the WIT world name "capsule-agent" → "wit_world" +# capsule-core generates bindings in wit_world.imports.api try: from wit_world.imports import api as host IS_WASM = True From a786c895192d25796043813813eb5a5253378822 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 17:30:21 +0100 Subject: [PATCH 04/10] use of a newer version of componentize-py --- crates/capsule-sdk/python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/capsule-sdk/python/pyproject.toml b/crates/capsule-sdk/python/pyproject.toml index 662563d..87a320a 100644 --- a/crates/capsule-sdk/python/pyproject.toml +++ b/crates/capsule-sdk/python/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ - "componentize-py>=0.9.2", + "componentize-py>=0.19.3", ] [project.optional-dependencies] From ff55307d4c676a293613257617925e97850b93e3 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 17:51:57 +0100 Subject: [PATCH 05/10] Add quick test for pyhton sdk functions --- crates/capsule-sdk/python/MANIFEST.in | 3 + crates/capsule-sdk/python/test_sdk.py | 92 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 crates/capsule-sdk/python/test_sdk.py diff --git a/crates/capsule-sdk/python/MANIFEST.in b/crates/capsule-sdk/python/MANIFEST.in index c39a936..b11862c 100644 --- a/crates/capsule-sdk/python/MANIFEST.in +++ b/crates/capsule-sdk/python/MANIFEST.in @@ -7,5 +7,8 @@ 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) diff --git a/crates/capsule-sdk/python/test_sdk.py b/crates/capsule-sdk/python/test_sdk.py new file mode 100644 index 0000000..14b5d66 --- /dev/null +++ b/crates/capsule-sdk/python/test_sdk.py @@ -0,0 +1,92 @@ +""" +Test the SDK locally (without WASM compilation). + +This tests: +1. Task registration with config +2. Task execution in local mode +3. Config storage and retrieval +""" + +import sys +import os + +# Add the SDK to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'crates', 'capsule-sdk', 'python')) + +from capsule import task +from capsule import app + + +def test_task_registration(): + """Test that tasks are registered with their config.""" + + @task(name="test_task", compute="HIGH", ram="1GB", timeout="60s", max_retries=5) + def my_test_task(x: int) -> int: + return x * 2 + + # Check that task is registered + assert "test_task" in app._TASKS, "Task should be registered" + + # Check that config is stored + config = app.get_task_config("test_task") + assert config["name"] == "test_task" + assert config["compute"] == "HIGH" + assert config["ram"] == "1GB" + assert config["timeout"] == "60s" + assert config["max_retries"] == 5 + + print("✓ Task registration test passed") + + +def test_task_execution_local(): + """Test task execution in local mode.""" + + @task(name="add_task", compute="LOW") + def add_task(a: int, b: int) -> int: + return a + b + + # Execute in local mode + result = add_task(5, 3) + assert result == 8, f"Expected 8, got {result}" + + print("✓ Task execution (local mode) test passed") + + +def test_task_config_defaults(): + """Test that default config values are applied.""" + + @task(name="default_task") + def default_task(): + pass + + config = app.get_task_config("default_task") + assert config["compute"] == "MEDIUM", "Default compute should be MEDIUM" + + print("✓ Config defaults test passed") + + +def test_task_config_env_vars(): + """Test that env_vars are properly formatted.""" + + @task(name="env_task", env_vars={"KEY1": "value1", "KEY2": "value2"}) + def env_task(): + pass + + config = app.get_task_config("env_task") + assert "env_vars" in config + assert isinstance(config["env_vars"], list) + assert ("KEY1", "value1") in config["env_vars"] + assert ("KEY2", "value2") in config["env_vars"] + + print("✓ Env vars test passed") + + +if __name__ == "__main__": + print("Testing Capsule SDK...\n") + + test_task_registration() + test_task_execution_local() + test_task_config_defaults() + test_task_config_env_vars() + + print("\n✨ All tests passed!") From 0be1f777b3a6342aaed89dcdae87782b143de7a8 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 18:10:32 +0100 Subject: [PATCH 06/10] Add sdk test to Ci --- .github/workflows/test.yml | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4351960..9b96bbc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,12 +1,16 @@ -name: Rust Tests +name: Tests on: pull_request: branches: - main + push: + branches: + - main jobs: - test: + rust-tests: + name: Rust Tests runs-on: ubuntu-latest steps: @@ -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/ From ddd994d6cf7fae85ad1a2c73fd34d361782dbdcf Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 18:10:38 +0100 Subject: [PATCH 07/10] Add sdk test to Ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b96bbc..c8ae45d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Tests +name: Unit Tests on: pull_request: From 28ce42559ea10f3d221d17324d707601406be465 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 18:11:24 +0100 Subject: [PATCH 08/10] Improve test --- crates/capsule-sdk/python/test_sdk.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/capsule-sdk/python/test_sdk.py b/crates/capsule-sdk/python/test_sdk.py index 14b5d66..017f0b3 100644 --- a/crates/capsule-sdk/python/test_sdk.py +++ b/crates/capsule-sdk/python/test_sdk.py @@ -10,7 +10,6 @@ import sys import os -# Add the SDK to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'crates', 'capsule-sdk', 'python')) from capsule import task @@ -24,10 +23,8 @@ def test_task_registration(): def my_test_task(x: int) -> int: return x * 2 - # Check that task is registered assert "test_task" in app._TASKS, "Task should be registered" - # Check that config is stored config = app.get_task_config("test_task") assert config["name"] == "test_task" assert config["compute"] == "HIGH" @@ -45,7 +42,6 @@ def test_task_execution_local(): def add_task(a: int, b: int) -> int: return a + b - # Execute in local mode result = add_task(5, 3) assert result == 8, f"Expected 8, got {result}" From 86cde518493b8453cf83d45b4f83493cc0084995 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 18:12:07 +0100 Subject: [PATCH 09/10] Quick wording --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8ae45d..9b5848c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,8 +9,8 @@ on: - main jobs: - rust-tests: - name: Rust Tests + rust-core-tests: + name: Rust Core Tests runs-on: ubuntu-latest steps: From 9db944f42109c26b5df690f7f2769cd71aa1bb56 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Tue, 9 Dec 2025 18:15:46 +0100 Subject: [PATCH 10/10] Update roadmap.md --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index dc7b841..93f20fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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.