diff --git a/ThermoScreening/cli/dftb_setup.py b/ThermoScreening/cli/dftb_setup.py index a0c8a4b..64bd9a1 100644 --- a/ThermoScreening/cli/dftb_setup.py +++ b/ThermoScreening/cli/dftb_setup.py @@ -4,8 +4,10 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +import importlib.util import os import shutil import tarfile @@ -70,6 +72,7 @@ class Diagnostic: name: str ok: bool detail: str + optional: bool = False def default_install_root() -> Path: @@ -287,9 +290,42 @@ def dftb_prefix_export(parameter_dir: str | Path) -> str: return f'export DFTB_PREFIX="{Path(parameter_dir).expanduser().resolve()}{os.sep}"' +def _xtb_diagnostics(env: Mapping[str, str]) -> list[Diagnostic]: + """ + Optional checks for the xTB engines (``--engine xtb`` and ``xtb-cli``). + """ + + # native xtb binary (for --engine xtb-cli): honour XTB_COMMAND, then PATH + xtb_command = env.get("XTB_COMMAND") or "xtb" + xtb_path = shutil.which(xtb_command) + xtb = Diagnostic( + "xtb", + xtb_path is not None, + xtb_path or "not found (set XTB_COMMAND or `conda install -c conda-forge xtb`; " + "needed for --engine xtb-cli)", + optional=True, + ) + + # tblite python package (for the in-process --engine xtb) + tblite_ok = importlib.util.find_spec("tblite") is not None + tblite = Diagnostic( + "tblite", + tblite_ok, + "importable" if tblite_ok + else "not importable (`conda install -c conda-forge tblite-python`; " + "needed for --engine xtb)", + optional=True, + ) + + return [xtb, tblite] + + def check_dftb_setup(env: dict[str, str] | None = None) -> list[Diagnostic]: """ - Check whether DFTB+ executables and parameters are available. + Check whether the calculation backends are available. + + Reports the DFTB+ toolchain (dftb+, modes, DFTB_PREFIX + a Slater-Koster + file) as required, and the xTB toolchain (xtb binary, tblite) as optional. """ current_env = os.environ if env is None else env @@ -314,25 +350,25 @@ def check_dftb_setup(env: dict[str, str] | None = None) -> list[Diagnostic]: Diagnostic(REQUIRED_PARAMETER_FILE, False, "DFTB_PREFIX is not set"), ] ) - return diagnostics - - parameter_dir = Path(prefix).expanduser() - parameter_file = parameter_dir / REQUIRED_PARAMETER_FILE - diagnostics.extend( - [ - Diagnostic( - "DFTB_PREFIX", - parameter_dir.is_dir(), - str(parameter_dir.resolve()) if parameter_dir.is_dir() else "directory not found", - ), - Diagnostic( - REQUIRED_PARAMETER_FILE, - parameter_file.is_file(), - str(parameter_file.resolve()) if parameter_file.is_file() else "not found", - ), - ] - ) + else: + parameter_dir = Path(prefix).expanduser() + parameter_file = parameter_dir / REQUIRED_PARAMETER_FILE + diagnostics.extend( + [ + Diagnostic( + "DFTB_PREFIX", + parameter_dir.is_dir(), + str(parameter_dir.resolve()) if parameter_dir.is_dir() else "directory not found", + ), + Diagnostic( + REQUIRED_PARAMETER_FILE, + parameter_file.is_file(), + str(parameter_file.resolve()) if parameter_file.is_file() else "not found", + ), + ] + ) + diagnostics.extend(_xtb_diagnostics(current_env)) return diagnostics @@ -346,6 +382,7 @@ def format_diagnostics(diagnostics: list[Diagnostic]) -> str: for item in diagnostics: status = "found" if item.ok else "missing" - lines.append(f"{item.name:<{width}} {status:<7} {item.detail}") + suffix = " (optional)" if item.optional and not item.ok else "" + lines.append(f"{item.name:<{width}} {status:<7} {item.detail}{suffix}") return "\n".join(lines) diff --git a/ThermoScreening/cli/thermo.py b/ThermoScreening/cli/thermo.py index 31f6441..c8cb066 100644 --- a/ThermoScreening/cli/thermo.py +++ b/ThermoScreening/cli/thermo.py @@ -180,13 +180,14 @@ def run_setup_dftb(parser_args): def run_doctor(): """ - Check whether DFTB+ executables and parameters are available. + Check whether the calculation backends are available. """ diagnostics = check_dftb_setup() print(format_diagnostics(diagnostics)) - return 0 if all(item.ok for item in diagnostics) else 1 + # optional backends (xtb, tblite) do not fail the check + return 0 if all(item.ok for item in diagnostics if not item.optional) else 1 def run_screen(parser_args): diff --git a/tests/cli/test_dftb_setup.py b/tests/cli/test_dftb_setup.py index 19b26cf..562b69c 100644 --- a/tests/cli/test_dftb_setup.py +++ b/tests/cli/test_dftb_setup.py @@ -218,7 +218,8 @@ def fake_which(command): diagnostics = check_dftb_setup({"DFTB_PREFIX": str(tmp_path)}) - assert [(item.name, item.ok) for item in diagnostics] == [ + required = [(item.name, item.ok) for item in diagnostics if not item.optional] + assert required == [ ("dftb+", True), ("modes", True), ("DFTB_PREFIX", True), @@ -231,7 +232,8 @@ def test_check_dftb_setup_reports_missing_environment(monkeypatch): diagnostics = check_dftb_setup({}) - assert [(item.name, item.ok) for item in diagnostics] == [ + required = [(item.name, item.ok) for item in diagnostics if not item.optional] + assert required == [ ("dftb+", False), ("modes", False), ("DFTB_PREFIX", False), @@ -239,13 +241,41 @@ def test_check_dftb_setup_reports_missing_environment(monkeypatch): ] +def test_check_dftb_setup_reports_xtb_toolchain_as_optional(monkeypatch): + # xtb resolved via XTB_COMMAND; tblite importable + monkeypatch.setattr(dftb_setup.shutil, "which", lambda command: "/bin/" + command) + monkeypatch.setattr( + dftb_setup.importlib.util, "find_spec", + lambda name: object() if name == "tblite" else None, + ) + + optional = { + item.name: item + for item in check_dftb_setup({"XTB_COMMAND": "/opt/xtb"}) + if item.optional + } + assert set(optional) == {"xtb", "tblite"} + assert optional["xtb"].ok is True + assert optional["tblite"].ok is True + + # missing xtb toolchain -> reported, still optional + monkeypatch.setattr(dftb_setup.shutil, "which", lambda command: None) + monkeypatch.setattr(dftb_setup.importlib.util, "find_spec", lambda name: None) + missing = {item.name: item for item in check_dftb_setup({}) if item.optional} + assert missing["xtb"].ok is False and missing["xtb"].optional + assert missing["tblite"].ok is False + + def test_format_diagnostics_aligns_statuses(): output = format_diagnostics( [ Diagnostic("dftb+", True, "/usr/bin/dftb+"), Diagnostic("DFTB_PREFIX", False, "not set"), + Diagnostic("xtb", False, "not found", optional=True), ] ) assert "dftb+ found" in output assert "DFTB_PREFIX missing" in output + # a missing optional backend is marked so it doesn't read as a hard failure + assert "xtb missing not found (optional)" in output diff --git a/tests/thermo/test_main.py b/tests/thermo/test_main.py index 0a4c3e5..cec1959 100644 --- a/tests/thermo/test_main.py +++ b/tests/thermo/test_main.py @@ -154,7 +154,7 @@ def test_main_setup_dftb_downloads_solvent(monkeypatch, tmp_path, capsys): def test_main_runs_doctor(monkeypatch, capsys): - diagnostic = type("DiagnosticStub", (), {"ok": False})() + diagnostic = type("DiagnosticStub", (), {"ok": False, "optional": False})() monkeypatch.setattr( thermo, @@ -167,5 +167,22 @@ def test_main_runs_doctor(monkeypatch, capsys): assert thermo.main() == 1 assert capsys.readouterr().out == "not ready\n" + +def test_main_doctor_ignores_missing_optional_backend(monkeypatch, capsys): + required_ok = type("D", (), {"ok": True, "optional": False})() + optional_missing = type("D", (), {"ok": False, "optional": True})() + + monkeypatch.setattr( + thermo, "parse_args", lambda: argparse.Namespace(command="doctor") + ) + monkeypatch.setattr( + thermo, "check_dftb_setup", lambda: [required_ok, optional_missing] + ) + monkeypatch.setattr(thermo, "format_diagnostics", lambda diagnostics: "ok") + + # a missing optional backend (xtb/tblite) must not fail the doctor + assert thermo.main() == 0 + + if __name__ == '__main__': unittest.main()