Source code for matmelt.commands.doctor

"""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), }