mirror of https://github.com/razor-ai/soup.git
317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""v0.68.0 Part D — ``soup apple-adapter``.
|
|
|
|
HF / PEFT <-> MLX <-> Apple Foundation Models adapter conversion + signing.
|
|
Extends v0.25 MLX backend, reuses v0.60 Part B Merkle-root signing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
|
|
class TestPublicSurface:
|
|
def test_module_importable(self) -> None:
|
|
from soup_cli.utils import apple_adapter
|
|
|
|
assert hasattr(apple_adapter, "SUPPORTED_ADAPTER_DIRECTIONS")
|
|
assert hasattr(apple_adapter, "validate_direction")
|
|
assert hasattr(apple_adapter, "validate_source_adapter")
|
|
assert hasattr(apple_adapter, "AppleAdapterPlan")
|
|
assert hasattr(apple_adapter, "build_apple_adapter_plan")
|
|
assert hasattr(apple_adapter, "convert_apple_adapter")
|
|
|
|
|
|
class TestAllowlist:
|
|
def test_frozenset(self) -> None:
|
|
from soup_cli.utils.apple_adapter import SUPPORTED_ADAPTER_DIRECTIONS
|
|
|
|
assert isinstance(SUPPORTED_ADAPTER_DIRECTIONS, frozenset)
|
|
assert "hf-to-mlx" in SUPPORTED_ADAPTER_DIRECTIONS
|
|
assert "mlx-to-hf" in SUPPORTED_ADAPTER_DIRECTIONS
|
|
assert "hf-to-apple" in SUPPORTED_ADAPTER_DIRECTIONS
|
|
assert "mlx-to-apple" in SUPPORTED_ADAPTER_DIRECTIONS
|
|
|
|
def test_immutable(self) -> None:
|
|
from soup_cli.utils.apple_adapter import SUPPORTED_ADAPTER_DIRECTIONS
|
|
|
|
with pytest.raises(AttributeError):
|
|
SUPPORTED_ADAPTER_DIRECTIONS.add("x") # type: ignore[attr-defined]
|
|
|
|
|
|
class TestValidateDirection:
|
|
def test_happy(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
assert validate_direction("hf-to-mlx") == "hf-to-mlx"
|
|
|
|
def test_case_insensitive(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
assert validate_direction("HF-TO-MLX") == "hf-to-mlx"
|
|
|
|
def test_bool_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
with pytest.raises(TypeError):
|
|
validate_direction(True) # type: ignore[arg-type]
|
|
|
|
def test_unknown_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
with pytest.raises(ValueError, match="unknown"):
|
|
validate_direction("evil")
|
|
|
|
def test_null_byte_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_direction("hf-to-mlx\x00")
|
|
|
|
def test_empty_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_direction
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_direction("")
|
|
|
|
|
|
class TestValidateSourceAdapter:
|
|
def test_happy(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_source_adapter
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
assert validate_source_adapter(str(adapter)).endswith("adapter")
|
|
|
|
def test_outside_cwd_rejected(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_source_adapter
|
|
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
adapter = outside / "ad"
|
|
adapter.mkdir()
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
monkeypatch.chdir(sub)
|
|
with pytest.raises(ValueError):
|
|
validate_source_adapter(str(adapter))
|
|
|
|
def test_non_directory_rejected(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_source_adapter
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
f = tmp_path / "not_a_dir.json"
|
|
f.write_text("{}", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="directory"):
|
|
validate_source_adapter(str(f))
|
|
|
|
def test_null_byte_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import validate_source_adapter
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_source_adapter("source\x00")
|
|
|
|
|
|
class TestAppleAdapterPlan:
|
|
def test_frozen(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.utils.apple_adapter import AppleAdapterPlan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
|
|
plan = AppleAdapterPlan(
|
|
source_dir=str(adapter),
|
|
output_dir="out",
|
|
direction="hf-to-mlx",
|
|
sign=False,
|
|
)
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
plan.sign = True # type: ignore[misc]
|
|
|
|
def test_invalid_direction(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.utils.apple_adapter import AppleAdapterPlan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError):
|
|
AppleAdapterPlan(
|
|
source_dir=str(adapter),
|
|
output_dir="out",
|
|
direction="evil",
|
|
sign=False,
|
|
)
|
|
|
|
def test_sign_must_be_bool(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.utils.apple_adapter import AppleAdapterPlan
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
|
|
with pytest.raises(TypeError):
|
|
AppleAdapterPlan(
|
|
source_dir=str(adapter),
|
|
output_dir="out",
|
|
direction="hf-to-mlx",
|
|
sign="yes", # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
class TestConvertLive:
|
|
def test_missing_weights_friendly(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""v0.71.21 #228 lifted the stub — live conversion now runs; a
|
|
source dir without weights surfaces a friendly FileNotFoundError
|
|
(full happy-path round-trip coverage lives in test_v07121.py)."""
|
|
from soup_cli.utils.apple_adapter import (
|
|
build_apple_adapter_plan,
|
|
convert_apple_adapter,
|
|
)
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
plan = build_apple_adapter_plan(
|
|
source_dir=str(adapter),
|
|
output_dir="out",
|
|
direction="hf-to-mlx",
|
|
sign=False,
|
|
)
|
|
with pytest.raises(FileNotFoundError, match="adapter_model"):
|
|
convert_apple_adapter(plan)
|
|
|
|
def test_non_plan_rejected(self) -> None:
|
|
from soup_cli.utils.apple_adapter import convert_apple_adapter
|
|
|
|
with pytest.raises(TypeError):
|
|
convert_apple_adapter({}) # type: ignore[arg-type]
|
|
|
|
|
|
class TestCli:
|
|
def test_help(self) -> None:
|
|
from soup_cli.cli import app
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["apple-adapter", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
|
|
def test_plan_only(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.cli import app
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"apple-adapter",
|
|
str(adapter),
|
|
"--direction",
|
|
"hf-to-mlx",
|
|
"--output",
|
|
"out",
|
|
"--plan-only",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
|
|
def test_unknown_direction_exits_2(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from soup_cli.cli import app
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"apple-adapter",
|
|
str(adapter),
|
|
"--direction",
|
|
"evil",
|
|
"--output",
|
|
"out",
|
|
"--plan-only",
|
|
],
|
|
)
|
|
assert result.exit_code == 2
|
|
|
|
def test_live_missing_weights_exits_2(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""v0.71.21 #228 — the runner is live; a weight-less source dir is
|
|
a validation failure (exit 2), not the old deferred exit 3."""
|
|
from soup_cli.cli import app
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"apple-adapter",
|
|
str(adapter),
|
|
"--direction",
|
|
"hf-to-mlx",
|
|
"--output",
|
|
"out",
|
|
],
|
|
)
|
|
assert result.exit_code == 2, (result.output, repr(result.exception))
|
|
|
|
|
|
class TestSourceWiring:
|
|
def test_no_top_level_heavy_imports(self) -> None:
|
|
path = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "src" / "soup_cli"
|
|
/ "utils"
|
|
/ "apple_adapter.py"
|
|
)
|
|
text = path.read_text(encoding="utf-8")
|
|
for token in (
|
|
"\nimport torch",
|
|
"\nimport transformers",
|
|
"\nimport mlx",
|
|
"\nimport safetensors",
|
|
):
|
|
assert token not in text
|
|
|
|
def test_cli_registered(self) -> None:
|
|
from soup_cli.cli import app
|
|
|
|
names = [c.name for c in app.registered_commands]
|
|
assert "apple-adapter" in names
|