Tasks could start from host content in 2.2.0, but there was still no post-create path to update a live workspace from the host. This change adds the next host-to-task step so repeated fix or review loops do not require recreating the task for every local change. Add task sync push across the CLI, Python SDK, and MCP server, reusing the existing safe archive import path from seeded task creation instead of introducing a second transfer stack. The implementation keeps sync separate from workspace_seed metadata, validates destinations under /workspace, and documents the current non-atomic recovery path as delete-and-recreate. Validation: - uv lock - UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_cli.py tests/test_vm_manager.py tests/test_api.py tests/test_server.py tests/test_public_contract.py - UV_CACHE_DIR=.uv-cache make check - UV_CACHE_DIR=.uv-cache make dist-check - real guest-backed smoke: task create --source-path, task sync push, task exec to verify both files, task delete
121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
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_ENV_SUBCOMMANDS,
|
|
PUBLIC_CLI_RUN_FLAGS,
|
|
PUBLIC_CLI_TASK_CREATE_FLAGS,
|
|
PUBLIC_CLI_TASK_SUBCOMMANDS,
|
|
PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS,
|
|
PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS,
|
|
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", "debian:12-base", "--", "true"])
|
|
assert run_help.command == "run"
|
|
assert run_help.environment == "debian:12-base"
|
|
assert run_help.vcpu_count == 1
|
|
assert run_help.mem_mib == 1024
|
|
|
|
run_help_text = _subparser_choice(parser, "run").format_help()
|
|
for flag in PUBLIC_CLI_RUN_FLAGS:
|
|
assert flag in run_help_text
|
|
|
|
env_help_text = _subparser_choice(parser, "env").format_help()
|
|
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
|
|
assert subcommand_name in env_help_text
|
|
|
|
task_help_text = _subparser_choice(parser, "task").format_help()
|
|
for subcommand_name in PUBLIC_CLI_TASK_SUBCOMMANDS:
|
|
assert subcommand_name in task_help_text
|
|
task_create_help_text = _subparser_choice(
|
|
_subparser_choice(parser, "task"), "create"
|
|
).format_help()
|
|
for flag in PUBLIC_CLI_TASK_CREATE_FLAGS:
|
|
assert flag in task_create_help_text
|
|
task_sync_help_text = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
|
|
for subcommand_name in PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS:
|
|
assert subcommand_name in task_sync_help_text
|
|
task_sync_push_help_text = _subparser_choice(
|
|
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
|
|
).format_help()
|
|
for flag in PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS:
|
|
assert flag in task_sync_push_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"}
|