Finish CLI M3 coverage pass
- SHA
1fdf1940830d54f7dde07b9eab7c8a5a63598db4- Parents
-
cda2c03 - Tree
2884406
1fdf194
1fdf1940830d54f7dde07b9eab7c8a5a63598db4cda2c03
2884406| Status | File | + | - |
|---|---|---|---|
| M |
src/dlm/cli/commands.py
|
15 | 16 |
| M |
tests/integration/audio/test_audio_training.py
|
11 | 4 |
| M |
tests/integration/vl/test_vl_training.py
|
11 | 4 |
| A |
tests/unit/cli/test_export_vl_cache_fallback.py
|
74 | 0 |
| A |
tests/unit/cli/test_misc_commands_coverage.py
|
520 | 0 |
| A |
tests/unit/cli/test_train_prompt_repl_coverage.py
|
465 | 0 |
src/dlm/cli/commands.pymodified@@ -1776,19 +1776,24 @@ def export_cmd( | ||
| 1776 | 1776 | store.ensure_layout() |
| 1777 | 1777 | |
| 1778 | 1778 | # VL bases: arch-probe + try single-file GGUF on SUPPORTED (with |
| 1779 | - # fallback to HF-snapshot on refusal or subprocess failure). We | |
| 1780 | - # still need the resolved plan + cached base dir for the GGUF | |
| 1781 | - # path, so resolve those first, then let the dispatcher decide | |
| 1782 | - # whether to use them. | |
| 1779 | + # fallback to HF-snapshot on refusal or subprocess failure). A | |
| 1780 | + # missing local base snapshot should not hard-fail the whole | |
| 1781 | + # export — the dispatcher can still emit the HF-snapshot path | |
| 1782 | + # without GGUF context. | |
| 1783 | 1783 | if export_dispatch.accepts_images: |
| 1784 | + gguf_emission_context = None | |
| 1784 | 1785 | try: |
| 1785 | 1786 | cached_vl = download_spec(spec, local_files_only=True) |
| 1786 | 1787 | except RuntimeError as exc: |
| 1787 | - console.print( | |
| 1788 | - f"[red]export:[/red] base model not in local cache " | |
| 1789 | - f"— run `dlm train` first.\n {exc}" | |
| 1790 | - ) | |
| 1791 | - raise typer.Exit(code=1) from exc | |
| 1788 | + _ = exc | |
| 1789 | + else: | |
| 1790 | + gguf_emission_context = { | |
| 1791 | + "plan": plan, | |
| 1792 | + "cached_base_dir": cached_vl.path, | |
| 1793 | + "source_dlm_path": path.resolve(), | |
| 1794 | + "training_sequence_len": parsed.frontmatter.training.sequence_len, | |
| 1795 | + "dlm_version": f"v{parsed.frontmatter.dlm_version}", | |
| 1796 | + } | |
| 1792 | 1797 | try: |
| 1793 | 1798 | dispatch_result = export_dispatch.dispatch_export( |
| 1794 | 1799 | store=store, |
@@ -1797,13 +1802,7 @@ def export_cmd( | ||
| 1797 | 1802 | quant=quant, |
| 1798 | 1803 | merged=merged, |
| 1799 | 1804 | adapter_mix_raw=adapter_mix, |
| 1800 | - gguf_emission_context={ | |
| 1801 | - "plan": plan, | |
| 1802 | - "cached_base_dir": cached_vl.path, | |
| 1803 | - "source_dlm_path": path.resolve(), | |
| 1804 | - "training_sequence_len": parsed.frontmatter.training.sequence_len, | |
| 1805 | - "dlm_version": f"v{parsed.frontmatter.dlm_version}", | |
| 1806 | - }, | |
| 1805 | + gguf_emission_context=gguf_emission_context, | |
| 1807 | 1806 | ) |
| 1808 | 1807 | except ExportError as exc: |
| 1809 | 1808 | console.print(f"[red]export:[/red] {exc}") |
tests/integration/audio/test_audio_training.pymodified@@ -114,7 +114,9 @@ def test_qwen2_audio_one_cycle_end_to_end( # pragma: no cover — slow + audio | ||
| 114 | 114 | ) -> None: |
| 115 | 115 | """Full audio cycle: init → ingest wav → train 1 step → verify adapter.""" |
| 116 | 116 | import dlm.train as dlm_train |
| 117 | + from dlm.base_models import resolve as resolve_base_model | |
| 117 | 118 | from dlm.doc.parser import parse_file |
| 119 | + from dlm.hardware import doctor | |
| 118 | 120 | from dlm.store.manifest import load_manifest |
| 119 | 121 | from dlm.store.paths import for_dlm |
| 120 | 122 | |
@@ -133,16 +135,21 @@ def test_qwen2_audio_one_cycle_end_to_end( # pragma: no cover — slow + audio | ||
| 133 | 135 | |
| 134 | 136 | parsed = parse_file(doc) |
| 135 | 137 | store = for_dlm(parsed.frontmatter.dlm_id, home=tmp_home) |
| 138 | + spec = resolve_base_model(parsed.frontmatter.base_model, accept_license=True) | |
| 139 | + plan = doctor(training_config=parsed.frontmatter.training).plan | |
| 140 | + if plan is None: | |
| 141 | + pytest.skip("no viable plan on this host — audio body needs a real trainer") | |
| 136 | 142 | |
| 137 | 143 | # Cap steps to 1 so the test completes on commodity hardware. |
| 138 | - result = dlm_train.run( | |
| 139 | - doc, | |
| 144 | + dlm_train.run( | |
| 145 | + store, | |
| 146 | + parsed, | |
| 147 | + spec, | |
| 148 | + plan, | |
| 140 | 149 | mode="fresh", |
| 141 | 150 | seed=42, |
| 142 | 151 | max_steps=1, |
| 143 | - home=tmp_home, | |
| 144 | 152 | ) |
| 145 | - assert result is not None | |
| 146 | 153 | |
| 147 | 154 | # Adapter committed under v0001/. |
| 148 | 155 | adapter_dir = store.resolve_current_adapter() |
tests/integration/vl/test_vl_training.pymodified@@ -133,7 +133,9 @@ def test_vl_one_cycle_end_to_end( # pragma: no cover — slow + vl | ||
| 133 | 133 | aren't locally cached. |
| 134 | 134 | """ |
| 135 | 135 | import dlm.train as dlm_train |
| 136 | + from dlm.base_models import resolve as resolve_base_model | |
| 136 | 137 | from dlm.doc.parser import parse_file |
| 138 | + from dlm.hardware import doctor | |
| 137 | 139 | from dlm.store.manifest import load_manifest |
| 138 | 140 | from dlm.store.paths import for_dlm |
| 139 | 141 | |
@@ -150,16 +152,21 @@ def test_vl_one_cycle_end_to_end( # pragma: no cover — slow + vl | ||
| 150 | 152 | |
| 151 | 153 | parsed = parse_file(doc) |
| 152 | 154 | store = for_dlm(parsed.frontmatter.dlm_id, home=tmp_home) |
| 155 | + spec = resolve_base_model(parsed.frontmatter.base_model, accept_license=True) | |
| 156 | + plan = doctor(training_config=parsed.frontmatter.training).plan | |
| 157 | + if plan is None: | |
| 158 | + pytest.skip("no viable plan on this host — VL body needs a real trainer") | |
| 153 | 159 | |
| 154 | 160 | # Cap steps to 1 so the test completes on commodity hardware. |
| 155 | - result = dlm_train.run( | |
| 156 | - doc, | |
| 161 | + dlm_train.run( | |
| 162 | + store, | |
| 163 | + parsed, | |
| 164 | + spec, | |
| 165 | + plan, | |
| 157 | 166 | mode="fresh", |
| 158 | 167 | seed=42, |
| 159 | 168 | max_steps=1, |
| 160 | - home=tmp_home, | |
| 161 | 169 | ) |
| 162 | - assert result is not None | |
| 163 | 170 | |
| 164 | 171 | # Adapter committed under v0001/. |
| 165 | 172 | adapter_dir = store.resolve_current_adapter() |
tests/unit/cli/test_export_vl_cache_fallback.pyadded@@ -0,0 +1,74 @@ | ||
| 1 | +"""CLI coverage for VL export when GGUF context is unavailable.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | +from types import SimpleNamespace | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from typer.testing import CliRunner | |
| 10 | + | |
| 11 | +from dlm.cli.app import app | |
| 12 | + | |
| 13 | + | |
| 14 | +def _scaffold_vl_doc(tmp_path: Path) -> Path: | |
| 15 | + doc = tmp_path / "vl.dlm" | |
| 16 | + runner = CliRunner() | |
| 17 | + result = runner.invoke( | |
| 18 | + app, | |
| 19 | + [ | |
| 20 | + "--home", | |
| 21 | + str(tmp_path / "home"), | |
| 22 | + "init", | |
| 23 | + str(doc), | |
| 24 | + "--base", | |
| 25 | + "paligemma-3b-mix-224", | |
| 26 | + "--multimodal", | |
| 27 | + "--i-accept-license", | |
| 28 | + ], | |
| 29 | + ) | |
| 30 | + assert result.exit_code == 0, result.output | |
| 31 | + return doc | |
| 32 | + | |
| 33 | + | |
| 34 | +class _FakeVlDispatch: | |
| 35 | + accepts_images = True | |
| 36 | + accepts_audio = False | |
| 37 | + | |
| 38 | + def __init__(self) -> None: | |
| 39 | + self.calls: list[dict[str, object]] = [] | |
| 40 | + | |
| 41 | + def dispatch_export(self, **kwargs: object) -> object: | |
| 42 | + self.calls.append(dict(kwargs)) | |
| 43 | + return SimpleNamespace( | |
| 44 | + export_dir=Path("/tmp/hf-snapshot"), | |
| 45 | + manifest_path=Path("/tmp/hf-snapshot/snapshot_manifest.json"), | |
| 46 | + artifacts=[], | |
| 47 | + banner_lines=["[green]export:[/green] HF snapshot fallback reached"], | |
| 48 | + ) | |
| 49 | + | |
| 50 | + | |
| 51 | +def test_vl_export_without_cached_base_uses_snapshot_dispatch( | |
| 52 | + tmp_path: Path, | |
| 53 | + monkeypatch: Any, | |
| 54 | +) -> None: | |
| 55 | + doc = _scaffold_vl_doc(tmp_path) | |
| 56 | + fake_dispatch = _FakeVlDispatch() | |
| 57 | + | |
| 58 | + def _raise_cache_miss(*_: object, **__: object) -> object: | |
| 59 | + raise RuntimeError("google/paligemma-3b-mix-224 not found in local cache") | |
| 60 | + | |
| 61 | + monkeypatch.setattr("dlm.base_models.download_spec", _raise_cache_miss) | |
| 62 | + monkeypatch.setattr("dlm.modality.modality_for", lambda _spec: fake_dispatch) | |
| 63 | + | |
| 64 | + runner = CliRunner() | |
| 65 | + result = runner.invoke( | |
| 66 | + app, | |
| 67 | + ["--home", str(tmp_path / "home"), "export", str(doc)], | |
| 68 | + ) | |
| 69 | + | |
| 70 | + assert result.exit_code == 0, result.output | |
| 71 | + assert len(fake_dispatch.calls) == 1 | |
| 72 | + assert fake_dispatch.calls[0]["gguf_emission_context"] is None | |
| 73 | + assert "HF snapshot fallback reached" in result.output | |
| 74 | + assert "base model not in local cache" not in result.output | |
tests/unit/cli/test_misc_commands_coverage.pyadded@@ -0,0 +1,520 @@ | ||
| 1 | +"""Coverage-oriented command tests for the remaining CLI surface.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from pathlib import Path | |
| 7 | +from types import SimpleNamespace | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from click.exceptions import Exit as ClickExit | |
| 12 | +from typer.testing import CliRunner | |
| 13 | + | |
| 14 | +from dlm.cli import commands | |
| 15 | +from dlm.cli.app import app | |
| 16 | +from dlm.share.signing import VerifyStatus | |
| 17 | +from dlm.store.paths import for_dlm | |
| 18 | + | |
| 19 | + | |
| 20 | +def _write_minimal_dlm( | |
| 21 | + path: Path, | |
| 22 | + *, | |
| 23 | + dlm_id: str = "01KPQ9M3" + "0" * 18, | |
| 24 | + base_model: str = "smollm2-135m", | |
| 25 | +) -> None: | |
| 26 | + path.write_text( | |
| 27 | + f"---\ndlm_id: {dlm_id}\ndlm_version: 12\nbase_model: {base_model}\n---\nbody\n", | |
| 28 | + encoding="utf-8", | |
| 29 | + ) | |
| 30 | + | |
| 31 | + | |
| 32 | +def _joined_output(result: object) -> str: | |
| 33 | + text = getattr(result, "output", "") + getattr(result, "stderr", "") | |
| 34 | + return " ".join(text.split()) | |
| 35 | + | |
| 36 | + | |
| 37 | +class TestMetricsAndDoctor: | |
| 38 | + def test_metrics_run_id_json_and_csv(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 39 | + doc = tmp_path / "doc.dlm" | |
| 40 | + _write_minimal_dlm(doc) | |
| 41 | + monkeypatch.setenv("DLM_HOME", str(tmp_path / "home")) | |
| 42 | + | |
| 43 | + run = SimpleNamespace( | |
| 44 | + run_id=7, | |
| 45 | + phase="sft", | |
| 46 | + seed=42, | |
| 47 | + status="ok", | |
| 48 | + started_at="2026-04-21T10:00:00Z", | |
| 49 | + ended_at="2026-04-21T10:01:00Z", | |
| 50 | + ) | |
| 51 | + steps = [SimpleNamespace(step=1, loss=0.5, lr=1e-4, grad_norm=0.9)] | |
| 52 | + evals = [SimpleNamespace(step=1, val_loss=0.4, perplexity=1.5)] | |
| 53 | + | |
| 54 | + monkeypatch.setattr("dlm.metrics.queries.recent_runs", lambda *args, **kwargs: [run]) | |
| 55 | + monkeypatch.setattr("dlm.metrics.queries.steps_for_run", lambda *args, **kwargs: steps) | |
| 56 | + monkeypatch.setattr("dlm.metrics.queries.evals_for_run", lambda *args, **kwargs: evals) | |
| 57 | + monkeypatch.setattr( | |
| 58 | + "dlm.metrics.queries.runs_to_dict", | |
| 59 | + lambda runs: [ | |
| 60 | + { | |
| 61 | + "run_id": r.run_id, | |
| 62 | + "phase": r.phase, | |
| 63 | + "seed": r.seed, | |
| 64 | + "status": r.status, | |
| 65 | + } | |
| 66 | + for r in runs | |
| 67 | + ], | |
| 68 | + ) | |
| 69 | + monkeypatch.setattr( | |
| 70 | + "dlm.metrics.queries.steps_to_dict", | |
| 71 | + lambda rows: [{"step": r.step, "loss": r.loss} for r in rows], | |
| 72 | + ) | |
| 73 | + monkeypatch.setattr( | |
| 74 | + "dlm.metrics.queries.evals_to_dict", | |
| 75 | + lambda rows: [{"step": r.step, "val_loss": r.val_loss} for r in rows], | |
| 76 | + ) | |
| 77 | + | |
| 78 | + import sys | |
| 79 | + from io import StringIO | |
| 80 | + | |
| 81 | + old_stdout = sys.stdout | |
| 82 | + try: | |
| 83 | + json_buf = StringIO() | |
| 84 | + sys.stdout = json_buf | |
| 85 | + commands.metrics_cmd(doc, json_out=True, run_id=7) | |
| 86 | + finally: | |
| 87 | + sys.stdout = old_stdout | |
| 88 | + payload = json.loads(json_buf.getvalue()) | |
| 89 | + assert payload["run"]["run_id"] == 7 | |
| 90 | + assert payload["steps"][0]["step"] == 1 | |
| 91 | + assert payload["evals"][0]["val_loss"] == 0.4 | |
| 92 | + | |
| 93 | + old_stdout = sys.stdout | |
| 94 | + try: | |
| 95 | + csv_buf = StringIO() | |
| 96 | + sys.stdout = csv_buf | |
| 97 | + commands.metrics_cmd(doc, csv_out=True, run_id=7) | |
| 98 | + finally: | |
| 99 | + sys.stdout = old_stdout | |
| 100 | + csv_text = csv_buf.getvalue() | |
| 101 | + assert "step,loss,lr,grad_norm,val_loss" in csv_text | |
| 102 | + assert "1,0.5,0.0001,0.9,0.4" in csv_text | |
| 103 | + | |
| 104 | + def test_metrics_since_parse_and_watch( | |
| 105 | + self, tmp_path: Path, monkeypatch: Any, capsys: Any | |
| 106 | + ) -> None: | |
| 107 | + doc = tmp_path / "doc.dlm" | |
| 108 | + home = tmp_path / "home" | |
| 109 | + _write_minimal_dlm(doc) | |
| 110 | + monkeypatch.setenv("DLM_HOME", str(home)) | |
| 111 | + | |
| 112 | + with pytest.raises(ClickExit) as excinfo: | |
| 113 | + commands.metrics_cmd(doc, since="bogus") | |
| 114 | + assert excinfo.type is ClickExit | |
| 115 | + err = capsys.readouterr().err | |
| 116 | + assert "not an integer+unit" in err | |
| 117 | + | |
| 118 | + monkeypatch.setattr("dlm.metrics.queries.latest_run_id", lambda *_: 7) | |
| 119 | + monkeypatch.setattr( | |
| 120 | + "dlm.metrics.queries.steps_for_run", | |
| 121 | + lambda *args, **kwargs: [SimpleNamespace(step=3, loss=0.25, lr=1e-4, grad_norm=0.8)], | |
| 122 | + ) | |
| 123 | + monkeypatch.setattr( | |
| 124 | + "dlm.metrics.queries.evals_for_run", | |
| 125 | + lambda *args, **kwargs: [SimpleNamespace(step=3, val_loss=0.2, perplexity=1.2)], | |
| 126 | + ) | |
| 127 | + | |
| 128 | + def _interrupt(*_: object) -> None: | |
| 129 | + raise KeyboardInterrupt | |
| 130 | + | |
| 131 | + monkeypatch.setattr("time.sleep", _interrupt) | |
| 132 | + commands.metrics_watch_cmd(doc, poll_seconds=0.0) | |
| 133 | + out = capsys.readouterr().out | |
| 134 | + assert "following run_id=7" in out | |
| 135 | + assert "eval @ step 3" in out | |
| 136 | + assert "bye" in out | |
| 137 | + | |
| 138 | + def test_doctor_json_and_text(self, monkeypatch: Any, capsys: Any) -> None: | |
| 139 | + fake = SimpleNamespace(to_dict=lambda: {"plan": "ok"}) | |
| 140 | + monkeypatch.setattr("dlm.hardware.doctor", lambda: fake) | |
| 141 | + monkeypatch.setattr("dlm.hardware.render_text", lambda result: f"doctor text: {result!r}") | |
| 142 | + | |
| 143 | + commands.doctor_cmd(json_out=True) | |
| 144 | + json_out = capsys.readouterr().out | |
| 145 | + assert '"plan": "ok"' in json_out | |
| 146 | + | |
| 147 | + commands.doctor_cmd(json_out=False) | |
| 148 | + text_out = capsys.readouterr().out | |
| 149 | + assert "doctor text" in text_out | |
| 150 | + | |
| 151 | + | |
| 152 | +class TestPackUnpackVerify: | |
| 153 | + def test_pack_and_unpack_success(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 154 | + runner = CliRunner() | |
| 155 | + pack_out = tmp_path / "bundle.dlm.pack" | |
| 156 | + monkeypatch.setattr( | |
| 157 | + "dlm.pack.packer.pack", | |
| 158 | + lambda *args, **kwargs: SimpleNamespace( | |
| 159 | + path=pack_out, | |
| 160 | + bytes_written=5 * 1024 * 1024, | |
| 161 | + content_type="application/dlm-pack", | |
| 162 | + ), | |
| 163 | + ) | |
| 164 | + pack_result = runner.invoke( | |
| 165 | + app, ["pack", str(tmp_path / "doc.dlm"), "--out", str(pack_out)] | |
| 166 | + ) | |
| 167 | + assert pack_result.exit_code == 0, pack_result.output | |
| 168 | + assert "packed:" in pack_result.output | |
| 169 | + assert "application/dlm-pack" in pack_result.output | |
| 170 | + | |
| 171 | + monkeypatch.setattr( | |
| 172 | + "dlm.pack.unpacker.unpack", | |
| 173 | + lambda *args, **kwargs: SimpleNamespace( | |
| 174 | + dlm_path=tmp_path / "restored.dlm", | |
| 175 | + store_path=tmp_path / "home" / "store" / "01XYZ", | |
| 176 | + dlm_id="01XYZ", | |
| 177 | + header=SimpleNamespace(pack_format_version=2), | |
| 178 | + applied_migrations=[2, 3], | |
| 179 | + ), | |
| 180 | + ) | |
| 181 | + unpack_result = runner.invoke( | |
| 182 | + app, | |
| 183 | + ["unpack", str(pack_out), "--out", str(tmp_path / "restore")], | |
| 184 | + ) | |
| 185 | + assert unpack_result.exit_code == 0, unpack_result.output | |
| 186 | + assert "unpacked:" in unpack_result.output | |
| 187 | + assert "migrated: v2" in unpack_result.output | |
| 188 | + | |
| 189 | + def test_verify_success_and_unsigned(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 190 | + runner = CliRunner() | |
| 191 | + pack_path = tmp_path / "bundle.dlm.pack" | |
| 192 | + pack_path.write_bytes(b"pack") | |
| 193 | + | |
| 194 | + provenance = SimpleNamespace( | |
| 195 | + adapter_sha256="a" * 64, | |
| 196 | + base_revision="main", | |
| 197 | + corpus_root_sha256="b" * 64, | |
| 198 | + signed_at="2026-04-21T12:00:00Z", | |
| 199 | + ) | |
| 200 | + monkeypatch.setattr( | |
| 201 | + "dlm.pack.unpacker.read_pack_member_bytes", lambda *args, **kwargs: b"{}" | |
| 202 | + ) | |
| 203 | + monkeypatch.setattr("dlm.share.provenance.load_provenance_json", lambda *_: provenance) | |
| 204 | + monkeypatch.setattr( | |
| 205 | + "dlm.share.provenance.verify_provenance", | |
| 206 | + lambda *args, **kwargs: SimpleNamespace( | |
| 207 | + signer_fingerprint="FINGERPRINT", | |
| 208 | + trusted_key_path=tmp_path / "trusted.pub", | |
| 209 | + tofu_recorded=True, | |
| 210 | + ), | |
| 211 | + ) | |
| 212 | + | |
| 213 | + result = runner.invoke(app, ["verify", str(pack_path), "--trust-on-first-use"]) | |
| 214 | + assert result.exit_code == 0, result.output | |
| 215 | + assert "verified:" in result.output | |
| 216 | + assert "recorded new trust entry" in result.output | |
| 217 | + | |
| 218 | + monkeypatch.setattr( | |
| 219 | + "dlm.pack.unpacker.read_pack_member_bytes", | |
| 220 | + lambda *args, **kwargs: None, | |
| 221 | + ) | |
| 222 | + unsigned = runner.invoke(app, ["verify", str(pack_path)]) | |
| 223 | + assert unsigned.exit_code == 1, unsigned.output | |
| 224 | + assert "is unsigned" in unsigned.output | |
| 225 | + | |
| 226 | + | |
| 227 | +class TestMigrateTemplatesShareAndServe: | |
| 228 | + def test_migrate_templates_push_pull_and_serve(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 229 | + runner = CliRunner() | |
| 230 | + doc = tmp_path / "doc.dlm" | |
| 231 | + home = tmp_path / "home" | |
| 232 | + _write_minimal_dlm(doc) | |
| 233 | + | |
| 234 | + monkeypatch.setattr( | |
| 235 | + "dlm.doc.migrate.migrate_file", | |
| 236 | + lambda *args, **kwargs: SimpleNamespace( | |
| 237 | + applied=[11], | |
| 238 | + target_version=12, | |
| 239 | + backup_path=tmp_path / "doc.dlm.bak", | |
| 240 | + ), | |
| 241 | + ) | |
| 242 | + migrate_result = runner.invoke(app, ["migrate", str(doc)]) | |
| 243 | + assert migrate_result.exit_code == 0, migrate_result.output | |
| 244 | + assert "migrated:" in migrate_result.output | |
| 245 | + | |
| 246 | + template = SimpleNamespace( | |
| 247 | + name="starter", | |
| 248 | + meta=SimpleNamespace( | |
| 249 | + title="Starter", | |
| 250 | + domain_tags=("docs",), | |
| 251 | + recommended_base="smollm2-135m", | |
| 252 | + expected_steps=25, | |
| 253 | + expected_duration={"minutes": 5}, | |
| 254 | + summary="Starter template", | |
| 255 | + sample_prompts=("hello",), | |
| 256 | + ), | |
| 257 | + ) | |
| 258 | + monkeypatch.setattr("dlm.templates.list_bundled", lambda: [template]) | |
| 259 | + templates_result = runner.invoke(app, ["templates", "list", "--json"]) | |
| 260 | + assert templates_result.exit_code == 0, templates_result.output | |
| 261 | + assert '"name":"starter"' in templates_result.output.replace(" ", "") | |
| 262 | + | |
| 263 | + templates_text = runner.invoke(app, ["templates", "list"]) | |
| 264 | + assert templates_text.exit_code == 0, templates_text.output | |
| 265 | + assert "Starter" in templates_text.output | |
| 266 | + assert "smollm2-135m" in templates_text.output | |
| 267 | + | |
| 268 | + from dlm.share.sinks import SinkKind | |
| 269 | + | |
| 270 | + monkeypatch.setattr( | |
| 271 | + "dlm.share.push", | |
| 272 | + lambda *args, **kwargs: SimpleNamespace( | |
| 273 | + destination="hf:org/repo", | |
| 274 | + bytes_sent=3 * 1024 * 1024, | |
| 275 | + sink_kind=SinkKind.HF, | |
| 276 | + detail="done", | |
| 277 | + ), | |
| 278 | + ) | |
| 279 | + push_result = runner.invoke( | |
| 280 | + app, | |
| 281 | + ["push", str(doc), "--to", "hf:org/repo"], | |
| 282 | + ) | |
| 283 | + assert push_result.exit_code == 0, push_result.output | |
| 284 | + assert "pushed:" in push_result.output | |
| 285 | + assert "dlm pull hf:org/repo" in push_result.output | |
| 286 | + | |
| 287 | + monkeypatch.setattr( | |
| 288 | + "dlm.share.pull", | |
| 289 | + lambda *args, **kwargs: SimpleNamespace( | |
| 290 | + source="hf:org/repo", | |
| 291 | + dlm_path=tmp_path / "restored.dlm", | |
| 292 | + bytes_received=2 * 1024 * 1024, | |
| 293 | + verification=SimpleNamespace( | |
| 294 | + status=VerifyStatus.VERIFIED, | |
| 295 | + key_path=tmp_path / "trusted.pub", | |
| 296 | + detail="", | |
| 297 | + ), | |
| 298 | + ), | |
| 299 | + ) | |
| 300 | + pull_result = runner.invoke(app, ["pull", "hf:org/repo"]) | |
| 301 | + assert pull_result.exit_code == 0, pull_result.output | |
| 302 | + assert "pulled:" in pull_result.output | |
| 303 | + assert "verified:" in pull_result.output | |
| 304 | + | |
| 305 | + store = for_dlm("01KPQ9M3" + "0" * 18, home=home) | |
| 306 | + store.ensure_layout() | |
| 307 | + store.manifest.write_text("{}", encoding="utf-8") | |
| 308 | + | |
| 309 | + def _fake_pack(path: Path, *, out: Path) -> None: | |
| 310 | + out.write_bytes(b"pack") | |
| 311 | + | |
| 312 | + handle = SimpleNamespace( | |
| 313 | + bind_host="127.0.0.1", | |
| 314 | + port=7337, | |
| 315 | + peer_url="peer://127.0.0.1:7337/01KPQ9M3", | |
| 316 | + wait_shutdown=lambda: None, | |
| 317 | + ) | |
| 318 | + monkeypatch.setattr("dlm.pack.packer.pack", _fake_pack) | |
| 319 | + monkeypatch.setattr("dlm.share.serve", lambda *args, **kwargs: handle) | |
| 320 | + serve_result = runner.invoke( | |
| 321 | + app, | |
| 322 | + ["--home", str(home), "serve", str(doc)], | |
| 323 | + ) | |
| 324 | + assert serve_result.exit_code == 0, serve_result.output | |
| 325 | + assert "serving:" in serve_result.output | |
| 326 | + assert "peer URL:" in serve_result.output | |
| 327 | + | |
| 328 | + def test_templates_refresh_empty_gallery_and_pull_statuses( | |
| 329 | + self, | |
| 330 | + tmp_path: Path, | |
| 331 | + monkeypatch: Any, | |
| 332 | + ) -> None: | |
| 333 | + runner = CliRunner() | |
| 334 | + | |
| 335 | + from dlm.templates.fetcher import RemoteFetchUnavailable | |
| 336 | + | |
| 337 | + monkeypatch.setattr( | |
| 338 | + "dlm.templates.fetcher.fetch_all", | |
| 339 | + lambda *args, **kwargs: (_ for _ in ()).throw(RemoteFetchUnavailable("offline")), | |
| 340 | + ) | |
| 341 | + monkeypatch.setattr("dlm.templates.fetcher.cache_dir", lambda: tmp_path / "cache") | |
| 342 | + monkeypatch.setattr("dlm.templates.list_bundled", lambda: []) | |
| 343 | + empty = runner.invoke(app, ["templates", "list", "--refresh"]) | |
| 344 | + assert empty.exit_code == 1, empty.output | |
| 345 | + joined = _joined_output(empty) | |
| 346 | + assert "Falling back to the bundled gallery" in joined | |
| 347 | + assert "no bundled templates found" in joined | |
| 348 | + | |
| 349 | + monkeypatch.setattr( | |
| 350 | + "dlm.share.pull", | |
| 351 | + lambda *args, **kwargs: SimpleNamespace( | |
| 352 | + source="hf:org/repo", | |
| 353 | + dlm_path=tmp_path / "restored.dlm", | |
| 354 | + bytes_received=1 * 1024 * 1024, | |
| 355 | + verification=SimpleNamespace( | |
| 356 | + status=VerifyStatus.UNVERIFIED, | |
| 357 | + key_path=None, | |
| 358 | + detail="missing key", | |
| 359 | + ), | |
| 360 | + ), | |
| 361 | + ) | |
| 362 | + unverified = runner.invoke(app, ["pull", "hf:org/repo"]) | |
| 363 | + assert unverified.exit_code == 0, unverified.output | |
| 364 | + assert "unverified:" in unverified.output | |
| 365 | + | |
| 366 | + monkeypatch.setattr( | |
| 367 | + "dlm.share.pull", | |
| 368 | + lambda *args, **kwargs: SimpleNamespace( | |
| 369 | + source="hf:org/repo", | |
| 370 | + dlm_path=tmp_path / "restored.dlm", | |
| 371 | + bytes_received=1 * 1024 * 1024, | |
| 372 | + verification=SimpleNamespace( | |
| 373 | + status=VerifyStatus.UNSIGNED, | |
| 374 | + key_path=None, | |
| 375 | + detail="", | |
| 376 | + ), | |
| 377 | + ), | |
| 378 | + ) | |
| 379 | + unsigned = runner.invoke(app, ["pull", "hf:org/repo"]) | |
| 380 | + assert unsigned.exit_code == 0, unsigned.output | |
| 381 | + assert "unsigned" in unsigned.output | |
| 382 | + | |
| 383 | + | |
| 384 | +class TestExportAndCacheCoverage: | |
| 385 | + def test_export_standard_path_success_and_errors( | |
| 386 | + self, tmp_path: Path, monkeypatch: Any | |
| 387 | + ) -> None: | |
| 388 | + doc = tmp_path / "doc.dlm" | |
| 389 | + runner = CliRunner() | |
| 390 | + result = runner.invoke( | |
| 391 | + app, | |
| 392 | + [ | |
| 393 | + "--home", | |
| 394 | + str(tmp_path / "home"), | |
| 395 | + "init", | |
| 396 | + str(doc), | |
| 397 | + "--base", | |
| 398 | + "smollm2-135m", | |
| 399 | + ], | |
| 400 | + ) | |
| 401 | + assert result.exit_code == 0, result.output | |
| 402 | + | |
| 403 | + cached = SimpleNamespace(path=tmp_path / "base") | |
| 404 | + monkeypatch.setattr("dlm.base_models.download_spec", lambda *args, **kwargs: cached) | |
| 405 | + monkeypatch.setattr( | |
| 406 | + "dlm.export.run_export", | |
| 407 | + lambda *args, **kwargs: SimpleNamespace( | |
| 408 | + cached=True, | |
| 409 | + export_dir=tmp_path / "exports" / "Q4_K_M", | |
| 410 | + artifacts=[SimpleNamespace(name="base.gguf"), SimpleNamespace(name="adapter.gguf")], | |
| 411 | + ollama_name="my-model", | |
| 412 | + ollama_version=1, | |
| 413 | + smoke_output_first_line="hello", | |
| 414 | + ), | |
| 415 | + ) | |
| 416 | + export_ok = runner.invoke( | |
| 417 | + app, | |
| 418 | + ["--home", str(tmp_path / "home"), "export", str(doc), "--skip-ollama"], | |
| 419 | + ) | |
| 420 | + assert export_ok.exit_code == 0, export_ok.output | |
| 421 | + assert "exported:" in export_ok.output | |
| 422 | + assert "ollama:" in export_ok.output | |
| 423 | + assert "smoke:" in export_ok.output | |
| 424 | + | |
| 425 | + monkeypatch.setattr( | |
| 426 | + "dlm.base_models.download_spec", | |
| 427 | + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("offline cache miss")), | |
| 428 | + ) | |
| 429 | + cache_miss = runner.invoke(app, ["--home", str(tmp_path / "home"), "export", str(doc)]) | |
| 430 | + assert cache_miss.exit_code == 1, cache_miss.output | |
| 431 | + assert "base model not in local cache" in cache_miss.output | |
| 432 | + | |
| 433 | + from dlm.export.ollama.errors import OllamaBinaryNotFoundError | |
| 434 | + | |
| 435 | + monkeypatch.setattr("dlm.base_models.download_spec", lambda *args, **kwargs: cached) | |
| 436 | + monkeypatch.setattr( | |
| 437 | + "dlm.export.run_export", | |
| 438 | + lambda *args, **kwargs: (_ for _ in ()).throw(OllamaBinaryNotFoundError("missing")), | |
| 439 | + ) | |
| 440 | + no_ollama = runner.invoke(app, ["--home", str(tmp_path / "home"), "export", str(doc)]) | |
| 441 | + assert no_ollama.exit_code == 1, no_ollama.output | |
| 442 | + assert "install from https://ollama.com/download" in no_ollama.output | |
| 443 | + | |
| 444 | + def test_cache_show_prune_and_clear(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 445 | + doc = tmp_path / "doc.dlm" | |
| 446 | + _write_minimal_dlm(doc) | |
| 447 | + | |
| 448 | + class _FakeCache: | |
| 449 | + def __init__(self, entry_count: int, total_bytes: int) -> None: | |
| 450 | + self.entry_count = entry_count | |
| 451 | + self.total_bytes = total_bytes | |
| 452 | + | |
| 453 | + def prune(self, *, older_than_seconds: float) -> int: | |
| 454 | + self.pruned_seconds = older_than_seconds | |
| 455 | + return 2 | |
| 456 | + | |
| 457 | + def clear(self) -> int: | |
| 458 | + return 5 | |
| 459 | + | |
| 460 | + def save_manifest(self) -> None: | |
| 461 | + return None | |
| 462 | + | |
| 463 | + fake_cache = _FakeCache(entry_count=5, total_bytes=2048) | |
| 464 | + monkeypatch.setattr("dlm.directives.cache.TokenizedCache.open", lambda *_: fake_cache) | |
| 465 | + monkeypatch.setattr( | |
| 466 | + "dlm.metrics.queries.latest_tokenization", | |
| 467 | + lambda *_: SimpleNamespace(run_id=9, hit_rate=0.75, cache_hits=3, cache_misses=1), | |
| 468 | + ) | |
| 469 | + | |
| 470 | + runner = CliRunner() | |
| 471 | + show_json = runner.invoke( | |
| 472 | + app, | |
| 473 | + ["--home", str(tmp_path / "home"), "cache", "show", str(doc), "--json"], | |
| 474 | + ) | |
| 475 | + assert show_json.exit_code == 0, show_json.output | |
| 476 | + assert '"entry_count": 5' in show_json.output | |
| 477 | + | |
| 478 | + monkeypatch.setattr("dlm.metrics.queries.latest_tokenization", lambda *_: None) | |
| 479 | + show_text = runner.invoke( | |
| 480 | + app, | |
| 481 | + ["--home", str(tmp_path / "home"), "cache", "show", str(doc)], | |
| 482 | + ) | |
| 483 | + assert show_text.exit_code == 0, show_text.output | |
| 484 | + assert "Cache for" in show_text.output | |
| 485 | + assert "no tokenization runs yet" in show_text.output | |
| 486 | + | |
| 487 | + prune_bad = runner.invoke( | |
| 488 | + app, | |
| 489 | + ["--home", str(tmp_path / "home"), "cache", "prune", str(doc), "--older-than", "bad"], | |
| 490 | + ) | |
| 491 | + assert prune_bad.exit_code == 2, prune_bad.output | |
| 492 | + assert "invalid --older-than" in prune_bad.output | |
| 493 | + | |
| 494 | + prune_ok = runner.invoke( | |
| 495 | + app, | |
| 496 | + ["--home", str(tmp_path / "home"), "cache", "prune", str(doc)], | |
| 497 | + ) | |
| 498 | + assert prune_ok.exit_code == 0, prune_ok.output | |
| 499 | + assert "older than 90d" in prune_ok.output | |
| 500 | + | |
| 501 | + monkeypatch.setattr("typer.confirm", lambda prompt: False) | |
| 502 | + clear_cancel = runner.invoke( | |
| 503 | + app, | |
| 504 | + ["--home", str(tmp_path / "home"), "cache", "clear", str(doc)], | |
| 505 | + ) | |
| 506 | + assert clear_cancel.exit_code == 0, clear_cancel.output | |
| 507 | + assert "clear cancelled" in clear_cancel.output | |
| 508 | + | |
| 509 | + clear_force = runner.invoke( | |
| 510 | + app, | |
| 511 | + ["--home", str(tmp_path / "home"), "cache", "clear", str(doc), "--force"], | |
| 512 | + ) | |
| 513 | + assert clear_force.exit_code == 0, clear_force.output | |
| 514 | + assert "cleared 5" in clear_force.output | |
| 515 | + | |
| 516 | + def test_parse_duration_helper(self) -> None: | |
| 517 | + assert commands._parse_duration("30m") == 1800.0 | |
| 518 | + assert commands._parse_duration("2h") == 7200.0 | |
| 519 | + assert commands._parse_duration("1d") == 86400.0 | |
| 520 | + assert commands._parse_duration("7x") is None | |
tests/unit/cli/test_train_prompt_repl_coverage.pyadded@@ -0,0 +1,465 @@ | ||
| 1 | +"""Coverage-oriented tests for train/prompt/repl command bodies.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | +from types import SimpleNamespace | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from typer.testing import CliRunner | |
| 10 | + | |
| 11 | +from dlm.cli.app import app | |
| 12 | + | |
| 13 | + | |
| 14 | +def _init_doc(tmp_path: Path, *, base: str = "smollm2-135m") -> Path: | |
| 15 | + doc = tmp_path / "doc.dlm" | |
| 16 | + runner = CliRunner() | |
| 17 | + result = runner.invoke( | |
| 18 | + app, | |
| 19 | + [ | |
| 20 | + "--home", | |
| 21 | + str(tmp_path / "home"), | |
| 22 | + "init", | |
| 23 | + str(doc), | |
| 24 | + "--base", | |
| 25 | + base, | |
| 26 | + ], | |
| 27 | + ) | |
| 28 | + assert result.exit_code == 0, result.output | |
| 29 | + return doc | |
| 30 | + | |
| 31 | + | |
| 32 | +def _fake_doctor_result() -> object: | |
| 33 | + return SimpleNamespace(plan=object(), capabilities=object()) | |
| 34 | + | |
| 35 | + | |
| 36 | +class TestTrainCommandCoverage: | |
| 37 | + def test_train_success_prints_phase_summary(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 38 | + doc = _init_doc(tmp_path) | |
| 39 | + runner = CliRunner() | |
| 40 | + | |
| 41 | + fake_result = SimpleNamespace( | |
| 42 | + adapter_version=1, | |
| 43 | + steps=3, | |
| 44 | + seed=42, | |
| 45 | + determinism=SimpleNamespace(class_="strict"), | |
| 46 | + adapter_path=tmp_path / "adapter", | |
| 47 | + log_path=tmp_path / "train.jsonl", | |
| 48 | + final_train_loss=0.125, | |
| 49 | + ) | |
| 50 | + fake_phase = SimpleNamespace(phase="sft", result=fake_result) | |
| 51 | + | |
| 52 | + monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result()) | |
| 53 | + monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1) | |
| 54 | + monkeypatch.setattr( | |
| 55 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 56 | + lambda *args, **kwargs: [fake_phase], | |
| 57 | + ) | |
| 58 | + | |
| 59 | + result = runner.invoke( | |
| 60 | + app, | |
| 61 | + ["--home", str(tmp_path / "home"), "train", str(doc), "--max-steps", "3"], | |
| 62 | + ) | |
| 63 | + assert result.exit_code == 0, result.output | |
| 64 | + assert "sft:" in result.output | |
| 65 | + assert "adapter:" in result.output | |
| 66 | + assert "0.125" in result.output | |
| 67 | + | |
| 68 | + def test_train_watch_with_rpc_starts_server(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 69 | + doc = _init_doc(tmp_path) | |
| 70 | + runner = CliRunner() | |
| 71 | + | |
| 72 | + fake_result = SimpleNamespace( | |
| 73 | + adapter_version=1, | |
| 74 | + steps=1, | |
| 75 | + seed=7, | |
| 76 | + determinism=SimpleNamespace(class_="strict"), | |
| 77 | + adapter_path=tmp_path / "adapter", | |
| 78 | + log_path=tmp_path / "train.jsonl", | |
| 79 | + final_train_loss=None, | |
| 80 | + ) | |
| 81 | + fake_phase = SimpleNamespace(phase="sft", result=fake_result) | |
| 82 | + | |
| 83 | + class _FakeQueue: | |
| 84 | + capacity = 123 | |
| 85 | + | |
| 86 | + def drain(self) -> list[object]: | |
| 87 | + return [] | |
| 88 | + | |
| 89 | + class _FakeServer: | |
| 90 | + def __init__(self, *, host: str, port: int, token: str, queue: object) -> None: | |
| 91 | + self.address = (host, port) | |
| 92 | + | |
| 93 | + def start(self) -> None: | |
| 94 | + return None | |
| 95 | + | |
| 96 | + def stop(self) -> None: | |
| 97 | + return None | |
| 98 | + | |
| 99 | + monkeypatch.setenv("DLM_PROBE_TOKEN", "secret") | |
| 100 | + monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result()) | |
| 101 | + monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1) | |
| 102 | + monkeypatch.setattr( | |
| 103 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 104 | + lambda *args, **kwargs: [fake_phase], | |
| 105 | + ) | |
| 106 | + monkeypatch.setattr("dlm.train.inject.InjectedProbeQueue", _FakeQueue) | |
| 107 | + monkeypatch.setattr("dlm.train.rpc.ProbeRpcServer", _FakeServer) | |
| 108 | + monkeypatch.setattr("dlm.watch.loop.run_watch", lambda *args, **kwargs: 0) | |
| 109 | + | |
| 110 | + result = runner.invoke( | |
| 111 | + app, | |
| 112 | + [ | |
| 113 | + "--home", | |
| 114 | + str(tmp_path / "home"), | |
| 115 | + "train", | |
| 116 | + str(doc), | |
| 117 | + "--watch", | |
| 118 | + "--listen-rpc", | |
| 119 | + "127.0.0.1:7777", | |
| 120 | + ], | |
| 121 | + ) | |
| 122 | + assert result.exit_code == 0, result.output | |
| 123 | + assert "rpc:" in result.output | |
| 124 | + assert "watch:" in result.output | |
| 125 | + | |
| 126 | + def test_train_noop_watch_repl_and_bounded_rpc_refusals( | |
| 127 | + self, | |
| 128 | + tmp_path: Path, | |
| 129 | + monkeypatch: Any, | |
| 130 | + ) -> None: | |
| 131 | + doc = _init_doc(tmp_path) | |
| 132 | + runner = CliRunner() | |
| 133 | + | |
| 134 | + monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result()) | |
| 135 | + monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1) | |
| 136 | + monkeypatch.setattr( | |
| 137 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 138 | + lambda *args, **kwargs: [], | |
| 139 | + ) | |
| 140 | + | |
| 141 | + no_op = runner.invoke( | |
| 142 | + app, | |
| 143 | + ["--home", str(tmp_path / "home"), "train", str(doc)], | |
| 144 | + ) | |
| 145 | + assert no_op.exit_code == 0, no_op.output | |
| 146 | + assert "nothing to train" in no_op.output | |
| 147 | + | |
| 148 | + fake_result = SimpleNamespace( | |
| 149 | + adapter_version=1, | |
| 150 | + steps=1, | |
| 151 | + seed=42, | |
| 152 | + determinism=SimpleNamespace(class_="strict"), | |
| 153 | + adapter_path=tmp_path / "adapter", | |
| 154 | + log_path=tmp_path / "train.jsonl", | |
| 155 | + final_train_loss=None, | |
| 156 | + ) | |
| 157 | + fake_phase = SimpleNamespace(phase="sft", result=fake_result) | |
| 158 | + monkeypatch.setattr( | |
| 159 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 160 | + lambda *args, **kwargs: [fake_phase], | |
| 161 | + ) | |
| 162 | + | |
| 163 | + watch_repl = runner.invoke( | |
| 164 | + app, | |
| 165 | + ["--home", str(tmp_path / "home"), "train", str(doc), "--watch", "--repl"], | |
| 166 | + ) | |
| 167 | + assert watch_repl.exit_code == 2, watch_repl.output | |
| 168 | + assert "not yet implemented" in watch_repl.output | |
| 169 | + | |
| 170 | + monkeypatch.setenv("DLM_PROBE_TOKEN", "secret") | |
| 171 | + bounded_rpc = runner.invoke( | |
| 172 | + app, | |
| 173 | + [ | |
| 174 | + "--home", | |
| 175 | + str(tmp_path / "home"), | |
| 176 | + "train", | |
| 177 | + str(doc), | |
| 178 | + "--listen-rpc", | |
| 179 | + "127.0.0.1:7777", | |
| 180 | + "--max-cycles", | |
| 181 | + "1", | |
| 182 | + ], | |
| 183 | + ) | |
| 184 | + assert bounded_rpc.exit_code == 2, bounded_rpc.output | |
| 185 | + assert "--watch for now" in bounded_rpc.output | |
| 186 | + | |
| 187 | + def test_multi_gpu_helper_and_strip(self, monkeypatch: Any) -> None: | |
| 188 | + from rich.console import Console | |
| 189 | + | |
| 190 | + from dlm.cli.commands import _maybe_dispatch_multi_gpu, _strip_gpus_from_argv | |
| 191 | + from dlm.train.distributed import UnsupportedGpuSpecError | |
| 192 | + | |
| 193 | + class _GpuSpec: | |
| 194 | + def __init__(self, device_ids: tuple[int, ...]) -> None: | |
| 195 | + self._device_ids = device_ids | |
| 196 | + | |
| 197 | + def resolve(self, device_count: int) -> tuple[int, ...]: | |
| 198 | + return self._device_ids | |
| 199 | + | |
| 200 | + console = Console(stderr=True) | |
| 201 | + monkeypatch.setattr( | |
| 202 | + "dlm.train.distributed.parse_gpus", | |
| 203 | + lambda raw: (_ for _ in ()).throw(UnsupportedGpuSpecError("bad gpus")), | |
| 204 | + ) | |
| 205 | + assert _maybe_dispatch_multi_gpu("bogus", ["dlm", "train"], console) == 2 | |
| 206 | + | |
| 207 | + monkeypatch.setattr("dlm.train.distributed.parse_gpus", lambda raw: _GpuSpec((0,))) | |
| 208 | + import torch | |
| 209 | + | |
| 210 | + monkeypatch.setattr(torch.cuda, "device_count", lambda: 2) | |
| 211 | + assert _maybe_dispatch_multi_gpu("1", ["dlm", "train"], console) is None | |
| 212 | + | |
| 213 | + launched: dict[str, object] = {} | |
| 214 | + monkeypatch.setattr("dlm.train.distributed.parse_gpus", lambda raw: _GpuSpec((1, 3))) | |
| 215 | + monkeypatch.setattr( | |
| 216 | + "dlm.train.distributed.launch_multi_gpu", | |
| 217 | + lambda device_ids, cli_args, mixed_precision="bf16": ( | |
| 218 | + launched.update( | |
| 219 | + { | |
| 220 | + "device_ids": device_ids, | |
| 221 | + "cli_args": cli_args, | |
| 222 | + "mixed_precision": mixed_precision, | |
| 223 | + } | |
| 224 | + ) | |
| 225 | + or 17 | |
| 226 | + ), | |
| 227 | + ) | |
| 228 | + exit_code = _maybe_dispatch_multi_gpu( | |
| 229 | + "1,3", | |
| 230 | + ["dlm", "train", "doc.dlm", "--gpus", "1,3"], | |
| 231 | + console, | |
| 232 | + ) | |
| 233 | + assert exit_code == 17 | |
| 234 | + assert launched["device_ids"] == (1, 3) | |
| 235 | + assert launched["cli_args"] == ["train", "doc.dlm"] | |
| 236 | + assert _strip_gpus_from_argv(["dlm", "train", "--gpus=0,1", "doc.dlm"]) == [ | |
| 237 | + "train", | |
| 238 | + "doc.dlm", | |
| 239 | + ] | |
| 240 | + | |
| 241 | + def test_train_error_mappings(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 242 | + doc = _init_doc(tmp_path) | |
| 243 | + runner = CliRunner() | |
| 244 | + | |
| 245 | + from dlm.lock.errors import LockValidationError | |
| 246 | + from dlm.train.errors import DiskSpaceError, OOMError, ResumeIntegrityError, TrainingError | |
| 247 | + from dlm.train.preference.errors import ( | |
| 248 | + DpoPhaseError, | |
| 249 | + NoPreferenceContentError, | |
| 250 | + PriorAdapterRequiredError, | |
| 251 | + ) | |
| 252 | + | |
| 253 | + monkeypatch.setattr("dlm.hardware.doctor", lambda **kwargs: _fake_doctor_result()) | |
| 254 | + monkeypatch.setattr("dlm.train.distributed.detect_world_size", lambda: 1) | |
| 255 | + | |
| 256 | + cases = [ | |
| 257 | + ( | |
| 258 | + LockValidationError(path=tmp_path / "dlm.lock", reasons=["torch drift"]), | |
| 259 | + "Re-run with", | |
| 260 | + ), | |
| 261 | + (DiskSpaceError(required_bytes=2_000_000_000, free_bytes=1_000_000_000), "disk:"), | |
| 262 | + (ResumeIntegrityError("resume mismatch"), "resume:"), | |
| 263 | + (NoPreferenceContentError("no preferences"), "dpo:"), | |
| 264 | + (PriorAdapterRequiredError("need prior adapter"), "dpo:"), | |
| 265 | + (DpoPhaseError("dpo failed"), "dpo:"), | |
| 266 | + (TrainingError("trainer failed"), "training:"), | |
| 267 | + ] | |
| 268 | + for error, needle in cases: | |
| 269 | + monkeypatch.setattr( | |
| 270 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 271 | + lambda *args, _error=error, **kwargs: (_ for _ in ()).throw(_error), | |
| 272 | + ) | |
| 273 | + result = runner.invoke( | |
| 274 | + app, | |
| 275 | + ["--home", str(tmp_path / "home"), "train", str(doc)], | |
| 276 | + ) | |
| 277 | + assert result.exit_code == 1, result.output | |
| 278 | + assert needle in result.output | |
| 279 | + | |
| 280 | + monkeypatch.setattr( | |
| 281 | + "dlm.train.preference.phase_orchestrator.run_phases", | |
| 282 | + lambda *args, **kwargs: (_ for _ in ()).throw( | |
| 283 | + OOMError( | |
| 284 | + step=5, | |
| 285 | + peak_bytes=2_000, | |
| 286 | + free_at_start_bytes=4_000, | |
| 287 | + current_grad_accum=1, | |
| 288 | + recommended_grad_accum=4, | |
| 289 | + ) | |
| 290 | + ), | |
| 291 | + ) | |
| 292 | + monkeypatch.setattr("dlm.train.format_oom_message", lambda **kwargs: "OOM advice") | |
| 293 | + oom = runner.invoke( | |
| 294 | + app, | |
| 295 | + ["--home", str(tmp_path / "home"), "train", str(doc)], | |
| 296 | + ) | |
| 297 | + assert oom.exit_code == 1, oom.output | |
| 298 | + assert "OOM advice" in oom.output | |
| 299 | + | |
| 300 | + | |
| 301 | +class TestPromptAndReplCoverage: | |
| 302 | + def test_prompt_text_backend_reads_stdin_and_generates( | |
| 303 | + self, tmp_path: Path, monkeypatch: Any | |
| 304 | + ) -> None: | |
| 305 | + doc = _init_doc(tmp_path) | |
| 306 | + runner = CliRunner() | |
| 307 | + | |
| 308 | + class _FakeBackend: | |
| 309 | + def load(self, spec: object, store: object, adapter_name: str | None = None) -> None: | |
| 310 | + return None | |
| 311 | + | |
| 312 | + def generate(self, query: str, **kwargs: object) -> str: | |
| 313 | + return f"reply:{query}" | |
| 314 | + | |
| 315 | + monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object())) | |
| 316 | + monkeypatch.setattr( | |
| 317 | + "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch" | |
| 318 | + ) | |
| 319 | + monkeypatch.setattr( | |
| 320 | + "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend() | |
| 321 | + ) | |
| 322 | + | |
| 323 | + result = runner.invoke( | |
| 324 | + app, | |
| 325 | + ["--home", str(tmp_path / "home"), "prompt", str(doc)], | |
| 326 | + input="hello from stdin\n", | |
| 327 | + ) | |
| 328 | + assert result.exit_code == 0, result.output | |
| 329 | + assert "reply:hello from stdin" in result.output | |
| 330 | + | |
| 331 | + def test_repl_success_and_adapter_validation(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 332 | + doc = _init_doc(tmp_path) | |
| 333 | + runner = CliRunner() | |
| 334 | + | |
| 335 | + adapter_bad = runner.invoke( | |
| 336 | + app, | |
| 337 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--adapter", "knowledge"], | |
| 338 | + ) | |
| 339 | + assert adapter_bad.exit_code == 2, adapter_bad.output | |
| 340 | + assert "only valid on multi-adapter" in adapter_bad.output | |
| 341 | + | |
| 342 | + class _FakeBackend: | |
| 343 | + def __init__(self) -> None: | |
| 344 | + self._loaded = SimpleNamespace(tokenizer="tok") | |
| 345 | + | |
| 346 | + def load(self, spec: object, store: object, adapter_name: str | None = None) -> None: | |
| 347 | + return None | |
| 348 | + | |
| 349 | + monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object())) | |
| 350 | + monkeypatch.setattr( | |
| 351 | + "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch" | |
| 352 | + ) | |
| 353 | + monkeypatch.setattr( | |
| 354 | + "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend() | |
| 355 | + ) | |
| 356 | + monkeypatch.setattr("dlm.repl.app.run_repl", lambda session, console: 5) | |
| 357 | + | |
| 358 | + repl_ok = runner.invoke( | |
| 359 | + app, | |
| 360 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"], | |
| 361 | + ) | |
| 362 | + assert repl_ok.exit_code == 5, repl_ok.output | |
| 363 | + | |
| 364 | + def test_repl_error_mappings(self, tmp_path: Path, monkeypatch: Any) -> None: | |
| 365 | + doc = _init_doc(tmp_path) | |
| 366 | + runner = CliRunner() | |
| 367 | + | |
| 368 | + from dlm.base_models.errors import GatedModelError | |
| 369 | + from dlm.inference import AdapterNotFoundError | |
| 370 | + from dlm.inference.backends.select import UnsupportedBackendError | |
| 371 | + | |
| 372 | + original = doc.read_text(encoding="utf-8") | |
| 373 | + fm_end = original.find("\n---\n", original.find("---") + 3) | |
| 374 | + multi = tmp_path / "multi.dlm" | |
| 375 | + multi.write_text( | |
| 376 | + original[:fm_end] + "\ntraining:\n adapters:\n knowledge: {}\n" + original[fm_end:], | |
| 377 | + encoding="utf-8", | |
| 378 | + ) | |
| 379 | + unknown = runner.invoke( | |
| 380 | + app, | |
| 381 | + ["--home", str(tmp_path / "home"), "repl", str(multi), "--adapter", "ghost"], | |
| 382 | + ) | |
| 383 | + assert unknown.exit_code == 2, unknown.output | |
| 384 | + assert "not declared" in unknown.output | |
| 385 | + | |
| 386 | + monkeypatch.setattr( | |
| 387 | + "dlm.base_models.resolve", | |
| 388 | + lambda *args, **kwargs: (_ for _ in ()).throw( | |
| 389 | + GatedModelError("hf/model", "https://license") | |
| 390 | + ), | |
| 391 | + ) | |
| 392 | + gated = runner.invoke( | |
| 393 | + app, | |
| 394 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"], | |
| 395 | + ) | |
| 396 | + assert gated.exit_code == 1, gated.output | |
| 397 | + assert "run `dlm train --i-accept-license` first" in gated.output | |
| 398 | + | |
| 399 | + monkeypatch.setattr("dlm.base_models.resolve", lambda *args, **kwargs: SimpleNamespace()) | |
| 400 | + monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object())) | |
| 401 | + monkeypatch.setattr( | |
| 402 | + "dlm.inference.backends.select_backend", | |
| 403 | + lambda *args, **kwargs: (_ for _ in ()).throw( | |
| 404 | + UnsupportedBackendError("backend not available") | |
| 405 | + ), | |
| 406 | + ) | |
| 407 | + unsupported = runner.invoke( | |
| 408 | + app, | |
| 409 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"], | |
| 410 | + ) | |
| 411 | + assert unsupported.exit_code == 2, unsupported.output | |
| 412 | + assert "backend not available" in unsupported.output | |
| 413 | + | |
| 414 | + class _MissingAdapterBackend: | |
| 415 | + def load(self, spec: object, store: object, adapter_name: str | None = None) -> None: | |
| 416 | + raise AdapterNotFoundError("missing adapter") | |
| 417 | + | |
| 418 | + monkeypatch.setattr( | |
| 419 | + "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch" | |
| 420 | + ) | |
| 421 | + monkeypatch.setattr( | |
| 422 | + "dlm.inference.backends.build_backend", | |
| 423 | + lambda *args, **kwargs: _MissingAdapterBackend(), | |
| 424 | + ) | |
| 425 | + missing = runner.invoke( | |
| 426 | + app, | |
| 427 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "pytorch"], | |
| 428 | + ) | |
| 429 | + assert missing.exit_code == 1, missing.output | |
| 430 | + assert "missing adapter" in missing.output | |
| 431 | + | |
| 432 | + def test_prompt_empty_query_and_repl_invalid_backend( | |
| 433 | + self, | |
| 434 | + tmp_path: Path, | |
| 435 | + monkeypatch: Any, | |
| 436 | + ) -> None: | |
| 437 | + doc = _init_doc(tmp_path) | |
| 438 | + runner = CliRunner() | |
| 439 | + | |
| 440 | + class _FakeBackend: | |
| 441 | + def load(self, spec: object, store: object, adapter_name: str | None = None) -> None: | |
| 442 | + return None | |
| 443 | + | |
| 444 | + monkeypatch.setattr("dlm.hardware.doctor", lambda: SimpleNamespace(capabilities=object())) | |
| 445 | + monkeypatch.setattr( | |
| 446 | + "dlm.inference.backends.select_backend", lambda *args, **kwargs: "pytorch" | |
| 447 | + ) | |
| 448 | + monkeypatch.setattr( | |
| 449 | + "dlm.inference.backends.build_backend", lambda *args, **kwargs: _FakeBackend() | |
| 450 | + ) | |
| 451 | + | |
| 452 | + prompt_result = runner.invoke( | |
| 453 | + app, | |
| 454 | + ["--home", str(tmp_path / "home"), "prompt", str(doc)], | |
| 455 | + input="", | |
| 456 | + ) | |
| 457 | + assert prompt_result.exit_code == 2, prompt_result.output | |
| 458 | + assert "empty query" in prompt_result.output | |
| 459 | + | |
| 460 | + repl_result = runner.invoke( | |
| 461 | + app, | |
| 462 | + ["--home", str(tmp_path / "home"), "repl", str(doc), "--backend", "bogus"], | |
| 463 | + ) | |
| 464 | + assert repl_result.exit_code == 2, repl_result.output | |
| 465 | + assert "--backend must be" in repl_result.output | |