feat(v0.53.8): Remote data + Hubs + Trackers (wave 2) — 6 features

- #85 fsspec live loaders — data/loader.py routes the v0.42.0 fsspec
  scheme allowlist (s3:// / gs:// / gcs:// / az:// / abfs:// / abfss:// /
  oci://) through fsspec.open with validate_remote_uri containment
  BEFORE connection. Friendly Rich panel names the pip install
  advisory when the backend SDK is missing. Threads data.streaming
  + data.buffer_size. Row count capped at 1M.

- #130 Hub dispatcher live — utils/hubs.download_repo() and
  upload_repo() lazy-import per backend (huggingface_hub /
  modelscope / openmind_hub). Shared _validate_repo_id_shape (bool /
  null-byte / leading-slash / .. / control-char / oversize) + cwd
  containment on local_dir / folder_path. commands/train.py pre-fetches
  non-HF base into .soup_hub_cache/ (sanitised slug, idempotent on
  resume, cfg.base updated via model_copy). soup data download --hub
  flag plumbed. Multi-command rollout for chat / serve / infer / merge
  / export / push tracked for v0.53.9.

- #89 [trackers] pyproject extra bundles mlflow / swanlab / trackio;
  tracker_missing_dep_message surfaces a friendly pip install advisory
  via importlib.util.find_spec (non-executing probe).

- #90 utils/trackers.send_telemetry_payload — opt-IN via SOUP_TELEMETRY=1;
  lazy httpx; 1s hard timeout; HTTPS-only with SSRF re-validation
  (mirrors v0.51.0 hub endpoint policy); silent-fail on every exception.

- #93 Fixtures migrated to soup_cli/data/_fixtures/ — zipapp /
  namespace-package safe via [tool.hatch.build.targets.wheel.force-include];
  _bundle_source_path falls back to examples/data/ for editable installs.

- #69 utils/hf_space.detect_space_sdk(requirements_text) — picks
  "streamlit" / "gradio" from the rendered requirements.txt; closes
  the v0.40.2 known limitation that custom Spaces always defaulted to
  gradio. Wired into commands/deploy.py.

Review pass: python-review + code-review + security-review ran in
parallel; 16 findings fixed (3 HIGH + 8 MEDIUM + 5 LOW). Highlights:
cwd-containment on local_dir/folder_path, Windows ..\ traversal
defence on .soup_hub_cache slug, Pydantic model_copy(update=...)
instead of attribute mutation, idempotent pre-fetch via cache probe,
1M-row cap on remote materialisation, SSRF re-validation on
telemetry endpoint override, 256 KB cap on detect_space_sdk input,
modelscope.push_model commit_message kwarg removed (would TypeError
at runtime), find_spec instead of __import__ to avoid swanlab
side-effects.

Test count: 8162 -> 8257 (+66 in tests/test_v0538.py + 29 net adjustments).
Lint clean. CPU smoke: version, --help, load_config_from_string with
hub: modelscope passes; mlx + non-HF rejected; data download --hub
modelscope advisory rendered; detect_space_sdk live on real
requirements.txt bodies; package-data fixtures resolve from
soup_cli/data/_fixtures/.

v0.53.7 known limitation #1 (bash 501 marker) bumped to v0.53.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-13 23:55:23 +05:00
parent 0ddefc5c6f
commit 6d2170c4f3
20 changed files with 1397 additions and 32 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 (190 files, 8162 tests)
tests/ - Test suite (188 files, 8257 tests)
examples/ - Real-world config examples and datasets
```

104
README.md
View File

@ -42,19 +42,15 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.53.7 — Data Forge + Pipeline live (wave 1)**: Eleven features wired end-to-end across synthetic data generation, preprocessing, and inference.
**v0.53.8 — Remote data + Hubs + Trackers (wave 2)**: Six features wired live across cloud storage, alternative model hubs, experiment trackers, and HF Spaces.
- **Data Forge live judge providers.** `soup data forge --judge-provider {ollama,anthropic,vllm}` now routes judge calls through real backends (not just offline stubs). Ollama enforces localhost-only SSRF; Anthropic uses env-only API key; vLLM validates scheme allowlist. Per-call exceptions logged at DEBUG so one bad provider never crashes the forge pipeline.
- **AOT tokenization with `soup data preprocess`.** New command tokenizes your dataset once and caches Arrow shards keyed by `(dataset, tokenizer, max_length, format)`. SFT and Pretrain trainers short-circuit at the schema level when `format='pre_tokenized'` + `tokenized_path` is set, eliminating the per-epoch tokenization tax. Resume-safe via atomic writes.
- **Data Recipe DAGs live (`soup data recipe --execute`).** Six node kinds now execute end-to-end: seed dataset, LLM text generation, code execution (RLVR sandbox), judge binary scoring, regex/JSON validation, and deterministic sampling. Checkpoint written per node; resume rehydrates from per-node sidecars. `failed_reason` fields redacted to prevent path leakage.
- **Anthropic-style `/v1/messages` + streaming SSE on both backends.** The transformers-only route from v0.53.6 now ships on vLLM too. Both backends convert Anthropic ↔ OpenAI messages, serve non-stream requests with Anthropic envelope, and stream with Anthropic event-shape SSE (`message_start` → `content_block_delta``message_delta` + `message_stop`). Loopback-only CORS on both.
- **Server-side `/v1/tools/python` + `/v1/tools/web_search` HTTP endpoints.** Python sandbox wraps the v0.25.0 RLVR `code_exec` with Bearer auth gate (5s timeout, 64KB code cap). Web search backend is httpx with hard 5s timeout, 5-result cap, and domain allowlist (deny-by-default via `WebSearchConfig`). (`/v1/tools/bash` reverted to 501 — child-process isolation insufficient; tracked for v0.53.8 with container/namespace work.)
- **Live trainer-plugin instantiation.** `instantiate_trainer_plugins` now lazy-imports upstream plugins (grokfast, spectrum, llmcompressor, sonicmoe, cce_plugin, math_verify) and friendlily advises on missing packages.
- **Markdown heading-aware ingest + operator-supplied decontamination corpus + custom prompt strategies.**
- `soup data ingest` now splits Markdown by headings (one JSONL row per section with `section` + `level` + `text` fields).
- `soup data decontaminate --benchmark-file <path>` accepts operator-supplied JSONL corpus instead of only bundled benchmarks.
- `soup data forge` and other data tools now support custom prompt strategies via `prompt_strategy: module:function` in the config (importlib resolver + per-row exception handling).
- **+111 net new tests** (8051 → 8162) in the new `test_v0537.py`. All four review agents ran; 38 findings fixed. Key hardening: symlink checks via `os.lstat` on raw paths (TOCTOU policy), atomic writes via tempfile + `os.replace`, error message redaction, narrowed exception handlers, tool endpoint Bearer auth opt-in, and comprehensive coverage for every live pathway.
- **fsspec live loaders for remote datasets.** `data.train: s3://bucket/data.jsonl` (plus `gs://` / `gcs://` / `az://` / `abfs://` / `abfss://` / `oci://`) now loads through `fsspec` with the v0.42.0 SSRF-hardened URI validator (bucket regex + userinfo/query/fragment rejection). Threads `data.streaming` (HF `load_dataset(streaming=True)`) and `data.buffer_size` shuffle. Friendly Rich panel names the `pip install <driver>` advisory when the backend SDK is missing. Row-count capped at 1M to defend against pathological remote objects.
- **Live ModelScope + Modelers hub dispatcher.** New `utils/hubs.download_repo()` and `upload_repo()` lazy-import the matching SDK per backend (`huggingface_hub`, `modelscope`, `openmind-hub`). Shared `_validate_repo_id_shape` rejects bool / null-byte / leading-slash / `..` segments / control characters / oversize; `local_dir` and `folder_path` containment-checked under cwd. `soup train` now pre-fetches non-HF `base` models into `.soup_hub_cache/<sanitized-slug>/` and reuses cached snapshots on resumed runs.
- **`[trackers]` extra + friendly missing-dep advisory.** New `pip install soup-cli[trackers]` bundles MLflow + SwanLab + Trackio. When you pass `--tracker mlflow` without the package installed, `soup train` now surfaces a clear `pip install` advisory before construction instead of HF Trainer's generic ImportError. The probe uses `importlib.util.find_spec` (non-executing) so swanlab can't initialise network threads during the check.
- **PostHog telemetry network (opt-IN, silent-fail).** `utils/trackers.send_telemetry_payload()` lazy-imports `httpx` and POSTs hardware-info-only payloads with a 1-second hard timeout. HTTPS-only endpoint check goes through the same SSRF validator as hub endpoints (private-IP / link-local / RFC1918 rejected). Disabled unless `SOUP_TELEMETRY=1`; every exception is swallowed so telemetry can never crash training.
- **Bundled demo fixtures as package data.** `soup data demo` fixtures (4 JSONLs) migrated from `examples/data/` to `soup_cli/data/_fixtures/` so they ship inside the wheel — zipapp / namespace-package safe. `_bundle_source_path` falls back to the legacy `examples/data/` location for editable installs.
- **HF Space SDK auto-pick from `requirements.txt`.** `soup deploy hf-space --template-dir <path>` now reads the rendered `requirements.txt` and picks `space_sdk="streamlit"` when the file lists `streamlit`, `"gradio"` otherwise. Closes the v0.40.2 known limitation that custom templates always created Spaces with `space_sdk="gradio"`.
- **+95 net new tests** (8162 → 8257) in `test_v0538.py`. Three review agents ran (python / code / security); 16 findings fixed: cwd-containment on `local_dir`/`folder_path`, Pydantic `model_copy(update=...)` instead of attribute mutation, idempotent train pre-fetch via cache probe, row-count cap on remote materialisation, `modelscope.push_model` kwarg fix, SSRF re-validation on telemetry endpoint override, `detect_space_sdk` size cap, and more.
## Why Soup?
@ -3481,6 +3477,90 @@ soup data decontaminate --input training.jsonl --benchmarks mmlu,gsm8k,humaneval
The scorecard reports PII flagged, toxic flagged, language distribution, mean educational value, and decontamination removed. PII detection uses a narrow ReDoS-hardened regex set (email / phone / SSN / credit-card) with a 50 KB pre-cap on every input. Language detection is a stopword heuristic across six languages. Toxicity is a keyword baseline; the Llama-Guard-3-1B variant + FineWeb-Edu classifier ship behind `[data-pro]` extras. Decontamination uses n-gram containment against benchmark corpora: use `--benchmarks mmlu,gsm8k` for built-in allowlist, or `--benchmark-file custom_benchmark.jsonl` for your own corpus.
## Remote Datasets (S3 / GCS / Azure / OCI)
Point `data.train` at any object in the v0.42.0 fsspec allowlist and `soup train` will stream it through `fsspec.open` after running the URI through the same SSRF-hardened validator used everywhere else in Soup (bucket regex, no userinfo / query / fragment):
```yaml
data:
train: s3://my-bucket/datasets/train.jsonl
format: alpaca
streaming: true # opt-in HF datasets streaming with shuffle
buffer_size: 10000 # shuffle buffer (requires streaming=true)
```
Recognised schemes: `s3://`, `gs://`, `gcs://`, `az://`, `abfs://`, `abfss://`, `oci://`. The matching backend SDK (`s3fs` / `gcsfs` / `adlfs` / `ocifs`) is lazy-imported — install only what you need or grab the convenience extra:
```bash
pip install soup-cli[remote] # fsspec + s3fs + gcsfs + adlfs
```
Materialised rows are capped at 1M to defend against pathological remote objects; use a local split for larger jobs.
## Alternative Model Hubs (ModelScope / Modelers)
Set `training.hub` to fetch the base model from a non-HF Hub:
```yaml
base: baichuan-inc/Baichuan2-7B
task: sft
training:
hub: modelscope # or "modelers"
```
`soup train` pre-fetches the model into `./.soup_hub_cache/<sanitized-slug>/` via the matching SDK (`modelscope.snapshot_download` / `openmind_hub.snapshot_download`) and rewrites `cfg.base` to the local snapshot. Re-runs reuse the cached snapshot. Both `huggingface-hub`, `modelscope`, and `openmind-hub` are lazy-imported — install only what you need.
Programmatic API:
```python
from soup_cli.utils.hubs import download_repo, upload_repo
local_path = download_repo("modelscope", "baichuan-inc/Baichuan2-7B", local_dir="./snap")
upload_repo("modelers", "my-org/my-model", folder_path="./output", commit_message="Soup v0.53.8")
```
The dispatcher enforces shape validation on every input (bool / null-byte / leading-slash / `..` segments / control characters / oversize all rejected) and runs cwd-containment on `local_dir` / `folder_path`.
## Experiment Trackers (MLflow / SwanLab / Trackio)
Pick a tracker on the CLI; Soup threads it into HF Trainer's `report_to`:
```bash
soup train --tracker mlflow
soup train --tracker swanlab
soup train --tracker trackio
```
If the package is not installed, Soup now surfaces a friendly advisory before training starts instead of a mid-run ImportError:
```
--tracker mlflow requires the 'mlflow' package. Install with: pip install soup-cli[trackers] (or pip install mlflow)
```
```bash
pip install soup-cli[trackers] # mlflow + swanlab + trackio
```
## Telemetry (opt-IN, hardware-info-only)
Soup ships an opt-IN telemetry sender that POSTs hardware-info-only payloads (`soup_version` / `command` / `python` major.minor / `os` / `arch` / optional `duration_seconds`) — no dataset paths, model names, or config contents. Enable per-shell:
```bash
SOUP_TELEMETRY=1 soup train --config soup.yaml
```
The sender uses a 1-second hard timeout, HTTPS-only with private-IP / link-local rejection (same SSRF policy as hub endpoints), and swallows every exception silently — telemetry can never crash training. Disabled by default until a public privacy policy ships.
## HF Space SDK Auto-Pick
When you deploy a custom Space template directory, Soup now picks `space_sdk="streamlit"` / `"gradio"` from the rendered `requirements.txt`:
```bash
soup deploy hf-space --space my-org/my-app --model my-org/my-model --template-dir ./my-template
```
If `requirements.txt` lists `streamlit`, the Space is created with the Streamlit SDK. Otherwise (no requirements, gradio listed, etc.), Soup falls back to the Gradio default. The HF Hub allows `docker` and `static` SDKs too, but those cannot be inferred from `requirements.txt` alone — use the built-in templates or supply a custom one with an explicit `--sdk` override.
## Plugin System
Drop a Python module under `soup_cli/plugins/` (or any package importable by Soup) and register at import time:

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.53.7"
version = "0.53.8"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"
@ -61,6 +61,10 @@ sglang = ["sglang>=0.2.0", "fastapi>=0.104.0", "uvicorn>=0.24.0"]
mlx = ["mlx>=0.20.0", "mlx-lm>=0.20.0"]
cce = ["cut-cross-entropy>=24.10.0"]
tui = ["textual>=0.50.0"]
# v0.53.8 #89 — bundle MLflow / SwanLab / Trackio for `--tracker` users.
trackers = ["mlflow>=2.0.0", "swanlab>=0.3.0", "trackio>=0.0.1"]
# v0.53.8 #85 — fsspec backends for remote dataset loading (s3 / gs / az / oci).
remote = ["fsspec>=2024.1.0", "s3fs>=2024.1.0", "gcsfs>=2024.1.0", "adlfs>=2024.1.0"]
[project.scripts]
soup = "soup_cli.cli:run"
@ -72,6 +76,10 @@ Issues = "https://github.com/MakazhanAlpamys/Soup/issues"
[tool.hatch.build.targets.wheel]
packages = ["soup_cli"]
# v0.53.8 #93 — include bundled fixture JSONLs as package data so
# `soup data demo` works in zipapp / namespace-package installs.
[tool.hatch.build.targets.wheel.force-include]
"soup_cli/data/_fixtures" = "soup_cli/data/_fixtures"
[tool.ruff]
target-version = "py39"

View File

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

View File

@ -1189,8 +1189,34 @@ def download_dataset(
"(v0.36.0). Only enable if you trust the source."
),
),
hub: str = typer.Option(
"hf",
"--hub",
help=(
"Source hub: hf (default) / modelscope / modelers. "
"Non-HF hubs require the matching SDK (v0.53.8 #130)."
),
),
):
"""Download a HuggingFace dataset and save as JSONL."""
# v0.53.8 #130 — validate --hub at the CLI boundary; only `hf` is wired
# for live dataset download in this release (modelscope / modelers
# dataset SDKs differ from snapshot_download; live wiring tracked for
# v0.53.9). Non-HF hubs surface a friendly error.
from soup_cli.utils.hubs import validate_hub_name
try:
hub_canonical = validate_hub_name(hub)
except (TypeError, ValueError) as exc:
console.print(f"[red]{exc}[/]")
raise typer.Exit(code=2) from exc
if hub_canonical != "hf":
console.print(
f"[red]--hub {hub_canonical} dataset download is not yet wired; "
f"use `from soup_cli.utils.hubs import download_repo` to snapshot "
f"a repo, or wait for v0.53.9.[/]"
)
raise typer.Exit(code=1)
max_download_samples = 1_000_000
if samples is not None and samples > max_download_samples:
console.print(

View File

@ -499,11 +499,15 @@ def hf_space(
# --- Render template files ---
try:
if template_dir is not None:
from soup_cli.utils.hf_space import render_custom_template_dir
from soup_cli.utils.hf_space import (
detect_space_sdk,
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"
# v0.53.8 #69 — auto-pick space_sdk from requirements.txt
# (closes the v0.40.2 known limitation that custom templates
# always created the Space with space_sdk="gradio").
sdk = detect_space_sdk(files.get("requirements.txt"))
else:
files = render_space_template(template, model_repo=model)
sdk = HF_SPACE_TEMPLATES[template]["sdk"]

View File

@ -1286,12 +1286,12 @@ def _create_app(
# the RLVR sandbox's OS-level isolation (``unshare(CLONE_NEWNET)``
# / macOS ``sandbox-exec``); a caller can reach
# ``http://169.254.169.254/...`` from the child shell. Reverted to
# 501 until container/namespace work lands in v0.53.8.
# 501 until container/namespace work lands in v0.53.9.
raise HTTPException(
status_code=501,
detail=(
"Server-side tool 'bash' live execution deferred to "
"v0.53.8 — sandbox isolation requires container/namespace "
"v0.53.9 — sandbox isolation requires container/namespace "
"work."
),
)

View File

@ -692,6 +692,65 @@ def train(
console.print(f"[red]{_esc(str(exc))}[/]")
raise typer.Exit(code=2) from exc
console.print("[dim]Setting up model + trainer...[/]")
# v0.53.8 #130 — pre-fetch model from non-HF hub into a local cache and
# rewrite cfg.base to point at the local snapshot. The trainer wrappers
# still use transformers.from_pretrained, which reads HF Hub by default;
# by snapshotting first we keep every wrapper unchanged.
hub_name = getattr(cfg.training, "hub", "hf") or "hf"
if hub_name != "hf":
import re as _re
from rich.markup import escape as _markup_escape
from soup_cli.utils.hubs import download_repo
from soup_cli.utils.paths import is_under_cwd
# Sanitise cache subdir name — strip every path-separator and
# `..` segment so a crafted ``base: ../../etc`` cannot escape the
# cache root (Windows ``\\`` and POSIX ``/`` both blocked).
safe_slug = _re.sub(r"[^A-Za-z0-9._-]+", "__", cfg.base).strip("._-") or "model"
cache_dir = (Path.cwd() / ".soup_hub_cache" / safe_slug).resolve()
if not is_under_cwd(str(cache_dir)):
console.print(
"[red]Resolved hub cache dir escaped the current working "
"directory; refusing to download.[/]"
)
raise typer.Exit(code=1)
try:
# Idempotency: if the cache dir already has a config.json, skip
# the re-download (modelscope/openmind-hub also short-circuit on
# match but having an explicit probe lets us print a clear hint).
existing_cfg = cache_dir / "config.json"
if existing_cfg.is_file():
local_path = str(cache_dir)
console.print(
f"[dim]Using cached snapshot at {local_path}[/]"
)
else:
local_path = download_repo(
hub_name,
cfg.base,
local_dir=str(cache_dir),
)
console.print(
f"[dim]Fetched {cfg.base} from hub={hub_name}"
f"{local_path}[/]"
)
# Use ``model_copy(update=...)`` so the Pydantic field
# validators on ``base`` rerun (matches v0.33.0 #47 / v0.40.0
# Part B immutability policy).
cfg = cfg.model_copy(update={"base": local_path})
except ImportError as exc:
console.print(f"[red]{_markup_escape(str(exc))}[/]")
raise typer.Exit(code=1) from exc
# v0.53.8 #89 — friendly missing-dep advisory for `--tracker <name>`.
if report_to and report_to not in ("none", "wandb", "tensorboard"):
from soup_cli.utils.trackers import tracker_missing_dep_message
msg = tracker_missing_dep_message(report_to)
if msg:
console.print(f"[yellow]{msg}[/]")
trainer_kwargs = {
"device": device,
"report_to": report_to,

View File

@ -0,0 +1,10 @@
{"instruction": "Classify the sentiment of this sentence.", "input": "I love this product, it's amazing!", "output": "Positive"}
{"instruction": "Translate to Spanish.", "input": "Hello, how are you?", "output": "Hola, ¿cómo estás?"}
{"instruction": "Write a haiku about nature.", "input": "", "output": "Green leaves dance in wind\nWhispering ancient forest\nPeace in silence dwells"}
{"instruction": "Explain quantum computing in simple terms.", "input": "", "output": "Quantum computers use quantum bits (qubits) instead of normal bits. Unlike regular bits which are 0 or 1, qubits can be both at once (superposition). This allows quantum computers to solve certain problems much faster than regular computers."}
{"instruction": "Fix the grammar in this sentence.", "input": "She go to the store yesterday.", "output": "She went to the store yesterday."}
{"instruction": "List 3 benefits of exercise.", "input": "", "output": "1. Improves cardiovascular health and strengthens the heart.\n2. Helps maintain a healthy weight and builds muscle.\n3. Reduces stress and improves mental health and mood."}
{"instruction": "What is the capital of France?", "input": "", "output": "The capital of France is Paris."}
{"instruction": "Summarize this text in one sentence.", "input": "Machine learning is a subset of artificial intelligence that enables computers to learn from data without being explicitly programmed. It uses algorithms to identify patterns in data and improve through experience.", "output": "Machine learning allows computers to learn patterns from data without explicit programming."}
{"instruction": "Create a Python function to add two numbers.", "input": "", "output": "def add_numbers(a, b):\n \"\"\"Add two numbers and return the result.\"\"\"\n return a + b\n\n# Example usage\nresult = add_numbers(5, 3)\nprint(result) # Output: 8"}
{"instruction": "Identify the main idea of this paragraph.", "input": "Climate change is caused by increased greenhouse gases in the atmosphere, primarily from human activities like burning fossil fuels. This leads to global warming, which affects weather patterns, sea levels, and ecosystems.", "output": "Greenhouse gases from human activities cause climate change and have significant environmental impacts."}

View File

@ -0,0 +1,5 @@
{"prompt": "What is machine learning?", "chosen": [{"role": "user", "content": "What is machine learning?"}, {"role": "assistant", "content": "Machine learning is a field of artificial intelligence where systems learn from data automatically. It uses algorithms to identify patterns and improve performance through experience, without being explicitly programmed for every task."}], "rejected": [{"role": "user", "content": "What is machine learning?"}, {"role": "assistant", "content": "It's computers doing stuff with data I guess."}]}
{"prompt": "How do I learn Python?", "chosen": [{"role": "user", "content": "How do I learn Python?"}, {"role": "assistant", "content": "Here are effective ways to learn Python:\n1. Start with fundamentals: variables, loops, functions\n2. Practice with small projects\n3. Use resources like Python.org docs and tutorials\n4. Build projects to apply what you learn\n5. Join communities for support"}], "rejected": [{"role": "user", "content": "How do I learn Python?"}, {"role": "assistant", "content": "Just google it."}]}
{"prompt": "What's the difference between AI and ML?", "chosen": [{"role": "user", "content": "What's the difference between AI and ML?"}, {"role": "assistant", "content": "AI (Artificial Intelligence) is the broader field of creating intelligent systems. ML (Machine Learning) is a subset of AI focused on learning from data. All machine learning is AI, but not all AI uses machine learning."}], "rejected": [{"role": "user", "content": "What's the difference between AI and ML?"}, {"role": "assistant", "content": "They're basically the same thing, just different names."}]}
{"prompt": "Explain neural networks simply.", "chosen": [{"role": "user", "content": "Explain neural networks simply."}, {"role": "assistant", "content": "Neural networks are inspired by the human brain. They consist of layers of connected nodes (neurons) that process information. Each connection has a weight that adjusts during training. This allows the network to learn complex patterns in data."}], "rejected": [{"role": "user", "content": "Explain neural networks simply."}, {"role": "assistant", "content": "Neural networks are very complex mathematical structures that use backpropagation through computational graphs with gradient descent optimization."}]}
{"prompt": "How should I structure a machine learning project?", "chosen": [{"role": "user", "content": "How should I structure a machine learning project?"}, {"role": "assistant", "content": "Follow this structure:\n1. Define the problem and gather data\n2. Explore and clean the data\n3. Choose and train a model\n4. Evaluate performance\n5. Iterate and improve\n6. Deploy to production\n7. Monitor and maintain"}], "rejected": [{"role": "user", "content": "How should I structure a machine learning project?"}, {"role": "assistant", "content": "Just throw all your code in one file and train the model."}]}

View File

@ -0,0 +1,8 @@
{"prompt": "What is photosynthesis?", "chosen": [{"role": "user", "content": "What is photosynthesis?"}, {"role": "assistant", "content": "Photosynthesis is the process by which green plants, algae, and some bacteria convert light energy into chemical energy. Using sunlight, water, and carbon dioxide, they produce glucose and oxygen. The overall equation is: 6CO2 + 6H2O + light energy → C6H12O6 + 6O2. This process occurs primarily in the chloroplasts of plant cells."}], "rejected": [{"role": "user", "content": "What is photosynthesis?"}, {"role": "assistant", "content": "It's when plants eat sunlight."}]}
{"prompt": "How do I write a good commit message?", "chosen": [{"role": "user", "content": "How do I write a good commit message?"}, {"role": "assistant", "content": "A good commit message should:\n1. Use the imperative mood in the subject line (e.g., 'Add feature' not 'Added feature')\n2. Keep the subject line under 50 characters\n3. Separate the subject from the body with a blank line\n4. Use the body to explain what and why, not how\n5. Reference relevant issue numbers when applicable"}], "rejected": [{"role": "user", "content": "How do I write a good commit message?"}, {"role": "assistant", "content": "Just write 'fix' or 'update' and push it."}]}
{"prompt": "What are the benefits of exercise?", "chosen": [{"role": "user", "content": "What are the benefits of exercise?"}, {"role": "assistant", "content": "Regular exercise provides numerous benefits:\n- Cardiovascular health: strengthens the heart and improves circulation\n- Mental health: reduces anxiety and depression through endorphin release\n- Weight management: helps maintain a healthy body composition\n- Bone and muscle strength: prevents osteoporosis and sarcopenia\n- Improved sleep quality and cognitive function\n- Reduced risk of chronic diseases like type 2 diabetes"}], "rejected": [{"role": "user", "content": "What are the benefits of exercise?"}, {"role": "assistant", "content": "Exercise is good for you. You should do it."}]}
{"prompt": "Explain the concept of recursion in programming.", "chosen": [{"role": "user", "content": "Explain the concept of recursion in programming."}, {"role": "assistant", "content": "Recursion is when a function calls itself to solve a problem by breaking it into smaller subproblems. Every recursive function needs:\n1. A base case that stops the recursion\n2. A recursive case that reduces the problem\n\nFor example, calculating factorial:\n```python\ndef factorial(n):\n if n <= 1: # base case\n return 1\n return n * factorial(n - 1) # recursive case\n```\nfactorial(4) calls factorial(3), which calls factorial(2), and so on until reaching the base case."}], "rejected": [{"role": "user", "content": "Explain the concept of recursion in programming."}, {"role": "assistant", "content": "To understand recursion, you must first understand recursion. It's just a function that calls itself forever."}]}
{"prompt": "What is the difference between HTTP and HTTPS?", "chosen": [{"role": "user", "content": "What is the difference between HTTP and HTTPS?"}, {"role": "assistant", "content": "HTTP (HyperText Transfer Protocol) transmits data in plain text, making it vulnerable to interception. HTTPS (HTTP Secure) adds TLS/SSL encryption, which:\n- Encrypts data in transit between client and server\n- Authenticates the server's identity via certificates\n- Ensures data integrity (no tampering)\nHTTPS is now the standard for all websites and is required for handling sensitive data like passwords and payments."}], "rejected": [{"role": "user", "content": "What is the difference between HTTP and HTTPS?"}, {"role": "assistant", "content": "HTTPS has an S at the end. It's more secure I think."}]}
{"prompt": "How does a hash table work?", "chosen": [{"role": "user", "content": "How does a hash table work?"}, {"role": "assistant", "content": "A hash table stores key-value pairs for fast lookup. Here's how it works:\n1. A hash function converts the key into an array index\n2. The value is stored at that index\n3. On lookup, the same hash function computes the index to retrieve the value\n\nCollisions (two keys mapping to the same index) are handled via chaining (linked lists at each slot) or open addressing (probing for the next empty slot). Average time complexity is O(1) for insert, lookup, and delete."}], "rejected": [{"role": "user", "content": "How does a hash table work?"}, {"role": "assistant", "content": "It's like a dictionary. You put stuff in and get stuff out."}]}
{"prompt": "What should I consider when choosing a database?", "chosen": [{"role": "user", "content": "What should I consider when choosing a database?"}, {"role": "assistant", "content": "Key factors when choosing a database:\n- Data model: relational (SQL) for structured data with relationships, document (NoSQL) for flexible schemas, key-value for simple lookups, graph for relationship-heavy data\n- Scale requirements: read-heavy vs write-heavy workloads, expected data volume\n- Consistency needs: strong consistency (ACID) vs eventual consistency (BASE)\n- Query patterns: complex joins favor SQL; simple lookups favor NoSQL\n- Operational concerns: hosting, backups, team expertise, licensing costs"}], "rejected": [{"role": "user", "content": "What should I consider when choosing a database?"}, {"role": "assistant", "content": "Just use MongoDB for everything, it's web scale."}]}
{"prompt": "How do vaccines work?", "chosen": [{"role": "user", "content": "How do vaccines work?"}, {"role": "assistant", "content": "Vaccines train the immune system to recognize and fight specific pathogens without causing the disease. They work by introducing a harmless component of a pathogen (such as an inactivated virus, a protein subunit, or mRNA instructions) to the body. The immune system responds by producing antibodies and memory cells. If the real pathogen is encountered later, the immune system can mount a rapid, effective response, preventing or reducing the severity of illness."}], "rejected": [{"role": "user", "content": "How do vaccines work?"}, {"role": "assistant", "content": "They inject you with stuff to make you immune. That's pretty much it."}]}

View File

@ -0,0 +1,5 @@
{"instruction": "Solve this math problem: 7 + 5 * 2 =", "input": "", "output": "To solve 7 + 5 * 2, I need to follow the order of operations (PEMDAS).\n\nStep 1: Multiply first (5 * 2)\n5 * 2 = 10\n\nStep 2: Add (7 + 10)\n7 + 10 = 17\n\nAnswer: 17"}
{"instruction": "Explain the Pythagorean theorem and solve an example.", "input": "", "output": "The Pythagorean theorem states that in a right triangle, a² + b² = c², where c is the hypotenuse (longest side).\n\nExample: If a = 3 and b = 4, find c\n\nStep 1: Substitute into formula\n3² + 4² = c²\n\nStep 2: Calculate squares\n9 + 16 = c²\n\nStep 3: Add\n25 = c²\n\nStep 4: Take square root\nc = √25 = 5\n\nAnswer: c = 5"}
{"instruction": "Find the GCD of 48 and 18.", "input": "", "output": "To find the GCD (Greatest Common Divisor) of 48 and 18:\n\nMethod: Euclidean algorithm\n\nStep 1: 48 = 18 × 2 + 12\nStep 2: 18 = 12 × 1 + 6\nStep 3: 12 = 6 × 2 + 0\n\nWhen remainder is 0, the GCD is the last non-zero remainder.\n\nGCD(48, 18) = 6\n\nAnswer: 6"}
{"instruction": "Solve: x + 5 = 12", "input": "", "output": "This is a simple linear equation.\n\nGiven: x + 5 = 12\n\nStep 1: Subtract 5 from both sides\nx + 5 - 5 = 12 - 5\n\nStep 2: Simplify\nx = 7\n\nVerification: 7 + 5 = 12 ✓\n\nAnswer: x = 7"}
{"instruction": "What is 15% of 200?", "input": "", "output": "To find 15% of 200:\n\nMethod: Multiply by the percentage as a decimal\n15% = 15/100 = 0.15\n\n0.15 × 200 = 30\n\nAnswer: 30"}

View File

@ -116,9 +116,18 @@ def load_dataset(data_config: DataConfig) -> dict:
Supports:
- Local files (.jsonl, .json, .csv, .parquet, .txt)
- HuggingFace dataset names (auto-detected if no file extension)
- Remote fsspec URIs (s3://, gs://, gcs://, az://, abfs://, abfss://, oci://) v0.53.8 #85
"""
train_path = data_config.train
# v0.53.8 #85 — fsspec live remote loader. Schema accepts these URIs
# since v0.42.0; live loader lands here. Lazy-imports fsspec + the
# backend driver (s3fs / gcsfs / adlfs / ocifs) and surfaces a
# friendly Rich panel naming the pip install when the driver is
# missing.
if _looks_like_remote_uri(train_path):
return _load_remote_dataset(train_path, data_config)
# Check if it's a HuggingFace dataset
if not Path(train_path).suffix:
return _load_hf_dataset(train_path, data_config)
@ -214,6 +223,128 @@ def _validate_audio_files(data: list[dict], audio_dir: Path) -> list[dict]:
return valid
def _looks_like_remote_uri(value: str) -> bool:
"""Quick sniff for the fsspec scheme allowlist (v0.42.0 Part B)."""
if not isinstance(value, str) or "://" not in value:
return False
from soup_cli.utils.data_pipeline import is_remote_uri
return is_remote_uri(value)
def _load_remote_dataset(train_path: str, data_config: DataConfig) -> dict:
"""Load JSONL from a remote fsspec URI (s3 / gs / az / oci / etc.).
Validates the URI via the v0.42.0 ``validate_remote_uri`` allowlist
(bucket regex, no userinfo/query/fragment) BEFORE opening any
connection defends against URL injection into the fsspec backend.
Streaming knobs (``data_config.streaming`` + ``buffer_size`` + ``shards``)
are honoured via :func:`datasets.load_dataset` when present; otherwise
the file is streamed as JSONL through :func:`fsspec.open`.
"""
from soup_cli.utils.data_pipeline import (
required_remote_package,
validate_remote_uri,
)
canonical = validate_remote_uri(train_path)
scheme = canonical.split("://", 1)[0]
try:
import fsspec # type: ignore[import-not-found]
except ImportError:
from rich.panel import Panel
pkg = required_remote_package(scheme) or scheme
console.print(
Panel(
f"[bold yellow]Missing dependency:[/] reading from "
f"[bold]{scheme}://[/] requires the [bold]{pkg}[/] package.\n\n"
f"Install with:\n [bold]pip install {pkg}[/]",
title="Remote loader",
border_style="yellow",
)
)
raise
# Cap on rows materialised from a remote URI — matches v0.24.0
# ``soup data download --samples`` ceiling. Defends against OOM when a
# crafted / oversized bucket object is pointed at via streaming +
# eager-materialise.
max_remote_rows = 1_000_000
# Try the HF datasets streaming path first when the user opted in via
# ``data.streaming=true`` — gives us free interleaving, shuffling, and
# caching. Falls back to direct fsspec.open when datasets is missing or
# rejects the URI.
if data_config.streaming:
try:
from datasets import load_dataset as hf_load
except ImportError as exc:
raise ImportError(
"data.streaming=true requires the 'datasets' package: "
"pip install datasets"
) from exc
ds = hf_load(
"json",
data_files=canonical,
split="train",
streaming=True,
)
buf = data_config.buffer_size
if buf:
ds = ds.shuffle(buffer_size=buf)
# Eager materialise capped at max_remote_rows — emit a clear advisory
# if the cap trips.
raw_data: list[dict] = []
for i, row in enumerate(ds):
if i >= max_remote_rows:
console.print(
f"[yellow]Remote dataset truncated at {max_remote_rows:,} "
f"rows (use a local split for larger jobs).[/]"
)
break
raw_data.append(row)
else:
# Non-streaming: open once, read lines, decode JSON.
raw_data = []
with fsspec.open(canonical, mode="rt", encoding="utf-8-sig") as fh:
for i, raw_line in enumerate(fh):
if i >= max_remote_rows:
console.print(
f"[yellow]Remote dataset truncated at "
f"{max_remote_rows:,} rows.[/]"
)
break
stripped = raw_line.strip()
if not stripped:
continue
try:
raw_data.append(json.loads(stripped))
except json.JSONDecodeError as exc:
console.print(
f"[yellow]Warning: invalid JSON on line "
f"{i + 1}: {exc}[/]"
)
fmt = data_config.format
if fmt == "auto":
fmt = detect_format(raw_data)
console.print(f"[dim]Auto-detected format: {fmt}[/]")
formatted = [format_to_messages(row, fmt) for row in raw_data]
formatted = [r for r in formatted if r is not None]
if data_config.val_split > 0:
split_idx = int(len(formatted) * (1 - data_config.val_split))
return {
"train": formatted[:split_idx],
"val": formatted[split_idx:],
}
return {"train": formatted}
def _load_hf_dataset(name: str, data_config: DataConfig) -> dict:
"""Load a dataset from HuggingFace Hub."""
try:

View File

@ -89,7 +89,12 @@ def get_bundle(name: str) -> DemoBundle:
def _bundle_source_path(bundle: DemoBundle) -> str:
"""Resolve the on-disk path for a bundle's fixture.
Uses importlib.resources to handle both editable + wheel installs.
v0.53.8 #93 — fixtures live under ``soup_cli/data/_fixtures/`` (package
data), with a back-compat fallback to ``examples/data/`` for editable
installs / repo-root invocations. The package-data location is
zipapp / namespace-package safe; the legacy location is kept so
contributors editing fixtures via ``examples/data/`` still see their
changes.
"""
# Filenames are baked-in constants (no user input), so direct join
# is safe; we still defensively reject path separators.
@ -97,17 +102,23 @@ def _bundle_source_path(bundle: DemoBundle) -> str:
raise ValueError(
f"bundle fixture name has separator: {bundle.fixture!r}"
)
# examples/ lives at the repo root, alongside the soup_cli/ package.
# 1) Preferred — package data at soup_cli/data/_fixtures/.
pkg_root = files("soup_cli")
pkg_candidate = os.path.realpath(
os.path.join(str(pkg_root), "data", "_fixtures", bundle.fixture)
)
if os.path.isfile(pkg_candidate):
return pkg_candidate
# 2) Fallback — legacy examples/data/ at repo root.
repo_root = os.path.dirname(str(pkg_root))
candidate = os.path.realpath(
legacy = os.path.realpath(
os.path.join(repo_root, "examples", "data", bundle.fixture)
)
if not os.path.isfile(candidate):
raise FileNotFoundError(
f"bundle fixture missing: {bundle.fixture}"
)
return candidate
if os.path.isfile(legacy):
return legacy
raise FileNotFoundError(
f"bundle fixture missing: {bundle.fixture}"
)
def copy_bundle_to(name: str, output_path: str) -> str:

View File

@ -28,6 +28,66 @@ _MAX_TEMPLATE_FILE_BYTES = 256 * 1024 # 256 KB
_KNOWN_FILES = ("app.py", "README.md", "requirements.txt")
_REQUIRED_FILES = ("app.py", "README.md")
# v0.53.8 #69 — Auto-pick Space SDK from requirements.txt.
# Reads the rendered requirements.txt content and returns one of the HF
# Hub-supported `space_sdk` strings. Closes the v0.40.2 known limitation
# that all custom templates defaulted to `space_sdk="gradio"`.
_SUPPORTED_SDKS = ("gradio", "streamlit", "docker", "static")
def detect_space_sdk(requirements_text: str | None) -> str:
"""Detect the Space SDK from a requirements.txt body.
Returns ``"streamlit"`` when the rendered ``requirements.txt`` lists
``streamlit``, ``"gradio"`` when it lists ``gradio`` (or in any other
case the project default for chat templates).
The HF Hub Space-SDK allowlist also recognises ``"docker"`` and
``"static"``, but those cannot be inferred from ``requirements.txt``
alone operators wanting those SDKs must use the built-in templates
or extend this helper.
Rules (first-match-wins on a case-insensitive scan of dependency names,
after stripping ``#`` comments / extras ``[all]`` / version specifiers
``>=1.0``):
- any line whose base name is ``streamlit`` ``"streamlit"``
- any line whose base name is ``gradio`` ``"gradio"``
- else ``"gradio"``
Input is capped at 256 KB to defend against pathological requirements
files (matches the v0.39.0 Part E template size policy).
"""
if requirements_text is None:
return "gradio"
if not isinstance(requirements_text, str):
# Defence-in-depth — caller shouldn't pass non-str but be safe.
return "gradio"
if len(requirements_text) > _MAX_TEMPLATE_FILE_BYTES:
# Refuse to scan oversized text — degrade to the default.
return "gradio"
for raw_line in requirements_text.splitlines():
line = raw_line.strip().lower()
if not line or line.startswith("#"):
continue
# Strip version specifiers / extras: "streamlit>=1.0" → "streamlit"
token = line
for sep in ("=", ">", "<", "!", "~", "[", " ", ";"):
idx = token.find(sep)
if idx >= 0:
token = token[:idx]
token = token.strip()
if token == "streamlit":
return "streamlit"
if token == "gradio":
return "gradio"
return "gradio"
def is_supported_space_sdk(sdk: str) -> bool:
"""Whether ``sdk`` is in the HF Hub Space-SDK allowlist."""
return isinstance(sdk, str) and sdk in _SUPPORTED_SDKS
def render_custom_template_dir(template_dir: str, model_repo: str) -> dict[str, str]:
"""Render a custom Space template directory.

View File

@ -204,3 +204,243 @@ def is_hf(hub: str) -> bool:
if not isinstance(hub, str):
return False
return hub.lower() == "hf"
# v0.53.8 #130 — Live download / upload dispatcher.
# Each backend lazy-imports its SDK so a missing optional dep only surfaces
# when the user actually selects that hub. Mirrors v0.51.0 stub-then-live
# pattern: schema (TrainingConfig.hub Literal) shipped v0.51.0; live wiring
# ships now.
_REPO_ID_MAX = 200
def _validate_repo_id_shape(repo_id: str) -> str:
"""Cheap shape-only repo-id validator shared by all hub adapters.
Does NOT mirror the full v0.29.0 HF ``validate_repo_id`` regex (which is
HF-specific). Each hub's SDK applies its own canonicalisation; we just
reject obviously dangerous shapes (null bytes, leading slash, ``..``,
oversize) before forwarding.
"""
if isinstance(repo_id, bool):
raise TypeError(f"repo_id must not be bool, got {repo_id!r}")
if not isinstance(repo_id, str):
raise TypeError(
f"repo_id must be str, got {type(repo_id).__name__}"
)
if not repo_id:
raise ValueError("repo_id must be non-empty")
if "\x00" in repo_id:
raise ValueError("repo_id must not contain null bytes")
if len(repo_id) > _REPO_ID_MAX:
raise ValueError(
f"repo_id too long (max {_REPO_ID_MAX} chars)"
)
if repo_id.startswith("/") or repo_id.startswith("\\"):
raise ValueError("repo_id must not start with a path separator")
if ".." in repo_id.split("/"):
raise ValueError("repo_id must not contain '..' segments")
# Defence-in-depth: control chars (incl. CR / LF) would be a header
# injection hazard if the id ever flowed into an HTTP request line.
if any(ord(c) < 0x20 for c in repo_id):
raise ValueError("repo_id must not contain control characters")
return repo_id
def _missing_dep_message(hub: str) -> str:
"""Friendly ImportError message naming the pip install command."""
pkg = required_hub_package(hub) or hub
return (
f"hub={hub!r} requires the '{pkg}' package. "
f"Install with: pip install {pkg}"
)
def _validate_local_path(value: str, *, field: str) -> str:
"""Cwd-containment + shape check for ``local_dir`` / ``folder_path``.
Mirrors the project-standard ``utils.paths.is_under_cwd`` policy used by
every other path-accepting helper since v0.26.0. Rejects bool BEFORE
`isinstance(str)` (matches v0.30.0 ``Candidate`` policy).
"""
from soup_cli.utils.paths import is_under_cwd
if isinstance(value, bool):
raise TypeError(f"{field} must not be bool, got {value!r}")
if not isinstance(value, str):
raise TypeError(
f"{field} must be str, got {type(value).__name__}"
)
if not value:
raise ValueError(f"{field} must be a non-empty string")
if "\x00" in value:
raise ValueError(f"{field} must not contain null bytes")
if not is_under_cwd(value):
raise ValueError(
f"{field} must stay under the current working directory"
)
return value
def download_repo(
hub: str,
repo_id: str,
*,
local_dir: str,
revision: str | None = None,
allow_patterns: list[str] | None = None,
repo_type: str = "model",
) -> str:
"""Snapshot-download ``repo_id`` from ``hub`` into ``local_dir``.
Returns the absolute local path to the downloaded snapshot. Lazy-imports
the appropriate SDK per ``hub``:
* ``hf`` :func:`huggingface_hub.snapshot_download`
* ``modelscope`` :func:`modelscope.snapshot_download`
* ``modelers`` :func:`openmind_hub.snapshot_download`
Raises ``ImportError`` (with pip-install hint) when the SDK is missing,
``ValueError`` for invalid args, ``TypeError`` for wrong types.
"""
canonical = validate_hub_name(hub)
_validate_repo_id_shape(repo_id)
_validate_local_path(local_dir, field="local_dir")
if revision is not None:
if not isinstance(revision, str):
raise TypeError("revision must be str or None")
if "\x00" in revision or any(ord(c) < 0x20 for c in revision):
raise ValueError("revision must not contain control characters")
if repo_type not in ("model", "dataset", "space"):
raise ValueError(
"repo_type must be one of 'model' / 'dataset' / 'space'"
)
if canonical == "hf":
try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise ImportError(_missing_dep_message("hf")) from exc
return snapshot_download(
repo_id=repo_id,
repo_type=repo_type,
revision=revision,
local_dir=local_dir,
allow_patterns=allow_patterns,
)
if canonical == "modelscope":
try:
from modelscope import (
snapshot_download as ms_download, # type: ignore[import-not-found]
)
except ImportError as exc:
raise ImportError(_missing_dep_message("modelscope")) from exc
# modelscope's snapshot_download uses a different kwarg set; we map
# the canonical args here so callers see one consistent API.
ms_kwargs: dict[str, object] = {
"model_id": repo_id,
"cache_dir": local_dir,
}
if revision is not None:
ms_kwargs["revision"] = revision
if allow_patterns is not None:
ms_kwargs["allow_file_pattern"] = allow_patterns
return ms_download(**ms_kwargs)
if canonical == "modelers":
try:
from openmind_hub import (
snapshot_download as om_download, # type: ignore[import-not-found]
)
except ImportError as exc:
raise ImportError(_missing_dep_message("modelers")) from exc
om_kwargs: dict[str, object] = {
"repo_id": repo_id,
"local_dir": local_dir,
}
if revision is not None:
om_kwargs["revision"] = revision
if allow_patterns is not None:
om_kwargs["allow_patterns"] = allow_patterns
return om_download(**om_kwargs)
# Unreachable — validate_hub_name has already rejected unknown hubs.
raise ValueError(f"hub {canonical!r} has no download adapter")
def upload_repo(
hub: str,
repo_id: str,
*,
folder_path: str,
commit_message: str = "Upload via Soup",
token: str | None = None,
repo_type: str = "model",
) -> None:
"""Upload ``folder_path`` to ``repo_id`` on ``hub``.
Same lazy-import policy as :func:`download_repo`. Token resolution is
left to the caller (each backend has its own conventions); pass
``token`` explicitly or rely on the SDK's env-var defaults.
"""
canonical = validate_hub_name(hub)
_validate_repo_id_shape(repo_id)
_validate_local_path(folder_path, field="folder_path")
if not isinstance(commit_message, str) or not commit_message:
raise ValueError("commit_message must be a non-empty string")
# Mirror v0.29.0 push policy: first line only, ≤200 chars (prevents
# multi-line injection into public commit history).
commit_message = commit_message.splitlines()[0][:200]
if repo_type not in ("model", "dataset", "space"):
raise ValueError(
"repo_type must be one of 'model' / 'dataset' / 'space'"
)
if canonical == "hf":
try:
from huggingface_hub import HfApi
except ImportError as exc:
raise ImportError(_missing_dep_message("hf")) from exc
api = HfApi(token=token)
api.upload_folder(
repo_id=repo_id,
folder_path=folder_path,
repo_type=repo_type,
commit_message=commit_message,
)
return
if canonical == "modelscope":
try:
from modelscope.hub.api import HubApi # type: ignore[import-not-found]
except ImportError as exc:
raise ImportError(_missing_dep_message("modelscope")) from exc
api = HubApi()
if token:
api.login(token)
# ModelScope's `push_model` does not accept `commit_message` — pass
# only the model id + dir. The sanitised commit_message is recorded
# in the operator's local git log via the HF/Modelers backends.
api.push_model(
model_id=repo_id,
model_dir=folder_path,
)
return
if canonical == "modelers":
try:
from openmind_hub import HubApi # type: ignore[import-not-found]
except ImportError as exc:
raise ImportError(_missing_dep_message("modelers")) from exc
api = HubApi(token=token)
api.upload_folder(
repo_id=repo_id,
folder_path=folder_path,
repo_type=repo_type,
commit_message=commit_message,
)
return
raise ValueError(f"hub {canonical!r} has no upload adapter")

View File

@ -157,6 +157,139 @@ def build_telemetry_payload(
}
# v0.53.8 #90 — PostHog telemetry live wiring.
# Opt-IN via SOUP_TELEMETRY=1; silent-fail on any network/transport error
# so telemetry can NEVER crash training. 1s hard timeout, HTTPS-only.
_POSTHOG_HOST = "https://us.i.posthog.com"
_POSTHOG_ENDPOINT = f"{_POSTHOG_HOST}/i/v0/e/"
# Public write-only key. Live deployments will swap this via env var.
_POSTHOG_DEFAULT_KEY = "phc_soup_public_write_only"
_TELEMETRY_TIMEOUT_S = 1.0
def _telemetry_endpoint_is_safe(endpoint: str) -> bool:
"""Re-validate the telemetry endpoint via the v0.51.0 SSRF policy.
Even though :func:`send_telemetry_payload` only POSTs to a static
PostHog URL by default, callers can override ``endpoint``. Re-run the
same private-IP / link-local rejection used for hub endpoints so a
crafted ``endpoint='https://10.0.0.1/'`` cannot reach an internal
network from a misconfigured caller.
"""
if not isinstance(endpoint, str) or not endpoint.startswith("https://"):
return False
try:
from soup_cli.utils.hubs import validate_hub_endpoint
validate_hub_endpoint(endpoint, hub="telemetry")
except (TypeError, ValueError):
return False
return True
def send_telemetry_payload(
payload: dict[str, object],
*,
api_key: str | None = None,
timeout: float = _TELEMETRY_TIMEOUT_S,
endpoint: str = _POSTHOG_ENDPOINT,
) -> bool:
"""POST ``payload`` to PostHog if telemetry is enabled, else no-op.
Returns ``True`` on a 2xx response, ``False`` on any failure or skip.
NEVER raises telemetry is best-effort and must never crash training.
Args:
payload: dict built by :func:`build_telemetry_payload`. Required keys
are validated upstream by the builder.
api_key: PostHog project key. Defaults to the bundled write-only key.
timeout: hard wall-clock cap (default 1 s).
endpoint: full PostHog capture URL (must be HTTPS).
"""
if not is_telemetry_enabled():
return False
if not isinstance(payload, dict) or not payload:
return False
# HTTPS-only + private-IP / link-local rejection (mirrors v0.51.0 hub
# endpoint SSRF policy). Defence-in-depth: any caller override goes
# through the same validator that hub endpoints do.
if not _telemetry_endpoint_is_safe(endpoint):
return False
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
return False
if not math.isfinite(float(timeout)) or timeout <= 0:
return False
key = api_key or _POSTHOG_DEFAULT_KEY
if not isinstance(key, str) or not key:
return False
try:
import httpx # lazy — optional dep, surfaces no advisory
except ImportError:
return False
body = {
"api_key": key,
"event": payload.get("command", "soup_event"),
"properties": {k: v for k, v in payload.items() if k != "command"},
}
try:
resp = httpx.post(endpoint, json=body, timeout=timeout)
return 200 <= resp.status_code < 300
except Exception: # noqa: BLE001 — telemetry must never crash training
return False
# v0.53.8 #89 — Friendly missing-dep panel for HF Trainer `--tracker`.
# When user passes `--tracker mlflow` without mlflow installed, HF raises
# a generic ImportError mid-training; this helper lets the CLI surface a
# pip-install advisory BEFORE construction.
def tracker_missing_dep_message(name: str) -> str | None:
"""Return a friendly install advisory for ``name`` if the package is
missing, else None.
Always returns ``None`` for `wandb` / `tensorboard` / `none` (the
legacy backends), since those are part of the standard HF Trainer
extra and not part of v0.43.0's additive set.
"""
if not isinstance(name, str):
return None
canonical = name.lower()
if canonical not in NEW_TRACKERS_V0_43:
return None
pkg = required_tracker_package(canonical)
if not pkg:
return None
# Use ``importlib.util.find_spec`` (non-executing probe) so we don't
# incur side effects from the tracker's top-level module (e.g. swanlab
# initialises network threads on import). ``sys.modules[pkg] = None``
# raises ``ValueError`` on find_spec — treat that as missing too so
# tests can simulate the absent-package path without subprocess.
import importlib.util
import sys
sentinel = object()
cached = sys.modules.get(pkg, sentinel)
if cached is None:
missing = True
elif cached is not sentinel:
# Module is already imported (or test injected a real-shaped mock).
missing = False
else:
try:
missing = importlib.util.find_spec(pkg) is None
except (ImportError, ValueError):
missing = True
if missing:
return (
f"--tracker {canonical} requires the '{pkg}' package. "
f"Install with: pip install soup-cli[trackers] "
f"(or pip install {pkg})"
)
return None
def resolve_report_to(
*,
wandb: bool = False,

View File

@ -952,7 +952,7 @@ class TestToolEndpointsLive:
json={"command": "echo hello"},
)
assert resp.status_code == 501
assert "v0.53.8" in resp.text
assert "v0.53.9" in resp.text
def test_bash_tool_returns_501_with_empty_body(self):
"""The 501 stub does not parse the body — it always returns 501."""
@ -1609,7 +1609,7 @@ class TestReviewFixesC1BashStub:
client = TestClient(app)
resp = client.post("/v1/tools/bash", json={"command": "ls"})
assert resp.status_code == 501
assert "v0.53.8" in resp.text
assert "v0.53.9" in resp.text
class TestReviewFixesAuthToken:

583
tests/test_v0538.py Normal file
View File

@ -0,0 +1,583 @@
"""v0.53.8 — Remote data + Hubs + Trackers (wave 2).
Tests cover:
* #85 — fsspec live loaders (data/loader.py)
* #130 — Live hub download/upload dispatcher (utils/hubs.py)
* #89 — `[trackers]` extra + missing-dep advisory
* #90 — PostHog telemetry network (best-effort, silent-fail)
* #93 — package-data migration (soup_cli/data/_fixtures/)
* #69 — HF Space SDK auto-pick from requirements.txt
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ----------------------------------------------------------------------
# #69 — HF Space SDK auto-pick
# ----------------------------------------------------------------------
class TestDetectSpaceSdk:
def _import(self):
from soup_cli.utils.hf_space import detect_space_sdk
return detect_space_sdk
def test_none_defaults_to_gradio(self):
assert self._import()(None) == "gradio"
def test_empty_defaults_to_gradio(self):
assert self._import()("") == "gradio"
def test_streamlit_picked(self):
assert self._import()("streamlit>=1.0\nrequests") == "streamlit"
def test_gradio_picked(self):
assert self._import()("gradio==4.0") == "gradio"
def test_streamlit_wins_when_first(self):
# First match wins
assert self._import()("streamlit\ngradio") == "streamlit"
def test_comment_ignored(self):
assert self._import()("# streamlit\ngradio") == "gradio"
def test_no_match_defaults_gradio(self):
assert self._import()("torch\nnumpy") == "gradio"
def test_non_string_defaults_gradio(self):
assert self._import()(123) == "gradio" # type: ignore[arg-type]
def test_with_extras(self):
assert self._import()("streamlit[all]>=1.0") == "streamlit"
def test_uppercase_normalised(self):
assert self._import()("STREAMLIT==1.0") == "streamlit"
def test_oversize_input_degrades_to_default(self):
# security-review LOW — size cap defends against pathological input
huge = "streamlit\n" + ("x" * (260 * 1024))
assert self._import()(huge) == "gradio"
class TestIsSupportedSpaceSdk:
def test_known(self):
from soup_cli.utils.hf_space import is_supported_space_sdk
assert is_supported_space_sdk("gradio")
assert is_supported_space_sdk("streamlit")
assert is_supported_space_sdk("docker")
assert is_supported_space_sdk("static")
def test_unknown(self):
from soup_cli.utils.hf_space import is_supported_space_sdk
assert not is_supported_space_sdk("flask")
assert not is_supported_space_sdk("")
assert not is_supported_space_sdk(None) # type: ignore[arg-type]
# ----------------------------------------------------------------------
# #130 — Hub download / upload dispatcher
# ----------------------------------------------------------------------
class TestDownloadRepoValidation:
def test_unknown_hub_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="not supported"):
download_repo("evil_hub", "owner/repo", local_dir="./snap_v0538")
def test_empty_repo_id_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="non-empty"):
download_repo("hf", "", local_dir="./snap_v0538")
def test_null_byte_repo_id_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="null bytes"):
download_repo("hf", "owner\x00/repo", local_dir="./snap_v0538")
def test_bool_repo_id_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(TypeError, match="bool"):
download_repo("hf", True, local_dir="./snap_v0538") # type: ignore[arg-type]
def test_leading_slash_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="path separator"):
download_repo("hf", "/etc/passwd", local_dir="./snap_v0538")
def test_dotdot_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match=".."):
download_repo("hf", "../escape", local_dir="./snap_v0538")
def test_control_char_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="control"):
download_repo("hf", "owner/repo\n", local_dir="./snap_v0538")
def test_oversize_repo_id_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="too long"):
download_repo("hf", "a" * 250, local_dir="./snap_v0538")
def test_empty_local_dir_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="local_dir"):
download_repo("hf", "owner/repo", local_dir="")
def test_local_dir_outside_cwd_raises(self, tmp_path):
# security-review HIGH — containment check on local_dir
from soup_cli.utils.hubs import download_repo
# tmp_path is a sibling of cwd, not under it
out = str(tmp_path / "snap")
with pytest.raises(ValueError, match="under the current working"):
download_repo("hf", "owner/repo", local_dir=out)
def test_local_dir_bool_rejected(self):
# security-review LOW — bool before str check
from soup_cli.utils.hubs import download_repo
with pytest.raises(TypeError, match="bool"):
download_repo("hf", "owner/repo", local_dir=True) # type: ignore[arg-type]
def test_bad_repo_type_raises(self):
from soup_cli.utils.hubs import download_repo
with pytest.raises(ValueError, match="repo_type"):
download_repo("hf", "owner/repo", local_dir="./snap_v0538", repo_type="evil")
class TestDownloadRepoLazyImport:
def test_modelscope_missing_friendly_error(self, monkeypatch):
from soup_cli.utils import hubs
# Block modelscope import even if installed
monkeypatch.setitem(sys.modules, "modelscope", None)
with pytest.raises(ImportError, match="modelscope"):
hubs.download_repo("modelscope", "owner/repo", local_dir="./snap_v0538")
def test_modelers_missing_friendly_error(self, monkeypatch):
from soup_cli.utils import hubs
monkeypatch.setitem(sys.modules, "openmind_hub", None)
with pytest.raises(ImportError, match="openmind-hub"):
hubs.download_repo("modelers", "owner/repo", local_dir="./snap_v0538")
def test_hf_dispatch_calls_snapshot_download(self):
with patch(
"huggingface_hub.snapshot_download", return_value="/local/snap"
) as mocked:
from soup_cli.utils.hubs import download_repo
result = download_repo("hf", "owner/repo", local_dir="./snap_v0538")
assert result == "/local/snap"
mocked.assert_called_once()
kwargs = mocked.call_args.kwargs
assert kwargs["repo_id"] == "owner/repo"
assert kwargs["local_dir"] == "./snap_v0538"
class TestUploadRepoValidation:
def test_unknown_hub_raises(self):
from soup_cli.utils.hubs import upload_repo
with pytest.raises(ValueError, match="not supported"):
upload_repo("evil", "o/r", folder_path="./folder_v0538")
def test_empty_folder_raises(self):
from soup_cli.utils.hubs import upload_repo
with pytest.raises(ValueError, match="folder_path"):
upload_repo("hf", "o/r", folder_path="")
def test_folder_path_outside_cwd_raises(self, tmp_path):
# security-review HIGH — containment check on folder_path
from soup_cli.utils.hubs import upload_repo
out = str(tmp_path / "out")
with pytest.raises(ValueError, match="under the current working"):
upload_repo("hf", "o/r", folder_path=out)
def test_empty_commit_raises(self):
from soup_cli.utils.hubs import upload_repo
with pytest.raises(ValueError, match="commit_message"):
upload_repo("hf", "o/r", folder_path="./folder_v0538", commit_message="")
def test_commit_message_truncated(self):
# First line + 200 char cap (mirrors v0.29.0 push policy)
with patch("huggingface_hub.HfApi") as mock_api_cls:
mock_api = MagicMock()
mock_api_cls.return_value = mock_api
from soup_cli.utils.hubs import upload_repo
long_msg = "line1\nline2" + "x" * 500
upload_repo("hf", "o/r", folder_path="./folder_v0538", commit_message=long_msg)
sent = mock_api.upload_folder.call_args.kwargs["commit_message"]
assert "\n" not in sent
assert len(sent) <= 200
# ----------------------------------------------------------------------
# #90 — PostHog telemetry network
# ----------------------------------------------------------------------
class TestSendTelemetryPayload:
def test_disabled_short_circuits(self, monkeypatch):
monkeypatch.delenv("SOUP_TELEMETRY", raising=False)
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload({"command": "train"}) is False
def test_empty_payload_returns_false(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload({}) is False
def test_non_dict_returns_false(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload("not a dict") is False # type: ignore[arg-type]
def test_http_endpoint_rejected(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert (
send_telemetry_payload(
{"command": "train"}, endpoint="http://evil.example/i/"
)
is False
)
def test_private_ip_endpoint_rejected(self, monkeypatch):
# security-review MEDIUM — SSRF via private IP override
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert (
send_telemetry_payload(
{"command": "train"}, endpoint="https://10.0.0.1/i/"
)
is False
)
def test_link_local_endpoint_rejected(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert (
send_telemetry_payload(
{"command": "train"}, endpoint="https://169.254.169.254/i/"
)
is False
)
def test_bool_timeout_rejected(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload({"command": "train"}, timeout=True) is False # type: ignore[arg-type]
def test_negative_timeout_rejected(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload({"command": "train"}, timeout=-1) is False
def test_httpx_missing_returns_false(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
# Force ImportError on the lazy import
monkeypatch.setitem(sys.modules, "httpx", None)
from soup_cli.utils.trackers import send_telemetry_payload
assert send_telemetry_payload({"command": "train"}) is False
def test_happy_path_2xx(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
fake_httpx = MagicMock()
fake_resp = MagicMock()
fake_resp.status_code = 200
fake_httpx.post.return_value = fake_resp
monkeypatch.setitem(sys.modules, "httpx", fake_httpx)
from soup_cli.utils.trackers import send_telemetry_payload
result = send_telemetry_payload({"command": "train", "soup_version": "x"})
assert result is True
# HTTPS-only + 1s timeout
call = fake_httpx.post.call_args
assert call.args[0].startswith("https://")
assert call.kwargs["timeout"] == 1.0
def test_network_exception_swallowed(self, monkeypatch):
monkeypatch.setenv("SOUP_TELEMETRY", "1")
fake_httpx = MagicMock()
fake_httpx.post.side_effect = RuntimeError("network down")
monkeypatch.setitem(sys.modules, "httpx", fake_httpx)
from soup_cli.utils.trackers import send_telemetry_payload
# Must never raise
assert send_telemetry_payload({"command": "train"}) is False
# ----------------------------------------------------------------------
# #89 — Tracker missing-dep advisory
# ----------------------------------------------------------------------
class TestTrackerMissingDepMessage:
def test_wandb_returns_none(self):
from soup_cli.utils.trackers import tracker_missing_dep_message
# Legacy backend — never advise
assert tracker_missing_dep_message("wandb") is None
def test_tensorboard_returns_none(self):
from soup_cli.utils.trackers import tracker_missing_dep_message
assert tracker_missing_dep_message("tensorboard") is None
def test_unknown_returns_none(self):
from soup_cli.utils.trackers import tracker_missing_dep_message
assert tracker_missing_dep_message("evil") is None
def test_non_string_returns_none(self):
from soup_cli.utils.trackers import tracker_missing_dep_message
assert tracker_missing_dep_message(123) is None # type: ignore[arg-type]
def test_missing_mlflow_advisory(self, monkeypatch):
monkeypatch.setitem(sys.modules, "mlflow", None)
from soup_cli.utils.trackers import tracker_missing_dep_message
msg = tracker_missing_dep_message("mlflow")
assert msg is not None
assert "mlflow" in msg
assert "soup-cli[trackers]" in msg
def test_present_mlflow_returns_none(self, monkeypatch):
# Pretend mlflow is installed
fake = MagicMock()
monkeypatch.setitem(sys.modules, "mlflow", fake)
from soup_cli.utils.trackers import tracker_missing_dep_message
assert tracker_missing_dep_message("mlflow") is None
# ----------------------------------------------------------------------
# #85 — fsspec live remote loader
# ----------------------------------------------------------------------
class TestRemoteLoader:
def test_looks_like_remote_uri(self):
from soup_cli.data.loader import _looks_like_remote_uri
assert _looks_like_remote_uri("s3://bucket/path")
assert _looks_like_remote_uri("gs://bucket/file.jsonl")
assert _looks_like_remote_uri("oci://bkt/x")
assert not _looks_like_remote_uri("local.jsonl")
assert not _looks_like_remote_uri("owner/dataset")
assert not _looks_like_remote_uri("")
assert not _looks_like_remote_uri(None) # type: ignore[arg-type]
def test_fsspec_missing_raises_friendly(self, monkeypatch, tmp_path):
# Force fsspec ImportError
monkeypatch.setitem(sys.modules, "fsspec", None)
from soup_cli.config.schema import DataConfig
from soup_cli.data.loader import _load_remote_dataset
cfg = DataConfig(train="s3://my-bucket/data.jsonl", format="alpaca", val_split=0)
with pytest.raises(ImportError):
_load_remote_dataset("s3://my-bucket/data.jsonl", cfg)
def test_invalid_remote_uri_rejected(self, monkeypatch):
from soup_cli.config.schema import DataConfig
from soup_cli.data.loader import _load_remote_dataset
cfg = DataConfig(train="s3://bucket/x", format="alpaca", val_split=0)
# Userinfo embedded URI should be rejected by validate_remote_uri
# BEFORE fsspec is even imported.
with pytest.raises(ValueError):
_load_remote_dataset("s3://user:pw@bucket/x", cfg)
def test_non_streaming_reads_jsonl(self, monkeypatch, tmp_path):
from soup_cli.config.schema import DataConfig
# Build a fake fsspec that yields two JSONL rows
rows = [
'{"instruction": "hi", "output": "hello"}',
'{"instruction": "bye", "output": "later"}',
]
class FakeFile:
def __enter__(self):
return iter([r + "\n" for r in rows])
def __exit__(self, *a):
return False
fake_fsspec = MagicMock()
fake_fsspec.open.return_value = FakeFile()
monkeypatch.setitem(sys.modules, "fsspec", fake_fsspec)
from soup_cli.data.loader import _load_remote_dataset
cfg = DataConfig(
train="s3://bucket/data.jsonl", format="alpaca", val_split=0
)
result = _load_remote_dataset("s3://bucket/data.jsonl", cfg)
assert "train" in result
assert len(result["train"]) == 2
# ----------------------------------------------------------------------
# #93 — Package-data fixture migration
# ----------------------------------------------------------------------
class TestPackageDataFixtures:
def test_fixtures_dir_exists(self):
import soup_cli
pkg = Path(soup_cli.__file__).parent
fixtures = pkg / "data" / "_fixtures"
assert fixtures.is_dir(), f"missing package-data fixtures dir: {fixtures}"
def test_all_known_bundles_present(self):
import soup_cli
pkg = Path(soup_cli.__file__).parent
fixtures = pkg / "data" / "_fixtures"
expected = {
"alpaca_tiny.jsonl",
"chat_preferences.jsonl",
"dpo_sample.jsonl",
"reasoning_math.jsonl",
}
present = {p.name for p in fixtures.glob("*.jsonl")}
assert expected.issubset(present)
def test_bundle_source_prefers_package_data(self):
from soup_cli.utils.demo_bundles import _bundle_source_path, get_bundle
bundle = get_bundle("alpaca_demo")
src = _bundle_source_path(bundle)
# Should resolve under soup_cli/data/_fixtures (not examples/data/).
assert os.sep + "_fixtures" + os.sep in src, src
def test_bundle_content_valid_jsonl(self):
from soup_cli.utils.demo_bundles import _bundle_source_path, get_bundle
for name in ("alpaca_demo", "sharegpt_demo", "dpo_demo", "grpo_demo"):
bundle = get_bundle(name)
src = _bundle_source_path(bundle)
with open(src, encoding="utf-8") as fh:
for line in fh:
if line.strip():
json.loads(line)
# ----------------------------------------------------------------------
# #130 wiring smoke (CLI flag plumbing)
# ----------------------------------------------------------------------
class TestDataDownloadHubFlag:
def test_help_lists_hub_flag(self):
from typer.testing import CliRunner
from soup_cli.commands.data import app
result = CliRunner().invoke(app, ["download", "--help"])
assert result.exit_code == 0
assert "--hub" in result.output
def test_unknown_hub_rejected(self):
from typer.testing import CliRunner
from soup_cli.commands.data import app
result = CliRunner().invoke(
app, ["download", "ds", "--hub", "evilcorp"]
)
assert result.exit_code != 0
assert "evilcorp" in result.output or "not supported" in result.output
def test_modelscope_hub_advisory(self):
from typer.testing import CliRunner
from soup_cli.commands.data import app
result = CliRunner().invoke(
app, ["download", "ds", "--hub", "modelscope"]
)
assert result.exit_code != 0
assert "modelscope" in result.output
assert "v0.53.9" in result.output or "download_repo" in result.output
# ----------------------------------------------------------------------
# pyproject extras
# ----------------------------------------------------------------------
class TestPyprojectExtras:
def test_trackers_extra_present(self):
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert "trackers = [" in text
assert "mlflow" in text
assert "swanlab" in text
assert "trackio" in text
def test_remote_extra_present(self):
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert "remote = [" in text
assert "fsspec" in text
def test_force_include_package_data(self):
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert "_fixtures" in text
# ----------------------------------------------------------------------
# Version bump
# ----------------------------------------------------------------------
class TestVersionBump:
def test_init_version(self):
import soup_cli
assert soup_cli.__version__ == "0.53.8"
def test_pyproject_version(self):
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert 'version = "0.53.8"' in text