multipass-sdk
Unofficial Python SDK for Canonical Multipass. Wraps the Multipass CLI to manage Ubuntu VMs programmatically.
- Full coverage of the Multipass CLI (launch, list, find, exec, transfer, mount, snapshot, clone, …)
- Testable without Multipass installed — inject a
FakeBackendin unit tests - Typed exception hierarchy, dataclass models, no external runtime dependencies beyond
haikunatorandpyyaml
Installation
From GitHub (recommended for internal projects)
uv add git+https://github.com/Nanofaas/multipass-vm-sdk.git
To pin to a specific commit or tag:
uv add git+https://github.com/Nanofaas/multipass-vm-sdk.git@v0.6.0
Multipass itself must be installed on the machine where the SDK is used at runtime. It is not required for unit tests.
Quick start
from multipass import MultipassClient, VmConfig
client = MultipassClient()
# Launch a VM (name is auto-generated if omitted)
vm = client.launch(name="my-vm", cpus=2, memory="1G", disk="10G", image="22.04")
# Or use a VmConfig for reusable configs
vm = client.launch(VmConfig(name="my-vm", cpus=4, memory="8G"))
# Launch multiple VMs in parallel (rolls back on failure)
vms = client.launch_many(
[
VmConfig(name="web", cpus=2),
VmConfig(name="db", cpus=4, memory="8G"),
]
)
# Wait until SSH is reachable, then connect
ip = vm.wait_ready(timeout=180, port=22)
print(f"VM ready at {ip}")
# Run a command
result = vm.exec(["uname", "-r"])
print(result.stdout)
# Structured exec with cwd and env
result = vm.exec_structured(
["sh", "-c", 'echo "$PWD $MSG"'], cwd="/tmp", env={"MSG": "hello"}
)
# Lifecycle
vm.stop()
vm.start()
vm.suspend()
vm.restart()
# Cleanup
vm.delete(purge=True)
API reference
MultipassClient
client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary
| Method | Description |
|---|---|
launch(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM |
Launch a new VM. Accepts str | VmConfig | None as first argument |
launch_many(configs, *, max_workers) → list[MultipassVM] |
Launch multiple VMs in parallel; rolls back all on any failure |
ensure_running(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM |
Idempotent: launch, start, or no-op so the VM ends up Running |
get_vm(name) → MultipassVM |
Get a VM object by name |
list_vms() → list[VmInfo] |
List all VMs |
find() → list[ImageInfo] |
List available images |
purge() |
Permanently delete all soft-deleted VMs |
networks() → list[NetworkInfo] |
List available networks |
version() → VersionInfo |
Multipass and multipassd versions |
get(key) → str |
Read a Multipass setting |
set(key, value) |
Write a Multipass setting |
aliases() → list[AliasInfo] |
List all aliases |
alias(name, vm, command) |
Create an alias |
unalias(name) |
Remove an alias |
MultipassVM
| Method | Description |
|---|---|
info() → VmInfo |
Get current VM state and metadata |
start() |
Start the VM |
stop(*, force, time) |
Stop the VM |
restart() |
Restart the VM |
suspend() |
Suspend the VM |
recover() |
Recover a VM in error state |
delete(*, purge) |
Delete the VM (soft or permanent) |
exec(command: list[str]) → CommandResult |
Run a command in the VM |
exec_structured(argv, *, env, cwd) → CommandResult |
Structured exec: builds the bash prologue (cd + export) from typed arguments |
transfer(source, dest) |
Transfer files between host and VM (recursive) |
mount(source, target, *, mount_type, uid_map, gid_map) |
Mount a host directory |
unmount(mount) |
Unmount a directory |
snapshots() → list[SnapshotInfo] |
List snapshots |
snapshot(name, *, comment) → SnapshotInfo |
Create a snapshot (VM must be stopped) |
restore(snapshot, *, destructive) |
Restore a snapshot |
clone(new_name) → MultipassVM |
Clone the VM (VM must be stopped) |
wait_for_ip(timeout, *, interval) → str |
Poll until the VM has an IPv4 address |
wait_ready(timeout, port, *, interval) → str |
Poll until the VM has an IP and a TCP port is reachable |
wait_for_ip / wait_ready
These methods make the common "launch → SSH" pattern reliable:
vm = client.launch(name="worker", disk="10G")
# Returns the first IPv4 address once assigned
ip = vm.wait_for_ip(timeout=120)
# Returns the IP once the given TCP port is reachable (default: 22)
ip = vm.wait_ready(timeout=180, port=22)
Both raise MultipassTimeoutError if the deadline is exceeded.
VmConfig
Reusable configuration for VM launches, shared across launch(), launch_many(), and ensure_running():
from multipass import VmConfig
cfg = VmConfig(name="worker", cpus=4, memory="8G", disk="30G", image="22.04")
vm = client.launch(cfg)
vms = client.launch_many([cfg, VmConfig(name="worker2", cpus=2)])
Fields: name, image, cpus (default 1), memory (default "1G"), disk (default "5G"), cloud_init, cloud_init_config.
CloudInitConfig
Structured cloud-init configuration as an alternative to raw dicts:
from multipass import CloudInitConfig
cfg = CloudInitConfig(
packages=["git", "curl"],
ssh_authorized_keys=["ssh-ed25519 AAAA..."],
runcmd=[["apt-get", "update"], ["apt-get", "upgrade", "-y"]],
)
vm = client.launch(
name="my-vm",
cloud_init_config=cfg.to_dict(),
)
Fields: packages, ssh_authorized_keys, runcmd, write_files, users — all optional. to_dict() returns only the fields that were set.
launch_many
Launch multiple VMs concurrently. If any launch fails, already-created VMs are deleted:
configs = [
VmConfig(name="web", cpus=2, memory="4G"),
VmConfig(name="db", cpus=4, memory="16G", disk="100G"),
]
vms = client.launch_many(configs, max_workers=4)
File transfer
Use instance-name:/path notation for VM paths, plain paths for host paths:
vm.transfer("/host/file.txt", "my-vm:/home/ubuntu/file.txt")
vm.transfer("my-vm:/home/ubuntu/output.txt", "/host/dest/")
Snapshots
vm.stop()
snap = vm.snapshot("snap1", comment="before upgrade")
vm.start()
# restore (--destructive consumes the snapshot)
vm.stop()
vm.restore("snap1", destructive=True)
vm.start()
cloud-init
Pass a file path, a YAML string, or a dict — the SDK handles the rest.
File path (you manage the file):
vm = client.launch(name="my-vm", cloud_init="/home/user/cloud-init.yaml")
Inline dict (serialized to JSON, which cloud-init accepts natively):
vm = client.launch(
name="my-vm",
cloud_init_config={
"packages": ["git", "curl"],
"runcmd": ["apt-get upgrade -y"],
},
)
Inline YAML string:
vm = client.launch(
name="my-vm",
cloud_init_config="""
#cloud-config
packages:
- git
- curl
runcmd:
- apt-get upgrade -y
""",
)
When cloud_init_config is used, the SDK writes a temporary file to the user's home directory (not /tmp) so that Multipass installed via snap can read it. The file is deleted automatically after launch.
Custom user and SSH key
from pathlib import Path
ssh_key = Path("~/.ssh/id_rsa.pub").expanduser().read_text().strip()
vm = client.launch(
name="my-vm",
cloud_init_config={
"users": [
{
"name": "michele",
"groups": ["sudo"],
"shell": "/bin/bash",
"sudo": "ALL=(ALL) NOPASSWD:ALL",
"ssh_authorized_keys": [ssh_key],
}
]
},
)
To keep the default ubuntu user alongside your custom one, add "default" as the first entry in the users list:
"users": ["default", {"name": "michele", ...}]
SSH key injection helper
find_ssh_public_key() returns the content of the first SSH public key found in
~/.ssh/ (priority: ed25519 → rsa → ecdsa → dsa), or None if none exists.
Combine it with cloud_init_config to make new VMs immediately accessible:
from multipass import MultipassClient, find_ssh_public_key
client = MultipassClient()
pub_key = find_ssh_public_key()
vm = client.ensure_running(
"my-vm",
cloud_init_config={"ssh_authorized_keys": [pub_key]} if pub_key else None,
)
ip = vm.wait_ready(timeout=180)
Exceptions
All exceptions inherit from MultipassError.
| Exception | When raised |
|---|---|
MultipassCommandError |
CLI exits with non-zero status |
MultipassNotInstalledError |
multipass binary not found |
MultipassTimeoutError |
wait_for_ip / wait_ready deadline exceeded |
VmNotFoundError |
VM does not exist |
VmAlreadyRunningError |
Operation requires stopped VM |
VmNotRunningError |
Operation requires running VM |
VmAlreadySuspendedError |
VM is already suspended |
Testing without Multipass
Inject a FakeBackend to unit-test code that uses the SDK:
import json
from multipass import MultipassClient
from multipass.testing import FakeBackend
from multipass import CommandResult
backend = FakeBackend(
{
("multipass", "list", "--format", "json"): CommandResult(
args=[],
returncode=0,
stdout=json.dumps({"list": []}),
stderr="",
)
}
)
client = MultipassClient(backend=backend)
vms = client.list_vms() # no Multipass required
FakeBackend also supports queued responses for polling scenarios and tracks cwd/env for assertions:
backend = FakeBackend()
backend.push(
"multipass",
"info",
"my-vm",
"--format",
"json",
result=CommandResult(..., stdout=info_no_ip),
)
backend.push(
"multipass",
"info",
"my-vm",
"--format",
"json",
result=CommandResult(..., stdout=info_with_ip),
)
assert backend.last_cwd() is None
assert backend.last_env() is None
Running the tests
# Setup
uv sync
uv run pre-commit install
# Unit tests (no Multipass required)
uv run pytest tests/unit/ -v
# Unit tests with the coverage gate (fails below 90%)
uv run pytest tests/unit --cov --cov-report=term-missing
# Integration tests (require Multipass installed and running)
uv run pytest -m integration -v
# Lint and format (ruff)
uv run ruff check .
uv run ruff format .
# Type checking (basedpyright)
uv run basedpyright
# Security (bandit)
uv run bandit -c pyproject.toml -r src
# Everything pre-commit runs, the same way CI does
uv run pre-commit run --all-files
End-to-end script
The SDK ships a multipass-vm-e2e console script for full lifecycle verification:
uv run multipass-vm-e2e
uv run multipass-vm-e2e --name my-test --cpus 2 --memory 4G --disk 10G
uv run multipass-vm-e2e --count 3
uv run multipass-vm-e2e --configs '[{"name":"web","cpus":2},{"name":"db","cpus":4}]'
uv run multipass-vm-e2e --list-images
uv run multipass-vm-e2e --skip transfer,clone
Five-stage pipeline: launch → basic verification → feature tests (exec_structured, stop/start, restart, transfer, clone, snapshot/restore) → cleanup clones → delete VMs.
Feature tests run only in single-VM mode (--count 1). Use --skip all for a basic lifecycle-only test or --skip <feature>,... to skip individual tests.
The integration test suite covers: full VM lifecycle, resources, soft delete + purge, wait_for_ip, wait_ready + SSH, suspend/resume, file transfer, snapshot/restore, clone, cloud-init, and error handling.
Contributing
Send a pull request. Unit tests and pre-commit (ruff, basedpyright, bandit) must pass; integration tests are welcome but not required in CI.
Metadata
Release files for multipass-vm-sdk 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| multipass_vm_sdk-0.6.0.tar.gz | 67.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| multipass_vm_sdk-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 90.5 kB
Release files / multipass_vm_sdk-0.6.0.tar.gz
| Download URL | multipass_vm_sdk-0.6.0.tar.gz |
|---|---|
| Size | 67.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9195fa1d9a9aabf4adfce89ce2e07fef264485dd104afe5c04a633e76397eabd
|
|
BLAKE2b-256 checksum How to use checksums |
0fa4f1f7539c62ef55015b8ab1c1a043cce62f57ab023974156e9ced9d91d0bb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / multipass_vm_sdk-0.6.0-py3-none-any.whl
| Download URL | multipass_vm_sdk-0.6.0-py3-none-any.whl |
|---|---|
| Size | 22.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0ba64a7ed4fccbcdef2456f27ca0d546a162d1017eb48d806c8c762e62664f34
|
|
BLAKE2b-256 checksum How to use checksums |
0f7f865c30de3878a8239d10e475102ea4d307f15060447f647053ac6b4392be
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log