Add Phase 3.1: friendly errors, soup doctor, soup quickstart, UX polish (v0.3.1)

- Friendly error messages: wrap all commands in try/except, map known errors
  (CUDA OOM, missing deps, connection errors) to 2-3 line messages with fix hints
- Global --verbose flag for full tracebacks
- soup doctor: check system info, GPU, all dependency versions with fix suggestions
- soup quickstart: one-command demo (creates data + config + trains TinyLlama)
- Confirmation prompts before train/sweep (skip with --yes)
- 40 new tests (321 total), all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-03-05 19:10:36 +05:00
parent 8cd19164a0
commit df21329a52
14 changed files with 1010 additions and 10 deletions

View File

@ -71,6 +71,14 @@ soup train --config soup.yaml
**DeepSpeed:** `utils/deepspeed.py` provides ZeRO Stage 2/3 config templates. `commands/train.py` supports `--deepspeed zero2|zero3|zero2_offload|<path>`. Trainers (SFT/DPO) pass `deepspeed` to HF TrainingArguments. Requires `pip install 'soup-cli[deepspeed]'`.
**Error handling:** `utils/errors.py` maps known exceptions (CUDA OOM, missing deps, connection errors, validation errors) to friendly 2-3 line messages with fix suggestions. `cli.py` wraps all commands in a try/except and uses `--verbose` flag for full tracebacks.
**Doctor:** `commands/doctor.py` checks system info, GPU availability, and all dependency versions. Reports missing/outdated packages with fix suggestions.
**Quickstart:** `commands/quickstart.py` runs a complete demo — creates 20-example alpaca dataset, TinyLlama config, and trains a LoRA adapter. Supports `--dry-run` to create files only.
**Confirmation prompts:** `commands/train.py` and `commands/sweep.py` ask for confirmation before starting. Skip with `--yes` / `-y`.
## Code Conventions
- **Line length:** 100 chars (ruff enforced)
@ -99,7 +107,7 @@ soup train --config soup.yaml
## Tests
Test suite (~281 tests) lives in `tests/`:
Test suite (~321 tests) lives in `tests/`:
| File | Covers |
|---|---|
@ -128,3 +136,6 @@ Test suite (~281 tests) lives in `tests/`:
| `test_sweep.py` | Sweep params parsing, combinations, nested config |
| `test_diff.py` | Diff prompts collection, metrics, CLI |
| `test_deepspeed.py` | DeepSpeed configs, multi-GPU detection, trainer integration |
| `test_errors.py` | Friendly error messages, --verbose flag, error mapping |
| `test_doctor.py` | `soup doctor` command, version checking, dependency table |
| `test_quickstart.py` | `soup quickstart` demo, data/config creation, --dry-run |

View File

@ -21,7 +21,7 @@
<a href="https://pypi.org/project/soup-cli/"><img src="https://img.shields.io/pypi/v/soup-cli?color=blue" alt="PyPI"></a>
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License">
<img src="https://img.shields.io/badge/tests-281%20passed-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/tests-321%20passed-brightgreen" alt="Tests">
<a href="https://github.com/MakazhanAlpamys/Soup/actions"><img src="https://github.com/MakazhanAlpamys/Soup/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
</p>
@ -324,6 +324,43 @@ soup train --config soup.yaml --deepspeed zero2_offload
soup train --config soup.yaml --deepspeed ./my_ds_config.json
```
## Quickstart Demo
Run a complete demo in one command — creates sample data, config, and trains a tiny model:
```bash
# Full demo (creates data + config + trains TinyLlama)
soup quickstart
# Just create files without training
soup quickstart --dry-run
# Skip confirmation
soup quickstart --yes
```
## Health Check
Check your environment for compatibility issues:
```bash
soup doctor
```
Shows: Python version, GPU availability, all dependency versions, and fix suggestions.
## Error Handling
Soup shows friendly error messages by default (2-3 lines with a fix suggestion). For full tracebacks:
```bash
# Any command with --verbose
soup train --config soup.yaml --verbose
# Global flag works with all commands
soup --verbose eval --model ./output --benchmarks mmlu
```
## Data Formats
Soup supports these formats (auto-detected):
@ -429,6 +466,10 @@ soup eval --model ./output --benchmarks mmlu --run-id run_20260223_143052_a1b2
| Hyperparameter sweep (grid/random) | ✅ |
| Model comparison (diff) | ✅ |
| Multi-GPU / DeepSpeed | ✅ |
| Friendly error messages | ✅ |
| Health check (soup doctor) | ✅ |
| Quickstart demo | ✅ |
| Confirmation prompts | ✅ |
| Web dashboard | 🔜 |
| Cloud mode (BYOG) | 🔜 |
@ -459,7 +500,10 @@ soup sweep --config soup.yaml --param lr=... Hyperparameter search
soup diff --model-a ./a --model-b ./b Compare two models
soup data generate --prompt "..." --count 100 Generate synthetic data
soup train --deepspeed zero2 Multi-GPU with DeepSpeed
soup doctor Check environment & dependencies
soup quickstart [--dry-run] Full demo: create data + config + train
soup version Show version
soup --verbose <command> Show full traceback on errors
```
## Requirements
@ -478,7 +522,7 @@ pip install -e ".[dev]"
# Lint
ruff check soup_cli/ tests/
# Run unit tests (fast, no GPU needed — 281 tests)
# Run unit tests (fast, no GPU needed — 321 tests)
pytest tests/ -v
# Run smoke tests (downloads tiny model, runs real training)

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.3.0"
version = "0.3.1"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "MIT"
@ -48,7 +48,7 @@ generate = ["httpx>=0.24.0"]
deepspeed = ["deepspeed>=0.12.0"]
[project.scripts]
soup = "soup_cli.cli:app"
soup = "soup_cli.cli:run"
[project.urls]
Homepage = "https://github.com/MakazhanAlpamys/Soup"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.3.0"
__version__ = "0.3.1"

View File

@ -1,5 +1,5 @@
"""Allow running as `python -m soup_cli`."""
from soup_cli.cli import app
from soup_cli.cli import run
app()
run()

View File

@ -1,5 +1,7 @@
"""Main CLI entry point — all commands registered here."""
import sys
import typer
from rich.console import Console
@ -19,9 +21,14 @@ from soup_cli.commands import (
sweep,
train,
)
from soup_cli.commands import doctor as doctor_cmd
from soup_cli.commands import quickstart as quickstart_cmd
console = Console()
# Global verbose flag — set via callback, read by error handler
_verbose = False
app = typer.Typer(
name="soup",
help="Fine-tune LLMs in one command. No SSH, no config hell.",
@ -45,6 +52,8 @@ app.command(name="eval")(eval.eval_model)
app.command()(serve.serve)
app.command()(sweep.sweep)
app.command(name="diff")(diff.diff)
app.command()(doctor_cmd.doctor)
app.command()(quickstart_cmd.quickstart)
# Register data generate as a subcommand of data
data.app.command(name="generate")(generate.generate)
@ -57,6 +66,39 @@ def version():
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
def main(
ctx: typer.Context,
verbose: bool = typer.Option(
False,
"--verbose",
"-V",
help="Show full traceback on errors",
),
):
"""Soup — fine-tune LLMs in one command."""
pass
global _verbose
_verbose = verbose
def run():
"""Entry point with friendly error handling."""
try:
app()
except SystemExit:
raise
except typer.Exit:
raise
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted.[/]")
sys.exit(130)
except Exception as exc:
from soup_cli.utils.errors import format_friendly_error
format_friendly_error(exc, verbose=_verbose)
sys.exit(1)
# When invoked via `soup` entry point, use run() for error handling.
# When invoked via `python -m soup_cli`, __main__.py calls run() directly.
if __name__ == "__main__":
run()

174
soup_cli/commands/doctor.py Normal file
View File

@ -0,0 +1,174 @@
"""soup doctor — check dependency compatibility and system health."""
import platform
import sys
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
# Dependencies to check: (import_name, package_name, min_version, required)
DEPS = [
("torch", "torch", "2.0.0", True),
("transformers", "transformers", "4.36.0", True),
("peft", "peft", "0.7.0", True),
("trl", "trl", "0.7.0", True),
("datasets", "datasets", "2.14.0", True),
("bitsandbytes", "bitsandbytes", "0.41.0", True),
("accelerate", "accelerate", "0.25.0", True),
("pydantic", "pydantic", "2.0.0", True),
("typer", "typer", "0.9.0", True),
("rich", "rich", "13.0.0", True),
("yaml", "pyyaml", "6.0", True),
("plotext", "plotext", "5.2.0", True),
# Optional
("fastapi", "fastapi", "0.104.0", False),
("uvicorn", "uvicorn", "0.24.0", False),
("datasketch", "datasketch", "1.6.0", False),
("lm_eval", "lm-eval", "0.4.0", False),
("wandb", "wandb", "0.15.0", False),
("deepspeed", "deepspeed", "0.12.0", False),
("httpx", "httpx", "0.24.0", False),
]
def doctor():
"""Check system dependencies, GPU, and compatibility."""
console.print("[bold]Soup Doctor[/] — checking your environment...\n")
# System info
console.print(
Panel(
f"Python: [bold]{sys.version.split()[0]}[/]\n"
f"Platform: [bold]{platform.system()} {platform.release()}[/]\n"
f"Arch: [bold]{platform.machine()}[/]",
title="System",
)
)
# GPU check
_check_gpu()
# Dependencies table
table = Table(title="Dependencies")
table.add_column("Package", style="bold")
table.add_column("Required", justify="center")
table.add_column("Installed", justify="center")
table.add_column("Min Version")
table.add_column("Status")
issues = []
for import_name, pkg_name, min_ver, required in DEPS:
try:
mod = __import__(import_name)
version = getattr(mod, "__version__", getattr(mod, "VERSION", "?"))
version_str = str(version)
if _version_ok(version_str, min_ver):
status = "[green]OK[/]"
else:
status = f"[yellow]outdated (need >={min_ver})[/]"
issues.append(f"Upgrade {pkg_name}: pip install '{pkg_name}>={min_ver}'")
table.add_row(
pkg_name,
"yes" if required else "optional",
version_str,
f">={min_ver}",
status,
)
except ImportError:
if required:
status = "[red]MISSING[/]"
issues.append(f"Install {pkg_name}: pip install '{pkg_name}>={min_ver}'")
else:
status = "[dim]not installed[/]"
table.add_row(
pkg_name,
"yes" if required else "optional",
"",
f">={min_ver}",
status,
)
console.print(table)
# Summary
if issues:
console.print(f"\n[yellow]Found {len(issues)} issue(s):[/]")
for issue in issues:
console.print(f" [red]>[/] {issue}")
console.print("\n[dim]Fix all: pip install -U " + " ".join(
f"'{pkg_name}>={min_ver}'"
for _, pkg_name, min_ver, required in DEPS
if required
) + "[/]")
else:
console.print("\n[bold green]All checks passed![/] Your environment is ready.")
def _check_gpu():
"""Check GPU availability and display info."""
try:
import torch
if torch.cuda.is_available():
gpu_count = torch.cuda.device_count()
gpus = []
for idx in range(gpu_count):
name = torch.cuda.get_device_name(idx)
mem = torch.cuda.get_device_properties(idx)
total_gb = getattr(mem, "total_memory", getattr(mem, "total_mem", 0))
total_gb = total_gb / (1024 ** 3)
gpus.append(f" GPU {idx}: [bold]{name}[/] ({total_gb:.1f} GB)")
gpu_info = "\n".join(gpus)
cuda_ver = torch.version.cuda or "N/A"
console.print(
Panel(
f"CUDA: [bold green]available[/] (v{cuda_ver})\n"
f"GPUs: [bold]{gpu_count}[/]\n{gpu_info}",
title="GPU",
)
)
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
console.print(
Panel(
"Backend: [bold green]MPS (Apple Silicon)[/]\n"
"Status: [bold green]available[/]",
title="GPU",
)
)
else:
console.print(
Panel(
"Backend: [bold yellow]CPU only[/]\n"
"Warning: Training will be slow without GPU.",
title="GPU",
)
)
except ImportError:
console.print(
Panel(
"Backend: [red]unknown (torch not installed)[/]",
title="GPU",
)
)
def _version_ok(installed: str, minimum: str) -> bool:
"""Check if installed version meets minimum requirement."""
try:
inst_parts = [int(x) for x in installed.split(".")[:3]]
min_parts = [int(x) for x in minimum.split(".")[:3]]
# Pad to same length
while len(inst_parts) < 3:
inst_parts.append(0)
while len(min_parts) < 3:
min_parts.append(0)
return inst_parts >= min_parts
except (ValueError, AttributeError):
return True # Can't parse, assume OK

View File

@ -0,0 +1,137 @@
"""soup quickstart — one command for a complete demo (create data + config + train)."""
import json
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
console = Console()
# Minimal demo dataset — 20 instruction-following examples
DEMO_DATA = [
{"instruction": "What is machine learning?", "input": "",
"output": "Machine learning is a subset of AI where computers learn patterns from data."},
{"instruction": "Explain what a neural network is.", "input": "",
"output": "A neural network is a computing system inspired by biological neural networks."},
{"instruction": "What is Python?", "input": "",
"output": "Python is a high-level programming language known for its readability."},
{"instruction": "Define overfitting.", "input": "",
"output": "Overfitting is when a model learns noise in training data instead of patterns."},
{"instruction": "What is a GPU?", "input": "",
"output": "A GPU is a specialized processor designed for parallel computation."},
{"instruction": "Explain LoRA.", "input": "",
"output": "LoRA (Low-Rank Adaptation) is a technique to fine-tune large models efficiently."},
{"instruction": "What is tokenization?", "input": "",
"output": "Tokenization is the process of splitting text into smaller units called tokens."},
{"instruction": "Define transfer learning.", "input": "",
"output": "Transfer learning uses a pre-trained model as a starting point for a new task."},
{"instruction": "What is an epoch?", "input": "",
"output": "An epoch is one complete pass through the entire training dataset."},
{"instruction": "Explain gradient descent.", "input": "",
"output": "Gradient descent is an optimization algorithm that minimizes loss iteratively."},
{"instruction": "What is a loss function?", "input": "",
"output": "A loss function measures how far model predictions are from actual values."},
{"instruction": "Define batch size.", "input": "",
"output": "Batch size is the number of training samples processed before updating weights."},
{"instruction": "What is quantization?", "input": "",
"output": "Quantization reduces model precision (e.g., 32-bit to 4-bit) to save memory."},
{"instruction": "Explain attention mechanism.", "input": "",
"output": "Attention lets models focus on relevant parts of input when generating output."},
{"instruction": "What is fine-tuning?", "input": "",
"output": "Fine-tuning is training a pre-trained model on task-specific data."},
{"instruction": "Define learning rate.", "input": "",
"output": "Learning rate controls how much model weights change during each training step."},
{"instruction": "What is a transformer?", "input": "",
"output": "A transformer is a neural network architecture based on self-attention."},
{"instruction": "Explain backpropagation.", "input": "",
"output": "Backpropagation computes gradients by propagating errors backward through layers."},
{"instruction": "What is RLHF?", "input": "",
"output": "RLHF trains models using human feedback as a reward signal."},
{"instruction": "Define inference.", "input": "",
"output": "Inference is using a trained model to make predictions on new data."},
]
DEMO_CONFIG = """# Soup Quickstart Config — auto-generated demo
base: TinyLlama/TinyLlama-1.1B-Chat-v1.0
task: sft
data:
train: ./quickstart_data.jsonl
format: alpaca
val_split: 0.1
training:
epochs: 1
lr: 2e-4
batch_size: auto
lora:
r: 16
alpha: 32
quantization: "none"
output: ./quickstart_output
"""
def quickstart(
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation prompt",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Create data and config only, do not train",
),
):
"""Run a complete demo: create sample data, config, and train."""
console.print(
Panel(
"This will:\n"
" 1. Create [bold]quickstart_data.jsonl[/] (20 examples)\n"
" 2. Create [bold]quickstart_soup.yaml[/] config\n"
" 3. Train a tiny LoRA adapter (~1 min on GPU)\n\n"
"Model: [bold]TinyLlama/TinyLlama-1.1B-Chat-v1.0[/]",
title="[bold]Soup Quickstart[/]",
)
)
if not yes and not dry_run:
confirm = typer.confirm("Continue?", default=True)
if not confirm:
console.print("[yellow]Cancelled.[/]")
raise typer.Exit()
# 1. Create demo data
data_path = Path("quickstart_data.jsonl")
if data_path.exists():
console.print(f"[yellow]Data file already exists:[/] {data_path}")
else:
with open(data_path, "w", encoding="utf-8") as fh:
for entry in DEMO_DATA:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
console.print(f"[green]Created:[/] {data_path} ({len(DEMO_DATA)} examples)")
# 2. Create demo config
config_path = Path("quickstart_soup.yaml")
if config_path.exists():
console.print(f"[yellow]Config file already exists:[/] {config_path}")
else:
config_path.write_text(DEMO_CONFIG, encoding="utf-8")
console.print(f"[green]Created:[/] {config_path}")
if dry_run:
console.print("\n[yellow]Dry run — files created, skipping training.[/]")
console.print(f"To train: [bold]soup train --config {config_path}[/]")
raise typer.Exit()
# 3. Train
console.print("\n[bold]Starting training...[/]\n")
from soup_cli.commands.train import train as train_cmd
train_cmd(config=str(config_path), yes=True)

View File

@ -50,6 +50,12 @@ def sweep(
"--dry-run",
help="Show planned runs without executing",
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation prompt",
),
):
"""Run hyperparameter sweep: grid or random search over training parameters."""
config_path = Path(config)
@ -96,6 +102,11 @@ def sweep(
console.print("[yellow]Dry run — no training will be executed.[/]")
raise typer.Exit()
if not yes:
if not typer.confirm(f"Start {len(combinations)} training run(s)?", default=True):
console.print("[yellow]Cancelled.[/]")
raise typer.Exit()
# Execute sweep
base_cfg = load_config(config_path)
results = []

View File

@ -49,6 +49,12 @@ def train(
"--deepspeed",
help="Enable DeepSpeed: zero2, zero3, zero2_offload, or path to config JSON",
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation prompt",
),
):
"""Start training from a soup.yaml config."""
config_path = Path(config)
@ -106,6 +112,11 @@ def train(
)
)
if not dry_run and not yes:
if not typer.confirm("Start training?", default=True):
console.print("[yellow]Cancelled.[/]")
raise typer.Exit()
if dry_run:
console.print("[yellow]Dry run — validating data...[/]")
dataset = load_dataset(cfg.data)

160
soup_cli/utils/errors.py Normal file
View File

@ -0,0 +1,160 @@
"""Friendly error handling — maps raw exceptions to actionable messages."""
import traceback
from rich.console import Console
from rich.panel import Panel
console = Console(stderr=True)
# Map known error patterns to (short message, fix suggestion)
ERROR_MAP = [
# CUDA OOM
(
"CUDA out of memory",
"GPU ran out of memory during training.",
"Try: reduce batch_size, use quantization: 4bit, or use a smaller model.",
),
(
"OutOfMemoryError",
"GPU ran out of memory.",
"Try: reduce batch_size, use quantization: 4bit, or use a smaller model.",
),
# Missing optional deps
(
"No module named 'fastapi'",
"FastAPI is not installed (needed for soup serve).",
"Run: pip install 'soup-cli\\[serve]'",
),
(
"No module named 'uvicorn'",
"Uvicorn is not installed (needed for soup serve).",
"Run: pip install 'soup-cli\\[serve]'",
),
(
"No module named 'datasketch'",
"Datasketch is not installed (needed for dedup).",
"Run: pip install 'soup-cli\\[data]'",
),
(
"No module named 'lm_eval'",
"lm-evaluation-harness is not installed (needed for eval).",
"Run: pip install 'soup-cli\\[eval]'",
),
(
"No module named 'wandb'",
"Weights & Biases is not installed.",
"Run: pip install wandb",
),
(
"No module named 'deepspeed'",
"DeepSpeed is not installed.",
"Run: pip install 'soup-cli\\[deepspeed]'",
),
(
"No module named 'httpx'",
"httpx is not installed (needed for data generate).",
"Run: pip install 'soup-cli\\[generate]'",
),
# Peft / transformers incompatibility
(
"No module named 'peft'",
"PEFT is not installed.",
"Run: pip install peft>=0.7.0",
),
(
"No module named 'trl'",
"TRL is not installed.",
"Run: pip install trl>=0.7.0",
),
(
"No module named 'bitsandbytes'",
"BitsAndBytes is not installed (needed for quantization).",
"Run: pip install bitsandbytes>=0.41.0",
),
# Connection errors
(
"ConnectionError",
"Network connection failed.",
"Check your internet connection. If downloading from HuggingFace, check HF_TOKEN.",
),
(
"HTTPError",
"HTTP request failed.",
"Check your internet connection and API keys (OPENAI_API_KEY, HF_TOKEN).",
),
(
"ConnectTimeout",
"Connection timed out.",
"Check your internet connection and try again.",
),
# File not found
(
"No such file or directory",
None, # Will use the original message
"Check the file path. Run 'soup init' to create a config.",
),
# YAML errors
(
"yaml.scanner.ScannerError",
"Invalid YAML syntax in config file.",
"Check your soup.yaml for syntax errors (indentation, colons, quotes).",
),
# Pydantic validation
(
"validation error",
"Config validation failed.",
"Check your soup.yaml values. Run 'soup init' to generate a valid config.",
),
# Auth errors
(
"401",
"Authentication failed.",
"Check your API key or token (HF_TOKEN, OPENAI_API_KEY, WANDB_API_KEY).",
),
(
"403",
"Access denied.",
"Check your permissions. Some models require accepting a license on HuggingFace.",
),
]
def format_friendly_error(exc: Exception, verbose: bool = False) -> None:
"""Display a friendly error message for the given exception.
In normal mode: 2-3 lines with error + fix suggestion.
In verbose mode: full traceback.
"""
exc_str = str(exc)
exc_type = type(exc).__name__
# Search for known error patterns
for pattern, short_msg, fix in ERROR_MAP:
if pattern in exc_str or pattern in exc_type:
error_msg = short_msg or exc_str
console.print(f"\n[bold red]Error:[/] {error_msg}")
console.print(f"[green]Fix:[/] {fix}")
if verbose:
console.print()
console.print(
Panel(
traceback.format_exc(),
title="[dim]Full Traceback[/]",
border_style="dim",
)
)
return
# Unknown error — show type + message
console.print(f"\n[bold red]Error:[/] {exc_type}: {exc_str}")
console.print("[dim]Run with --verbose for the full traceback.[/]")
if verbose:
console.print()
console.print(
Panel(
traceback.format_exc(),
title="[dim]Full Traceback[/]",
border_style="dim",
)
)

121
tests/test_doctor.py Normal file
View File

@ -0,0 +1,121 @@
"""Tests for soup doctor command."""
from unittest.mock import patch
from typer.testing import CliRunner
from soup_cli.cli import app
from soup_cli.commands.doctor import _version_ok
runner = CliRunner()
# --- _version_ok tests ---
def test_version_ok_exact():
assert _version_ok("2.0.0", "2.0.0") is True
def test_version_ok_higher():
assert _version_ok("2.1.0", "2.0.0") is True
def test_version_ok_lower():
assert _version_ok("1.9.0", "2.0.0") is False
def test_version_ok_patch():
assert _version_ok("2.0.1", "2.0.0") is True
def test_version_ok_major_higher():
assert _version_ok("3.0.0", "2.0.0") is True
def test_version_ok_two_part():
assert _version_ok("6.0", "6.0") is True
def test_version_ok_unparseable():
"""Unparseable versions should return True (assume OK)."""
assert _version_ok("unknown", "2.0.0") is True
def test_version_ok_dev_suffix():
"""Version with dev suffix (can't fully parse)."""
assert _version_ok("2.1.0.dev0", "2.0.0") is True
# --- doctor CLI tests ---
def test_doctor_runs():
"""soup doctor runs without crashing."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "Soup Doctor" in result.output
def test_doctor_shows_system_info():
"""soup doctor shows system info panel."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "Python" in result.output
assert "Platform" in result.output
def test_doctor_shows_dependencies():
"""soup doctor shows dependency table."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "Dependencies" in result.output
assert "Package" in result.output
def test_doctor_shows_gpu_section():
"""soup doctor shows GPU section."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "GPU" in result.output
def test_doctor_checks_torch():
"""soup doctor checks for torch."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "torch" in result.output
def test_doctor_checks_pydantic():
"""soup doctor checks for pydantic."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "pydantic" in result.output
def test_doctor_checks_optional_deps():
"""soup doctor shows optional deps."""
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "optional" in result.output
def test_doctor_missing_dep():
"""soup doctor reports missing required dep."""
with patch("soup_cli.commands.doctor.DEPS", [
("nonexistent_fake_pkg_xyz", "nonexistent-pkg", "1.0.0", True),
]):
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
assert "MISSING" in result.output
def test_doctor_outdated_dep():
"""soup doctor reports outdated dep."""
with patch("soup_cli.commands.doctor.DEPS", [
("sys", "sys", "999.0.0", True), # sys has no __version__ but import won't fail
]):
result = runner.invoke(app, ["doctor"])
assert result.exit_code == 0
# Either outdated or OK (depends on version attr presence)

186
tests/test_errors.py Normal file
View File

@ -0,0 +1,186 @@
"""Tests for friendly error handling and --verbose flag."""
from io import StringIO
from unittest.mock import patch
from rich.console import Console
from typer.testing import CliRunner
from soup_cli.cli import app
from soup_cli.utils.errors import format_friendly_error
runner = CliRunner()
# --- format_friendly_error tests ---
def test_cuda_oom_error():
"""CUDA OOM gets a friendly message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "GPU ran out of memory" in output
assert "batch_size" in output
def test_missing_fastapi_error():
"""Missing fastapi gives install hint."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ModuleNotFoundError("No module named 'fastapi'")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "soup-cli[serve]" in output
def test_missing_datasketch_error():
"""Missing datasketch gives install hint."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ModuleNotFoundError("No module named 'datasketch'")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "soup-cli[data]" in output
def test_missing_wandb_error():
"""Missing wandb gives install hint."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ModuleNotFoundError("No module named 'wandb'")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "pip install wandb" in output
def test_missing_deepspeed_error():
"""Missing deepspeed gives install hint."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ModuleNotFoundError("No module named 'deepspeed'")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "soup-cli[deepspeed]" in output
def test_connection_error():
"""Connection error gets friendly message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ConnectionError("Failed to connect")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "Network connection failed" in output
def test_unknown_error_shows_type():
"""Unknown errors show the exception type and message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = ZeroDivisionError("division by zero")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "ZeroDivisionError" in output
assert "division by zero" in output
assert "--verbose" in output
def test_verbose_shows_traceback():
"""Verbose mode shows full traceback."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
try:
raise RuntimeError("CUDA out of memory. Tried to allocate")
except RuntimeError as exc:
format_friendly_error(exc, verbose=True)
output = buf.getvalue()
assert "GPU ran out of memory" in output
assert "Traceback" in output or "RuntimeError" in output
def test_verbose_unknown_error():
"""Verbose mode for unknown errors shows traceback."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
try:
raise ValueError("something weird")
except ValueError as exc:
format_friendly_error(exc, verbose=True)
output = buf.getvalue()
assert "ValueError" in output
assert "something weird" in output
def test_yaml_error():
"""YAML syntax error gets friendly message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = Exception("yaml.scanner.ScannerError: mapping values are not allowed here")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "YAML syntax" in output
def test_validation_error():
"""Pydantic validation error gets friendly message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = Exception("2 validation error for SoupConfig")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "Config validation failed" in output
def test_auth_401_error():
"""401 error gets auth hint."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = Exception("401 Client Error: Unauthorized")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "Authentication failed" in output
def test_file_not_found_error():
"""File not found gets friendly message."""
buf = StringIO()
test_console = Console(file=buf, stderr=False)
with patch("soup_cli.utils.errors.console", test_console):
exc = FileNotFoundError("No such file or directory: 'model.bin'")
format_friendly_error(exc, verbose=False)
output = buf.getvalue()
assert "No such file or directory" in output
assert "soup init" in output
# --- CLI --verbose flag tests ---
def test_verbose_flag_in_help():
"""--verbose flag is shown in help."""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "--verbose" in result.output
def test_help_shows_doctor_and_quickstart():
"""New commands are visible in help."""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "doctor" in result.output
assert "quickstart" in result.output

103
tests/test_quickstart.py Normal file
View File

@ -0,0 +1,103 @@
"""Tests for soup quickstart command."""
import json
from typer.testing import CliRunner
from soup_cli.cli import app
from soup_cli.commands.quickstart import DEMO_CONFIG, DEMO_DATA
runner = CliRunner()
def test_quickstart_help():
"""soup quickstart --help works."""
result = runner.invoke(app, ["quickstart", "--help"])
assert result.exit_code == 0
assert "demo" in result.output.lower() or "quickstart" in result.output.lower()
def test_quickstart_dry_run(tmp_path, monkeypatch):
"""soup quickstart --dry-run creates files but does not train."""
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["quickstart", "--dry-run"])
assert result.exit_code == 0
assert "Dry run" in result.output
# Check files were created
data_path = tmp_path / "quickstart_data.jsonl"
config_path = tmp_path / "quickstart_soup.yaml"
assert data_path.exists()
assert config_path.exists()
# Validate data
with open(data_path) as fh:
lines = fh.readlines()
assert len(lines) == len(DEMO_DATA)
first = json.loads(lines[0])
assert "instruction" in first
assert "output" in first
def test_quickstart_dry_run_existing_files(tmp_path, monkeypatch):
"""soup quickstart --dry-run does not overwrite existing files."""
monkeypatch.chdir(tmp_path)
# Create existing files
data_path = tmp_path / "quickstart_data.jsonl"
data_path.write_text("existing data\n")
config_path = tmp_path / "quickstart_soup.yaml"
config_path.write_text("existing config\n")
result = runner.invoke(app, ["quickstart", "--dry-run"])
assert result.exit_code == 0
assert "already exists" in result.output
# Files should NOT be overwritten
assert data_path.read_text() == "existing data\n"
assert config_path.read_text() == "existing config\n"
def test_quickstart_cancel(tmp_path, monkeypatch):
"""soup quickstart can be cancelled."""
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["quickstart"], input="n\n")
assert result.exit_code == 0
assert "Cancelled" in result.output
def test_demo_data_valid():
"""Demo data is valid alpaca format."""
for entry in DEMO_DATA:
assert "instruction" in entry
assert "output" in entry
assert "input" in entry
assert len(entry["instruction"]) > 0
assert len(entry["output"]) > 0
def test_demo_data_count():
"""Demo data has 20 examples."""
assert len(DEMO_DATA) == 20
def test_demo_config_valid():
"""Demo config is valid YAML."""
import yaml
config = yaml.safe_load(DEMO_CONFIG)
assert config["base"] == "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
assert config["task"] == "sft"
assert config["data"]["train"] == "./quickstart_data.jsonl"
assert config["data"]["format"] == "alpaca"
assert config["training"]["epochs"] == 1
def test_quickstart_yes_dry_run(tmp_path, monkeypatch):
"""soup quickstart --yes --dry-run skips confirmation."""
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["quickstart", "--yes", "--dry-run"])
assert result.exit_code == 0
assert "Dry run" in result.output
assert (tmp_path / "quickstart_data.jsonl").exists()
assert (tmp_path / "quickstart_soup.yaml").exists()