feat(deploy): On-Device Deploy Autopilot — 10-profile catalog + recipe/script writer (v0.46.0 Part A)

Closes the v0.45.0 Known Limitations gap that flagged the 15-entry external
integrations catalog as descriptive-only. This release ships the live
autopilot CLI that picks PEFT + quantisation + speculative-decoding for the
target hardware and emits a ready-to-train recipe.

* soup_cli/utils/deploy_autopilot.py — `DeployProfile` frozen dataclass +
  10-entry MappingProxyType-wrapped catalog: mac-m3 / mac-m4-pro /
  rtx-3060-12gb / rtx-4090-24gb / iphone-16 / pixel-9 / ollama-local /
  lm-studio / runpod-a100 / hf-jobs-h100. `_make` factory rejects bool
  recommended_max_length, non-kebab-case names, runtime/quant/peft outside
  closed allowlists. `render_recipe_yaml` validates `base` (≤200 chars, no
  NUL/newline) + `output_dir` (≤4096 chars). `render_deploy_script` uses
  `shlex.quote` on model_path. `write_recipe` / `write_deploy_script`
  enforce `is_under_cwd` + `os.lstat + S_ISLNK` TOCTOU rejection at the
  write target (matches v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B /
  v0.45.0 Part E policy).

* soup_cli/commands/deploy.py — new `autopilot` Typer subcommand with
  `--target`, `--base`, `--recipe-out`, `--script-out`, `--list`. Every
  Rich-rendered profile field passes through `rich.markup.escape`.

* tests/test_v0460_part_a.py — 76 tests covering catalog immutability,
  10-profile presence, case-insensitive resolution, every `_make` failure
  mode, render-* validation matrix, write-* path-containment + symlink
  rejection (POSIX-only), CLI smoke (list / writes / outside-cwd reject).

Live Quant-Lobotomy auto-measure (against the v0.26.0 Checker) is deferred
to v0.46.1 — this release writes the canonical combo per profile, the
runtime measurement step lands in the patch release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-11 17:11:36 +05:00
parent 74301843a2
commit 479931282c
3 changed files with 1082 additions and 0 deletions

View File

@ -559,6 +559,122 @@ def hf_space(
)
@app.command(name="autopilot")
def autopilot(
target: Optional[str] = typer.Option(
None,
"--target",
"-t",
help="Profile name (e.g. mac-m3, rtx-4090-24gb, ollama-local).",
),
base: str = typer.Option(
"meta-llama/Llama-3.2-1B",
"--base",
"-b",
help="Base model HF repo id or local path to embed in the recipe.",
),
recipe_out: str = typer.Option(
"deploy_autopilot.yaml",
"--recipe-out",
help="Where to write the rendered soup.yaml recipe (under cwd).",
),
script_out: str = typer.Option(
"deploy_autopilot.sh",
"--script-out",
help="Where to write the planned deploy shell script (under cwd).",
),
output_dir: str = typer.Option(
"./output",
"--output-dir",
help="Recipe's training output directory.",
),
list_targets: bool = typer.Option(
False,
"--list",
"-l",
help="List all known deploy profiles.",
),
):
"""Pick PEFT + quant + spec-decoding combo for a hardware target.
Writes a ready-to-train ``soup.yaml`` recipe and a planned deploy
shell script. Live Quant-Lobotomy measurement deferred to v0.46.1.
"""
from soup_cli.utils.deploy_autopilot import (
get_profile,
list_profiles,
write_deploy_script,
write_recipe,
)
if list_targets:
table = Table(title="Deploy Autopilot Profiles")
table.add_column("Name", style="bold cyan")
table.add_column("Runtime", style="magenta")
table.add_column("Quant", style="green")
table.add_column("PEFT", style="yellow")
table.add_column("Description")
for profile in list_profiles().values():
table.add_row(
profile.name,
profile.runtime,
profile.quant,
profile.peft,
profile.description,
)
console.print(table)
raise typer.Exit(0)
if not target:
console.print("[red]--target is required (or use --list).[/]")
raise typer.Exit(2)
try:
profile = get_profile(target)
except (KeyError, ValueError, TypeError) as exc:
from rich.markup import escape
console.print(f"[red]Unknown profile:[/] {escape(str(exc))}")
console.print(
"[dim]Run [bold]soup deploy autopilot --list[/] to see options.[/]"
)
raise typer.Exit(2) from exc
try:
recipe_path = write_recipe(
profile, base=base, output_dir=output_dir, recipe_path=recipe_out
)
script_path = write_deploy_script(
profile, model_path=output_dir, script_path=script_out
)
except (ValueError, TypeError) as exc:
from rich.markup import escape
console.print(f"[red]Autopilot failed:[/] {escape(str(exc))}")
raise typer.Exit(1) from exc
from rich.markup import escape as _escape
console.print(
Panel(
f"Target: [bold]{_escape(profile.name)}[/]\n"
f"Runtime: [bold]{_escape(profile.runtime)}[/]\n"
f"Quant: [bold]{_escape(profile.quant)}[/]\n"
f"PEFT: [bold]{_escape(profile.peft)}[/]\n"
f"Spec dec: [bold]{profile.spec_decoding}[/]\n"
f"Recipe: [bold]{_escape(recipe_path)}[/]\n"
f"Script: [bold]{_escape(script_path)}[/]",
title="[bold green]Deploy Autopilot[/]",
)
)
if profile.notes:
console.print(f"[dim]Notes: {_escape(profile.notes)}[/]")
console.print(
"[yellow]Note:[/] Live Quant-Lobotomy auto-measure deferred to v0.46.1; "
"this release writes the canonical combo + recipe."
)
def _auto_detect_template() -> Optional[str]:
"""Try to infer chat template from soup.yaml in cwd."""
from soup_cli.utils.ollama import infer_chat_template

View File

@ -0,0 +1,394 @@
"""v0.46.0 Part A — On-Device Deploy Autopilot.
Closed allowlist of deploy-target profiles. Each profile maps a hardware /
runtime target (mac-m3, rtx-4090-24gb, iphone-16, ollama-local, ...) to a
PEFT + quantisation + speculative-decoding combo plus an output recipe and
a deploy shell script.
Live wiring into the v0.26.0 Quant-Lobotomy Checker (so the autopilot
actually *measures* OK/MINOR/MAJOR before picking a quant) is deferred to
v0.46.1; this release ships the schema + the canonical combo table + the
recipe-yaml / deploy-script writers so users have a reproducible artifact
the moment they run ``soup deploy autopilot --target mac-m3``.
The catalog is frozen at import time (``MappingProxyType``); name lookup is
case-insensitive over a strict kebab-case regex (matches v0.45.0 Part A
``register_plugin`` policy).
"""
from __future__ import annotations
import os
import re
import shlex
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping, Optional, Tuple
from soup_cli.utils.paths import is_under_cwd
_PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,31}$")
_MAX_BASE_LEN = 200
_MAX_OUTPUT_PATH_LEN = 4096
# Allowlist for the quant field — matches v0.38.0 / v0.40.5 Quant Menu names
# plus the lightweight ``none`` / ``4bit`` / ``8bit`` legacy values. We keep
# this small on purpose: a profile that picks an unknown quant is a bug, not
# a free-form string.
_ALLOWED_QUANT = frozenset(
{"none", "4bit", "8bit", "gptq", "awq", "fp8", "mxfp4", "hqq:4bit", "hqq:8bit"}
)
_ALLOWED_PEFT = frozenset({"lora", "dora", "qlora", "full"})
_ALLOWED_RUNTIME = frozenset(
{"transformers", "vllm", "sglang", "mlx", "ollama", "lm-studio", "executorch"}
)
@dataclass(frozen=True)
class DeployProfile:
"""One canonical deploy-target combo entry."""
name: str
description: str
runtime: str
quant: str
peft: str
spec_decoding: bool
recommended_max_length: int
notes: str
def _make(
name: str,
description: str,
runtime: str,
quant: str,
peft: str,
spec_decoding: bool,
recommended_max_length: int,
notes: str = "",
) -> DeployProfile:
if not isinstance(name, str) or not _PROFILE_NAME_RE.match(name):
raise ValueError(
"profile name must be kebab-case ([a-z0-9][a-z0-9-]{0,31})"
)
if runtime not in _ALLOWED_RUNTIME:
raise ValueError(f"runtime {runtime!r} not in allowlist")
if quant not in _ALLOWED_QUANT:
raise ValueError(f"quant {quant!r} not in allowlist")
if peft not in _ALLOWED_PEFT:
raise ValueError(f"peft {peft!r} not in allowlist")
if not isinstance(spec_decoding, bool):
raise TypeError("spec_decoding must be bool")
if isinstance(recommended_max_length, bool) or not isinstance(
recommended_max_length, int
):
raise TypeError("recommended_max_length must be int (not bool)")
if not (64 <= recommended_max_length <= 1_048_576):
raise ValueError("recommended_max_length must be in [64, 1048576]")
for label, txt in (("description", description), ("notes", notes)):
if not isinstance(txt, str) or "\x00" in txt:
raise ValueError(f"{label} must be NUL-free string")
if len(txt) > 512:
raise ValueError(f"{label} exceeds 512 chars")
return DeployProfile(
name=name,
description=description,
runtime=runtime,
quant=quant,
peft=peft,
spec_decoding=spec_decoding,
recommended_max_length=recommended_max_length,
notes=notes,
)
_BUILTIN: Mapping[str, DeployProfile] = MappingProxyType(
{
"mac-m3": _make(
"mac-m3", "Apple Silicon M3 / M3 Pro / M3 Max — MLX inference",
"mlx", "4bit", "lora", False, 8192,
"MLX backend handles quantisation; LoRA stays as adapter",
),
"mac-m4-pro": _make(
"mac-m4-pro", "Apple Silicon M4 / M4 Pro — MLX inference",
"mlx", "4bit", "lora", False, 16384,
"Higher memory band; doubles default context window",
),
"rtx-3060-12gb": _make(
"rtx-3060-12gb", "Consumer NVIDIA 12GB — 4bit + speculative decoding",
"transformers", "4bit", "qlora", True, 4096,
),
"rtx-4090-24gb": _make(
"rtx-4090-24gb", "Consumer NVIDIA 24GB — AWQ + vLLM",
"vllm", "awq", "lora", True, 8192,
),
"iphone-16": _make(
"iphone-16", "iPhone 16 / 16 Pro — ExecuTorch on-device",
"executorch", "4bit", "qlora", False, 2048,
"Plan-only; ExecuTorch packaging lands in v0.54.0 Part D",
),
"pixel-9": _make(
"pixel-9", "Pixel 9 — ExecuTorch / AICore on-device",
"executorch", "4bit", "qlora", False, 2048,
"Plan-only; export pipeline lands in v0.54.0",
),
"ollama-local": _make(
"ollama-local", "Local Ollama / llama.cpp via GGUF",
"ollama", "4bit", "lora", False, 4096,
),
"lm-studio": _make(
"lm-studio", "LM Studio desktop app — GGUF model",
"lm-studio", "4bit", "lora", False, 4096,
),
"runpod-a100": _make(
"runpod-a100", "RunPod A100 40GB — bf16 vLLM with speculative",
"vllm", "none", "lora", True, 32768,
),
"hf-jobs-h100": _make(
"hf-jobs-h100", "HF Jobs H100 80GB — FP8 + vLLM prefix cache",
"vllm", "fp8", "lora", True, 65536,
),
}
)
def list_profiles() -> Mapping[str, DeployProfile]:
"""Return an immutable view of the deploy profile registry."""
return _BUILTIN
def get_profile(name: str) -> DeployProfile:
"""Return the profile for ``name`` (case-insensitive)."""
if not isinstance(name, str):
raise TypeError("name must be a string")
canonical = name.strip().lower()
if not canonical or "\x00" in canonical:
raise ValueError("name must be a non-empty NUL-free string")
if canonical not in _BUILTIN:
raise KeyError(canonical)
return _BUILTIN[canonical]
def has_profile(name: str) -> bool:
"""True when ``name`` resolves to a known profile."""
if not isinstance(name, str):
return False
return name.strip().lower() in _BUILTIN
def _validate_base(base: str) -> str:
if not isinstance(base, str):
raise TypeError("base must be a string")
if not base or "\x00" in base or "\n" in base or "\r" in base:
raise ValueError("base must be a non-empty single-line NUL-free string")
if len(base) > _MAX_BASE_LEN:
raise ValueError(f"base exceeds {_MAX_BASE_LEN} chars")
return base
def render_recipe_yaml(profile: DeployProfile, base: str, output_dir: str) -> str:
"""Render a ready-to-train ``soup.yaml`` for ``profile`` + ``base``.
Returns the YAML *text*; callers handle write-out with their own
containment checks. The output_dir is validated as a non-empty
NUL/newline-free string but is NOT path-realpath'd here so the recipe
can be re-used from a different working dir later.
"""
if not isinstance(profile, DeployProfile):
raise TypeError("profile must be a DeployProfile")
base = _validate_base(base)
if not isinstance(output_dir, str):
raise TypeError("output_dir must be a string")
if (
not output_dir
or "\x00" in output_dir
or "\n" in output_dir
or "\r" in output_dir
):
raise ValueError("output_dir must be a non-empty single-line NUL-free string")
if len(output_dir) > _MAX_OUTPUT_PATH_LEN:
raise ValueError(f"output_dir exceeds {_MAX_OUTPUT_PATH_LEN} chars")
peft_section = ""
if profile.peft in ("lora", "qlora", "dora"):
peft_section = (
" lora:\n"
" r: 16\n"
" alpha: 32\n"
" dropout: 0.05\n"
)
if profile.peft == "dora":
peft_section += " use_dora: true\n"
lines = [
f"# Soup deploy autopilot — profile: {profile.name}",
f"# {profile.description}",
f"base: {base}",
"task: sft",
f"backend: {'mlx' if profile.runtime == 'mlx' else 'transformers'}",
"data:",
" train: ./data.jsonl",
" format: auto",
f" max_length: {profile.recommended_max_length}",
"training:",
" epochs: 3",
" lr: 2.0e-5",
" batch_size: auto",
f" quantization: {profile.quant}",
]
if peft_section:
lines.append(peft_section.rstrip("\n"))
lines.append(f"output: {output_dir}")
return "\n".join(lines) + "\n"
def render_deploy_script(profile: DeployProfile, model_path: str) -> str:
"""Render the shell script that takes the trained model to the target.
The script is intentionally a stub: it prints the planned ``soup serve``
or ``soup deploy`` command rather than executing it, so the user keeps
the final approval gate. ``model_path`` is shell-escaped via
``shlex.quote`` so a crafted path cannot inject shell syntax.
"""
if not isinstance(profile, DeployProfile):
raise TypeError("profile must be a DeployProfile")
if not isinstance(model_path, str):
raise TypeError("model_path must be a string")
if (
not model_path
or "\x00" in model_path
or "\n" in model_path
or "\r" in model_path
):
raise ValueError("model_path must be non-empty single-line NUL-free string")
if len(model_path) > _MAX_OUTPUT_PATH_LEN:
raise ValueError(f"model_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
quoted = shlex.quote(model_path)
header = (
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
f"# Soup deploy autopilot — profile: {profile.name}\n"
f"# {profile.description}\n"
f"MODEL={quoted}\n"
)
if profile.runtime == "ollama":
body = (
'NAME="soup-${USER:-local}-$(basename \"$MODEL\")"\n'
'echo "Planned: soup deploy ollama --model $MODEL --name $NAME"\n'
)
elif profile.runtime == "lm-studio":
body = 'echo "Planned: copy GGUF to ~/.cache/lm-studio/models/ then load in UI"\n'
elif profile.runtime == "executorch":
body = (
'echo "Plan-only: ExecuTorch packaging not yet implemented (v0.54.0)"\n'
'echo "Target: $MODEL"\n'
)
elif profile.runtime == "mlx":
body = 'echo "Planned: soup serve --backend mlx --model $MODEL"\n'
else:
spec_flag = " --auto-spec" if profile.spec_decoding else ""
body = (
'echo "Planned: soup serve --backend '
f'{profile.runtime} --model $MODEL{spec_flag}"\n'
)
return header + body
def _reject_symlink_target(path: str, label: str) -> None:
"""Reject if ``path`` already exists and is a symlink (TOCTOU defence).
Matches v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B policy.
"""
import stat as _stat
try:
st = os.lstat(path)
except FileNotFoundError:
return
if _stat.S_ISLNK(st.st_mode):
raise ValueError(
f"{label} must not be a symlink: {os.path.basename(path)}"
)
def write_recipe(
profile: DeployProfile,
base: str,
output_dir: str,
recipe_path: str,
) -> str:
"""Write the recipe YAML under cwd; returns the realpath written."""
if not isinstance(recipe_path, str):
raise TypeError("recipe_path must be a string")
if not recipe_path or "\x00" in recipe_path:
raise ValueError("recipe_path must be non-empty NUL-free string")
if len(recipe_path) > _MAX_OUTPUT_PATH_LEN:
raise ValueError(f"recipe_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
if not is_under_cwd(recipe_path):
raise ValueError(
f"recipe_path must stay under cwd: {os.path.basename(recipe_path)}"
)
_reject_symlink_target(recipe_path, "recipe_path")
text = render_recipe_yaml(profile, base=base, output_dir=output_dir)
real = os.path.realpath(recipe_path)
os.makedirs(os.path.dirname(real) or ".", exist_ok=True)
with open(real, "w", encoding="utf-8") as fh:
fh.write(text)
return real
def write_deploy_script(
profile: DeployProfile, model_path: str, script_path: str
) -> str:
"""Write the deploy bash script under cwd; returns the realpath written."""
if not isinstance(script_path, str):
raise TypeError("script_path must be a string")
if not script_path or "\x00" in script_path:
raise ValueError("script_path must be non-empty NUL-free string")
if len(script_path) > _MAX_OUTPUT_PATH_LEN:
raise ValueError(f"script_path exceeds {_MAX_OUTPUT_PATH_LEN} chars")
if not is_under_cwd(script_path):
raise ValueError(
f"script_path must stay under cwd: {os.path.basename(script_path)}"
)
_reject_symlink_target(script_path, "script_path")
text = render_deploy_script(profile, model_path=model_path)
real = os.path.realpath(script_path)
os.makedirs(os.path.dirname(real) or ".", exist_ok=True)
with open(real, "w", encoding="utf-8") as fh:
fh.write(text)
if os.name != "nt":
try:
os.chmod(real, 0o755)
except OSError:
pass
return real
def autopilot_artifacts(
profile_name: str,
base: str,
output_dir: str,
model_path: Optional[str] = None,
) -> Tuple[str, str]:
"""Return (recipe_yaml_text, deploy_script_text) for a profile.
Helper for callers that want both artifacts in-memory without writing
them out. ``model_path`` defaults to ``output_dir`` when omitted.
"""
profile = get_profile(profile_name)
recipe = render_recipe_yaml(profile, base=base, output_dir=output_dir)
script = render_deploy_script(profile, model_path=model_path or output_dir)
return recipe, script
__all__ = [
"DeployProfile",
"list_profiles",
"get_profile",
"has_profile",
"render_recipe_yaml",
"render_deploy_script",
"write_recipe",
"write_deploy_script",
"autopilot_artifacts",
]

572
tests/test_v0460_part_a.py Normal file
View File

@ -0,0 +1,572 @@
"""v0.46.0 Part A — On-Device Deploy Autopilot tests."""
from __future__ import annotations
import os
import stat as _stat
import sys
from types import MappingProxyType
import pytest
from typer.testing import CliRunner
from soup_cli.utils.deploy_autopilot import (
DeployProfile,
autopilot_artifacts,
get_profile,
has_profile,
list_profiles,
render_deploy_script,
render_recipe_yaml,
write_deploy_script,
write_recipe,
)
runner = CliRunner()
# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------
def test_catalog_is_mapping_proxy():
profiles = list_profiles()
assert isinstance(profiles, MappingProxyType)
with pytest.raises(TypeError):
profiles["x"] = "y" # type: ignore[index]
def test_catalog_has_all_10_documented_profiles():
names = set(list_profiles().keys())
expected = {
"mac-m3", "mac-m4-pro", "rtx-3060-12gb", "rtx-4090-24gb",
"iphone-16", "pixel-9", "ollama-local", "lm-studio",
"runpod-a100", "hf-jobs-h100",
}
assert expected.issubset(names)
def test_get_profile_known():
profile = get_profile("mac-m3")
assert isinstance(profile, DeployProfile)
assert profile.name == "mac-m3"
assert profile.runtime == "mlx"
def test_get_profile_case_insensitive():
assert get_profile("MAC-M3").name == "mac-m3"
assert get_profile(" Mac-M3 ").name == "mac-m3"
def test_get_profile_unknown_raises_keyerror():
with pytest.raises(KeyError):
get_profile("unknown-target")
def test_get_profile_empty_rejected():
with pytest.raises(ValueError, match="non-empty"):
get_profile("")
def test_get_profile_null_byte_rejected():
with pytest.raises(ValueError, match="NUL"):
get_profile("mac-m3\x00")
def test_get_profile_non_string_rejected():
with pytest.raises(TypeError):
get_profile(123) # type: ignore[arg-type]
def test_has_profile_truthy():
assert has_profile("mac-m3") is True
assert has_profile("unknown-target") is False
assert has_profile(None) is False # type: ignore[arg-type]
assert has_profile(b"mac-m3") is False # type: ignore[arg-type]
def test_deploy_profile_is_frozen():
import dataclasses
profile = get_profile("mac-m3")
with pytest.raises(dataclasses.FrozenInstanceError):
profile.name = "x" # type: ignore[misc]
# ---------------------------------------------------------------------------
# render_recipe_yaml
# ---------------------------------------------------------------------------
def test_render_recipe_includes_base_and_quant():
profile = get_profile("rtx-4090-24gb")
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "base: meta-llama/Llama-3.2-1B" in yaml_text
assert "quantization: awq" in yaml_text
assert "output: ./out" in yaml_text
def test_render_recipe_mlx_backend_for_mlx_runtime():
profile = get_profile("mac-m3")
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "backend: mlx" in yaml_text
def test_render_recipe_transformers_backend_default():
profile = get_profile("rtx-3060-12gb")
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "backend: transformers" in yaml_text
def test_render_recipe_lora_section_present_for_lora_peft():
profile = get_profile("rtx-4090-24gb")
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "lora:" in yaml_text
assert "r: 16" in yaml_text
def test_render_recipe_dora_flag_present():
# No built-in profile uses dora, but the helper should emit it correctly
# when given a synthetic profile. Validate by constructing manually.
profile = DeployProfile(
name="dora-test", description="x", runtime="transformers",
quant="4bit", peft="dora", spec_decoding=False,
recommended_max_length=2048, notes="",
)
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "use_dora: true" in yaml_text
def test_render_recipe_full_peft_omits_lora_section():
profile = DeployProfile(
name="full-test", description="x", runtime="transformers",
quant="none", peft="full", spec_decoding=False,
recommended_max_length=2048, notes="",
)
yaml_text = render_recipe_yaml(profile, base="meta-llama/Llama-3.2-1B",
output_dir="./out")
assert "lora:" not in yaml_text
def test_render_recipe_base_validation():
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="non-empty"):
render_recipe_yaml(profile, base="", output_dir="./out")
with pytest.raises(ValueError, match="NUL"):
render_recipe_yaml(profile, base="evil\x00", output_dir="./out")
with pytest.raises(ValueError):
render_recipe_yaml(profile, base="a\nb", output_dir="./out")
def test_render_recipe_base_too_long():
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="exceeds"):
render_recipe_yaml(profile, base="x" * 201, output_dir="./out")
def test_render_recipe_base_non_string():
profile = get_profile("mac-m3")
with pytest.raises(TypeError):
render_recipe_yaml(profile, base=123, output_dir="./out") # type: ignore[arg-type]
def test_render_recipe_output_dir_validation():
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
render_recipe_yaml(profile, base="m/r", output_dir="")
with pytest.raises(ValueError):
render_recipe_yaml(profile, base="m/r", output_dir="x\x00")
with pytest.raises(ValueError):
render_recipe_yaml(profile, base="m/r", output_dir="a\nb")
def test_render_recipe_profile_type_check():
with pytest.raises(TypeError):
render_recipe_yaml("not-a-profile", base="m/r", # type: ignore[arg-type]
output_dir="./o")
def test_render_recipe_max_length_used():
profile = get_profile("hf-jobs-h100")
yaml_text = render_recipe_yaml(profile, base="m/r", output_dir="./out")
assert "max_length: 65536" in yaml_text
# ---------------------------------------------------------------------------
# render_deploy_script
# ---------------------------------------------------------------------------
def test_deploy_script_for_ollama_uses_planned_command():
profile = get_profile("ollama-local")
script = render_deploy_script(profile, model_path="./out")
assert "soup deploy ollama" in script
def test_deploy_script_for_vllm_uses_serve():
profile = get_profile("rtx-4090-24gb")
script = render_deploy_script(profile, model_path="./out")
assert "soup serve --backend vllm" in script
assert "--auto-spec" in script # spec_decoding=True
def test_deploy_script_no_spec_flag_when_disabled():
profile = get_profile("ollama-local")
script = render_deploy_script(profile, model_path="./out")
assert "--auto-spec" not in script
def test_deploy_script_for_mlx():
profile = get_profile("mac-m3")
script = render_deploy_script(profile, model_path="./out")
assert "soup serve --backend mlx" in script
def test_deploy_script_for_lm_studio():
profile = get_profile("lm-studio")
script = render_deploy_script(profile, model_path="./out")
assert "lm-studio" in script
def test_deploy_script_for_executorch():
profile = get_profile("iphone-16")
script = render_deploy_script(profile, model_path="./out")
assert "ExecuTorch" in script or "executorch" in script.lower()
def test_deploy_script_quotes_model_path():
profile = get_profile("mac-m3")
# Path with spaces — shlex.quote must be applied
script = render_deploy_script(profile, model_path="./path with spaces")
assert "'./path with spaces'" in script
def test_deploy_script_rejects_newline_path():
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
render_deploy_script(profile, model_path="evil\necho")
def test_deploy_script_rejects_null_byte_path():
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
render_deploy_script(profile, model_path="evil\x00")
def test_deploy_script_rejects_empty_path():
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
render_deploy_script(profile, model_path="")
def test_deploy_script_rejects_oversize_path():
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
render_deploy_script(profile, model_path="x" * 4097)
def test_deploy_script_non_string_path():
profile = get_profile("mac-m3")
with pytest.raises(TypeError):
render_deploy_script(profile, model_path=123) # type: ignore[arg-type]
def test_deploy_script_profile_type_check():
with pytest.raises(TypeError):
render_deploy_script("not-a-profile", model_path="./out") # type: ignore[arg-type]
def test_deploy_script_has_shebang_and_set_e():
profile = get_profile("mac-m3")
script = render_deploy_script(profile, model_path="./out")
assert script.startswith("#!/usr/bin/env bash")
assert "set -euo pipefail" in script
# ---------------------------------------------------------------------------
# write_recipe / write_deploy_script (cwd containment)
# ---------------------------------------------------------------------------
def test_write_recipe_under_cwd_succeeds(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
out = write_recipe(profile, base="m/r", output_dir="./out",
recipe_path="recipe.yaml")
assert os.path.exists(out)
with open(out, encoding="utf-8") as fh:
assert "base: m/r" in fh.read()
def test_write_recipe_outside_cwd_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
abs_outside = str(tmp_path.parent / "evil.yaml")
with pytest.raises(ValueError, match="must stay under cwd"):
write_recipe(profile, base="m/r", output_dir="./out",
recipe_path=abs_outside)
def test_write_recipe_null_byte_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="NUL"):
write_recipe(profile, base="m/r", output_dir="./out",
recipe_path="recipe\x00.yaml")
def test_write_recipe_non_string_path(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
with pytest.raises(TypeError):
write_recipe(profile, base="m/r", output_dir="./out",
recipe_path=123) # type: ignore[arg-type]
def test_write_deploy_script_under_cwd_succeeds(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
out = write_deploy_script(profile, model_path="./out",
script_path="deploy.sh")
assert os.path.exists(out)
if os.name != "nt":
mode = os.stat(out).st_mode
assert mode & _stat.S_IXUSR
def test_write_deploy_script_outside_cwd_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
abs_outside = str(tmp_path.parent / "evil.sh")
with pytest.raises(ValueError, match="must stay under cwd"):
write_deploy_script(profile, model_path="./out",
script_path=abs_outside)
def test_write_deploy_script_null_byte_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
with pytest.raises(ValueError):
write_deploy_script(profile, model_path="./out",
script_path="x\x00.sh")
# ---------------------------------------------------------------------------
# autopilot_artifacts helper
# ---------------------------------------------------------------------------
def test_autopilot_artifacts_returns_pair():
recipe, script = autopilot_artifacts(
"rtx-4090-24gb", base="m/r", output_dir="./out",
)
assert "base: m/r" in recipe
assert "soup serve" in script
def test_autopilot_artifacts_default_model_path():
_, script = autopilot_artifacts(
"mac-m3", base="m/r", output_dir="./out",
)
assert "./out" in script
def test_autopilot_artifacts_explicit_model_path():
_, script = autopilot_artifacts(
"mac-m3", base="m/r", output_dir="./train", model_path="./serve",
)
assert "./serve" in script
assert "./serve" in script
def test_autopilot_artifacts_unknown_profile_raises():
with pytest.raises(KeyError):
autopilot_artifacts("nope", base="m/r", output_dir="./out")
# ---------------------------------------------------------------------------
# CLI smoke tests
# ---------------------------------------------------------------------------
def test_cli_autopilot_list(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands import deploy
result = runner.invoke(deploy.app, ["autopilot", "--list"])
assert result.exit_code == 0, (result.output, repr(result.exception))
# At least one known profile name appears
assert "mac-m3" in result.output
assert "rtx-4090-24gb" in result.output
def test_cli_autopilot_help():
from soup_cli.commands import deploy
result = runner.invoke(deploy.app, ["autopilot", "--help"])
assert result.exit_code == 0, result.output
assert "Profile name" in result.output or "profile" in result.output.lower()
def test_cli_autopilot_writes_recipe_and_script(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands import deploy
result = runner.invoke(
deploy.app,
["autopilot", "--target", "mac-m3", "--base", "meta-llama/Llama-3.2-1B",
"--recipe-out", "recipe.yaml", "--script-out", "deploy.sh"],
)
assert result.exit_code == 0, (result.output, repr(result.exception))
assert (tmp_path / "recipe.yaml").exists()
assert (tmp_path / "deploy.sh").exists()
def test_cli_autopilot_unknown_target_exits_2(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands import deploy
result = runner.invoke(deploy.app, ["autopilot", "--target", "nope"])
assert result.exit_code == 2, result.output
@pytest.mark.skipif(sys.platform == "win32", reason="symlink ACL on Windows")
def test_write_recipe_symlink_target_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
real = tmp_path / "real.yaml"
real.write_text("", encoding="utf-8")
link = tmp_path / "recipe.yaml"
try:
os.symlink(real, link)
except (OSError, NotImplementedError):
pytest.skip("symlink unavailable")
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="symlink"):
write_recipe(profile, base="m/r", output_dir="./out",
recipe_path="recipe.yaml")
@pytest.mark.skipif(sys.platform == "win32", reason="symlink ACL on Windows")
def test_write_deploy_script_symlink_target_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
real = tmp_path / "real.sh"
real.write_text("", encoding="utf-8")
link = tmp_path / "deploy.sh"
try:
os.symlink(real, link)
except (OSError, NotImplementedError):
pytest.skip("symlink unavailable")
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="symlink"):
write_deploy_script(profile, model_path="./out",
script_path="deploy.sh")
def test_write_recipe_path_too_long_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
profile = get_profile("mac-m3")
with pytest.raises(ValueError, match="exceeds"):
write_recipe(profile, base="m/r", output_dir="./out",
recipe_path="x" * 4097)
def test_cli_autopilot_outside_cwd_script_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands import deploy
abs_outside = str(tmp_path.parent / "evil.sh")
result = runner.invoke(
deploy.app,
["autopilot", "--target", "mac-m3", "--script-out", abs_outside],
)
assert result.exit_code == 1, result.output
def test_cli_autopilot_outside_cwd_recipe_rejected(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands import deploy
abs_outside = str(tmp_path.parent / "evil.yaml")
result = runner.invoke(
deploy.app,
["autopilot", "--target", "mac-m3", "--recipe-out", abs_outside],
)
assert result.exit_code == 1, result.output
# ---------------------------------------------------------------------------
# Internal validators (via DeployProfile construction)
# ---------------------------------------------------------------------------
def test_make_helper_rejects_invalid_runtime():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="runtime"):
_make("x", "d", runtime="nope", quant="4bit", peft="lora",
spec_decoding=False, recommended_max_length=2048)
def test_make_helper_rejects_invalid_quant():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="quant"):
_make("x", "d", runtime="transformers", quant="nope", peft="lora",
spec_decoding=False, recommended_max_length=2048)
def test_make_helper_rejects_invalid_peft():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="peft"):
_make("x", "d", runtime="transformers", quant="4bit", peft="nope",
spec_decoding=False, recommended_max_length=2048)
def test_make_helper_rejects_bool_max_length():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(TypeError, match="bool"):
_make("x", "d", runtime="transformers", quant="4bit", peft="lora",
spec_decoding=False, recommended_max_length=True)
def test_make_helper_rejects_max_length_out_of_bounds():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="64"):
_make("x", "d", runtime="transformers", quant="4bit", peft="lora",
spec_decoding=False, recommended_max_length=32)
def test_make_helper_rejects_bad_name():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="kebab"):
_make("Bad Name", "d", runtime="transformers", quant="4bit", peft="lora",
spec_decoding=False, recommended_max_length=2048)
def test_make_helper_rejects_null_byte_description():
from soup_cli.utils.deploy_autopilot import _make
with pytest.raises(ValueError, match="NUL"):
_make("ok", "evil\x00", runtime="transformers", quant="4bit", peft="lora",
spec_decoding=False, recommended_max_length=2048)
def test_make_helper_via_known_failure_modes():
# Internal _make is used by _BUILTIN at import time. If the catalog
# is loaded then all known profiles passed validation. Just assert the
# imports succeeded and the count matches.
assert "soup_cli.utils.deploy_autopilot" in sys.modules
def test_recommended_max_length_in_bounds():
for profile in list_profiles().values():
assert 64 <= profile.recommended_max_length <= 1_048_576