mirror of https://github.com/razor-ai/soup.git
1224 lines
43 KiB
Python
1224 lines
43 KiB
Python
"""Tests for HF Hub Deep Integration (v0.29.0)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import types
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Strip ANSI colour / style escape sequences before substring assertions —
|
|
# macOS/Windows Typer runs inject them even under pytest, and they split
|
|
# tokens like ``--push-as`` into ``-`` + ``-push-as`` across escape groups.
|
|
# Same pattern as ``test_eval_gate.py`` after the 899ad8e fix.
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
|
|
def _plain(text: str) -> str:
|
|
return _ANSI_RE.sub("", text or "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part A: utils/hf.py — token / endpoint / repo_id resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveToken:
|
|
def test_env_hf_token_wins(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HF_TOKEN", "env-token")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() == "env-token"
|
|
|
|
def test_cached_token_new_location(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
token_file = tmp_path / ".cache" / "huggingface" / "token"
|
|
token_file.parent.mkdir(parents=True)
|
|
token_file.write_text("cached-token\n")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() == "cached-token"
|
|
|
|
def test_cached_token_legacy_location(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
token_file = tmp_path / ".huggingface" / "token"
|
|
token_file.parent.mkdir(parents=True)
|
|
token_file.write_text("legacy-token")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() == "legacy-token"
|
|
|
|
def test_explicit_token_overrides_env(self, monkeypatch):
|
|
monkeypatch.setenv("HF_TOKEN", "env-token")
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token(explicit="explicit-token") == "explicit-token"
|
|
|
|
def test_huggingface_hub_token_env(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.setenv("HUGGINGFACE_HUB_TOKEN", "hh-token")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() == "hh-token"
|
|
|
|
def test_no_token_returns_none(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() is None
|
|
|
|
|
|
class TestResolveEndpoint:
|
|
def test_default_endpoint(self, monkeypatch):
|
|
monkeypatch.delenv("HF_ENDPOINT", raising=False)
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
assert resolve_endpoint() == "https://huggingface.co"
|
|
|
|
def test_custom_endpoint_env(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "https://hf.internal.example.com")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
assert resolve_endpoint() == "https://hf.internal.example.com"
|
|
|
|
def test_endpoint_strips_trailing_slash(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "https://hf.internal.example.com/")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
assert resolve_endpoint() == "https://hf.internal.example.com"
|
|
|
|
def test_endpoint_rejects_non_https_remote(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "http://evil.example.com")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
with pytest.raises(ValueError, match="HTTPS"):
|
|
resolve_endpoint()
|
|
|
|
def test_endpoint_allows_localhost_http(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "http://localhost:8080")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
assert resolve_endpoint() == "http://localhost:8080"
|
|
|
|
def test_endpoint_rejects_null_byte(self, monkeypatch):
|
|
# OS-level setenv rejects null bytes on macOS/Windows before
|
|
# ``resolve_endpoint`` ever runs, so patch the dict directly.
|
|
monkeypatch.setattr(
|
|
"soup_cli.utils.hf.os.environ",
|
|
{"HF_ENDPOINT": "https://hf.example.com\x00"},
|
|
)
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
with pytest.raises(ValueError):
|
|
resolve_endpoint()
|
|
|
|
def test_endpoint_rejects_bad_scheme(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "file:///etc/passwd")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
with pytest.raises(ValueError):
|
|
resolve_endpoint()
|
|
|
|
|
|
class TestValidateRepoId:
|
|
def test_valid_user_repo(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
validate_repo_id("user/my-model")
|
|
|
|
def test_valid_repo_only(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
validate_repo_id("my-model")
|
|
|
|
def test_rejects_empty(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("")
|
|
|
|
def test_rejects_slash_at_start(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("/my-model")
|
|
|
|
def test_rejects_double_slash(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("user//model")
|
|
|
|
def test_rejects_path_traversal(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("../../secrets")
|
|
|
|
def test_rejects_null_byte(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("user/model\x00")
|
|
|
|
def test_rejects_whitespace(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("user/my model")
|
|
|
|
def test_rejects_too_long(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("user/" + ("a" * 200))
|
|
|
|
def test_accepts_dots_underscores_hyphens(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
validate_repo_id("user/my_model.v2-beta")
|
|
|
|
|
|
class TestGetHfApi:
|
|
def test_requires_huggingface_hub(self, monkeypatch):
|
|
from soup_cli.utils.hf import get_hf_api
|
|
|
|
monkeypatch.setitem(sys.modules, "huggingface_hub", None)
|
|
with pytest.raises(ImportError):
|
|
get_hf_api()
|
|
|
|
def test_passes_token_and_endpoint(self, monkeypatch):
|
|
from soup_cli.utils import hf
|
|
|
|
fake_hub = types.ModuleType("huggingface_hub")
|
|
|
|
calls = {}
|
|
|
|
class FakeApi:
|
|
def __init__(self, token=None, endpoint=None):
|
|
calls["token"] = token
|
|
calls["endpoint"] = endpoint
|
|
|
|
fake_hub.HfApi = FakeApi
|
|
monkeypatch.setitem(sys.modules, "huggingface_hub", fake_hub)
|
|
|
|
hf.get_hf_api(token="t1", endpoint="https://e.example.com")
|
|
assert calls == {"token": "t1", "endpoint": "https://e.example.com"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part B: Auto-push checkpoints (--push-as flag)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPushAsCLIFlag:
|
|
def test_train_shows_push_as_flag_in_help(self):
|
|
result = runner.invoke(app, ["train", "--help"])
|
|
assert "--push-as" in _plain(result.output)
|
|
|
|
def test_push_as_rejects_invalid_repo(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "soup.yaml"
|
|
cfg.write_text("base: meta-llama/Llama-3.1-8B\ntask: sft\ndata:\n train: data.jsonl\n")
|
|
monkeypatch.chdir(tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"train",
|
|
"--config",
|
|
str(cfg),
|
|
"--push-as",
|
|
"../../escape",
|
|
"--dry-run",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "push-as" in result.output.lower()
|
|
|
|
|
|
class TestHFPushCallback:
|
|
def test_callback_import(self):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback # noqa: F401
|
|
|
|
def test_callback_uploads_checkpoint_on_save(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
ckpt_dir = tmp_path / "checkpoint-100"
|
|
ckpt_dir.mkdir()
|
|
(ckpt_dir / "adapter_config.json").write_text("{}")
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api)
|
|
|
|
callback = HFPushCallback(
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
args = types.SimpleNamespace(output_dir=str(tmp_path))
|
|
state = types.SimpleNamespace(global_step=100, epoch=1)
|
|
control = types.SimpleNamespace()
|
|
callback.on_save(args, state, control)
|
|
|
|
fake_api.create_repo.assert_called_once_with(
|
|
repo_id="user/my-model", private=False, exist_ok=True,
|
|
)
|
|
assert fake_api.upload_folder.called
|
|
kwargs = fake_api.upload_folder.call_args.kwargs
|
|
assert kwargs["repo_id"] == "user/my-model"
|
|
assert kwargs["folder_path"] == str(ckpt_dir)
|
|
assert kwargs["revision"] == "checkpoint-100"
|
|
assert "checkpoint-100" in kwargs["commit_message"]
|
|
|
|
def test_callback_swallows_upload_errors(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
ckpt_dir = tmp_path / "checkpoint-50"
|
|
ckpt_dir.mkdir()
|
|
(ckpt_dir / "adapter_config.json").write_text("{}")
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.upload_folder.side_effect = RuntimeError("network")
|
|
monkeypatch.setattr("soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api)
|
|
|
|
callback = HFPushCallback(
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
args = types.SimpleNamespace(output_dir=str(tmp_path))
|
|
state = types.SimpleNamespace(global_step=50, epoch=1)
|
|
control = types.SimpleNamespace()
|
|
callback.on_save(args, state, control)
|
|
|
|
def test_callback_skips_missing_checkpoint(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api)
|
|
|
|
callback = HFPushCallback(
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
output_dir=str(tmp_path),
|
|
)
|
|
|
|
args = types.SimpleNamespace(output_dir=str(tmp_path))
|
|
state = types.SimpleNamespace(global_step=200, epoch=2)
|
|
control = types.SimpleNamespace()
|
|
callback.on_save(args, state, control)
|
|
|
|
assert not fake_api.upload_folder.called
|
|
|
|
|
|
class TestResolveLatestRevision:
|
|
def test_returns_latest_step(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import resolve_latest_checkpoint_revision
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.list_repo_refs.return_value = types.SimpleNamespace(
|
|
branches=[
|
|
types.SimpleNamespace(name="main"),
|
|
types.SimpleNamespace(name="checkpoint-50"),
|
|
types.SimpleNamespace(name="checkpoint-150"),
|
|
types.SimpleNamespace(name="checkpoint-100"),
|
|
]
|
|
)
|
|
monkeypatch.setattr(
|
|
"soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api
|
|
)
|
|
result = resolve_latest_checkpoint_revision("user/my-model", token="t1")
|
|
assert result == "checkpoint-150"
|
|
|
|
def test_returns_none_when_no_checkpoints(self, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import resolve_latest_checkpoint_revision
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.list_repo_refs.return_value = types.SimpleNamespace(
|
|
branches=[types.SimpleNamespace(name="main")]
|
|
)
|
|
monkeypatch.setattr(
|
|
"soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api
|
|
)
|
|
result = resolve_latest_checkpoint_revision("user/my-model", token="t1")
|
|
assert result is None
|
|
|
|
def test_returns_none_on_api_error(self, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import resolve_latest_checkpoint_revision
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.list_repo_refs.side_effect = RuntimeError("no such repo")
|
|
monkeypatch.setattr(
|
|
"soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api
|
|
)
|
|
result = resolve_latest_checkpoint_revision("user/my-model", token="t1")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part C: Model Card v2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModelCardV2:
|
|
def test_generate_enhanced_card_basic(self, tmp_path):
|
|
from soup_cli.commands.push import generate_model_card_v2
|
|
|
|
adapter_dir = tmp_path / "adapter"
|
|
adapter_dir.mkdir()
|
|
(adapter_dir / "adapter_config.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"base_model_name_or_path": "meta-llama/Llama-3.1-8B",
|
|
"r": 32,
|
|
"lora_alpha": 64,
|
|
}
|
|
)
|
|
)
|
|
card = generate_model_card_v2(adapter_dir, repo_id="user/my-model")
|
|
assert "my-model" in card
|
|
assert "meta-llama/Llama-3.1-8B" in card
|
|
assert "soup-cli" in card
|
|
assert "LoRA" in card
|
|
|
|
def test_generate_full_model_card(self, tmp_path):
|
|
from soup_cli.commands.push import generate_model_card_v2
|
|
|
|
model_dir = tmp_path / "model"
|
|
model_dir.mkdir()
|
|
(model_dir / "config.json").write_text("{}")
|
|
card = generate_model_card_v2(model_dir, repo_id="user/full-model")
|
|
assert "full-model" in card
|
|
assert "soup-cli" in card
|
|
|
|
def test_card_includes_training_config_when_present(self, tmp_path):
|
|
from soup_cli.commands.push import generate_model_card_v2
|
|
|
|
adapter_dir = tmp_path / "adapter"
|
|
adapter_dir.mkdir()
|
|
(adapter_dir / "adapter_config.json").write_text("{}")
|
|
(adapter_dir / "training_config.yaml").write_text(
|
|
"base: meta-llama/Llama-3.1-8B\ntask: sft\ntraining:\n epochs: 3\n lr: 0.0002\n"
|
|
)
|
|
card = generate_model_card_v2(adapter_dir, repo_id="user/my-model")
|
|
assert "sft" in card or "Training" in card
|
|
assert "0.0002" in card or "lr" in card
|
|
|
|
def test_card_includes_eval_scorecard_when_registry_available(self, tmp_path):
|
|
from soup_cli.commands.push import generate_model_card_v2
|
|
|
|
adapter_dir = tmp_path / "adapter"
|
|
adapter_dir.mkdir()
|
|
(adapter_dir / "adapter_config.json").write_text("{}")
|
|
|
|
eval_data = {
|
|
"mmlu": 0.612,
|
|
"gsm8k": 0.812,
|
|
}
|
|
card = generate_model_card_v2(
|
|
adapter_dir,
|
|
repo_id="user/my-model",
|
|
eval_scorecard=eval_data,
|
|
)
|
|
assert "mmlu" in card.lower()
|
|
assert "0.612" in card or "61.2" in card
|
|
|
|
def test_card_safe_against_malformed_config(self, tmp_path):
|
|
from soup_cli.commands.push import generate_model_card_v2
|
|
|
|
adapter_dir = tmp_path / "adapter"
|
|
adapter_dir.mkdir()
|
|
(adapter_dir / "adapter_config.json").write_text("not json")
|
|
card = generate_model_card_v2(adapter_dir, repo_id="user/broken")
|
|
assert "broken" in card
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part D: HF Collections
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCollections:
|
|
def test_push_shows_collection_flag_in_help(self):
|
|
result = runner.invoke(app, ["push", "--help"])
|
|
assert "--collection" in _plain(result.output)
|
|
|
|
def test_add_to_collection_calls_api(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
add_to_collection(
|
|
collection_slug="user/collection-abc123",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
)
|
|
assert fake_api.add_collection_item.called
|
|
kwargs = fake_api.add_collection_item.call_args.kwargs
|
|
assert kwargs["collection_slug"] == "user/collection-abc123"
|
|
assert kwargs["item_id"] == "user/my-model"
|
|
assert kwargs["item_type"] == "model"
|
|
|
|
def test_add_to_collection_rejects_invalid_slug(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
with pytest.raises(ValueError):
|
|
add_to_collection(
|
|
collection_slug="../../escape",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
)
|
|
|
|
def test_add_to_collection_survives_duplicate(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.add_collection_item.side_effect = RuntimeError("already exists")
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
add_to_collection(
|
|
collection_slug="user/collection-abc123",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
ignore_duplicate=True,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part E: HF Datasets write
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDataPush:
|
|
def test_push_subcommand_exists(self):
|
|
result = runner.invoke(app, ["data", "push", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
assert "--hf-dataset" in _plain(result.output)
|
|
|
|
def test_push_rejects_missing_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data",
|
|
"push",
|
|
"--input",
|
|
"nonexistent.jsonl",
|
|
"--hf-dataset",
|
|
"user/my-dataset",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "not found" in result.output.lower() or "does not exist" in result.output.lower()
|
|
|
|
def test_push_rejects_invalid_dataset_name(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "data.jsonl").write_text('{"text":"a"}\n')
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data",
|
|
"push",
|
|
"--input",
|
|
"data.jsonl",
|
|
"--hf-dataset",
|
|
"../../escape",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "hf-dataset" in result.output.lower() or "repo" in result.output.lower()
|
|
|
|
def test_push_rejects_path_outside_cwd(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
outside = tmp_path.parent / "outside.jsonl"
|
|
outside.write_text('{"text":"a"}\n')
|
|
try:
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data",
|
|
"push",
|
|
"--input",
|
|
str(outside),
|
|
"--hf-dataset",
|
|
"user/my-dataset",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "current working directory" in result.output.lower()
|
|
finally:
|
|
outside.unlink(missing_ok=True)
|
|
|
|
def test_push_requires_token(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "no-creds")
|
|
(tmp_path / "data.jsonl").write_text('{"text":"a"}\n')
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data",
|
|
"push",
|
|
"--input",
|
|
"data.jsonl",
|
|
"--hf-dataset",
|
|
"user/my-dataset",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "token" in result.output.lower()
|
|
|
|
def test_push_dataset_happy_path(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
(tmp_path / "data.jsonl").write_text('{"text":"a"}\n{"text":"b"}\n')
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data",
|
|
"push",
|
|
"--input",
|
|
"data.jsonl",
|
|
"--hf-dataset",
|
|
"user/my-dataset",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
assert fake_api.create_repo.called
|
|
assert fake_api.upload_file.called or fake_api.upload_folder.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part F: HF Spaces auto-deploy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeployHfSpace:
|
|
def test_hf_space_command_registered(self):
|
|
result = runner.invoke(app, ["deploy", "--help"])
|
|
assert "hf-space" in _plain(result.output)
|
|
|
|
def test_hf_space_help_shows_flags(self):
|
|
result = runner.invoke(app, ["deploy", "hf-space", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
plain = _plain(result.output)
|
|
assert "--model" in plain
|
|
assert "--template" in plain
|
|
|
|
def test_hf_space_rejects_invalid_repo(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"deploy",
|
|
"hf-space",
|
|
"--model",
|
|
"user/my-model",
|
|
"--space",
|
|
"../../escape",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "space" in result.output.lower()
|
|
|
|
def test_hf_space_rejects_unknown_template(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"deploy",
|
|
"hf-space",
|
|
"--model",
|
|
"user/my-model",
|
|
"--space",
|
|
"user/my-space",
|
|
"--template",
|
|
"rocket-launcher",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "template" in result.output.lower()
|
|
|
|
def test_hf_space_requires_token(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "no-creds")
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"deploy",
|
|
"hf-space",
|
|
"--model",
|
|
"user/my-model",
|
|
"--space",
|
|
"user/my-space",
|
|
"--template",
|
|
"gradio-chat",
|
|
],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "token" in result.output.lower()
|
|
|
|
def test_hf_space_happy_path_gradio_chat(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"deploy",
|
|
"hf-space",
|
|
"--model",
|
|
"user/my-model",
|
|
"--space",
|
|
"user/my-space",
|
|
"--template",
|
|
"gradio-chat",
|
|
"--yes",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
assert fake_api.create_repo.called
|
|
|
|
def test_render_gradio_chat_template(self):
|
|
from soup_cli.commands.deploy import render_space_template
|
|
|
|
rendered = render_space_template("gradio-chat", model_repo="user/my-model")
|
|
assert "gradio" in rendered["app.py"].lower()
|
|
assert "user/my-model" in rendered["app.py"]
|
|
assert "requirements.txt" in rendered
|
|
assert "README.md" in rendered
|
|
|
|
def test_render_streamlit_chat_template(self):
|
|
from soup_cli.commands.deploy import render_space_template
|
|
|
|
rendered = render_space_template("streamlit-chat", model_repo="user/my-model")
|
|
assert "streamlit" in rendered["app.py"].lower()
|
|
assert "user/my-model" in rendered["app.py"]
|
|
|
|
def test_render_unknown_template_raises(self):
|
|
from soup_cli.commands.deploy import render_space_template
|
|
|
|
with pytest.raises(ValueError):
|
|
render_space_template("bogus", model_repo="user/my-model")
|
|
|
|
def test_readme_sets_sdk_to_match_template(self):
|
|
from soup_cli.commands.deploy import render_space_template
|
|
|
|
gradio = render_space_template("gradio-chat", model_repo="user/my-model")
|
|
assert "sdk: gradio" in gradio["README.md"]
|
|
streamlit = render_space_template("streamlit-chat", model_repo="user/my-model")
|
|
assert "sdk: streamlit" in streamlit["README.md"]
|
|
|
|
def test_template_escapes_model_repo_id(self):
|
|
from soup_cli.commands.deploy import render_space_template
|
|
|
|
with pytest.raises(ValueError):
|
|
render_space_template("gradio-chat", model_repo="../../escape")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-resume from HF (Part B)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAutoResume:
|
|
def test_train_help_shows_hf_resume(self):
|
|
result = runner.invoke(app, ["train", "--help"])
|
|
assert "--hf-resume" in _plain(result.output)
|
|
|
|
def test_prepare_hf_resume_downloads_latest(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import prepare_hf_resume
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
out_dir = tmp_path / "runs" / "experiment"
|
|
out_dir.mkdir(parents=True)
|
|
|
|
called = {}
|
|
|
|
def fake_download(repo_id, revision, local_dir, token, endpoint):
|
|
called["repo_id"] = repo_id
|
|
called["revision"] = revision
|
|
called["local_dir"] = local_dir
|
|
return 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 result is not None
|
|
assert called["revision"] == "checkpoint-300"
|
|
|
|
def test_prepare_hf_resume_no_checkpoint_returns_none(self, monkeypatch, tmp_path):
|
|
from soup_cli.monitoring.hf_push import prepare_hf_resume
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
out_dir = tmp_path / "runs"
|
|
out_dir.mkdir()
|
|
monkeypatch.setattr(
|
|
"soup_cli.monitoring.hf_push.resolve_latest_checkpoint_revision",
|
|
lambda repo_id, token=None, endpoint=None: None,
|
|
)
|
|
result = prepare_hf_resume(
|
|
repo_id="user/my-model",
|
|
output_dir=str(out_dir),
|
|
token="t1",
|
|
)
|
|
assert result is None
|
|
|
|
def test_prepare_hf_resume_rejects_output_dir_outside_cwd(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
import pytest
|
|
|
|
from soup_cli.monitoring.hf_push import prepare_hf_resume
|
|
|
|
cwd = tmp_path / "project"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
outside = tmp_path / "elsewhere"
|
|
outside.mkdir()
|
|
|
|
with pytest.raises(ValueError, match="under the current"):
|
|
prepare_hf_resume(
|
|
repo_id="user/my-model",
|
|
output_dir=str(outside),
|
|
token="t1",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: utils/hf consolidated behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHfUtilsModule:
|
|
def test_module_exports(self):
|
|
from soup_cli.utils import hf
|
|
|
|
assert hasattr(hf, "resolve_token")
|
|
assert hasattr(hf, "resolve_endpoint")
|
|
assert hasattr(hf, "validate_repo_id")
|
|
assert hasattr(hf, "get_hf_api")
|
|
assert hasattr(hf, "add_to_collection")
|
|
|
|
def test_token_with_whitespace_stripped(self, monkeypatch, tmp_path):
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
token_file = tmp_path / ".cache" / "huggingface" / "token"
|
|
token_file.parent.mkdir(parents=True)
|
|
token_file.write_text(" padded-token \n")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() == "padded-token"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extra coverage from TDD review: collection-slug negatives, callback factory,
|
|
# on_train_begin, short-circuit after repo failure, private-IP SSRF, edge
|
|
# cases around resolve_token.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateCollectionSlug:
|
|
def test_accepts_valid(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
validate_collection_slug("user/my-collection-abc12345")
|
|
|
|
def test_rejects_empty(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("")
|
|
|
|
def test_rejects_whitespace(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("user/my coll")
|
|
|
|
def test_rejects_null_byte(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("user/my-coll\x00")
|
|
|
|
def test_rejects_path_traversal(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("../../escape")
|
|
|
|
def test_rejects_too_long(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("user/" + ("a" * 300))
|
|
|
|
def test_rejects_missing_slash(self):
|
|
from soup_cli.utils.hf import validate_collection_slug
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_collection_slug("onlyname")
|
|
|
|
|
|
class TestAddToCollectionExtras:
|
|
def test_raises_on_duplicate_when_ignore_false(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.add_collection_item.side_effect = RuntimeError("already exists")
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
with pytest.raises(RuntimeError):
|
|
add_to_collection(
|
|
collection_slug="user/my-coll-abc12345",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
ignore_duplicate=False,
|
|
)
|
|
|
|
def test_raises_on_unrelated_error(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
fake_api.add_collection_item.side_effect = RuntimeError("auth required")
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
with pytest.raises(RuntimeError):
|
|
add_to_collection(
|
|
collection_slug="user/my-coll-abc12345",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
)
|
|
|
|
def test_rejects_invalid_item_type(self, monkeypatch):
|
|
from soup_cli.utils.hf import add_to_collection
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
with pytest.raises(ValueError, match="item_type"):
|
|
add_to_collection(
|
|
collection_slug="user/my-coll-abc12345",
|
|
repo_id="user/my-model",
|
|
token="t1",
|
|
item_type="notebook",
|
|
)
|
|
|
|
|
|
class TestBuildPushCallback:
|
|
def test_returns_none_when_no_token(self, monkeypatch, tmp_path):
|
|
from soup_cli.monitoring.hf_push import build_push_callback
|
|
|
|
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
monkeypatch.delenv("HUGGINGFACE_HUB_TOKEN", raising=False)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "no-creds")
|
|
cb = build_push_callback(
|
|
repo_id="user/my-model", output_dir=str(tmp_path)
|
|
)
|
|
assert cb is None
|
|
|
|
def test_returns_none_on_bad_endpoint(self, monkeypatch, tmp_path):
|
|
from soup_cli.monitoring.hf_push import build_push_callback
|
|
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
monkeypatch.setenv("HF_ENDPOINT", "http://evil.example.com")
|
|
cb = build_push_callback(
|
|
repo_id="user/my-model", output_dir=str(tmp_path)
|
|
)
|
|
assert cb is None
|
|
|
|
def test_happy_path_returns_callback(self, monkeypatch, tmp_path):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback, build_push_callback
|
|
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
monkeypatch.delenv("HF_ENDPOINT", raising=False)
|
|
cb = build_push_callback(
|
|
repo_id="user/my-model", output_dir=str(tmp_path)
|
|
)
|
|
assert isinstance(cb, HFPushCallback)
|
|
assert cb.repo_id == "user/my-model"
|
|
|
|
|
|
class TestCallbackLifecycle:
|
|
def _fake_api(self, monkeypatch):
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr(
|
|
"soup_cli.monitoring.hf_push.get_hf_api", lambda **_: fake_api
|
|
)
|
|
return fake_api
|
|
|
|
def test_on_train_begin_creates_repo(self, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
fake_api = self._fake_api(monkeypatch)
|
|
cb = HFPushCallback(
|
|
repo_id="user/my-model", token="t1", output_dir="/tmp",
|
|
)
|
|
cb.on_train_begin(
|
|
args=types.SimpleNamespace(output_dir="/tmp"),
|
|
state=types.SimpleNamespace(),
|
|
control=types.SimpleNamespace(),
|
|
)
|
|
fake_api.create_repo.assert_called_once_with(
|
|
repo_id="user/my-model", private=False, exist_ok=True,
|
|
)
|
|
|
|
def test_on_train_begin_swallows_failure(self, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
fake_api = self._fake_api(monkeypatch)
|
|
fake_api.create_repo.side_effect = RuntimeError("auth")
|
|
cb = HFPushCallback(
|
|
repo_id="user/my-model", token="t1", output_dir="/tmp",
|
|
)
|
|
cb.on_train_begin(
|
|
args=types.SimpleNamespace(output_dir="/tmp"),
|
|
state=types.SimpleNamespace(),
|
|
control=types.SimpleNamespace(),
|
|
)
|
|
assert cb._repo_failed is True
|
|
assert cb._repo_created is False
|
|
|
|
def test_create_repo_called_once_across_saves(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
fake_api = self._fake_api(monkeypatch)
|
|
for step in (50, 100, 150):
|
|
ckpt = tmp_path / f"checkpoint-{step}"
|
|
ckpt.mkdir()
|
|
(ckpt / "adapter_config.json").write_text("{}")
|
|
|
|
cb = HFPushCallback(
|
|
repo_id="user/my-model", token="t1", output_dir=str(tmp_path),
|
|
)
|
|
for step in (50, 100, 150):
|
|
cb.on_save(
|
|
args=types.SimpleNamespace(output_dir=str(tmp_path)),
|
|
state=types.SimpleNamespace(global_step=step, epoch=1),
|
|
control=types.SimpleNamespace(),
|
|
)
|
|
assert fake_api.create_repo.call_count == 1
|
|
assert fake_api.upload_folder.call_count == 3
|
|
|
|
def test_no_upload_after_repo_creation_fails(self, tmp_path, monkeypatch):
|
|
from soup_cli.monitoring.hf_push import HFPushCallback
|
|
|
|
fake_api = self._fake_api(monkeypatch)
|
|
fake_api.create_repo.side_effect = RuntimeError("auth forbidden")
|
|
|
|
ckpt = tmp_path / "checkpoint-10"
|
|
ckpt.mkdir()
|
|
(ckpt / "adapter_config.json").write_text("{}")
|
|
|
|
cb = HFPushCallback(
|
|
repo_id="user/my-model", token="t1", output_dir=str(tmp_path),
|
|
)
|
|
cb.on_train_begin(
|
|
args=types.SimpleNamespace(output_dir=str(tmp_path)),
|
|
state=types.SimpleNamespace(),
|
|
control=types.SimpleNamespace(),
|
|
)
|
|
cb.on_save(
|
|
args=types.SimpleNamespace(output_dir=str(tmp_path)),
|
|
state=types.SimpleNamespace(global_step=10, epoch=1),
|
|
control=types.SimpleNamespace(),
|
|
)
|
|
assert fake_api.upload_folder.call_count == 0
|
|
|
|
|
|
class TestPrivateIPSSRF:
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"http://10.0.0.1",
|
|
"http://192.168.1.1",
|
|
"http://172.16.0.1",
|
|
"http://169.254.169.254",
|
|
],
|
|
)
|
|
def test_private_ip_http_rejected(self, monkeypatch, host):
|
|
monkeypatch.setenv("HF_ENDPOINT", host)
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
with pytest.raises(ValueError):
|
|
resolve_endpoint()
|
|
|
|
def test_zero_address_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "http://0.0.0.0:8080")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
with pytest.raises(ValueError, match="0.0.0.0"):
|
|
resolve_endpoint()
|
|
|
|
def test_127_allowed(self, monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "http://127.0.0.1:8080")
|
|
from soup_cli.utils.hf import resolve_endpoint
|
|
|
|
assert resolve_endpoint() == "http://127.0.0.1:8080"
|
|
|
|
|
|
class TestResolveTokenEdgeCases:
|
|
def test_whitespace_env_falls_through(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HF_TOKEN", " ")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "no-creds")
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token() is None
|
|
|
|
def test_empty_explicit_falls_through_to_env(self, monkeypatch):
|
|
monkeypatch.setenv("HF_TOKEN", "from-env")
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
assert resolve_token(explicit="") == "from-env"
|
|
|
|
def test_explicit_non_printable_raises(self):
|
|
from soup_cli.utils.hf import resolve_token
|
|
|
|
with pytest.raises(ValueError):
|
|
resolve_token(explicit="abc\x01\x02")
|
|
|
|
|
|
class TestValidateRepoIdBoundaries:
|
|
def test_accepts_max_length_component(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
validate_repo_id("u/" + ("a" * 96))
|
|
|
|
def test_rejects_oversized_component(self):
|
|
from soup_cli.utils.hf import validate_repo_id
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_repo_id("u/" + ("a" * 97))
|
|
|
|
|
|
class TestEvalScorecardEdgeCases:
|
|
def test_non_numeric_score(self):
|
|
from soup_cli.commands.push import _render_eval_scorecard
|
|
|
|
rendered = _render_eval_scorecard({"gsm8k": "N/A"})
|
|
assert "N/A" in rendered
|
|
assert "gsm8k" in rendered
|
|
|
|
def test_escapes_pipe_in_task_name(self):
|
|
from soup_cli.commands.push import _render_eval_scorecard
|
|
|
|
rendered = _render_eval_scorecard({"math|injection": 0.5})
|
|
# The pipe must be neutralised to avoid breaking the markdown table.
|
|
for line in rendered.splitlines():
|
|
if "0.500" in line:
|
|
# Table row — exactly 3 unescaped pipes: leading, middle, trailing.
|
|
assert line.count("|") == 3
|
|
|
|
def test_escapes_markdown_injection(self):
|
|
from soup_cli.commands.push import _render_eval_scorecard
|
|
|
|
rendered = _render_eval_scorecard({"task\n| fake | 0.99": 0.5})
|
|
assert "fake" in rendered # literal preserved as text
|
|
# The injection newline / pipe must be neutralised.
|
|
assert "\n| fake" not in rendered
|
|
|
|
|
|
class TestHfResumeRequiresPushAs:
|
|
def test_hf_resume_alone_exits_one(self, tmp_path, monkeypatch):
|
|
cfg = tmp_path / "soup.yaml"
|
|
cfg.write_text("base: meta-llama/Llama-3.1-8B\ntask: sft\ndata:\n train: data.jsonl\n")
|
|
monkeypatch.chdir(tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
["train", "--config", str(cfg), "--hf-resume", "--dry-run"],
|
|
)
|
|
assert result.exit_code == 1, (result.output, repr(result.exception))
|
|
assert "push-as" in result.output.lower()
|
|
|
|
|
|
class TestDataPushHappyPathExtras:
|
|
def test_create_repo_called_with_dataset_type(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
(tmp_path / "data.jsonl").write_text('{"text":"a"}\n')
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
monkeypatch.setattr(
|
|
"soup_cli.commands.data.get_hf_api", lambda **_: fake_api, raising=False
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"data", "push", "--input", "data.jsonl",
|
|
"--hf-dataset", "user/my-dataset",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
fake_api.create_repo.assert_called_once_with(
|
|
repo_id="user/my-dataset", repo_type="dataset",
|
|
private=False, exist_ok=True,
|
|
)
|
|
|
|
|
|
class TestHfSpaceHappyPathExtras:
|
|
def test_create_repo_called_with_space_type_and_sdk(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("HF_TOKEN", "t1")
|
|
|
|
fake_api = MagicMock()
|
|
monkeypatch.setattr("soup_cli.utils.hf.get_hf_api", lambda **_: fake_api)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"deploy", "hf-space",
|
|
"--model", "user/my-model",
|
|
"--space", "user/my-space",
|
|
"--template", "gradio-chat",
|
|
"--yes",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
fake_api.create_repo.assert_called_once_with(
|
|
repo_id="user/my-space", repo_type="space",
|
|
space_sdk="gradio", private=False, exist_ok=True,
|
|
)
|