Skip to content

capa.config

Config IO and validation surface. :class:ConfigDocument tracks where a draft came from (file path, inline vs external hardware / method); the validation pipeline turns it into a frozen :class:~capa.experiment.config.ExperimentConfig.

Narrative guides:

capa.config

Config IO and validation surface.

ConfigDocument is the source-tracking layer — it knows what file the draft came from, what format it was, and whether hardware/method were inline or external. Distinct from :class:~capa.experiment.config.ExperimentConfig, which is the validated, frozen runtime object.

The validation pipeline (validate) and the ConfigProblem shape layer on top.

ConfigDocument dataclass

ConfigDocument(
    experiment_path: Path | None = None,
    hardware_path: Path | None = None,
    method_path: Path | None = None,
    experiment_format: _StructuredFormat | None = None,
    hardware_format: Literal["toml"] | None = None,
    method_format: Literal["toml"] | None = None,
    hardware_mode: Literal[
        "external", "inline"
    ] = "external",
    method_mode: Literal[
        "external", "inline", "none"
    ] = "none",
    experiment_payload: dict[str, Any] = dict(),
    hardware_payload: dict[str, Any] = dict(),
    method_payload: dict[str, Any] | None = None,
)

In-memory representation of a setup's on-disk layout.

Payloads are raw dict[str, Any] so mid-edit invalid state can live here without fighting frozen Pydantic models. Promote to :class:~capa.experiment.config.ExperimentConfig via :meth:build_config at save / apply / validate boundaries.

Convention: experiment_payload never contains hardware or method keys — those are tracked separately so save() can route them per-mode. :meth:build_config re-inlines them before validation.

load classmethod

load(path: str | Path) -> ConfigDocument

Open an experiment YAML/TOML; resolve hardware/method refs.

Mirrors :meth:ExperimentConfig.load's file-ref rules: when hardware: or method: is a string, treat it as a path relative to the experiment file's directory. The presence / absence of those refs determines hardware_mode / method_mode.

Source code in src/capa/config/document.py
@classmethod
def load(cls, path: str | Path) -> ConfigDocument:
    """Open an experiment YAML/TOML; resolve hardware/method refs.

    Mirrors :meth:`ExperimentConfig.load`'s file-ref rules: when
    ``hardware:`` or ``method:`` is a string, treat it as a path
    relative to the experiment file's directory. The presence /
    absence of those refs determines ``hardware_mode`` /
    ``method_mode``.
    """
    source = Path(path).resolve()
    data = _load_structured_file(source)
    if not isinstance(data, dict):
        raise ConfigError(f"{source}: top-level must be a mapping")
    format_ = _detect_format(source)

    doc = cls(
        experiment_path=source,
        experiment_format=format_,
    )
    doc.experiment_payload = dict(data)

    # Hardware: required.
    hw_ref = doc.experiment_payload.pop("hardware", None)
    if isinstance(hw_ref, str):
        hw_path = _resolve(hw_ref, source.parent)
        doc.hardware_path = hw_path
        doc.hardware_format = _detect_hardware_format(hw_path)
        doc.hardware_mode = "external"
        doc.hardware_payload = dict(_load_structured_file(hw_path))
    elif isinstance(hw_ref, dict):
        doc.hardware_mode = "inline"
        doc.hardware_payload = dict(hw_ref)
    elif hw_ref is None:
        raise ConfigError(f"{source}: missing required field 'hardware'")
    else:
        raise ConfigError(
            f"{source}: 'hardware' must be a string ref or mapping, got {type(hw_ref).__name__}"
        )

    # Method: optional. Three modes (external / inline / none).
    method_ref = doc.experiment_payload.pop("method", None)
    if isinstance(method_ref, str):
        method_path = _resolve(method_ref, source.parent)
        doc.method_path = method_path
        doc.method_format = "toml"
        doc.method_mode = "external"
        doc.method_payload = dict(_load_structured_file(method_path))
    elif isinstance(method_ref, dict):
        doc.method_mode = "inline"
        doc.method_payload = dict(method_ref)
    else:
        doc.method_mode = "none"
        doc.method_payload = None

    return doc

load_hardware_only classmethod

load_hardware_only(path: str | Path) -> ConfigDocument

Open a bare hardware TOML; produce a minimal experiment payload.

Used by the Setup tab when the operator wants to author hardware in isolation. The experiment payload is left empty; the Setup tab fills in operator / sample / procedure stubs as the operator edits.

Source code in src/capa/config/document.py
@classmethod
def load_hardware_only(cls, path: str | Path) -> ConfigDocument:
    """Open a bare hardware TOML; produce a minimal experiment payload.

    Used by the Setup tab when the operator wants to author hardware
    in isolation. The experiment payload is left empty; the Setup
    tab fills in operator / sample / procedure stubs as the operator
    edits.
    """
    source = Path(path).resolve()
    data = _load_structured_file(source)
    if not isinstance(data, dict):
        raise ConfigError(f"{source}: top-level must be a mapping")
    return cls(
        hardware_path=source,
        hardware_format="toml",
        hardware_mode="external",
        hardware_payload=dict(data),
        method_mode="none",
    )

composed_payload

composed_payload() -> dict[str, Any]

Re-inline hardware / method into a single experiment dict.

The returned dict is what :class:~capa.experiment.config.ExperimentConfig :meth:model_validate expects: a single mapping with hardware as a nested mapping and method either nested, absent, or nested-from-an-inline-method. Source-path bookkeeping is re-attached as the (excluded-from-serialisation) fields on the model so callers that still go through :meth:load see the same hardware_source_path / method_source_path they always did.

Source code in src/capa/config/document.py
def composed_payload(self) -> dict[str, Any]:
    """Re-inline hardware / method into a single experiment dict.

    The returned dict is what :class:`~capa.experiment.config.ExperimentConfig`
    :meth:`model_validate` expects: a single mapping with ``hardware``
    as a nested mapping and ``method`` either nested, absent, or
    nested-from-an-inline-method. Source-path bookkeeping is
    re-attached as the (excluded-from-serialisation) fields on the
    model so callers that still go through :meth:`load` see the same
    ``hardware_source_path`` / ``method_source_path`` they always did.
    """
    composed: dict[str, Any] = dict(self.experiment_payload)
    composed["hardware"] = dict(self.hardware_payload)
    if self.method_mode != "none" and self.method_payload is not None:
        composed["method"] = dict(self.method_payload)
    if self.method_path is not None:
        composed["method_source_path"] = self.method_path
    if self.hardware_path is not None:
        composed["hardware_source_path"] = self.hardware_path
    return composed

build_config

build_config() -> Any

Validate payloads into an :class:ExperimentConfig.

Local import to break the circular dep: capa.experiment.config imports nothing from capa.config; this module imports from capa.experiment.config.

Source code in src/capa/config/document.py
def build_config(self) -> Any:
    """Validate payloads into an :class:`ExperimentConfig`.

    Local import to break the circular dep:
    ``capa.experiment.config`` imports nothing from ``capa.config``;
    this module imports from ``capa.experiment.config``.
    """
    from capa.experiment.config import ExperimentConfig  # noqa: PLC0415

    composed = self.composed_payload()
    try:
        return ExperimentConfig.model_validate(composed)
    except Exception as exc:
        src = self.experiment_path or self.hardware_path
        label = str(src) if src else "<unsaved>"
        raise ConfigError(f"{label}: {exc}") from exc

save

save() -> None

Atomic multi-file save back to the loaded paths.

For each target file: write <path>.tmp in the same directory, then os.replace() into place. On any failure, delete already- written .tmp files. Original files remain intact when any member of the save set fails.

Pre-condition: every target path must already be set (use :meth:save_as for first-time writes).

Source code in src/capa/config/document.py
def save(self) -> None:
    """Atomic multi-file save back to the loaded paths.

    For each target file: write ``<path>.tmp`` in the same directory,
    then ``os.replace()`` into place. On any failure, delete already-
    written ``.tmp`` files. Original files remain intact when any
    member of the save set fails.

    Pre-condition: every target path must already be set (use
    :meth:`save_as` for first-time writes).
    """
    plan = self._save_plan()
    self._execute_save_plan(plan)

save_as

save_as(layout: SourceLayout) -> None

Save to a new layout; updates self in place on success.

Layout transitions (inline ↔ external for hardware / method) are applied here; the document does not silently change them on :meth:save.

Source code in src/capa/config/document.py
def save_as(self, layout: SourceLayout) -> None:
    """Save to a new layout; updates ``self`` in place on success.

    Layout transitions (inline ↔ external for hardware / method) are
    applied here; the document does not silently change them on
    :meth:`save`.
    """
    # Apply layout to a working copy of the document, then save.
    prev = (
        self.experiment_path,
        self.experiment_format,
        self.hardware_path,
        self.hardware_format,
        self.hardware_mode,
        self.method_path,
        self.method_format,
        self.method_mode,
    )
    try:
        self.experiment_path = layout.experiment_path
        self.experiment_format = layout.experiment_format
        self.hardware_path = layout.hardware_path
        self.hardware_format = layout.hardware_format
        self.hardware_mode = layout.hardware_mode
        self.method_path = layout.method_path
        self.method_format = layout.method_format
        self.method_mode = layout.method_mode
        plan = self._save_plan()
        self._execute_save_plan(plan)
    except Exception:
        (
            self.experiment_path,
            self.experiment_format,
            self.hardware_path,
            self.hardware_format,
            self.hardware_mode,
            self.method_path,
            self.method_format,
            self.method_mode,
        ) = prev
        raise

extract_hardware_inline_to_file

extract_hardware_inline_to_file(
    hardware_path: Path,
) -> None

Move inline hardware to an external file (without writing yet).

Only flips the mode and records the path; call :meth:save to commit.

Source code in src/capa/config/document.py
def extract_hardware_inline_to_file(self, hardware_path: Path) -> None:
    """Move inline hardware to an external file (without writing yet).

    Only flips the mode and records the path; call :meth:`save` to
    commit.
    """
    if self.hardware_mode != "inline":
        raise ConfigError("extract_hardware_inline_to_file: hardware is already external")
    self.hardware_path = Path(hardware_path).resolve()
    self.hardware_format = "toml"
    self.hardware_mode = "external"

inline_hardware_from_file

inline_hardware_from_file() -> None

Inline a currently-external hardware file (without writing yet).

Source code in src/capa/config/document.py
def inline_hardware_from_file(self) -> None:
    """Inline a currently-external hardware file (without writing yet)."""
    if self.hardware_mode != "external":
        raise ConfigError("inline_hardware_from_file: hardware is already inline")
    self.hardware_mode = "inline"
    self.hardware_path = None
    self.hardware_format = None

SaveError

SaveError(
    message: str,
    *,
    failed_path: Path | None = None,
    rolled_back_paths: tuple[Path, ...] = (),
)

Bases: CapaError

Raised when an atomic multi-file save fails.

The exception carries the path that failed and any partial-write paths that were rolled back so callers can present an actionable message ("hardware file save failed; experiment file unchanged").

Source code in src/capa/config/document.py
def __init__(
    self,
    message: str,
    *,
    failed_path: Path | None = None,
    rolled_back_paths: tuple[Path, ...] = (),
) -> None:
    super().__init__(message)
    self.failed_path = failed_path
    self.rolled_back_paths = rolled_back_paths

SourceLayout dataclass

SourceLayout(
    experiment_path: Path | None,
    experiment_format: Literal["yaml", "toml"] | None,
    hardware_path: Path | None,
    hardware_format: Literal["toml"] | None,
    hardware_mode: Literal["external", "inline"],
    method_path: Path | None,
    method_format: Literal["toml"] | None,
    method_mode: Literal["external", "inline", "none"],
)

Target layout passed to :meth:ConfigDocument.save_as.

Each field maps onto the same-named field on :class:ConfigDocument. Methods that mutate layout (e.g. extract-to-file) compose a new SourceLayout and call save_as.

ConfigProblem

Bases: BaseModel

One validation finding addressable by (section, path).

severity controls colour and whether Apply & Connect stays disabled (any "error" blocks). code is a stable identifier ("channel.missing_source_device") so the UI can offer code-keyed quick fixes; message is the operator-facing prose.

path is the tuple address from the section's root model — e.g. ("devices", 2, "params", "port") points at the third device's params.port field. source_file indicates which file would carry the fix (hardware TOML vs experiment YAML) so the Save dialog can show the right path next to the problem.

fix_label class-attribute instance-attribute

fix_label: str | None = None

Short imperative label for a one-click fix ("Choose a device"). Optional — only emitted by checks that have a canonical fix.

validate_live_async async

validate_live_async(
    document: ConfigDocument,
) -> list[ConfigProblem]

Async-native Layer 5 driver for the Setup tab's Check Hardware button.

Re-runs Layers 1–4 first (they're cheap and a failed schema means we can't compose a config to hand to the live layer), then runs Layer 5 concurrently. Returns the merged list ordered by severity.

Source code in src/capa/config/validate.py
async def validate_live_async(
    document: ConfigDocument,
) -> list[ConfigProblem]:
    """Async-native Layer 5 driver for the Setup tab's Check Hardware button.

    Re-runs Layers 1–4 first (they're cheap and a failed schema means we
    can't compose a config to hand to the live layer), then runs Layer 5
    concurrently. Returns the merged list ordered by severity.
    """
    # Layers 1–4 first. If schema fails hard, return early — Layer 5
    # needs a built config to walk.
    base_problems: list[ConfigProblem] = []
    schema_problems, valid_config = _layer1_schema(document)
    base_problems.extend(schema_problems)
    if valid_config is None:
        return _sorted_problems(base_problems)
    base_problems.extend(_layer2_referential(valid_config, document))
    base_problems.extend(_layer3_domain(valid_config, document))
    base_problems.extend(_layer4_resource(valid_config, document))

    live_problems = await _layer5_live_async(valid_config, document)
    return _sorted_problems([*base_problems, *live_problems])