"""The ``doctor`` command: diagnose config, backends, and science readiness (5.1b).
A read-only aggregation of the machinery that already exists: ``load_config``
-> ``build_runtime`` + ``build_stages`` (any config problem is an exit-2 with
its YAML path), then ``core.preflight.doctor_report`` (backend reachability +
location-aware per-stage requirement probes), the seed-enumeration report
(the n-ary blast radius), and -- when a screen stage is configured -- the
MACE model status (including the will-not-download-without-consent
disclosure).
The command renders and exits; it fixes nothing and never crashes on the
thing it diagnoses. Exit codes: 0 = everything healthy and ready, 1 =
problems found (the report names each one and its exact fix), 2 = config
error.
"""
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
from matmelt.commands import resolve_config_path
from matmelt.config import ConfigError, build_runtime, build_stages, load_config
from matmelt.core.preflight import (
aiida_status,
doctor_report,
enumeration_report,
mace_model_status,
)
from matmelt.ui.console import print_json_payload, render_config_error, render_doctor
if TYPE_CHECKING:
from matmelt.core.preflight import (
AiidaStatus,
DoctorReport,
EnumerationReport,
ModelStatus,
)
[docs]
def run_doctor(config_path: str, *, as_json: bool = False) -> int:
"""Diagnose the environment a config describes.
Parameters
----------
config_path
The config file to read.
as_json
Emit the report as a JSON payload on stdout instead of Rich tables.
Returns
-------
int
``0`` when every check passed, ``1`` when any problem was found, ``2``
for a config error.
Notes
-----
Three things are checked that the import probes alone cannot see: backend
reachability, the screen stage's ``model_path`` (config, not an importable
dependency -- and stat-ed *where the stage runs*), and, only when a
``calculate`` stage is configured, whether its AiiDA profile loads and its
code label resolves.
Stage readiness is probed on the interpreter that will actually run each
runner, so a remotely-wired stage is checked on its remote and any install
command is ssh-prefixed accordingly.
"""
config_path = resolve_config_path(config_path)
try:
config = load_config(config_path)
runtime = build_runtime(config)
stages = build_stages(config)
except ConfigError as exc:
render_config_error(str(exc))
return 2
report = doctor_report(stages, runtime.stage_backends, runtime.backends)
enumeration = enumeration_report(config.run.elements, config.run.combination_sizes)
# The screen stage's model is config, not an importable dependency, so the
# requirement probes cannot see it -- classify it explicitly. Its options
# already passed the stage's own validation in build_stages.
model: ModelStatus | None = None
screen_cfg = config.stages.get("screen")
if screen_cfg is not None:
model = mace_model_status(
screen_cfg.options.get("model_path", ""),
screen_cfg.options.get("model_size", "medium"),
backend=runtime.stage_backends.get("screen"),
)
# The calculate stage's AiiDA profile + code label are config the requirement
# probes cannot see (the mace-model precedent): probe them in the stage's own
# runner interpreter. Only when calculate is configured -- it is optional.
aiida: AiidaStatus | None = None
calc_cfg = config.stages.get("calculate")
if calc_cfg is not None:
aiida = aiida_status(
calc_cfg.options.get("python", "python3"),
calc_cfg.options.get("profile"),
calc_cfg.options.get("code_label", ""),
)
ok = report.ok and (model is None or model.ok) and (aiida is None or aiida.ok)
if as_json:
print_json_payload(_payload(config_path, report, enumeration, model, aiida, ok))
else:
render_doctor(report, enumeration, model, aiida, ok=ok)
return 0 if ok else 1
def _payload(
config_path: str,
report: DoctorReport,
enumeration: EnumerationReport,
model: ModelStatus | None,
aiida: AiidaStatus | None,
ok: bool,
) -> dict[str, Any]:
"""Build the ``--json`` payload.
Parameters
----------
config_path
Echoed so a captured payload records what was diagnosed.
report
Backend health and per-stage readiness.
enumeration
The seed-count report.
model
The screen model status, or ``None`` when no screen stage is
configured.
aiida
The AiiDA status, or ``None`` when no calculate stage is configured.
ok
The overall verdict, which is also the exit code.
Returns
-------
dict
The report dataclasses serialized verbatim, plus the verdict. Keys are
always present, taking ``null`` when a section does not apply, so a
consumer can index the payload without probing for keys.
"""
return {
"config": str(config_path),
"ok": ok,
"backends": [asdict(health) for health in report.backends],
"requirements": [asdict(status) for status in report.requirements],
"enumeration": asdict(enumeration),
"model": None if model is None else asdict(model),
"aiida": None if aiida is None else asdict(aiida),
}