Add adoption-focused examples, contract docs, and CLI polish
This commit is contained in:
parent
227983a877
commit
0aa5e25dc1
18 changed files with 560 additions and 2 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,4 +7,5 @@ __pycache__/
|
|||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
.uv-cache/
|
||||
*.egg-info/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
|
|||
- Install Git LFS before cloning or materializing the packaged runtime bundle: `git lfs install`.
|
||||
- Run `make setup` after cloning.
|
||||
- Run `make check` before opening a PR.
|
||||
- Run `make dist-check` when you change packaging, entrypoints, or install docs.
|
||||
- Public user-facing CLI is `pyro`.
|
||||
- Public Python SDK entrypoint is `from pyro_mcp import Pyro`.
|
||||
- The packaged runtime images under `src/pyro_mcp/runtime_bundle/` are stored in Git LFS.
|
||||
|
|
@ -28,6 +29,7 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
|
|||
- If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`.
|
||||
- After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`.
|
||||
- The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable.
|
||||
- Public contract documentation lives in `docs/public-contract.md`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -1,4 +1,5 @@
|
|||
PYTHON ?= uv run python
|
||||
UV_CACHE_DIR ?= .uv-cache
|
||||
OLLAMA_BASE_URL ?= http://localhost:11434/v1
|
||||
OLLAMA_MODEL ?= llama3.2:3b
|
||||
OLLAMA_DEMO_FLAGS ?=
|
||||
|
|
@ -8,7 +9,7 @@ RUNTIME_BUILD_DIR ?= build/runtime_bundle
|
|||
RUNTIME_BUNDLE_DIR ?= src/pyro_mcp/runtime_bundle
|
||||
RUNTIME_MATERIALIZED_DIR ?= build/runtime_sources
|
||||
|
||||
.PHONY: help setup lint format typecheck test check demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-boot-check runtime-network-check
|
||||
.PHONY: help setup lint format typecheck test check dist-check demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-boot-check runtime-network-check
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
|
|
@ -20,6 +21,7 @@ help:
|
|||
' typecheck Run mypy' \
|
||||
' test Run pytest' \
|
||||
' check Run lint, typecheck, and tests' \
|
||||
' dist-check Smoke-test the installed public pyro CLI entrypoint' \
|
||||
' demo Run the deterministic VM demo' \
|
||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||
' doctor Show runtime and host diagnostics' \
|
||||
|
|
@ -59,6 +61,10 @@ test:
|
|||
|
||||
check: lint typecheck test
|
||||
|
||||
dist-check:
|
||||
.venv/bin/pyro --version
|
||||
.venv/bin/pyro --help >/dev/null
|
||||
|
||||
demo:
|
||||
uv run pyro demo
|
||||
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -9,6 +9,13 @@ It exposes the same runtime in two public forms:
|
|||
|
||||
It also ships an MCP server so LLM clients can use the same VM runtime through tools.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Install: [docs/install.md](/home/thales/projects/personal/pyro/docs/install.md)
|
||||
- Host requirements: [docs/host-requirements.md](/home/thales/projects/personal/pyro/docs/host-requirements.md)
|
||||
- Public contract: [docs/public-contract.md](/home/thales/projects/personal/pyro/docs/public-contract.md)
|
||||
- Troubleshooting: [docs/troubleshooting.md](/home/thales/projects/personal/pyro/docs/troubleshooting.md)
|
||||
|
||||
## Public UX
|
||||
|
||||
Primary install/run path:
|
||||
|
|
@ -26,6 +33,12 @@ pyro mcp serve
|
|||
The public user-facing interface is `pyro` and `Pyro`.
|
||||
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
|
||||
|
||||
Check the installed CLI version:
|
||||
|
||||
```bash
|
||||
pyro --version
|
||||
```
|
||||
|
||||
## Repository Storage
|
||||
|
||||
This repository uses Git LFS for the packaged runtime images under
|
||||
|
|
@ -124,6 +137,13 @@ Verbose Ollama logs:
|
|||
pyro demo ollama -v
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
- Python one-shot SDK example: [examples/python_run.py](/home/thales/projects/personal/pyro/examples/python_run.py)
|
||||
- Python lifecycle example: [examples/python_lifecycle.py](/home/thales/projects/personal/pyro/examples/python_lifecycle.py)
|
||||
- MCP client config example: [examples/mcp_client_config.md](/home/thales/projects/personal/pyro/examples/mcp_client_config.md)
|
||||
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](/home/thales/projects/personal/pyro/examples/agent_vm_run.py)
|
||||
|
||||
## Python SDK
|
||||
|
||||
```python
|
||||
|
|
@ -160,6 +180,9 @@ result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
|||
print(result["stdout"])
|
||||
```
|
||||
|
||||
The recommended agent-facing default is still one-shot execution through `run_in_vm(...)` / `vm_run`.
|
||||
Use lifecycle methods only when the agent needs VM state to persist across multiple calls.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Primary agent-facing tool:
|
||||
|
|
@ -212,6 +235,7 @@ For work inside this repository:
|
|||
make help
|
||||
make setup
|
||||
make check
|
||||
make dist-check
|
||||
```
|
||||
|
||||
Runtime build and validation helpers remain available through `make`, including:
|
||||
|
|
|
|||
14
TASKS.tmp.md
Normal file
14
TASKS.tmp.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Temporary Task List
|
||||
|
||||
Execution order for the current adoption pass:
|
||||
|
||||
1. Add real integration examples for users.
|
||||
2. Freeze and document the public contract with regression tests.
|
||||
3. Improve distribution ergonomics and verify clean-install paths.
|
||||
4. Add one high-level agent-ready example centered on `vm_run`.
|
||||
5. Polish install, troubleshooting, and host-requirements docs.
|
||||
|
||||
Notes:
|
||||
- `Makefile` remains contributor-only.
|
||||
- Public user-facing surfaces are `pyro`, `Pyro`, and the MCP tool schemas.
|
||||
- Keep guest networking explicit and documented where examples depend on it.
|
||||
32
docs/host-requirements.md
Normal file
32
docs/host-requirements.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Host Requirements
|
||||
|
||||
`pyro-mcp` currently targets Linux x86_64 hosts.
|
||||
|
||||
## Required
|
||||
|
||||
- KVM available at `/dev/kvm`
|
||||
- support for Firecracker microVMs
|
||||
- sufficient disk for the bundled runtime images
|
||||
|
||||
## Required For Guest Networking
|
||||
|
||||
- `/dev/net/tun`
|
||||
- `ip`
|
||||
- `nft` or `iptables`
|
||||
- host IP forwarding enabled
|
||||
- privilege to create TAP devices and NAT rules
|
||||
|
||||
The current implementation uses `sudo -n` for host networking commands when a networked run is requested.
|
||||
|
||||
## Validate The Host
|
||||
|
||||
```bash
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
Check these fields in the output:
|
||||
|
||||
- `runtime_ok`
|
||||
- `kvm`
|
||||
- `networking.tun_available`
|
||||
- `networking.ip_forward_enabled`
|
||||
46
docs/install.md
Normal file
46
docs/install.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Install
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux host
|
||||
- Python 3.12+
|
||||
- Git LFS
|
||||
- `/dev/kvm`
|
||||
|
||||
If you want outbound guest networking:
|
||||
|
||||
- `ip`
|
||||
- `nft` or `iptables`
|
||||
- privilege to create TAP devices and configure NAT
|
||||
|
||||
## Fastest Start
|
||||
|
||||
Run the MCP server directly from the package without a manual install:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
Run one command in a sandbox:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro run --profile debian-git --vcpu-count 1 --mem-mib 1024 -- git --version
|
||||
```
|
||||
|
||||
## Installed CLI
|
||||
|
||||
```bash
|
||||
uv tool install .
|
||||
pyro --version
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
## Contributor Clone
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git clone <repo>
|
||||
cd pyro
|
||||
git lfs pull
|
||||
make setup
|
||||
```
|
||||
76
docs/public-contract.md
Normal file
76
docs/public-contract.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Public Contract
|
||||
|
||||
This document defines the supported public interface for `pyro-mcp`.
|
||||
|
||||
## Package Identity
|
||||
|
||||
- Distribution name: `pyro-mcp`
|
||||
- Public executable: `pyro`
|
||||
- Public Python import: `from pyro_mcp import Pyro`
|
||||
|
||||
## CLI Contract
|
||||
|
||||
Top-level commands:
|
||||
|
||||
- `pyro mcp serve`
|
||||
- `pyro run`
|
||||
- `pyro doctor`
|
||||
- `pyro demo`
|
||||
- `pyro demo ollama`
|
||||
|
||||
Stable `pyro run` flags:
|
||||
|
||||
- `--profile`
|
||||
- `--vcpu-count`
|
||||
- `--mem-mib`
|
||||
- `--timeout-seconds`
|
||||
- `--ttl-seconds`
|
||||
- `--network`
|
||||
|
||||
Behavioral guarantees:
|
||||
|
||||
- `pyro run -- <command>` returns structured JSON.
|
||||
- `pyro doctor` returns structured JSON diagnostics.
|
||||
- `pyro demo ollama` prints log lines plus a final summary line.
|
||||
|
||||
## Python SDK Contract
|
||||
|
||||
Primary facade:
|
||||
|
||||
- `Pyro`
|
||||
|
||||
Supported public methods:
|
||||
|
||||
- `create_server()`
|
||||
- `list_profiles()`
|
||||
- `create_vm(...)`
|
||||
- `start_vm(vm_id)`
|
||||
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `stop_vm(vm_id)`
|
||||
- `delete_vm(vm_id)`
|
||||
- `status_vm(vm_id)`
|
||||
- `network_info_vm(vm_id)`
|
||||
- `reap_expired()`
|
||||
- `run_in_vm(...)`
|
||||
|
||||
## MCP Contract
|
||||
|
||||
Primary tool:
|
||||
|
||||
- `vm_run`
|
||||
|
||||
Advanced lifecycle tools:
|
||||
|
||||
- `vm_list_profiles`
|
||||
- `vm_create`
|
||||
- `vm_start`
|
||||
- `vm_exec`
|
||||
- `vm_stop`
|
||||
- `vm_delete`
|
||||
- `vm_status`
|
||||
- `vm_network_info`
|
||||
- `vm_reap_expired`
|
||||
|
||||
## Compatibility Rule
|
||||
|
||||
Changes to any command name, public flag, public method name, or MCP tool name are breaking changes and should be treated as a deliberate contract version change.
|
||||
64
docs/troubleshooting.md
Normal file
64
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Troubleshooting
|
||||
|
||||
## `pyro doctor` reports runtime checksum mismatch
|
||||
|
||||
Cause:
|
||||
- the Git LFS pointer files are present, but the real runtime images have not been checked out
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
git lfs pull
|
||||
git lfs checkout
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
## `pyro run --network` fails before the guest starts
|
||||
|
||||
Cause:
|
||||
- the host cannot create TAP devices or NAT rules
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
- `ip`
|
||||
- `nft` or `iptables`
|
||||
- `/dev/net/tun`
|
||||
- host privilege for `sudo -n`
|
||||
|
||||
## Ollama demo exits with tool-call failures
|
||||
|
||||
Cause:
|
||||
- the model produced an invalid tool call or your Ollama model is not reliable enough for tool use
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
pyro demo ollama -v
|
||||
```
|
||||
|
||||
Inspect:
|
||||
|
||||
- model output
|
||||
- requested tool calls
|
||||
- tool results
|
||||
|
||||
## Repository clone is still huge after the LFS migration
|
||||
|
||||
Cause:
|
||||
- old refs are still present locally
|
||||
- `build/` or `.venv/` duplicates are consuming disk
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
rm -rf build
|
||||
git lfs prune
|
||||
```
|
||||
|
||||
If needed, recreate `.venv/`.
|
||||
62
examples/agent_vm_run.py
Normal file
62
examples/agent_vm_run.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Provider-agnostic agent tool example centered on vm_run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
VM_RUN_TOOL: dict[str, Any] = {
|
||||
"name": "vm_run",
|
||||
"description": "Run one command in an ephemeral Firecracker VM and clean it up.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": {"type": "string"},
|
||||
"command": {"type": "string"},
|
||||
"vcpu_count": {"type": "integer"},
|
||||
"mem_mib": {"type": "integer"},
|
||||
"timeout_seconds": {"type": "integer", "default": 30},
|
||||
"ttl_seconds": {"type": "integer", "default": 600},
|
||||
"network": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["profile", "command", "vcpu_count", "mem_mib"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
pyro = Pyro()
|
||||
return pyro.run_in_vm(
|
||||
profile=str(arguments["profile"]),
|
||||
command=str(arguments["command"]),
|
||||
vcpu_count=int(arguments["vcpu_count"]),
|
||||
mem_mib=int(arguments["mem_mib"]),
|
||||
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
|
||||
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
|
||||
network=bool(arguments.get("network", False)),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tool_arguments: dict[str, Any] = {
|
||||
"profile": "debian-git",
|
||||
"command": "git --version",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"timeout_seconds": 30,
|
||||
"network": False,
|
||||
}
|
||||
tool_call: dict[str, Any] = {
|
||||
"name": "vm_run",
|
||||
"arguments": tool_arguments,
|
||||
}
|
||||
|
||||
print(json.dumps({"tool": VM_RUN_TOOL, "tool_call": tool_call}, indent=2, sort_keys=True))
|
||||
result = call_vm_run(tool_arguments)
|
||||
print(json.dumps({"tool_result": result}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
examples/mcp_client_config.md
Normal file
35
examples/mcp_client_config.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# MCP Client Config Example
|
||||
|
||||
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||
|
||||
Generic stdio MCP configuration using `uvx`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `pyro-mcp` is already installed locally, the same server can be configured with:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "pyro",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Primary tool for most agents:
|
||||
|
||||
- `vm_run`
|
||||
|
||||
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
||||
30
examples/python_lifecycle.py
Normal file
30
examples/python_lifecycle.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Manage VM lifecycle directly through the public Python SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pyro = Pyro()
|
||||
created = pyro.create_vm(
|
||||
profile="debian-git",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
)
|
||||
vm_id = str(created["vm_id"])
|
||||
|
||||
try:
|
||||
pyro.start_vm(vm_id)
|
||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
finally:
|
||||
pyro.delete_vm(vm_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
examples/python_run.py
Normal file
24
examples/python_run.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Run one command in an ephemeral VM through the public Python SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pyro = Pyro()
|
||||
result = pyro.run_in_vm(
|
||||
profile="debian-git",
|
||||
command="git --version",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
timeout_seconds=30,
|
||||
network=False,
|
||||
)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -26,6 +26,7 @@ packages = ["src/pyro_mcp"]
|
|||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"docs/**",
|
||||
"src/pyro_mcp/runtime_bundle/**",
|
||||
"runtime_sources/**",
|
||||
"src/pyro_mcp/**/*.py",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"""Public package surface for pyro_mcp."""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.server import create_server
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
__all__ = ["Pyro", "VmManager", "create_server"]
|
||||
__version__ = version("pyro-mcp")
|
||||
|
||||
__all__ = ["Pyro", "VmManager", "__version__", "create_server"]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import argparse
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from pyro_mcp import __version__
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.demo import run_demo
|
||||
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
|
||||
|
|
@ -18,6 +19,7 @@ def _print_json(payload: dict[str, Any]) -> None:
|
|||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="pyro CLI for ephemeral Firecracker VMs.")
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
mcp_parser = subparsers.add_parser("mcp", help="Run the MCP server.")
|
||||
|
|
|
|||
41
src/pyro_mcp/contract.py
Normal file
41
src/pyro_mcp/contract.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Public contract constants for the CLI, SDK, and MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_CLI_COMMANDS = ("mcp", "run", "doctor", "demo")
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||
PUBLIC_CLI_RUN_FLAGS = (
|
||||
"--profile",
|
||||
"--vcpu-count",
|
||||
"--mem-mib",
|
||||
"--timeout-seconds",
|
||||
"--ttl-seconds",
|
||||
"--network",
|
||||
)
|
||||
|
||||
PUBLIC_SDK_METHODS = (
|
||||
"create_server",
|
||||
"create_vm",
|
||||
"delete_vm",
|
||||
"exec_vm",
|
||||
"list_profiles",
|
||||
"network_info_vm",
|
||||
"reap_expired",
|
||||
"run_in_vm",
|
||||
"start_vm",
|
||||
"status_vm",
|
||||
"stop_vm",
|
||||
)
|
||||
|
||||
PUBLIC_MCP_TOOLS = (
|
||||
"vm_run",
|
||||
"vm_list_profiles",
|
||||
"vm_create",
|
||||
"vm_start",
|
||||
"vm_exec",
|
||||
"vm_stop",
|
||||
"vm_delete",
|
||||
"vm_status",
|
||||
"vm_network_info",
|
||||
"vm_reap_expired",
|
||||
)
|
||||
94
tests/test_public_contract.py
Normal file
94
tests/test_public_contract.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import tomllib
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp import Pyro, __version__
|
||||
from pyro_mcp.cli import _build_parser
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_CLI_COMMANDS,
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS,
|
||||
PUBLIC_CLI_RUN_FLAGS,
|
||||
PUBLIC_MCP_TOOLS,
|
||||
PUBLIC_SDK_METHODS,
|
||||
)
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
|
||||
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
|
||||
subparsers = getattr(parser, "_subparsers", None)
|
||||
if subparsers is None:
|
||||
raise AssertionError("parser does not define subparsers")
|
||||
group_actions = cast(list[Any], subparsers._group_actions) # noqa: SLF001
|
||||
if not group_actions:
|
||||
raise AssertionError("parser subparsers are empty")
|
||||
choices = cast(dict[str, argparse.ArgumentParser], group_actions[0].choices)
|
||||
return choices[name]
|
||||
|
||||
|
||||
def test_public_sdk_methods_exist() -> None:
|
||||
assert tuple(sorted(PUBLIC_SDK_METHODS)) == PUBLIC_SDK_METHODS
|
||||
for method_name in PUBLIC_SDK_METHODS:
|
||||
assert hasattr(Pyro, method_name), method_name
|
||||
|
||||
|
||||
def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
||||
parser = _build_parser()
|
||||
help_text = parser.format_help()
|
||||
assert "--version" in help_text
|
||||
for command_name in PUBLIC_CLI_COMMANDS:
|
||||
assert command_name in help_text
|
||||
|
||||
run_parser = _build_parser()
|
||||
run_help = run_parser.parse_args(
|
||||
["run", "--profile", "debian-base", "--vcpu-count", "1", "--mem-mib", "512", "--", "true"]
|
||||
)
|
||||
assert run_help.command == "run"
|
||||
|
||||
run_help_text = _subparser_choice(parser, "run").format_help()
|
||||
for flag in PUBLIC_CLI_RUN_FLAGS:
|
||||
assert flag in run_help_text
|
||||
|
||||
demo_help_text = _subparser_choice(parser, "demo").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
|
||||
assert subcommand_name in demo_help_text
|
||||
|
||||
|
||||
def test_public_cli_version_matches_package_version() -> None:
|
||||
parser = _build_parser()
|
||||
stdout = io.StringIO()
|
||||
with pytest.raises(SystemExit, match="0"), redirect_stdout(stdout):
|
||||
parser.parse_args(["--version"])
|
||||
assert stdout.getvalue().strip().endswith(f" {__version__}")
|
||||
|
||||
|
||||
def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[str, ...]:
|
||||
server = pyro.create_server()
|
||||
tools = await server.list_tools()
|
||||
return tuple(sorted(tool.name for tool in tools))
|
||||
|
||||
tool_names = asyncio.run(_run())
|
||||
assert tool_names == tuple(sorted(PUBLIC_MCP_TOOLS))
|
||||
|
||||
|
||||
def test_pyproject_exposes_single_public_cli_script() -> None:
|
||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
scripts = pyproject["project"]["scripts"]
|
||||
assert scripts == {"pyro": "pyro_mcp.cli:main"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue