Skip to content

Python SDK

The Python SDK at sdks/python/ exposes three API surfaces from the same mvmforge package:

  • Authoring — decorators and factories that register a workload to be emitted as canonical IR.
  • Runtime — host-side f.remote(...) and session(...) for calling function-entrypoint workloads from a Python process. Dev-only by design (per ADR-0010 §2).
  • Sandbox — typed lifecycle handles (Sandbox, Process, FileEntry, …) over the local mvm sandbox primitives.

All three use keyword-argument decorator style per ADR-0003 §3.

Install

Available on PyPI: mvm.

Terminal window
pip install mvm

Or, in a pyproject.toml:

dependencies = ["mvm>=0.1.2"]

Optional extras:

Terminal window
pip install 'mvm[schema]' # pydantic-based mv.derive_schema(...)
pip install msgpack # only if a workload declares format="msgpack"

For local development against this repo (cloned), use cd sdks/python && uv sync instead.


Authoring

Two workload shapes share the same workload(...) + @app(...) registration:

  1. Command entrypoint — service exec’d at boot.
  2. Function entrypoint — wrapper reads stdin, dispatches module:function, writes return on stdout. Requires dependencies=.

Command entrypoint

import mvm as mv
mv.workload(id="my-service")
@mv.app(
name="my-service",
source=mv.local_path("src"),
image=mv.nix_packages(["python312"]),
entrypoint=mv.entrypoint(command=["python", "-m", "my_service"]),
resources=mv.resources(cpu_cores=2, memory_mb=512, rootfs_size_mb=1024),
)
def my_service():
"""The decorated function body is ignored at emit time."""

Function entrypoint (short form)

import mvm as mv
@mv.func(
name="adder",
image=mv.nix_packages(["python312"]),
resources=mv.resources(cpu_cores=1, memory_mb=256, rootfs_size_mb=512),
)
def add(a: int, b: int) -> int:
return a + b
add(2, 3) # → 5 (local in-process call)
add.remote(2, 3) # → 5 (dispatched in the VM via mvmctl invoke)

mv.func(...) registers the workload + app + function entrypoint in one call (mirrors the TypeScript SDK’s mv.func({...}, fn)). Defaults: module=fn.__module__, function=fn.__name__, language="python", dependencies=no_deps(), source=local_path("."). The decorated function becomes a RemoteFunction. If fn.__module__ is "__main__", you must pass module= explicitly so the emitted IR is deterministic across python entry.py and python -m pkg.entry invocation styles.

The decorated function’s typed parameters and return type populate args_schema / return_schema automatically — no mv.derive_schema(...) needed for the common case. See Payload schemas for the supported type table and when to reach for pydantic.

Function entrypoint (long form)

import mvm as mv
mv.workload(id="adder")
@mv.app(
name="adder",
source=mv.local_path("."),
image=mv.nix_packages(["python312"]),
entrypoint=mv.entrypoint_function(module="adder", function="add"),
resources=mv.resources(cpu_cores=1, memory_mb=256, rootfs_size_mb=512),
dependencies=mv.no_deps(),
)
def add(a: int, b: int) -> int:
return a + b

Same end-state as mv.func(...); useful for multi-app workloads or when you need a custom source= / workload id.

Multi-function apps (ADR-0014 Phase 2)

A workload can bundle several callable functions sharing one image, one resources block, and one source tree. Repeat @mv.func(name="X", ...) against the same workload name; subsequent decorations contribute additional entrypoints rather than registering a new app.

@mvm.func(name="math-svc", module="math", primary=True)
async def add(a: int, b: int) -> int:
return a + b
@mvm.func(name="math-svc", module="math")
async def mul(a: int, b: int) -> int:
return a * b

The first decoration carries the app-level config (image, resources, source, dependencies, network, depends_on, mounts, env). Subsequent decorations against the same name= must omit those — the SDK rejects them with a clear hint pointing at “app-level config goes on the FIRST decoration.”

primary=True marks the workload’s default function — the one mvmctl invoke math-svc (no --fn selector) dispatches to. The SDK auto-marks the first decoration primary; you can flip it explicitly via primary=True/False. The validator rejects multi-function apps with no primary (E_NO_PRIMARY_ENTRYPOINT), multiple primaries (E_MULTIPLE_PRIMARY_ENTRYPOINTS), or duplicate (module, function) pairs (E_DUPLICATE_ENTRYPOINT_FUNCTION).

For long-form authoring, mv.app(name=..., entrypoints=[...]) takes a plural entrypoints= list directly — useful when you want to declare a multi-function app explicitly.

Top-level functions

FunctionPurpose
workload(*, id)Declares the workload identity. Call once per emit. id matches [a-z][a-z0-9-]{0,62}.
app(...)Decorator factory; registers an app.
func(*, name, image, resources, module=None, function=None, language="python", format="json", source=None, working_dir="/app", env=None, network=None, mounts=None, dependencies=None, args_schema=None, return_schema=None, extra_imports=None)Workload + app + function-entrypoint shortcut. Defaults dependencies=no_deps(), source=local_path("."), infers module/function from the decorated callable.
local_path(path, *, include=None, exclude=None)Source tree, relative to the manifest. Glob syntax per Source bundling.
nix_packages(packages)Image declaration. List of nixpkgs attribute names.
entrypoint(*, command, working_dir="/app", env=None)Command-style entrypoint.
entrypoint_function(*, module, function, language="python", format="json", working_dir="/app", env=None, args_schema=None, return_schema=None, extra_imports=None)Function-style entrypoint.
resources(*, cpu_cores, memory_mb, rootfs_size_mb)Resource sizing.
python_deps(*, lockfile, tool="uv") / node_deps(*, lockfile, tool="pnpm") / no_deps()Dependency declarations (required for function entrypoints, per plan-0008).
network(*, mode="none", ports=None, egress=None, peers=None, dns=None)Network posture (plan-0004 §Phase 5). host mode rejected for function workloads.
egress(allowlist) / host_port(host, port) / dns_none() / dns_system() / dns_resolver(host, port=53)Granular network grants.
derive_schema(fn, *, return_only=False)Derive JSON Schema from type hints (requires mvm[schema]).
emit_json() / reset()Serialize / reset module state (used by tests + the emit subprocess).

language field

Per ADR-0010 §4, entrypoint_function(language=...) defaults to "python" — the SDK records its own language. Override explicitly to author a workload in a different language. Current allowlist: python, node, wasm. The host validator rejects values outside the allowlist with E_UNSUPPORTED_LANGUAGE (see error codes).

For language="wasm": module is interpreted as the relative path of a .wasm (WASI Preview 1) file inside the bundled source tree; function is captured for documentation but unused at dispatch time (wasmtime run invokes the module’s _start export). This unlocks any compile-to-WASM toolchain (Rust + wasm32-wasi, TinyGo, Zig, AssemblyScript, .NET NativeAOT-LLVM, etc.) without a per-language factory in mvmforge — the user’s .wasm IS the wrapper and satisfies the wire contract via WASI host functions.


Runtime

When entrypoint_function(...) is registered, the decorator returns a RemoteFunction wrapper.

RemoteFunction

AttributeNotes
add(2, 3)Calls the local function — for in-process testing.
add.remote(2, 3)Encodes args, shells out to mvmctl invoke, decodes the return. Synchronous.
add.localThe undecorated local function.
add.workload_idWorkload id at registration time.
add.formatWire format ("json" or "msgpack").

Transport contract:

  • stdin to mvmctl invoke: encoded [args, kwargs] per declared format.
  • stdout from mvmctl invoke: encoded return value.
  • non-zero exit + structured stderr envelopeRemoteError.
  • non-zero exit without an envelopeMvmTransportError.

mvmforge.session(workload_id)

Context manager holding a single VM warm across calls (plan-0003):

with mvmforge.session("adder"):
add.remote(2, 3) # boots the VM
add.remote(4, 5) # reuses it

Session id propagates via contextvars; works inside asyncio tasks. Torn down on exit even if the body raised.

Errors

ClassWhen
RemoteError(kind, error_id, message)User code in the VM raised. error_id is stable.
MvmTransportErrorTransport failure (couldn’t reach substrate, unparseable response, cap exceeded).
MsgpackUnavailableformat="msgpack" declared but msgpack package not installed.
SecretInArgWarning / SecretInArgErrorHeuristic flagged a secret-shaped kwarg name. Strict mode via MVMFORGE_STRICT_SECRETS=1.
EmittingContextErrorLayer-3 call (.remote() / session()) fired during mvmforge emit (MVMFORGE_EMITTING=1 is set). Per ADR-0010 §2 — prevents build-time recursion.

Transport caps + timeouts

Tunable via env vars (see Environment variables):

Env varDefaultAffects
MVMFORGE_INVOKE_TIMEOUT_SEC60Single mvmctl invoke wall-clock budget.
MVMFORGE_SESSION_START_TIMEOUT_SEC60mvmctl session start.
MVMFORGE_SESSION_STOP_TIMEOUT_SEC30mvmctl session stop.
MVMFORGE_MAX_OUTPUT_BYTES16 MiBHard cap on mvmctl stdout/stderr (each independently).
MVMFORGE_STRICT_SECRETSunset1 = SecretInArgError instead of warning.
MVMFORGE_EMITTING(set by host)1 blocks Layer-3 calls.
MVMFORGE_MVM_BINPATH searchThe mvmctl binary.

Result decoding adds defense-in-depth: max nesting depth 64, non-finite numbers rejected, duplicate JSON keys rejected.

Dev / production posture

The runtime SDK is a dev iteration tool, structurally absent from the production runtime call path (per ADR-0010 §2). Production microVMs are observed via mvmctl logs and output streams — no host-side .remote() calls. Two safety gates enforce this:

  • MVMFORGE_EMITTING=1 (build-time guard, set by host).
  • The wrapper’s runtime mode = "prod" | "dev" (gates dev-only execution surfaces inside the VM; default "prod").

Sandbox

Typed lifecycle surface for local mvm sandboxes (plan-0010 phase A). Use for ad-hoc dev iteration: spin up a sandbox, exec processes, manage filesystem and snapshots.

import mvm as mv
with mv.Sandbox.create(name="sandbox-1") as sb:
info = sb.info()
proc = sb.spawn(["python", "-c", "print('hello')"])
proc.await_exit(timeout=5)
sb.write_file("/tmp/hello", b"hi")
entries = sb.list_dir("/tmp")
snap = sb.snapshot()

Surfaces:

SymbolPurpose
SandboxLifecycle: create, list, get, info, destroy.
Process / ProcessInfoSpawned process handles + status.
FileEntry / FileStatFilesystem listings + metadata.
SnapshotEntrySnapshot lifecycle records.
SandboxInfo / SandboxMetricsSandbox state introspection.
SandboxError / SandboxNotFoundSubstrate-side failure types.

The sandbox surface is dev-only (same posture as the runtime SDK): production deployments don’t drive sandboxes from host SDK code.


The emit subprocess contract

Per ADR-0002:

python -m mvm <entry.py>
with MVMFORGE_IR_OUT=<output-path>
MVMFORGE_EMITTING=1 (host sets this; SDK reads it for guard)

__main__ loads <entry.py> (decorators register), calls emit_json(), writes canonical JSON to MVMFORGE_IR_OUT. Exit codes 0 / 1 / 2 (success / emit failure / I/O or CLI error). No stdout on success.

Terminal window
MVMFORGE_IR_OUT=/tmp/ir.json uv run python -m mvm app.py
cat /tmp/ir.json | jq

Internal layout

sdks/python/
pyproject.toml
uv.lock
mvmforge/
__init__.py # public re-exports
__main__.py # subprocess emitter
_dsl.py # authoring DSL
_remote.py # RemoteFunction + transport + EmittingContextError
_session.py # session() context manager
_sandbox.py # Sandbox surface
_subprocess.py # capped + timed mvmctl wrapper
_ir/workload.py # generated lower layer (datamodel-codegen)
tests/
test_emit*.py test_remote.py test_session.py test_emitting_guard.py
test_sandbox*.py test_schema_derive.py

mvmforge (everything in __init__.py’s __all__) is the stable public surface. _ir/ and the _dsl / _remote / _session / _sandbox modules are internal — pin exact mvmforge versions if you import from them. Layer isolation is enforced via import-linter per ADR-0003 §5.

Regenerating the lower layer

Terminal window
just sdk-python-gen # writes sdks/python/mvmforge/_ir/workload.py
just sdk-python-check # asserts the committed file matches fresh regeneration

just sdk-python-check is wired into just ci and fails on drift.

Tests

Terminal window
just sdk-python-test

128 tests cover the DSL surface, the subprocess contract, the runtime transport (against a fake mvmctl), the emit-context guard, the secret-name heuristic, and the sandbox surface (including a cross-language argv-corpus parity check shared with the TypeScript SDK).

Cross-SDK conformance

The Python SDK is one of two active SDKs. The golden corpus at tests/corpus/ enforces byte-identical canonical IR across SDKs for semantically equivalent source. just corpus-check runs every SDK against every corpus entry; cross-language pairs (Python authoring + TS authoring of the same workload) must produce identical bytes.

When you change DSL behavior in one SDK, change it in the other in the same PR, and add a corpus entry that exercises the new behavior.