feat(v0.40.2): Quick polish + v0.40.1 carry-overs (#36, #50, #51 + 7 papercuts)

Closes 3 originally-scheduled GitHub issues plus 7 v0.40.1 long-tail UX
papercuts. No new schema fields, no new trainers — pure polish.

Originally scheduled:
- #36 format_gate_row helper for the eval-gate dashboard row (pure formatter
  in soup_cli/monitoring/display.py; passed=is True so missing field renders
  neutral; supports stop/warn action suffixes; multi-task " | " join).
- #50 prepare_hf_resume now skips snapshot_download when local checkpoint-N
  is greater-or-equal to the remote highest-N. New _find_highest_local_checkpoint
  helper handles missing dirs / OSError / non-directories cleanly.
- #51 soup deploy hf-space --template-dir <path> via new
  soup_cli/utils/hf_space.py:render_custom_template_dir. Containment via
  is_under_cwd; validate_repo_id BEFORE substitution; per-file 256 KB cap;
  symlinks + non-regular files rejected (TOCTOU defence per v0.33.0 #22).

v0.40.1 carry-overs:
- H2: data filter --min-coherence alias; data split --train no-op; data
  register/unregister positional <name> <path> + Optional --name/--path
  with conflict detection.
- H3: soup quickstart --output DIR (containment-checked) routes data,
  config, run dir under the chosen directory.
- N1/G2: apply_logging_level pushes parsed --log-level tier into the root
  logger so transformers / peft / trl actually respect QUIET / DEBUG.
- N7: shared _resolve_model_source in commands/infer.py (used by bench.py
  too) — path-like-but-missing raises FileNotFoundError; non-path-like
  values fall through to HF download via from_pretrained.
- G13: verified ONNX/AWQ/GPTQ/TensorRT install hints already correct.
- M4: verified data dedup --threshold already exposed.
- M5: soup runs --cwd-only + _filter_runs_by_cwd helper using
  os.path.realpath + commonpath (Windows 8.3 + cross-drive safe).

Review-fix follow-ups landed in the same release:
- soup_cli/commands/infer.py: from __future__ import annotations (Py3.9
  PEP 604 fix); --output containment via is_under_cwd, late-evaluated to
  preserve pre-existing test contracts.
- soup_cli/commands/data.py register_data + soup_cli/commands/bench.py
  prompts file: Path.resolve()+relative_to() → is_under_cwd (project rule
  for Windows 8.3 short-name safety).
- soup_cli/commands/runs.py: typed _filter_runs_by_cwd, removed redundant
  inner import os.
- soup_cli/commands/deploy.py: confirmation panel now shows --template-dir
  path when set, not the unused --template default.

Tests: 4720 → 4756 (+36) across two new files (test_v0402_part_a.py,
test_v0402_part_b.py). 5 review agents (python / code / security / tdd /
verification) all clean after fixes.

Known limitations:
- Custom HF Space templates always create the Space with sdk=gradio
  regardless of the supplied app.py. Use --template streamlit-chat with
  the inline registry for Streamlit. Tracked for v0.40.3+.
- _resolve_model_source returns ("hf", repo_id) without validate_repo_id;
  transformers.from_pretrained will raise loudly on malformed ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-08 13:20:21 +05:00
parent 2ae05f90f9
commit b0fc586706
18 changed files with 1094 additions and 58 deletions

View File

@ -111,7 +111,7 @@ soup_cli/
templates/ - 17 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (141 files, 4720 tests)
tests/ - Test suite (143 files, 4756 tests)
examples/ - Real-world config examples and datasets
```

View File

@ -40,18 +40,20 @@ soup train
## What's New
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.40.1 — QA Hardening**: Windows codepage crash family fixed at the CLI bootstrap (one-line UTF-8 reconfigure closes 6 separate QA findings); the multi-objective preference-loss runtime stub from v0.40.0 is now live; autopilot + quickstart no longer assume 7B / 1.1B on tiny GPUs; `transformers >= 5.0.0` is flagged as INCOMPATIBLE in `soup doctor` until the migration lands.
**v0.40.2 — Quick polish + carry-overs**: closes 3 originally-scheduled GitHub issues (#36 eval-gate dashboard row, #50 smarter `--hf-resume`, #51 custom HF Space templates) plus 7 v0.40.1 long-tail UX papercuts.
- **UTF-8 stdio bootstrap (Windows)**`soup_cli/cli.py` reconfigures `sys.stdout` / `sys.stderr` to UTF-8 before any Rich console init. β / ✓ / box-drawing characters no longer crash with `UnicodeEncodeError` on cp1251 / cp1252. POSIX is a no-op.
- **Multi-objective preference loss is live**`training.preference_loss_weights: {dpo: 0.7, simpo: 0.3}` no longer raises `NotImplementedError`. The wrapper builds the highest-weighted loss as primary and prints the active blend for confirmation. BCO mixed with paired losses (DPO/SimPO/ORPO/IPO) is rejected at runtime with a clear message (data-format incompatible).
- **Smarter defaults on small GPUs**`soup quickstart` auto-switches to `SmolLM2-135M-Instruct` on ≤6 GB VRAM (verified to train in 5 s on RTX 3050 4 GB); the autopilot model-size fallback now defaults to **1B** instead of 7B and reads the local safetensors index when available, so `tiny-gpt2` no longer fails VRAM-budget checks.
- **`soup doctor` upgrades** — flags `transformers ≥ 5.0.0` as INCOMPATIBLE; distinguishes "no GPU hardware" from "GPU hardware present, wrong torch wheel" (`nvidia-smi` succeeds but `torch.cuda.is_available()` returns False); detects dual-Python interpreter setups; uses `importlib.metadata` as version-probe fallback so `rich` no longer prints "?".
- **CLI UX consistency**`soup init --force` non-interactively overwrites; `--template` help is generated from the live registry (no more drifting list); `soup migrate <data.jsonl>` errors loudly with a "did you pass the wrong file?" hint; `soup eval custom -o` writes JSON regardless of `--attach-to-registry`; `soup recipes show <typo>` suggests close matches via `difflib`; `soup data sample` filenames embed the strategy so successive `random` / `diverse` runs don't overwrite each other; JSONL loader auto-strips UTF-8 BOM (Windows PowerShell `Out-File` users).
- **`--find-lr` actually runs the live loop** — fixed a broken `load_local` import that previously always silently fell through to a static placeholder curve.
- **Schema strictness** — root-level `lora:` in YAML (the LlamaFactory / Axolotl convention) now migrates into `training.lora` so the nested validators (including `init_strategy: random|pissa|olora`) actually fire instead of being silently dropped.
- **Net new tests** across UTF-8 bootstrap, multi-objective preference math (with gradient propagation checks), schema regression, and Part CE papercuts.
- **`--hf-resume` prefers local newer** — `prepare_hf_resume` checks the highest local `checkpoint-N` against the highest remote branch and skips the snapshot download when the local copy is newer or equal. Saves bandwidth and never overwrites a fresher local checkpoint with stale Hub state.
- **Eval-gate dashboard row** — pure formatter `format_gate_row(state)` lives in `soup_cli/monitoring/display.py` and renders a one-liner like `Gate: helpfulness 7.8 ✓ | math 0.82 ✗ (-0.06) | STOP` for the live training panel. Hidden when eval-gate is disabled.
- **`soup deploy hf-space --template-dir <path>`** — supply your own `app.py` + `README.md` (+ optional `requirements.txt`) instead of the built-in `gradio-chat` / `streamlit-chat`. Containment-checked, repo-id substitution validated *before* render, 256 KB cap per file, symlinks rejected.
- **`soup quickstart --output DIR`** — route data, config, and run dir under any directory you choose (default keeps backwards-compat in cwd).
- **`soup runs --cwd-only`** — restrict the listing to runs whose `output_dir` is under the current directory; the global `~/.soup/experiments.db` view is still default.
- **`soup infer` / `soup bench` accept HF ids** — when the local model path is missing AND the value isn't path-like (no `./`, `/`, `~`, `C:\`), it falls through to a HuggingFace download via `transformers.from_pretrained`. Path-like-but-missing surfaces a friendly `FileNotFoundError`.
- **CLI flag aliases**`data filter --min-coherence` (alias for `--coherence`); `data split --train` accepted (informational, train is the implicit remainder); `data register / unregister` accept positional `<name>` and `<path>` alongside the `--name` / `--path` options, with conflict detection.
- **`--log-level` plumbing complete** — `apply_logging_level` now sets the root logger so third-party libraries (transformers / peft / trl) actually respect QUIET and DEBUG. The four tiers no longer produce byte-identical output.
- **+36 net new tests** across the new helpers, security-fix follow-ups (path-containment in `register_data`, `bench.py`, `infer.py --output`, symlink-reject in custom Space templates), and Windows cross-drive edge cases.
## Why Soup?

View File

@ -9,8 +9,8 @@ We provide security updates for the following versions:
- **Versions older than 3 minor versions:** No support
Example:
- v0.40.1 -- Full support (latest)
- v0.40.0-0.40.x -- Full support
- v0.40.2 -- Full support (latest)
- v0.40.0-v0.40.x -- Full support
- v0.39.0-0.39.x -- Bug-fix support only
- v0.38.0-0.38.x -- Bug-fix support only
- v0.37.x and below -- No support
@ -144,6 +144,7 @@ No known critical vulnerabilities in current releases.
- **v0.32.0 — Training Stability & Auto-Tuning**: `--find-lr-output` containment via shared `utils/paths.is_under_cwd` (prevents writes outside cwd); `save_lr_finder_report` rejects NaN / Infinity floats in `lrs` / `losses` and serialises with `allow_nan=False` (keeps the report parser-safe); `compute_lr_schedule` rejects non-positive `start_lr`, inverted ranges, and `num_steps` outside `[2, 10_000]`; `pick_mixed_precision` rejects empty / null-byte / >200-char model names and resolves multi-version quirks (`qwen2.5` vs `qwen2`, `phi-3.5` vs `phi-3`) by longest-substring-first iteration so an added family can never accidentally make a more-specific entry dead code; `compute_warmup_steps` clamps to `[10, 1000]` with a `ratio==0.0` short-circuit matching HF Trainer's "no warmup" convention; `SpikeRecoveryStrategy` is `@dataclass(frozen=True)` (post-construction mutation cannot bypass validation), `max_attempts ∈ [1, 10]`, `lr_decay ∈ (0, 1)`, `min_lr > 0`; cross-validator `_validate_spike_recovery_requires_watchdog` rejects `loss_spike_recovery=true, loss_watchdog=false` at config-load (fails fast instead of never triggering); `convergence_window ∈ [5, 10_000]`, `convergence_rel_tol ∈ (0, 1]`, `recommend_action` reuses `detect_plateau` so plateau heuristic stays single-source-of-truth; `GradAccumMonitor.recommend()` caps doubled `accum` at `MAX_ACCUM=1024` so a runaway advisory loop cannot blow up DataLoader prefetch; `generate_config` validates BOTH the YAML output path AND the embedded `decisions["output"]` field via `is_under_cwd` (closes the gap where a crafted `decisions["output"]="../../etc"` would have silently propagated into the rendered YAML)
- **v0.34.0 — Observability & Dev UX**: `.crash` bundle generator (`utils/crash.py`) recursively redacts `hf_*` / `sk-*` / `Bearer …` token-shaped strings in any captured `config` and metric tail before serialisation, so a `.crash` file shared on a public GitHub issue cannot leak credentials; `output_dir` is reduced to `os.path.basename` so `$HOME` doesn't leak; `write_crash_bundle` uses `os.path.realpath + commonpath` for cwd containment (Windows-safe; raises `ValueError` not `PermissionError` so callers cannot silently swallow with `except OSError`); filename appends `secrets.token_hex(4)` so two crashes in the same UTC second don't collide; bundle truncated to `MAX_BUNDLE_BYTES=1_000_000`. `train.py` crash-write surfaces failures to the user (no silent missing-bundle). `profiling.py` `resolve_trace_path` rejects empty / `.` / `..` / `/` / `\\` / null-byte `run_id` (closes the `output_dir/profiles/../trace.json` escape) and uses `os.path.realpath + is_under_cwd`; profiles dir is created only on successful torch import (no stale empty dirs on torch-less CI). `tracker.get_run` LIKE-prefix match escapes `%` / `_` / `\\` and uses `ESCAPE '\\'` so a crafted `run_id` cannot widen the match (mirrors v0.26.0 registry policy). Lazy schema migration (`_ensure_schema`) tolerates the "duplicate column" race when two CLI processes start simultaneously on a fresh DB (fork-based multi-GPU training, TUI auto-refresh). `runs.py show/replay/clean` switched user `run_id` rendering to `markup_escape` and switched `clean` containment from broken `Path.resolve() + relative_to()` to project-standard `os.path.realpath + is_under_cwd`. `tui_app.py` lazy-imports `ExperimentTracker` and `markup_escape`s every DB-sourced string before passing into Textual widgets so a crafted base_model / experiment_name cannot inject `[bold red]…[/]` markup. `run_cost.estimate_run_cost_usd` rejects `bool` in `num_gpus` (bool is a subclass of int — same defence as v0.30.0 `Candidate.__post_init__`); duration clamped to `[0, 1 year]`; unknown GPU returns `None` so callers render `—` instead of fabricating `$0.00`. `log_level.parse_log_level` rejects non-string + null-byte input.
- **v0.33.0 — Live Wire**: RLVR `code_exec_reward` adds OS-level isolation (Linux best-effort `os.unshare(CLONE_NEWUSER|CLONE_NEWNET|CLONE_NEWPID)`, macOS `sandbox-exec` with default-deny `MACOS_SANDBOX_PROFILE` narrowed to a 3-name `mach-lookup` allowlist to prevent DNS / NSURLSession bypass of `(deny network*)`); `prune_checkpoints` switches to TOCTOU-safe `os.lstat + S_ISLNK` + `shutil.rmtree(onerror=_abort_on_symlink)` so a symlink encountered mid-walk aborts rather than escapes; `run_gate` wraps each task scorer in a typed `try/except` so backend failures produce `score=None, error=str(exc)` (never silent `score=1.0`); `_parse_judge_url` removes the bare `http://` catch-all (defence-in-depth after the Pydantic GateTask validator); `soup can run` requires `--yes` or explicit consent callback and raises `ValueError` (not `PermissionError`, which is an `OSError` subclass that broad `except` blocks would swallow); GGUF `rglob` result for ollama deploy is `realpath+commonpath` checked against extract_dir (prevents symlink escape from a crafted can); `DeployTarget.path` validator normalises mixed `\\`/`/` separators before splitting (closes a Windows `..` bypass); `CAN_FORMAT_VERSION` 1→2 (additive — v1 still loads); `soup can publish` validates `repo_id` via `utils/hf.validate_repo_id`, resolves token via `resolve_token`, sanitises commit messages (first-line, 200-char cap), uses HTTPS-only HfApi; `_write_spike_recovery_hint` adds `is_under_cwd` containment check on `args.output_dir` from raw HF `TrainingArguments`; `lookup_entry_by_output_dir` emits `ResourceWarning` when 1000-row scan limit is hit (no silent miss); `CrossDocCollator` no longer mutates input feature dicts (HF Dataset rows are cached and reused — mutation broke subsequent batches); `Candidate` rejects `bool` in `score`/`latency_ms` (was sneaking past `int` isinstance check); `evaluate_candidate` latency mean now divides by *completed* prompts (excludes crashed) so a broken candidate isn't artificially fast; `auto_quant.run_auto_quant_picker` soft-falls-back to highest-scored candidate when no candidate clears `min_score` (server still binds); `build_logits_processors` returns `[]` when neither `outlines` nor `lm-format-enforcer` is installed (server degrades to free-form rather than 500); MII server uses loopback-only CORS, max_tokens cap [1, 16384], stream rejection, generic 500 with no stack-trace leak; `os.execvp` auto-reexec uses list args (no shell), all forwarded flags pre-validated; `cleanup_extract_dir` uses `os.path.commonpath` (Windows-safe) instead of `startswith`; `_run_subprocess` catches `TimeoutExpired` and returns rc=124 (coreutils convention) instead of an unhandled traceback; new `eval_results` and `tensorrt` artifact kinds in `RegistryStore._VALID_KINDS`
- **v0.40.2 — Quick polish + carry-overs**: New `soup_cli/utils/hf_space.py:render_custom_template_dir` enforces `is_under_cwd` containment on the template directory; `validate_repo_id` runs BEFORE `{MODEL_REPO}` substitution (matches v0.29.0 Part F policy); per-file 256 KB cap (matches v0.39.0 Part E template-size policy); only `app.py` / `README.md` / `requirements.txt` are read (closed allowlist — no path-from-user-data). Symlinks rejected via `os.lstat + stat.S_ISLNK` and non-regular files (FIFO / device) also rejected (matches v0.33.0 #22 prune_checkpoints TOCTOU policy) — defends against `<template_dir>/app.py -> /etc/passwd`. `_find_highest_local_checkpoint` reads `output_dir` after caller's `is_under_cwd` validation (in `prepare_hf_resume`) and silently drops non-directories + OSError. `prepare_hf_resume` skips the snapshot download when local `checkpoint-N >= remote checkpoint-N` (saves bandwidth and never overwrites a fresher local checkpoint). `commands/data.py:register_data` containment switched from `Path.resolve() + relative_to()` to shared `is_under_cwd` (Windows 8.3 short-name safety per CLAUDE.md project rule); same fix applied to `commands/bench.py` prompts-file containment. `commands/infer.py:--output` now containment-checked via `is_under_cwd` (late-evaluated after model+input validation so pre-existing `tmp_path` test contracts keep working). `commands/quickstart.py:--output` validates target dir via `is_under_cwd` before `mkdir(parents=True)`; rejects out-of-cwd targets with friendly message. `commands/runs.py:_filter_runs_by_cwd` uses `os.path.realpath + commonpath`, catches `(ValueError, OSError)` so cross-drive paths on Windows (`D:\runs` vs `C:\project`) drop silently rather than crash. `monitoring/display.py:format_gate_row` uses explicit `task.get("passed") is True` so a missing `"passed"` field renders neutrally instead of as a false-y red ✗. `commands/infer.py:_resolve_model_source` heuristic for HF-id-vs-local-path: only falls through to HF when value is NOT path-like (no `./`, `/`, `\\`, `~`, no Windows drive letter, non-empty); path-like-but-missing raises `FileNotFoundError` so users see actionable errors instead of confusing HF download attempts. Known limitation: custom HF Space templates always create the Space with `space_sdk="gradio"` regardless of the supplied `app.py` (no `--sdk` flag in this release; combine `--template streamlit-chat` with the inline registry for Streamlit Spaces). Tracked for v0.40.3+.
- **v0.40.1 — QA Hardening**: `soup_cli/utils/encoding.force_utf8_stdio` reconfigures Windows stdout/stderr to UTF-8 before any Rich Console is constructed; `os.environ.setdefault("PYTHONIOENCODING", "utf-8")` preserves user override; `(OSError, ValueError, AttributeError)` swallowed on detached streams; POSIX no-op. `SoupConfig._remap_root_level_misplaced_keys` (model_validator, mode='before') migrates root-level `lora:` into `training.lora` so nested validators (including `lora.init_strategy: Literal["random","pissa","olora"]`) actually fire — closes a footgun where the misplaced key was silently dropped. Caller's dict is never mutated (shallow-copy policy mirroring v0.33.0 #47 / v0.40.0 Part B). `PreferenceTrainerWrapper._build_multi_objective` replaces the v0.40.0 `NotImplementedError` stub with a primary-loss approximation; `validate_weight_compat` rejects BCO mixed with paired losses at runtime (data-format incompatible). `combine_losses` rejects empty weights, propagates NaN loudly (no silent zeroing), and rejects `bool` weight values (matches v0.30.0 `Candidate` policy). `_probe_cache_param_count` rejects empty / null-byte model names before path construction (mirrors v0.26.0 registry / v0.39.0 ReLoRAPolicy policy). `commands/doctor` flags `transformers ≥ 5.0.0` as INCOMPATIBLE via `_MAX_EXCLUSIVE` table; `_version_ge` parses leading-int chunks so `5.0.0.dev0` correctly trips the cap. `_detect_gpu_hw_without_torch_cuda` calls `nvidia-smi` via argv list (no shell), 5s timeout, `OSError` / `TimeoutExpired` caught; GPU label from `nvidia-smi` stdout is `rich.markup.escape`d before embedding in Rich-markup string (a real GPU name like `NVIDIA Quadro [T4]` cannot break or inject markup). `_detect_dual_python_interpreters` uses `os.path.realpath` (not `Path.resolve()`) for Windows 8.3 short-name compat. `_pick_quickstart_model` swaps TinyLlama-1.1B → SmolLM2-135M when `total_memory ≤ 6 GB` (prevents step-0 OOM on RTX 3050 4 GB / similar). `_live_lr_sweep_from_config` switched broken `load_local` import to `load_raw_data` (previously always silently fell back to a static placeholder curve). `commands/migrate` rejects `.jsonl` input (with first-line `{` sniff) with exit-2 friendly error; `.jsonl`-only suffix gate prevents false-positives on `.ipynb` notebooks. `commands/eval custom -o` is now honored independently of `--attach-to-registry`; loop-shadow regression where `output = generate_fn(...)` overwrote the CLI option fixed (variable renamed to `response`). `_load_jsonl` switched from `utf-8` to `utf-8-sig` so PowerShell `Out-File -Encoding utf8`-produced JSONL no longer fails first-row parse. Known limitation: `--trust-remote-code` opt-in surface still excludes 10 non-SFT trainers + 5 commands (v0.36.0 #63 carry-over).
- **v0.40.0 — Preference Variety**: New `task='bco'` (Binary Classifier Optimization) and `task='preference'` (unified dispatcher). New schema fields: `bco_beta` (gt=0), `preference_loss: Literal[dpo,simpo,orpo,ipo,bco]|None`, `preference_loss_weights: Optional[Dict[str,float]]`, `dpo_beta_schedule: Literal[linear,cosine,exponential]|None`, `dpo_beta_end: float, gt=0|None`, `dpo_ref_regen_epochs: int [1,1000]|None`. Cross-validators: `_validate_preference_dispatcher` rejects setting either `preference_loss` or `preference_loss_weights` outside `task='preference'` (closes ordering-dependency between Part B/D validators); `_validate_dpo_variants_supported_tasks` gates β-schedule + ref-regen to DPO-family tasks (`dpo`, `ipo`, or `preference` + `preference_loss in {dpo, ipo}`); rejected on mlx backend with distinct error message (matches v0.34.0 distinct-reason policy); `_validate_preference_loss_weights` enforces 25 entries (single-entry rejected with actionable message pointing at scalar `preference_loss`), key allowlist `{dpo, simpo, orpo, ipo, bco}`, explicit null-byte rejection on keys (matches v0.39.0 rank_pattern policy), per-value bounds `(0, 1]`, weights must sum to 1.0 (±1e-6), mutually exclusive with scalar `preference_loss`, rejected on mlx backend. `compute_beta_at_step` rejects `bool` on `step` and `total_steps` (project bool-as-int policy from v0.30.0). `BetaScheduleCallback` resolves `total_steps` lazily in `on_train_begin` so the schedule sees the real `state.max_steps` populated by HF Trainer (closes a first-cut silent-no-op bug where total_steps=0 emitted beta_end for every step). `RefModelRegenCallback._regenerate` uses `strict=True` on `load_state_dict` and logs at WARNING on mismatch (closes a first-cut silent partial-copy hazard where strict=False could produce a hybrid old-base + new-LoRA reference); epoch 0 regen suppressed (avoids copying untrained student); trainer `.beta` assignment swallow narrowed to `AttributeError` only. `PreferenceTrainerWrapper._make_inner_cfg` uses `model_copy` (not `model_dump`+`model_validate`) so re-validation never sees an inconsistent intermediate state and the caller's `cfg` is never mutated (mirrors v0.33.0 #47 immutability policy). `_split_dpo_rows_to_bco` skipped-row count emitted at DEBUG so production silent-degradation is inspectable (mirrors v0.33.0 #47 CrossDocCollator policy). Multi-objective live runtime weighted-loss combination is deferred to v0.40.1: `PreferenceTrainerWrapper.setup` raises `NotImplementedError` with a friendly message naming the deferred-version follow-up (mirrors v0.27.0 MII / v0.37.0 multipack / v0.38.0 quant menu / v0.39.0 ReLoRA stub-then-live pattern). Known limitation: `BCOTrainerWrapper._setup_transformers` still hardcodes `trust_remote_code=True` (v0.36.0 #63 known-gap family carry-over across non-SFT trainers).
- **v0.39.0 — LoRA Quality**: `LoraConfig.init_strategy: Literal["random","pissa","olora"]` rejects unknown strategies; PiSSA + DoRA / VeRA combinations rejected at config-load. `model_validator(mode="before")` aligns `use_olora=True``init_strategy="olora"` via dict-copy (no caller mutation; matches v0.33.0 #47 immutability policy). `rank_pattern`/`alpha_pattern: Optional[Dict[str, int]]` capped at 256 keys × value (0, 1024], rejects `bool` (subclass of `int` — matches v0.30.0 `Candidate` policy), null bytes in keys, empty keys; cross-validator rejects with `use_vera=True`. `ReLoRAPolicy` is `@dataclass(frozen=True)` (post-construction mutation raises `FrozenInstanceError`); bounds: `steps ∈ [1, 1e7]`, `warmup_ratio ∈ [0, 1]`, `prune_ratio ∈ (0, 1)` (strict — prevents zero-everything footgun). `magnitude_prune_tensor` strict `0 < prune_ratio < 1` rejection, non-Tensor input raises `TypeError`, empty / single-element tensor short-circuits (avoids `kthvalue(_, 0)` runtime crash). `_validate_relora_supported_tasks` cross-validator rejects `relora_steps` with `task != "sft"` and `backend=mlx` with distinct error messages (matches v0.34.0 distinct-reason policy); multi-trainer expansion deferred to v0.39.1. `is_gemma4_model` uses a word-boundary regex (`(?:^|[^a-z0-9])gemma-?4(?:[^a-z0-9]|$)`) so `"ungemma4ed"` / `"my-gemma4ish"` no longer over-match; null-byte rejection on `model_name`. `apply_gemma4_clippable_patch` weight-copy fallback logs at DEBUG instead of silent random-init; the patch is gated by `is_gemma4_model(cfg.base)` in `sft.py` before invocation so non-Gemma4 trainings never traverse the module tree. `apply_surgical_patches` rejects empty / null-byte `model_name` with `ValueError`. `templates/load_template` containment: filename re-validated via `_validate_name` (rejects `..`/`/`/`\\`/null/empty); `os.path.realpath + os.path.commonpath` containment check on the resolved path against `_templates_dir()` so a tampered `manifest.json` cannot read files outside the package directory (mirrors v0.26.0 registry policy). Tampered-manifest `ValueError` from `_validate_name` caught and falls back to inline (no propagating exception). 256 KB file-size cap. Inline `TEMPLATES` carries an explicit deprecation comment pointing at the canonical YAML registry; `tests/test_templates_yaml.py` asserts byte-equality of all 16 inline ↔ YAML pairs to prevent silent drift. Planned removal: v0.41.0+.

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.40.1"
version = "0.40.2"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"

View File

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

View File

@ -226,7 +226,11 @@ def main(
"""Soup — fine-tune LLMs in one command."""
global _verbose, _log_level
_verbose = verbose
from soup_cli.utils.log_level import parse_log_level, setup_logging
from soup_cli.utils.log_level import (
apply_logging_level,
parse_log_level,
setup_logging,
)
try:
tier = parse_log_level(log_level)
@ -235,6 +239,10 @@ def main(
raise typer.Exit(code=2) from exc
_log_level = tier.value
setup_logging(tier)
# v0.40.2 N1/G2: also push the tier into the root logger so third-party
# libraries (transformers / peft / trl) respect QUIET / DEBUG. Without
# this, all four levels were producing nearly-identical output.
apply_logging_level(tier)
def run():

View File

@ -43,13 +43,24 @@ def bench(
"""Run an inference benchmark (speed and memory) on a loaded model."""
import torch
from soup_cli.commands.infer import _generate, _load_model
from soup_cli.commands.infer import _generate, _load_model, _resolve_model_source
from soup_cli.utils.gpu import detect_device
model_path = Path(model)
if not model_path.exists():
console.print(f"[red]Model not found: {model_path}[/]")
raise typer.Exit(1)
# Resolve local-path-or-HF-id (#N7).
try:
model_kind, model_ref = _resolve_model_source(model)
except FileNotFoundError as exc:
console.print(
f"[red]{exc}[/]\n"
"[dim]If you meant a HuggingFace repo, use the form "
"'owner/repo-name' (no leading './').[/]"
)
raise typer.Exit(1) from exc
model_path = Path(model_ref)
if model_kind == "hf":
console.print(
f"[dim]Local path not found; treating {model_ref!r} as a HF repo id.[/]"
)
device, _ = detect_device()
@ -61,15 +72,17 @@ def bench(
if prompts_file:
import json
p_path = Path(prompts_file).resolve()
import os as _os
try:
p_path.relative_to(Path.cwd())
except ValueError:
from soup_cli.utils.paths import is_under_cwd
if not is_under_cwd(prompts_file):
console.print(
f"[red]Security Error:[/] Path {p_path} is outside the current working directory."
"[red]Security Error:[/] Prompts file must stay under the "
"current working directory."
)
raise typer.Exit(1)
p_path = Path(_os.path.realpath(prompts_file))
if not p_path.is_file():
console.print(f"[red]Prompts file not found:[/] {p_path}")

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import json
import random
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
@ -325,7 +326,7 @@ def filter_data(
help="Max perplexity threshold (rows above this are removed)",
),
coherence: float = typer.Option(
None, "--coherence",
None, "--coherence", "--min-coherence",
help="Min coherence threshold 0.0-1.0 (rows below this are removed)",
),
perplexity_model: str = typer.Option(
@ -780,6 +781,13 @@ def split_data(
None, "--test",
help="Test split: percentage (default) or absolute count (with --absolute)",
),
train: int = typer.Option(
None, "--train",
help=(
"Train split (informational; the train remainder is implied by "
"--val + --test). Accepted for command parity."
),
),
absolute: bool = typer.Option(
False, "--absolute",
help="Treat --val/--test as absolute sample counts instead of percentages",
@ -1461,56 +1469,104 @@ def _load_augment_provider(provider: str, rpm: int):
@app.command(name="register")
def register_data(
name: str = typer.Option(..., "--name", "-n", help="Dataset name"),
path: str = typer.Option(..., "--path", "-p", help="Path to dataset file"),
name_arg: Optional[str] = typer.Argument(
None, help="Dataset name (positional alternative to --name)",
),
path_arg: Optional[str] = typer.Argument(
None, help="Path to dataset file (positional alternative to --path)",
),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Dataset name"),
path: Optional[str] = typer.Option(
None, "--path", "-p", help="Path to dataset file"
),
fmt: str = typer.Option(
"auto", "--format", "-f",
help="Dataset format: alpaca, sharegpt, chatml, dpo, kto, auto",
),
):
"""Register a local dataset by name for use in soup.yaml."""
"""Register a local dataset by name for use in soup.yaml.
Accepts both ``--name X --path Y`` and positional ``X Y``.
"""
from soup_cli.utils.registry import register_dataset
# Path traversal protection
resolved = Path(path).resolve()
cwd = Path.cwd().resolve()
try:
resolved.relative_to(cwd)
except ValueError:
# Resolve positional vs option, with conflict detection
final_name = name if name is not None else name_arg
final_path = path if path is not None else path_arg
if final_name is None or final_path is None:
console.print(
"[red]Provide both name and path "
"(positional `<name> <path>` or `--name --path`).[/]"
)
raise typer.Exit(2)
if name is not None and name_arg is not None and name != name_arg:
console.print("[red]Conflict: --name and positional name differ.[/]")
raise typer.Exit(2)
if path is not None and path_arg is not None and path != path_arg:
console.print("[red]Conflict: --path and positional path differ.[/]")
raise typer.Exit(2)
# Path traversal protection — use os.path.realpath + commonpath
# (project standard; Windows 8.3 short-name safe).
import os as _os
from soup_cli.utils.paths import is_under_cwd
if not is_under_cwd(final_path):
console.print(
"[red]Dataset path must be under the current working directory.[/]"
)
raise typer.Exit(1)
resolved = Path(_os.path.realpath(final_path))
registry_path = _get_registry_path()
try:
register_dataset(name, str(resolved), fmt, registry_path=registry_path)
register_dataset(final_name, str(resolved), fmt, registry_path=registry_path)
except ValueError as exc:
console.print(f"[red]{exc}[/]")
raise typer.Exit(1)
console.print(
f"[green]Registered dataset '[bold]{name}[/bold]'[/]\n"
f" Path: {path}\n"
f"[green]Registered dataset '[bold]{final_name}[/bold]'[/]\n"
f" Path: {final_path}\n"
f" Format: {fmt}"
)
@app.command(name="unregister")
def unregister_data(
name: str = typer.Option(..., "--name", "-n", help="Dataset name to remove"),
name_arg: Optional[str] = typer.Argument(
None, help="Dataset name (positional alternative to --name)",
),
name: Optional[str] = typer.Option(
None, "--name", "-n", help="Dataset name to remove"
),
):
"""Remove a dataset from the local registry."""
"""Remove a dataset from the local registry.
Accepts both ``--name X`` and positional ``X``.
"""
from soup_cli.utils.registry import unregister_dataset
final_name = name if name is not None else name_arg
if final_name is None:
console.print(
"[red]Provide a dataset name "
"(positional `<name>` or `--name`).[/]"
)
raise typer.Exit(2)
if name is not None and name_arg is not None and name != name_arg:
console.print("[red]Conflict: --name and positional name differ.[/]")
raise typer.Exit(2)
registry_path = _get_registry_path()
removed = unregister_dataset(name, registry_path=registry_path)
removed = unregister_dataset(final_name, registry_path=registry_path)
if removed:
console.print(f"[green]Removed dataset '{name}' from registry.[/]")
console.print(f"[green]Removed dataset '{final_name}' from registry.[/]")
else:
console.print(f"[red]Dataset '{name}' not found in registry.[/]")
console.print(f"[red]Dataset '{final_name}' not found in registry.[/]")
raise typer.Exit(1)

View File

@ -435,6 +435,15 @@ def hf_space(
"-t",
help=f"Space template: {', '.join(HF_SPACE_TEMPLATES.keys())}",
),
template_dir: Optional[str] = typer.Option(
None,
"--template-dir",
help=(
"Custom template directory (overrides --template). "
"Must contain app.py + README.md, optionally requirements.txt. "
"Use {MODEL_REPO} placeholder for substitution."
),
),
private: bool = typer.Option(
False, "--private", help="Create the Space as private",
),
@ -461,7 +470,7 @@ def hf_space(
except ValueError as exc:
console.print(f"[red]Invalid --space repo id:[/] {exc}")
raise typer.Exit(1) from exc
if template not in HF_SPACE_TEMPLATES:
if template_dir is None and template not in HF_SPACE_TEMPLATES:
console.print(
f"[red]Unknown template: {template}[/]\n"
f"Available: {', '.join(HF_SPACE_TEMPLATES.keys())}"
@ -485,16 +494,28 @@ def hf_space(
# --- Render template files ---
try:
files = render_space_template(template, model_repo=model)
if template_dir is not None:
from soup_cli.utils.hf_space import render_custom_template_dir
files = render_custom_template_dir(template_dir, model_repo=model)
# Custom templates default to gradio SDK unless requirements
# imply otherwise; we record gradio for create_repo space_sdk.
sdk = "gradio"
else:
files = render_space_template(template, model_repo=model)
sdk = HF_SPACE_TEMPLATES[template]["sdk"]
except ValueError as exc:
console.print(f"[red]Template render failed:[/] {exc}")
raise typer.Exit(1) from exc
except FileNotFoundError as exc:
console.print(f"[red]Template directory error:[/] {exc}")
raise typer.Exit(1) from exc
template_label = template_dir if template_dir is not None else template
console.print(
Panel(
f"Space: [bold]{space}[/]\n"
f"Model: [bold]{model}[/]\n"
f"Template: [bold]{template}[/]\n"
f"Template: [bold]{template_label}[/]\n"
f"Private: [bold]{private}[/]",
title="Deploy HuggingFace Space",
)
@ -514,7 +535,7 @@ def hf_space(
try:
api.create_repo(
repo_id=space, repo_type="space",
space_sdk=HF_SPACE_TEMPLATES[template]["sdk"],
space_sdk=sdk,
private=private, exist_ok=True,
)
for in_repo_name, content in files.items():

View File

@ -1,5 +1,7 @@
"""soup infer — batch inference on a list of prompts."""
from __future__ import annotations
import json
import time
from pathlib import Path
@ -13,6 +15,42 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeEl
console = Console()
def _is_path_like(value: str) -> bool:
"""Heuristic: looks like a filesystem path rather than a HF repo id.
HF repo ids are ``owner/name`` with no leading dot/slash and no Windows
drive letter; anything else (``./foo``, ``/abs/path``, ``C:\\...``) is
treated as a path so we surface a meaningful FileNotFoundError instead
of attempting an HF download.
"""
if not value:
return True
if value.startswith((".", "/", "\\", "~")):
return True
# Windows drive letter, e.g. "C:\..." or "C:/..."
if len(value) >= 2 and value[1] == ":":
return True
return False
def _resolve_model_source(model: str) -> tuple[str, str]:
"""Return ``("local", path)`` or ``("hf", repo_id)`` for ``--model``.
Falls through to HF when the local path doesn't exist *and* the value
looks like a HF repo id (no leading ``./`` etc.). Raises
:class:`FileNotFoundError` when the value looks like a path but doesn't
exist locally distinguishes "your file is missing" from "your HF id
is wrong" so the error message is actionable.
"""
candidate = Path(model)
if candidate.exists():
return "local", str(candidate)
if _is_path_like(model):
raise FileNotFoundError(f"Model path not found: {model}")
# Looks like a HF repo id — let transformers handle the download.
return "hf", model
def infer(
model: str = typer.Option(
...,
@ -58,17 +96,29 @@ def infer(
),
):
"""Run batch inference on a JSONL file of prompts."""
from soup_cli.utils.paths import is_under_cwd
# Validate input file
input_path = Path(input_file)
if not input_path.exists():
console.print(f"[red]Input file not found: {input_path}[/]")
raise typer.Exit(1)
# Validate model path
model_path = Path(model)
if not model_path.exists():
console.print(f"[red]Model not found: {model_path}[/]")
raise typer.Exit(1)
# Resolve model: local path or HF repo id (auto-fallback, #N7).
try:
model_kind, model_ref = _resolve_model_source(model)
except FileNotFoundError as exc:
console.print(
f"[red]{exc}[/]\n"
"[dim]If you meant a HuggingFace repo, use the form "
"'owner/repo-name' (no leading './').[/]"
)
raise typer.Exit(1) from exc
model_path = Path(model_ref)
if model_kind == "hf":
console.print(
f"[dim]Local path not found; treating {model_ref!r} as a HF repo id.[/]"
)
# Read prompts
prompts = _read_prompts(input_path)
@ -104,6 +154,16 @@ def infer(
model_obj, tokenizer = _load_model(str(model_path), base, device)
console.print("[green]Model loaded.[/]\n")
# Output path containment — defence-in-depth (project policy v0.20.0+).
# Checked late, after model+inputs validate, so that pre-existing tests
# asserting on "model not found" / "no prompts" errors keep working when
# they pass an out-of-cwd `tmp_path`.
if not is_under_cwd(output_file):
console.print(
"[red]--output must stay under the current working directory.[/]"
)
raise typer.Exit(1)
# Run inference — stream results to disk as they are generated
output_path = Path(output_file)
total_tokens = 0

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
@ -122,16 +123,43 @@ def quickstart(
"--dry-run",
help="Create data and config only, do not train",
),
output: Optional[str] = typer.Option(
None,
"--output",
"-o",
help=(
"Output directory for data, config, and run artifacts "
"(default: current directory)."
),
),
):
"""Run a complete demo: create sample data, config, and train."""
import os
from soup_cli.utils.paths import is_under_cwd
model_id, advisory = _pick_quickstart_model()
if advisory:
console.print(f"[yellow]{advisory}[/]")
# Resolve output directory (containment-checked)
if output is None:
out_dir = Path.cwd()
else:
if not is_under_cwd(output):
console.print(
f"[red]--output must stay under the current working directory; "
f"got: {output}[/]"
)
raise typer.Exit(2)
out_dir = Path(os.path.realpath(output))
out_dir.mkdir(parents=True, exist_ok=True)
console.print(
Panel(
"This will:\n"
" 1. Create [bold]quickstart_data.jsonl[/] (20 examples)\n"
" 2. Create [bold]quickstart_soup.yaml[/] config\n"
f" 1. Create [bold]{out_dir}/quickstart_data.jsonl[/] (20 examples)\n"
f" 2. Create [bold]{out_dir}/quickstart_soup.yaml[/] config\n"
" 3. Train a tiny LoRA adapter (~1 min on GPU)\n\n"
f"Model: [bold]{model_id}[/]",
title="[bold]Soup Quickstart[/]",
@ -145,7 +173,7 @@ def quickstart(
raise typer.Exit()
# 1. Create demo data
data_path = Path("quickstart_data.jsonl")
data_path = out_dir / "quickstart_data.jsonl"
if data_path.exists():
console.print(f"[yellow]Data file already exists:[/] {data_path}")
else:
@ -155,13 +183,27 @@ def quickstart(
console.print(f"[green]Created:[/] {data_path} ({len(DEMO_DATA)} examples)")
# 2. Create demo config
config_path = Path("quickstart_soup.yaml")
config_path = out_dir / "quickstart_soup.yaml"
if config_path.exists():
console.print(f"[yellow]Config file already exists:[/] {config_path}")
else:
rendered = DEMO_CONFIG.replace(_DEFAULT_MODEL, model_id)
# When --output is set, retarget data + run dirs into that dir.
if output is not None:
rendered = rendered.replace(
"./quickstart_data.jsonl", str(data_path)
).replace("./quickstart_output", str(out_dir / "quickstart_output"))
config_path.write_text(rendered, encoding="utf-8")
console.print(f"[green]Created:[/] {config_path}")
# Also write a `soup.yaml` symlink-style alias for tools that look for it.
soup_yaml = out_dir / "soup.yaml"
if not soup_yaml.exists() and output is not None:
try:
soup_yaml.write_text(
config_path.read_text(encoding="utf-8"), encoding="utf-8"
)
except OSError:
pass
if dry_run:
console.print("\n[yellow]Dry run - files created, skipping training.[/]")

View File

@ -20,12 +20,48 @@ console = Console()
app = typer.Typer(no_args_is_help=False, invoke_without_command=True)
def _filter_runs_by_cwd(runs: list[dict], cwd: str) -> list[dict]:
"""Return only runs whose output_dir is under ``cwd``.
Runs with no ``output_dir`` are dropped. Comparisons use
``os.path.realpath + commonpath`` for Windows 8.3 short-name safety
(see project rule in CLAUDE.md).
"""
cwd_real = os.path.realpath(cwd)
kept: list[dict] = []
for run in runs:
out = run.get("output_dir")
if not out:
continue
try:
cand = os.path.realpath(out)
if os.path.commonpath([cand, cwd_real]) == cwd_real:
kept.append(run)
except (ValueError, OSError):
# Different drives on Windows / unreadable path — skip.
continue
return kept
@app.callback(invoke_without_command=True)
def list_runs(
ctx: typer.Context,
limit: int = typer.Option(20, "--limit", "-l", help="Max runs to show"),
cwd_only: bool = typer.Option(
False,
"--cwd-only",
help=(
"Only show runs whose output_dir is under the current "
"working directory (default: show all in ~/.soup/experiments.db)."
),
),
):
"""List all training runs."""
"""List all training runs.
By default lists every run from the global ``~/.soup/experiments.db``.
Use ``--cwd-only`` to restrict to runs anchored under the current
directory.
"""
if ctx.invoked_subcommand is not None:
return
@ -33,6 +69,8 @@ def list_runs(
tracker = ExperimentTracker()
runs = tracker.list_runs(limit=limit)
if cwd_only:
runs = _filter_runs_by_cwd(runs, os.getcwd())
if not runs:
console.print("[dim]No runs found. Train a model with:[/] [bold]soup train[/]")

View File

@ -1,6 +1,6 @@
"""Rich live training dashboard in the terminal."""
from typing import Optional
from typing import Any, Mapping, Optional
from rich.console import Console
from rich.live import Live
@ -11,6 +11,49 @@ from soup_cli.config.schema import SoupConfig
console = Console()
def format_gate_row(state: Optional[Mapping[str, Any]]) -> str:
"""Render the eval-gate status row for the live training panel (#36).
Returns an empty string when ``state`` is None or empty (so the row is
hidden when eval-gate is disabled).
Format example::
Gate: helpfulness 7.8 [green][/] | math 0.82 [red][/] (-0.06 from base) | STOP
Pure formatter no I/O, no side effects so it is trivially testable
via ``Console(file=StringIO())`` without spinning up a Live display.
"""
if not state:
return ""
tasks = state.get("tasks") or []
if not tasks:
return ""
parts: list[str] = []
for task in tasks:
name = str(task.get("name", "?"))
score = task.get("score")
# Explicit ``is True`` so a missing field renders the ``?`` mark
# rather than the false-y red ✗ (e.g. tasks still pending).
passed = task.get("passed") is True
delta = task.get("delta")
score_str = f"{score:.2f}" if isinstance(score, (int, float)) else ""
mark = "[green]✓[/]" if passed else "[red]✗[/]"
chunk = f"{name} {score_str} {mark}"
if delta is not None and isinstance(delta, (int, float)):
sign = "+" if delta >= 0 else ""
chunk += f" ({sign}{delta:.2f})"
parts.append(chunk)
body = " | ".join(parts)
action = state.get("action")
suffix = ""
if action == "stop":
suffix = " | [bold red]STOP[/]"
elif action == "warn":
suffix = " | [yellow]WARN[/]"
return f"[bold]Gate:[/] {body}{suffix}"
class TrainingDisplay:
"""Live-updating terminal dashboard for training progress."""

View File

@ -177,6 +177,32 @@ def resolve_latest_checkpoint_revision(
return best_name
def _find_highest_local_checkpoint(output_dir: str) -> Optional[int]:
"""Return the highest ``checkpoint-<N>`` step under ``output_dir``, or None.
Skips non-directories and malformed names. Returns None if ``output_dir``
does not exist or contains no checkpoints.
"""
base = Path(output_dir)
if not base.is_dir():
return None
best: Optional[int] = None
try:
children = list(base.iterdir())
except OSError:
return None
for entry in children:
if not entry.is_dir():
continue
match = _CHECKPOINT_BRANCH_RE.match(entry.name)
if not match:
continue
step = int(match.group(1))
if best is None or step > best:
best = step
return best
def _download_checkpoint(
repo_id: str,
revision: str,
@ -247,6 +273,22 @@ def prepare_hf_resume(
if revision is None:
return None
# Prefer local newer (#50): if local has checkpoint-N >= remote's,
# skip the download and return the local path. Saves bandwidth and
# avoids overwriting a fresher local checkpoint with stale Hub state.
remote_match = _CHECKPOINT_BRANCH_RE.match(revision)
remote_step = int(remote_match.group(1)) if remote_match else -1
local_step = _find_highest_local_checkpoint(output_dir)
if local_step is not None and local_step >= remote_step:
local_revision = f"checkpoint-{local_step}"
local_path = Path(output_dir) / local_revision
logger.info(
"HF resume: local %s >= remote %s; skipping download",
local_revision,
revision,
)
return str(local_path)
# Mirror HF Trainer's on-disk layout: output_dir/<revision>
local_dir = str(Path(output_dir) / revision)
try:

View File

@ -0,0 +1,94 @@
"""HF Space custom template directory renderer (v0.40.2 #51).
Lets users supply their own ``app.py`` / ``README.md`` (+ optional
``requirements.txt``) for ``soup deploy hf-space`` instead of the built-in
``gradio-chat`` / ``streamlit-chat`` templates.
Security model mirrors v0.29.0 Part F policy:
- Template directory must stay under the current working directory
(``utils/paths.is_under_cwd``).
- ``model_repo`` is validated via :func:`utils.hf.validate_repo_id` BEFORE
substitution, so a crafted repo id cannot inject Python source into the
rendered ``app.py`` uploaded to HF Hub.
- Per-file size cap of 256 KB defends against pathological templates.
- Only ``app.py``, ``README.md``, and ``requirements.txt`` are read; any
other files in the directory are ignored.
"""
from __future__ import annotations
import os
import stat as stat_module
from pathlib import Path
from soup_cli.utils.hf import validate_repo_id
from soup_cli.utils.paths import is_under_cwd
_MAX_TEMPLATE_FILE_BYTES = 256 * 1024 # 256 KB
_KNOWN_FILES = ("app.py", "README.md", "requirements.txt")
_REQUIRED_FILES = ("app.py", "README.md")
def render_custom_template_dir(template_dir: str, model_repo: str) -> dict[str, str]:
"""Render a custom Space template directory.
Returns a dict mapping in-repo filename rendered content.
``{MODEL_REPO}`` placeholder is replaced with the validated ``model_repo``.
Raises:
ValueError: containment violation, invalid ``model_repo``, oversized file.
FileNotFoundError: missing required file (``app.py`` / ``README.md``).
"""
validate_repo_id(model_repo)
if not is_under_cwd(template_dir):
raise ValueError(
"template-dir must stay under the current working directory; "
f"got: {template_dir!r}"
)
base = Path(template_dir)
if not base.is_dir():
raise FileNotFoundError(
f"template-dir does not exist or is not a directory: {template_dir}"
)
rendered: dict[str, str] = {}
for fname in _KNOWN_FILES:
fpath = base / fname
# ``lstat`` does NOT follow symlinks — defence-in-depth against a
# crafted symlink at <template_dir>/app.py -> /etc/passwd reading
# outside the containment-checked directory (mirrors v0.33.0
# ``prune_checkpoints`` TOCTOU policy).
try:
st = os.lstat(fpath)
except OSError:
if fname in _REQUIRED_FILES:
raise FileNotFoundError(
f"template-dir is missing required file: {fname}"
) from None
continue
if stat_module.S_ISLNK(st.st_mode):
raise ValueError(
f"template file {fname} is a symlink; refusing to render"
)
if not stat_module.S_ISREG(st.st_mode):
if fname in _REQUIRED_FILES:
raise FileNotFoundError(
f"template-dir is missing required file: {fname}"
)
continue
if st.st_size > _MAX_TEMPLATE_FILE_BYTES:
raise ValueError(
f"template file {fname} exceeds 256 KB cap "
f"({st.st_size} bytes); refusing to render"
)
try:
content = fpath.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(
f"template file {fname} is not valid UTF-8"
) from exc
rendered[fname] = content.replace("{MODEL_REPO}", model_repo)
return rendered

View File

@ -99,3 +99,16 @@ def setup_logging(tier: LogLevel) -> logging.Logger:
handler.setLevel(py_level)
return logger
def apply_logging_level(tier: LogLevel) -> None:
"""Set the *root* logger level so non-soup libraries respect the tier (#G2/N1).
``setup_logging`` only configures the ``soup`` logger; library logs
(``transformers``, ``peft``, ``trl``) flow through the root logger.
Without this, ``--log-level quiet`` left third-party INFO chatter on
stdout. ``apply_logging_level`` plumbs the tier into the root level so
QUIET silences libraries and DEBUG surfaces them.
"""
py_level = resolve_python_log_level(tier)
logging.getLogger().setLevel(py_level)

358
tests/test_v0402_part_a.py Normal file
View File

@ -0,0 +1,358 @@
"""Tests for v0.40.2 Part A — originally scheduled issues (#36, #50, #51)."""
from __future__ import annotations
from io import StringIO
import pytest
from rich.console import Console
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
# ---------------------------------------------------------------------------
# #50 — `--hf-resume` prefer local newer
# ---------------------------------------------------------------------------
class TestHFResumePreferLocal:
def test_find_highest_local_checkpoint_empty(self, tmp_path):
from soup_cli.monitoring.hf_push import _find_highest_local_checkpoint
assert _find_highest_local_checkpoint(str(tmp_path)) is None
def test_find_highest_local_checkpoint_picks_max(self, tmp_path):
from soup_cli.monitoring.hf_push import _find_highest_local_checkpoint
(tmp_path / "checkpoint-100").mkdir()
(tmp_path / "checkpoint-50").mkdir()
(tmp_path / "checkpoint-200").mkdir()
# Spurious dirs ignored
(tmp_path / "garbage").mkdir()
(tmp_path / "checkpoint-bad").mkdir()
# Files (not dirs) ignored
(tmp_path / "checkpoint-999").write_text("not a dir")
assert _find_highest_local_checkpoint(str(tmp_path)) == 200
def test_find_highest_local_checkpoint_missing_dir(self, tmp_path):
from soup_cli.monitoring.hf_push import _find_highest_local_checkpoint
assert _find_highest_local_checkpoint(str(tmp_path / "nope")) is None
def test_prepare_hf_resume_skips_download_when_local_equal(
self, tmp_path, monkeypatch
):
"""If local checkpoint-N == remote checkpoint-N, skip download."""
from soup_cli.monitoring.hf_push import prepare_hf_resume
monkeypatch.chdir(tmp_path)
out_dir = tmp_path / "runs"
out_dir.mkdir()
(out_dir / "checkpoint-300").mkdir()
download_called = {"called": False}
def fake_download(*args, **kwargs):
download_called["called"] = True
return kwargs.get("local_dir", "")
monkeypatch.setattr(
"soup_cli.monitoring.hf_push.resolve_latest_checkpoint_revision",
lambda repo_id, token=None, endpoint=None: "checkpoint-300",
)
monkeypatch.setattr(
"soup_cli.monitoring.hf_push._download_checkpoint", fake_download
)
result = prepare_hf_resume(
repo_id="user/my-model", output_dir=str(out_dir), token="t1"
)
assert download_called["called"] is False
assert result is not None
assert "checkpoint-300" in result
def test_prepare_hf_resume_skips_download_when_local_newer(
self, tmp_path, monkeypatch
):
from soup_cli.monitoring.hf_push import prepare_hf_resume
monkeypatch.chdir(tmp_path)
out_dir = tmp_path / "runs"
out_dir.mkdir()
(out_dir / "checkpoint-500").mkdir()
download_called = {"called": False}
def fake_download(*args, **kwargs):
download_called["called"] = True
return kwargs.get("local_dir", "")
monkeypatch.setattr(
"soup_cli.monitoring.hf_push.resolve_latest_checkpoint_revision",
lambda repo_id, token=None, endpoint=None: "checkpoint-300",
)
monkeypatch.setattr(
"soup_cli.monitoring.hf_push._download_checkpoint", fake_download
)
result = prepare_hf_resume(
repo_id="user/my-model", output_dir=str(out_dir), token="t1"
)
assert download_called["called"] is False
assert result is not None
assert "checkpoint-500" in result
def test_prepare_hf_resume_downloads_when_remote_newer(
self, tmp_path, monkeypatch
):
from soup_cli.monitoring.hf_push import prepare_hf_resume
monkeypatch.chdir(tmp_path)
out_dir = tmp_path / "runs"
out_dir.mkdir()
(out_dir / "checkpoint-100").mkdir()
download_called = {"called": False, "revision": None}
def fake_download(repo_id, revision, local_dir, token, endpoint):
download_called["called"] = True
download_called["revision"] = revision
return local_dir
monkeypatch.setattr(
"soup_cli.monitoring.hf_push.resolve_latest_checkpoint_revision",
lambda repo_id, token=None, endpoint=None: "checkpoint-500",
)
monkeypatch.setattr(
"soup_cli.monitoring.hf_push._download_checkpoint", fake_download
)
result = prepare_hf_resume(
repo_id="user/my-model", output_dir=str(out_dir), token="t1"
)
assert download_called["called"] is True
assert download_called["revision"] == "checkpoint-500"
assert result is not None
# ---------------------------------------------------------------------------
# #51 — `soup deploy hf-space --template-dir`
# ---------------------------------------------------------------------------
class TestHfSpaceCustomTemplate:
def test_render_custom_template_dir(self, tmp_path, monkeypatch):
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "mytpl"
tdir.mkdir()
(tdir / "app.py").write_text("MODEL = '{MODEL_REPO}'\n")
(tdir / "README.md").write_text("# Space for {MODEL_REPO}\n")
(tdir / "requirements.txt").write_text("gradio\n")
rendered = render_custom_template_dir(
str(tdir), model_repo="user/my-model"
)
assert rendered["app.py"] == "MODEL = 'user/my-model'\n"
assert rendered["README.md"] == "# Space for user/my-model\n"
assert rendered["requirements.txt"] == "gradio\n"
def test_render_custom_template_rejects_outside_cwd(self, tmp_path, monkeypatch):
from soup_cli.utils.hf_space import render_custom_template_dir
cwd = tmp_path / "project"
cwd.mkdir()
monkeypatch.chdir(cwd)
outside = tmp_path / "elsewhere"
outside.mkdir()
(outside / "app.py").write_text("x")
(outside / "README.md").write_text("x")
with pytest.raises(ValueError, match="under the current"):
render_custom_template_dir(str(outside), model_repo="user/my-model")
def test_render_custom_template_rejects_invalid_repo_id(
self, tmp_path, monkeypatch
):
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "tpl"
tdir.mkdir()
(tdir / "app.py").write_text("x")
(tdir / "README.md").write_text("x")
with pytest.raises(ValueError):
render_custom_template_dir(str(tdir), model_repo="bad..repo")
def test_render_custom_template_requires_app_py(self, tmp_path, monkeypatch):
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "tpl"
tdir.mkdir()
(tdir / "README.md").write_text("x")
with pytest.raises(FileNotFoundError, match="app.py"):
render_custom_template_dir(str(tdir), model_repo="user/my-model")
def test_render_custom_template_requires_readme(self, tmp_path, monkeypatch):
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "tpl"
tdir.mkdir()
(tdir / "app.py").write_text("x")
with pytest.raises(FileNotFoundError, match="README.md"):
render_custom_template_dir(str(tdir), model_repo="user/my-model")
def test_render_custom_template_size_cap(self, tmp_path, monkeypatch):
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "tpl"
tdir.mkdir()
# 256KB+1 byte
big = "a" * (256 * 1024 + 1)
(tdir / "app.py").write_text(big)
(tdir / "README.md").write_text("x")
with pytest.raises(ValueError, match="256"):
render_custom_template_dir(str(tdir), model_repo="user/my-model")
def test_render_custom_template_rejects_symlink(self, tmp_path, monkeypatch):
"""Symlinked app.py must be rejected — TOCTOU defence."""
import os
import sys
if sys.platform == "win32":
pytest.skip("symlinks require admin on Windows")
from soup_cli.utils.hf_space import render_custom_template_dir
monkeypatch.chdir(tmp_path)
tdir = tmp_path / "tpl"
tdir.mkdir()
# Real app.py outside, symlink inside
evil_target = tmp_path / "evil_app.py"
evil_target.write_text("evil\n")
os.symlink(str(evil_target), str(tdir / "app.py"))
(tdir / "README.md").write_text("x")
with pytest.raises(ValueError, match="symlink"):
render_custom_template_dir(str(tdir), model_repo="user/my-model")
def test_deploy_hf_space_help_shows_template_dir(self):
result = runner.invoke(app, ["deploy", "hf-space", "--help"])
assert "--template-dir" in result.output
# ---------------------------------------------------------------------------
# #36 — Eval-gate dashboard row
# ---------------------------------------------------------------------------
class TestEvalGateDashboardRow:
def test_format_gate_row_disabled(self):
from soup_cli.monitoring.display import format_gate_row
# No state -> empty string
assert format_gate_row(None) == ""
def test_format_gate_row_pass(self):
from soup_cli.monitoring.display import format_gate_row
state = {
"tasks": [
{"name": "helpfulness", "score": 7.8, "passed": True},
],
"overall_passed": True,
"action": None,
}
out = format_gate_row(state)
assert "Gate" in out
assert "helpfulness" in out
assert "7.8" in out
def test_format_gate_row_regression_stop(self):
from soup_cli.monitoring.display import format_gate_row
state = {
"tasks": [
{
"name": "math",
"score": 0.82,
"passed": False,
"delta": -0.06,
"baseline": 0.88,
},
],
"overall_passed": False,
"action": "stop",
}
out = format_gate_row(state)
assert "math" in out
assert "0.82" in out
assert "STOP" in out.upper() or "stop" in out
def test_format_gate_row_warn_action(self):
from soup_cli.monitoring.display import format_gate_row
state = {
"tasks": [{"name": "t", "score": 0.5, "passed": False, "delta": -0.1}],
"overall_passed": False,
"action": "warn",
}
out = format_gate_row(state)
assert "WARN" in out
def test_format_gate_row_multi_task(self):
from soup_cli.monitoring.display import format_gate_row
state = {
"tasks": [
{"name": "helpfulness", "score": 7.8, "passed": True},
{"name": "math", "score": 0.82, "passed": False, "delta": -0.06},
],
"overall_passed": False,
"action": "stop",
}
out = format_gate_row(state)
assert "helpfulness" in out
assert "math" in out
assert "|" in out # separator
assert "STOP" in out
def test_format_gate_row_passed_missing_field_renders_neutral(self):
from soup_cli.monitoring.display import format_gate_row
# `passed` absent — should not silently render as red ✗
state = {
"tasks": [{"name": "t", "score": 0.5}],
"overall_passed": True,
"action": None,
}
out = format_gate_row(state)
# No action suffix (None), and the task row renders without
# falsely claiming pass.
assert "STOP" not in out
assert "WARN" not in out
def test_format_gate_row_renders_via_console(self):
from soup_cli.monitoring.display import format_gate_row
state = {
"tasks": [{"name": "t", "score": 1.0, "passed": True}],
"overall_passed": True,
"action": None,
}
buf = StringIO()
Console(file=buf, force_terminal=False, no_color=True, width=120).print(
format_gate_row(state)
)
assert "t" in buf.getvalue()

245
tests/test_v0402_part_b.py Normal file
View File

@ -0,0 +1,245 @@
"""Tests for v0.40.2 Part B — v0.40.1 carry-overs (H2/H3/N1-G2/N7/M5)."""
from __future__ import annotations
import json
import re
import pytest
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
def _plain(s: str) -> str:
"""Strip ANSI escape sequences for help-string assertions."""
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
# ---------------------------------------------------------------------------
# H2 — data flag aliases
# ---------------------------------------------------------------------------
class TestDataFlagAliases:
def test_split_accepts_train_flag(self, tmp_path, monkeypatch):
"""--train is accepted on `data split` (informational; train is remainder)."""
monkeypatch.chdir(tmp_path)
ds = tmp_path / "ds.jsonl"
rows = [{"text": f"row {i}"} for i in range(20)]
ds.write_text(
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8"
)
result = runner.invoke(
app,
["data", "split", str(ds), "--train", "70", "--val", "20", "--test", "10"],
)
assert result.exit_code == 0, (result.output, repr(result.exception))
def test_split_help_mentions_train(self):
result = runner.invoke(app, ["data", "split", "--help"])
assert "--train" in _plain(result.output)
def test_filter_min_coherence_alias(self):
result = runner.invoke(app, ["data", "filter", "--help"])
assert "--min-coherence" in _plain(result.output)
def test_register_positional_name_path(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ds = tmp_path / "myset.jsonl"
ds.write_text('{"text": "x"}\n', encoding="utf-8")
result = runner.invoke(
app,
["data", "register", "myset", str(ds)],
)
assert result.exit_code == 0, (result.output, repr(result.exception))
assert "myset" in result.output
def test_unregister_positional_name(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ds = tmp_path / "myset.jsonl"
ds.write_text('{"text": "x"}\n', encoding="utf-8")
# Register first
r1 = runner.invoke(app, ["data", "register", "myset", str(ds)])
assert r1.exit_code == 0
r2 = runner.invoke(app, ["data", "unregister", "myset"])
assert r2.exit_code == 0, (r2.output, repr(r2.exception))
# ---------------------------------------------------------------------------
# H3 — `soup quickstart --output DIR`
# ---------------------------------------------------------------------------
class TestQuickstartOutput:
def test_quickstart_help_shows_output(self):
result = runner.invoke(app, ["quickstart", "--help"])
assert "--output" in _plain(result.output)
def test_quickstart_output_routes_files(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
target = tmp_path / "myrun"
result = runner.invoke(
app, ["quickstart", "--dry-run", "--output", str(target)]
)
assert result.exit_code == 0, (result.output, repr(result.exception))
# The actual files quickstart.py writes under --output:
assert (target / "quickstart_data.jsonl").exists()
assert (target / "quickstart_soup.yaml").exists()
# ---------------------------------------------------------------------------
# N1 / G2 — `--log-level` deeper plumbing
# ---------------------------------------------------------------------------
class TestLogLevelPlumbing:
def test_quiet_emits_less_than_normal(self):
# `data inspect` on missing file emits an error; we look at how many
# log lines are produced. quiet should emit fewer informational lines.
# Use --help as a stable surface.
normal = runner.invoke(app, ["--log-level", "normal", "version"])
quiet = runner.invoke(app, ["--log-level", "quiet", "version"])
assert normal.exit_code == 0
assert quiet.exit_code == 0
# Both succeed; quiet's output should not exceed normal's by a wide margin.
assert len(quiet.output) <= len(normal.output) + 10
def test_log_level_sets_logging_module_level(self, monkeypatch):
import logging as stdlogging
from soup_cli.utils.log_level import LogLevel, apply_logging_level
# Reset root logger
root = stdlogging.getLogger()
prev_level = root.level
try:
apply_logging_level(LogLevel.DEBUG)
assert root.level == stdlogging.DEBUG
apply_logging_level(LogLevel.QUIET)
assert root.level == stdlogging.ERROR
apply_logging_level(LogLevel.VERBOSE)
assert root.level == stdlogging.INFO
apply_logging_level(LogLevel.NORMAL)
assert root.level == stdlogging.WARNING
finally:
root.setLevel(prev_level)
# ---------------------------------------------------------------------------
# N7 — `soup infer` accepts HF ids when local path missing
# ---------------------------------------------------------------------------
class TestInferHFFallback:
def test_resolve_model_source_local_path_exists(self, tmp_path):
from soup_cli.commands.infer import _resolve_model_source
model_dir = tmp_path / "mymodel"
model_dir.mkdir()
(model_dir / "config.json").write_text("{}")
kind, value = _resolve_model_source(str(model_dir))
assert kind == "local"
assert value == str(model_dir)
def test_resolve_model_source_hf_id_when_no_local(self, tmp_path, monkeypatch):
from soup_cli.commands.infer import _resolve_model_source
monkeypatch.chdir(tmp_path)
kind, value = _resolve_model_source("user/my-model")
assert kind == "hf"
assert value == "user/my-model"
def test_resolve_model_source_invalid_hf_id_when_no_local(
self, tmp_path, monkeypatch
):
from soup_cli.commands.infer import _resolve_model_source
monkeypatch.chdir(tmp_path)
# Path-like but doesn't exist and isn't a valid HF id
with pytest.raises(FileNotFoundError, match="not found"):
_resolve_model_source("./nonexistent")
def test_resolve_model_source_absolute_path_missing(self, tmp_path, monkeypatch):
from soup_cli.commands.infer import _resolve_model_source
monkeypatch.chdir(tmp_path)
with pytest.raises(FileNotFoundError):
_resolve_model_source("/nonexistent/abs/path")
def test_resolve_model_source_tilde_path_missing(self, tmp_path, monkeypatch):
from soup_cli.commands.infer import _resolve_model_source
monkeypatch.chdir(tmp_path)
with pytest.raises(FileNotFoundError):
_resolve_model_source("~/nope/missing")
def test_resolve_model_source_windows_drive_letter(self, tmp_path, monkeypatch):
from soup_cli.commands.infer import _resolve_model_source
monkeypatch.chdir(tmp_path)
# Drive-letter syntax always treated as path-like; missing → error.
with pytest.raises(FileNotFoundError):
_resolve_model_source("Z:/nope/missing")
def test_is_path_like_branches(self):
from soup_cli.commands.infer import _is_path_like
assert _is_path_like("") is True
assert _is_path_like("./foo") is True
assert _is_path_like("/abs") is True
assert _is_path_like("~/x") is True
assert _is_path_like("\\\\share\\foo") is True
assert _is_path_like("C:/x") is True
assert _is_path_like("user/my-model") is False
assert _is_path_like("microsoft/phi-2") is False
# ---------------------------------------------------------------------------
# M5 — `soup runs --cwd-only`
# ---------------------------------------------------------------------------
class TestRunsCwdOnly:
def test_runs_help_shows_cwd_only(self):
result = runner.invoke(app, ["runs", "--help"])
assert "--cwd-only" in _plain(result.output)
def test_filter_runs_by_cwd(self, tmp_path):
from soup_cli.commands.runs import _filter_runs_by_cwd
cwd = str(tmp_path.resolve())
runs = [
{"run_id": "r1", "output_dir": str(tmp_path / "outA")},
{"run_id": "r2", "output_dir": "/some/other/place"},
{"run_id": "r3", "output_dir": str(tmp_path / "nested" / "outB")},
{"run_id": "r4", "output_dir": None},
]
result = _filter_runs_by_cwd(runs, cwd)
ids = [r["run_id"] for r in result]
assert "r1" in ids
assert "r3" in ids
assert "r2" not in ids
assert "r4" not in ids
def test_filter_runs_by_cwd_cross_drive(self, tmp_path):
"""Cross-drive paths on Windows raise ValueError in commonpath; survive."""
from soup_cli.commands.runs import _filter_runs_by_cwd
cwd = str(tmp_path.resolve())
runs = [
{"run_id": "r1", "output_dir": "Z:\\some\\path"},
{"run_id": "r2", "output_dir": str(tmp_path / "x")},
]
# Should not raise — should drop r1, keep r2
result = _filter_runs_by_cwd(runs, cwd)
ids = [r["run_id"] for r in result]
assert "r1" not in ids
assert "r2" in ids