File size: 14,858 Bytes
eb23a18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
"""Tests for v3.4.0: Biomni HPC backend fully removed; every step runs locally.

These tests assert that:
  * the ``_hpc.py`` shim is gone and no module under ``core/`` imports ``biomni``
    or calls ``hpc_run_tool`` / ``hpc_get_job_results`` / ``hpc_search_tools``;
  * ``Boltz2Config`` no longer exposes ``use_hpc`` / ``hpc_tool_id`` and its
    Boltz cache default is a user-home path (not an HPC-cluster path);
  * the new local-run config fields exist (antibody RFAntibody dirs, ThermoMPNN);
  * the CLI exposes and wires the new local flags;
  * the local/skip/graft dispatch paths behave correctly on a box with no GPU,
    no local binaries, and no API key (fold -> graft or skip, dock -> skip,
    ddG -> ESM2 tier, antibody -> clean skip).

The suite is designed to pass on a CPU-only sandbox with none of the external
binaries (boltz, thermompnn, rfantibody, boltzgen) installed.
"""
import ast
import os
import glob
import importlib
import inspect
import tempfile

import pytest

import proteoform_analyzer
from proteoform_analyzer.core.config import (
    Boltz2Config, AntibodyConfig, AnalysisConfig,
)

# Root of the installed package (…/proteoform_analyzer)
PKG_ROOT = os.path.dirname(os.path.abspath(proteoform_analyzer.__file__))
STEPS_DIR = os.path.join(PKG_ROOT, "core", "steps")


# ---------------------------------------------------------------------------
# 0. Version
# ---------------------------------------------------------------------------

def test_version_is_340():
    assert proteoform_analyzer.__version__ == "3.4.0"


# ---------------------------------------------------------------------------
# 1. The HPC shim is deleted
# ---------------------------------------------------------------------------

def test_hpc_shim_deleted():
    assert not os.path.exists(os.path.join(STEPS_DIR, "_hpc.py")), \
        "core/steps/_hpc.py must be deleted in v3.4.0"


def test_hpc_shim_not_importable():
    with pytest.raises(ModuleNotFoundError):
        importlib.import_module("proteoform_analyzer.core.steps._hpc")


# ---------------------------------------------------------------------------
# 2. No active HPC / biomni code references anywhere under core/ (source scan)
# ---------------------------------------------------------------------------

# Substrings that would indicate a *live* HPC/biomni dependency. Historical
# changelog wording ("removed in v3.4.0") is allowed, so we scan for the actual
# call/import tokens rather than the word "HPC".
_FORBIDDEN_TOKENS = (
    "hpc_run_tool",
    "hpc_get_job_results",
    "hpc_search_tools",
    "hpc_get_logs",
    "hpc_cancel_job",
    "get_hpc_run_tool",
    "get_hpc_get_job_results",
    "HpcUnavailable",
    "import biomni",
    "from biomni",
    "from ._hpc",
    "import _hpc",
    ".hpc_tool_id",
    "use_hpc",
)


def _py_files_under(root):
    return glob.glob(os.path.join(root, "**", "*.py"), recursive=True)


def test_no_active_hpc_tokens_in_core():
    core_dir = os.path.join(PKG_ROOT, "core")
    offenders = {}
    for fp in _py_files_under(core_dir):
        with open(fp, "r", encoding="utf-8") as fh:
            src = fh.read()
        hits = [tok for tok in _FORBIDDEN_TOKENS if tok in src]
        if hits:
            offenders[os.path.relpath(fp, PKG_ROOT)] = hits
    assert not offenders, f"Active HPC/biomni tokens found: {offenders}"


def test_no_active_hpc_tokens_in_cli_and_gui():
    offenders = {}
    for fp in (os.path.join(PKG_ROOT, "cli.py"), os.path.join(PKG_ROOT, "gui.py")):
        with open(fp, "r", encoding="utf-8") as fh:
            src = fh.read()
        hits = [tok for tok in _FORBIDDEN_TOKENS if tok in src]
        if hits:
            offenders[os.path.basename(fp)] = hits
    assert not offenders, f"Active HPC/biomni tokens found: {offenders}"


def test_biomni_not_imported_by_package_tree():
    """AST-level: no module in the package has a real `import biomni` statement."""
    offenders = []
    for fp in _py_files_under(PKG_ROOT):
        # skip the test suite itself
        if os.sep + "tests" + os.sep in fp:
            continue
        with open(fp, "r", encoding="utf-8") as fh:
            try:
                tree = ast.parse(fh.read())
            except SyntaxError:
                offenders.append((fp, "SYNTAX ERROR"))
                continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for n in node.names:
                    if n.name.split(".")[0] == "biomni":
                        offenders.append((os.path.relpath(fp, PKG_ROOT), n.name))
            elif isinstance(node, ast.ImportFrom):
                if (node.module or "").split(".")[0] == "biomni":
                    offenders.append((os.path.relpath(fp, PKG_ROOT), node.module))
    assert not offenders, f"biomni imports found: {offenders}"


# ---------------------------------------------------------------------------
# 3. Boltz2Config: HPC fields removed, cache default changed
# ---------------------------------------------------------------------------

def test_boltz2config_hpc_fields_removed():
    b = Boltz2Config()
    assert not hasattr(b, "use_hpc"), "Boltz2Config.use_hpc must be removed"
    assert not hasattr(b, "hpc_tool_id"), "Boltz2Config.hpc_tool_id must be removed"


def test_boltz2config_rejects_hpc_kwargs():
    with pytest.raises(TypeError):
        Boltz2Config(use_hpc=True)
    with pytest.raises(TypeError):
        Boltz2Config(hpc_tool_id="boltz-2")


def test_boltz2config_cache_default_is_home():
    b = Boltz2Config()
    # Must no longer point at the old HPC-cluster path.
    assert "/mnt/fsx" not in b.cache_dir
    assert b.cache_dir == os.path.expanduser("~/.cache/boltz")


# ---------------------------------------------------------------------------
# 4. New local-run config fields
# ---------------------------------------------------------------------------

def test_antibody_local_fields_exist():
    a = AntibodyConfig()
    for f in ("local_rfantibody_dir", "local_weights_dir",
              "local_python", "local_framework_pdb"):
        assert hasattr(a, f), f"AntibodyConfig.{f} missing"
        assert getattr(a, f) is None  # default: unconfigured


def test_thermompnn_config_fields_exist():
    c = AnalysisConfig()
    for f in ("thermompnn_dir", "thermompnn_script",
              "thermompnn_checkpoint", "thermompnn_python"):
        assert hasattr(c, f), f"AnalysisConfig.{f} missing"
        assert getattr(c, f) is None


# ---------------------------------------------------------------------------
# 5. CLI exposes + wires the new local flags
# ---------------------------------------------------------------------------

def test_cli_has_new_local_flags():
    from proteoform_analyzer.cli import build_parser
    parser = build_parser()
    help_txt = parser.format_help() if hasattr(parser, "format_help") else ""
    # Parse a representative command line and confirm the fields land on args.
    args = parser.parse_args([
        "run", "--uniprot", "P69905", "--n-subunits", "1",
        "--antibody",
        "--antibody-rfantibody-dir", "/tmp/rfab",
        "--antibody-weights-dir", "/tmp/w",
        "--thermompnn-dir", "/tmp/tmpnn",
        "--thermompnn-checkpoint", "/tmp/ckpt.pt",
    ])
    assert args.antibody_rfantibody_dir == "/tmp/rfab"
    assert args.antibody_weights_dir == "/tmp/w"
    assert args.thermompnn_dir == "/tmp/tmpnn"
    assert args.thermompnn_checkpoint == "/tmp/ckpt.pt"


def test_cli_structure_source_help_has_no_hpc():
    from proteoform_analyzer.cli import build_parser
    parser = build_parser()
    txt = parser.format_help()
    # The word "HPC" must not survive in the run-subcommand help text.
    # (We check the subparser help by formatting the full parser tree.)
    assert "HPC" not in txt


# ---------------------------------------------------------------------------
# 6. Folding dispatch: graft / skip on a bare box
# ---------------------------------------------------------------------------

def _bare_cfg(monkeypatch, **boltz_kw):
    """AnalysisConfig with a Boltz2Config; helper for dispatch tests."""
    ac = AnalysisConfig(uniprot_ids=["P69905"], n_subunits=1)
    ac.boltz2 = Boltz2Config(**boltz_kw)
    return ac


def test_fold_dispatch_functions_present():
    from proteoform_analyzer.core.steps import boltz2_fold as bf
    # Local + graft dispatch helpers must exist; HPC helpers must be gone.
    for fn in ("_fold_local", "_fold_graft", "_fold_api", "build_structures",
               "_resolve_local_binary", "_run_local_boltz"):
        assert hasattr(bf, fn), f"missing {fn}"
    for gone in ("_submit_hpc", "_hpc_command", "_try_collect_job",
                 "_manifest_path", "_load_manifest", "_save_manifest"):
        assert not hasattr(bf, gone), f"{gone} should have been deleted"


def test_build_structures_skips_cleanly_without_backend(monkeypatch):
    """No API key, no local binary, graft disabled -> clean skip (not crash)."""
    from proteoform_analyzer.core.steps import boltz2_fold as bf
    from proteoform_analyzer.core.steps import _boltz_backend as bb

    monkeypatch.setattr(bb, "resolve_backend", lambda cfg, kind: "none")

    cfg = _bare_cfg(monkeypatch, allow_graft_fallback=False)
    with tempfile.TemporaryDirectory() as tmp:
        paths = {k: os.path.join(tmp, k) for k in ("pdbs", "pdbs_monomer")}
        for d in paths.values():
            os.makedirs(d, exist_ok=True)
        res = bf.build_structures(cfg, paths)
    assert res.status in ("skipped", "ok")
    # On a bare box with backend 'none', it must not be 'failed'.
    assert res.status != "failed", res.message


# ---------------------------------------------------------------------------
# 7. Docking: clean skip when backend resolves to none
# ---------------------------------------------------------------------------

def test_docking_boltz2_skips_cleanly_without_backend(monkeypatch):
    from proteoform_analyzer.core.steps import docking as dk
    from proteoform_analyzer.core.steps import _boltz_backend as bb

    # Ensure the boltz2 docking path resolves to 'none' and no local binary.
    monkeypatch.setattr(bb, "resolve_backend", lambda cfg, kind: "none")

    # _run_boltz2_dock is the internal entry; assert it exists and skips.
    assert hasattr(dk, "_run_boltz2_dock")
    src = inspect.getsource(dk)
    # The HPC submit branch must be gone from the docking source.
    for tok in ("hpc_run_tool", "get_hpc_run_tool", "hpc_tool_id"):
        assert tok not in src, f"docking.py still references {tok}"


# ---------------------------------------------------------------------------
# 8. ddG: ThermoMPNN local resolver + ESM2 fallback structure
# ---------------------------------------------------------------------------

def test_ddg_thermompnn_resolver_returns_none_when_unconfigured():
    from proteoform_analyzer.core.steps import ddg
    cfg = AnalysisConfig()  # thermompnn_* all None
    assert ddg._resolve_thermompnn(cfg) is None


def test_ddg_thermompnn_resolver_needs_checkpoint(tmp_path):
    """A dir with a script but no checkpoint must not resolve (falls back to ESM2)."""
    from proteoform_analyzer.core.steps import ddg
    tdir = tmp_path / "thermompnn"
    tdir.mkdir()
    (tdir / "custom_inference.py").write_text("# stub\n")
    cfg = AnalysisConfig(thermompnn_dir=str(tdir))  # no checkpoint
    assert ddg._resolve_thermompnn(cfg) is None


def test_ddg_has_esm2_fallback_and_local_thermompnn():
    from proteoform_analyzer.core.steps import ddg
    for fn in ("_resolve_thermompnn", "_thermompnn_for_chain_local",
               "_run_thermompnn", "_esm2_zeroshot_ddg", "_run_esm2_zeroshot",
               "run_ddg"):
        assert hasattr(ddg, fn), f"missing {fn}"
    # The old HPC ThermoMPNN submit helper must be gone.
    assert not hasattr(ddg, "_thermompnn_for_chain"), \
        "_thermompnn_for_chain (HPC submit) should have been replaced"
    # ddg must not import biomni / call hpc.
    src = inspect.getsource(ddg)
    for tok in ("hpc_run_tool", "biomni", "get_hpc_run_tool"):
        assert tok not in src, f"ddg.py still references {tok}"


# ---------------------------------------------------------------------------
# 9. Antibody: local RFAntibody or clean skip (no HPC)
# ---------------------------------------------------------------------------

def test_antibody_no_hpc_helpers():
    from proteoform_analyzer.core.steps import antibody as ab
    for gone in ("_load_manifest", "_save_manifest", "_manifest_path",
                 "_submit", "_hpc_command"):
        assert not hasattr(ab, gone), f"{gone} should have been deleted"
    for fn in ("_resolve_local_rfantibody", "_stage1_cmd", "_stage2_cmd",
               "_stage3_cmd", "_run_stage", "run_antibody"):
        assert hasattr(ab, fn), f"missing {fn}"
    src = inspect.getsource(ab)
    for tok in ("hpc_run_tool", "biomni", "get_hpc_run_tool", "HpcUnavailable"):
        assert tok not in src, f"antibody.py still references {tok}"


def test_antibody_resolver_none_when_unconfigured():
    from proteoform_analyzer.core.steps.antibody import _resolve_local_rfantibody
    cfg = AnalysisConfig()
    cfg.antibody.enabled = True
    # local_rfantibody_dir is None -> no runnable install
    assert _resolve_local_rfantibody(cfg) is None


def test_antibody_skips_cleanly_without_local_install(tmp_path):
    """Enabled antibody step with a target but no local RFAntibody -> clean skip."""
    from proteoform_analyzer.core.steps.antibody import run_antibody

    paths = {k: str(tmp_path / k) for k in ("antibody", "pdbs", "pdbs_monomer")}
    for d in paths.values():
        os.makedirs(d, exist_ok=True)
    # Minimal WT monomer PDB (two CA atoms, chain A).
    pdb = os.path.join(paths["pdbs_monomer"], "wt-monomer.pdb")
    with open(pdb, "w") as f:
        f.write("ATOM      1  CA  ALA A 305      11.000  11.000  11.000  1.00  0.00           C\n")
        f.write("ATOM      2  CA  GLY A 306      15.000  15.000  15.000  1.00  0.00           C\n")
        f.write("END\n")

    cfg = AnalysisConfig(uniprot_ids=["P69905"], n_subunits=1)
    cfg.antibody.enabled = True
    cfg.antibody.hotspot_source = "user"
    cfg.antibody.hotspot_residues = ["305"]

    res = run_antibody(cfg, paths)
    assert res.status == "skipped"
    assert "local RFAntibody" in res.message
    # No design_context.json is written when we skip before provenance.
    assert not os.path.exists(os.path.join(paths["antibody"], "design_context.json"))


def test_antibody_disabled_skips():
    from proteoform_analyzer.core.steps.antibody import run_antibody
    cfg = AnalysisConfig()
    with tempfile.TemporaryDirectory() as tmp:
        res = run_antibody(cfg, {"antibody": os.path.join(tmp, "ab")})
    assert res.status == "skipped"
    assert "not enabled" in res.message