Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions ThermoScreening/cli/dftb_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +72,7 @@ class Diagnostic:
name: str
ok: bool
detail: str
optional: bool = False


def default_install_root() -> Path:
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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)
5 changes: 3 additions & 2 deletions ThermoScreening/cli/thermo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
34 changes: 32 additions & 2 deletions tests/cli/test_dftb_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -231,21 +232,50 @@ 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),
(REQUIRED_PARAMETER_FILE, False),
]


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
19 changes: 18 additions & 1 deletion tests/thermo/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Loading