| 1 | """C7: positively assert that sway works without ``dlm`` installed. |
| 2 | |
| 3 | The architectural promise from the plan: ``dlm-sway`` does not depend |
| 4 | on the ``dlm`` package; the bridge under |
| 5 | ``src/dlm_sway/integrations/dlm/`` is the only place that imports it, |
| 6 | and only when actually invoked. |
| 7 | |
| 8 | This test simulates the dlm-not-installed environment by patching |
| 9 | ``sys.modules['dlm'] = None`` (which makes Python's import machinery |
| 10 | raise ``ModuleNotFoundError`` on subsequent ``import dlm``). It then: |
| 11 | |
| 12 | 1. Builds a small dummy-backend suite end-to-end and confirms it runs |
| 13 | to completion with no ImportError. |
| 14 | 2. Invokes ``sway autogen`` and confirms a clean error pointing at |
| 15 | the ``[dlm]`` extra (rather than a stack trace). |
| 16 | |
| 17 | The integration suite cannot actually uninstall dlm at test time, so |
| 18 | this is the next-best regression guard — it pins the lazy-import |
| 19 | boundary against future refactors that might pull dlm into a |
| 20 | top-level ``dlm_sway.*`` import path. |
| 21 | """ |
| 22 | |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import sys |
| 26 | |
| 27 | import pytest |
| 28 | from typer.testing import CliRunner |
| 29 | |
| 30 | from dlm_sway.backends.dummy import DummyDifferentialBackend, DummyResponses |
| 31 | from dlm_sway.cli.app import app |
| 32 | from dlm_sway.suite.runner import run as run_suite |
| 33 | from dlm_sway.suite.spec import SwaySpec |
| 34 | |
| 35 | |
| 36 | @pytest.fixture |
| 37 | def dlm_unimportable(monkeypatch: pytest.MonkeyPatch): |
| 38 | """Make ``import dlm`` raise ModuleNotFoundError for the test.""" |
| 39 | # Block the parent package and any submodule the bridge imports. |
| 40 | for name in ( |
| 41 | "dlm", |
| 42 | "dlm.doc", |
| 43 | "dlm.doc.parser", |
| 44 | "dlm.base_models", |
| 45 | "dlm.store", |
| 46 | "dlm.store.paths", |
| 47 | "dlm.data", |
| 48 | "dlm.data.instruction_parser", |
| 49 | "dlm.data.preference_parser", |
| 50 | ): |
| 51 | monkeypatch.setitem(sys.modules, name, None) |
| 52 | |
| 53 | |
| 54 | def test_dummy_suite_runs_without_dlm(dlm_unimportable) -> None: |
| 55 | """A normal suite run never touches ``dlm`` and must work without it.""" |
| 56 | backend = DummyDifferentialBackend(base=DummyResponses(), ft=DummyResponses()) |
| 57 | spec = SwaySpec.model_validate( |
| 58 | { |
| 59 | "version": 1, |
| 60 | "models": { |
| 61 | "base": {"base": "b"}, |
| 62 | "ft": {"base": "b", "adapter": "/tmp/a"}, |
| 63 | }, |
| 64 | "suite": [ |
| 65 | { |
| 66 | "name": "dk", |
| 67 | "kind": "delta_kl", |
| 68 | "prompts": ["q1", "q2"], |
| 69 | "assert_mean_gte": 0.0, |
| 70 | } |
| 71 | ], |
| 72 | } |
| 73 | ) |
| 74 | result = run_suite(spec, backend) |
| 75 | assert len(result.probes) == 1 |
| 76 | |
| 77 | |
| 78 | def test_autogen_emits_clean_error_without_dlm(dlm_unimportable, tmp_path) -> None: |
| 79 | """``sway autogen`` is the one path that needs dlm — it must surface |
| 80 | a clean error pointing at the ``[dlm]`` extra, not a stack trace.""" |
| 81 | fake_dlm = tmp_path / "doesnt-matter.dlm" |
| 82 | fake_dlm.write_text("# stub\n") |
| 83 | result = CliRunner().invoke(app, ["autogen", str(fake_dlm)]) |
| 84 | assert result.exit_code == 2, ( |
| 85 | f"expected exit 2; got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr if result.stderr_bytes else ''}" |
| 86 | ) |
| 87 | combined = (result.stdout or "") + (result.stderr or "") |
| 88 | assert "dlm-sway[dlm]" in combined, f"expected install hint in error output; got {combined!r}" |