Source code for itasc.contact_analysis.config
"""The TOML *run-config* — Contact Analysis's hand-authored knob file.
One small file, authored once and git-versioned (it carries *code status*, per the
artifact-contract spec §1): it names the per-position **catalog** CSV, selects
which **quantities** to compute, and supplies the shared build **params**.
Everything in it is a run-level choice — the
per-position table itself stays a CSV (tabular, many-row, with its own
relative-path resolution); this file is the "author once, then run" surface a
single ``run(config)`` entry point consumes.
The run produces **label-agnostic** tidy tables only; no classification step and
no plot/figure export live here (a subpopulation classification and any plots are a
downstream, dataset-specific concern).
Paths resolve relative to the config file's own directory, so a project folder is
relocatable: keep ``config.toml`` next to ``catalog.csv`` and the whole thing moves
together.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
try: # Python 3.11+
import tomllib
except ModuleNotFoundError: # pragma: no cover - exercised on 3.10 only
import tomli as tomllib # type: ignore[no-redef]
from .quantifier import available_quantifiers
__all__ = ["RunConfig", "load_config", "write_config"]
[docs]
@dataclass(frozen=True)
class RunConfig:
"""A parsed run-config. Paths are absolute (resolved against the config dir).
*quantities* empty means "every available quantifier"; a non-empty tuple
selects a subset by ``quantity_id`` (dependency producers are pulled in at run
time even when omitted). *params* is the shared build-knob mapping threaded to
quantifiers that opt in. *out_dir* is where the pooled tidy tables land (flat);
``None`` defaults to the catalogue root (the positions' common ancestor).
"""
catalog: Path
out_dir: Path | None = None
quantities: tuple[str, ...] = ()
params: dict = field(default_factory=dict)
[docs]
def load_config(config_path: Path | str) -> RunConfig:
"""Parse the TOML run-config at *config_path* into a :class:`RunConfig`.
``catalog`` is required. Relative paths resolve against the config file's
directory. Selected ``quantities`` are validated against the registered
quantifiers so a typo fails loudly rather than silently computing nothing.
"""
path = Path(config_path)
base = path.parent
with path.open("rb") as handle:
data = tomllib.load(handle)
if "catalog" not in data:
raise ValueError(
f"Run-config {path.name!r} is missing the required 'catalog' key "
"(the path to the per-position catalog CSV)."
)
quantities = tuple(data.get("quantities", ()))
_check_known_quantities(quantities)
out_dir = data.get("out_dir")
return RunConfig(
catalog=_resolve(base, data["catalog"]),
out_dir=_resolve(base, out_dir) if out_dir else None,
quantities=quantities,
params=dict(data.get("params", {})),
)
def _resolve(base: Path, raw: str) -> Path:
candidate = Path(raw)
if not candidate.is_absolute():
candidate = base / candidate
return candidate.expanduser().resolve(strict=False)
def _check_known_quantities(quantities: tuple[str, ...]) -> None:
known = {cls.quantity_id for cls in available_quantifiers()}
unknown = [q for q in quantities if q not in known]
if unknown:
listed = ", ".join(repr(q) for q in unknown)
available = ", ".join(sorted(known))
raise ValueError(
f"Run-config selects unknown quantit(y/ies): {listed}. "
f"Available: {available}."
)
[docs]
def write_config(
path: Path | str,
*,
catalog: str = "catalog.csv",
out_dir: str | None = None,
quantities: Sequence[str] = (),
params: Mapping[str, object] | None = None,
) -> Path:
"""Author a TOML run-config at *path* — the inverse of :func:`load_config`.
Paths are written **relative** (verbatim), so the project folder stays
relocatable. ``out_dir`` (where the flat tables land) is emitted only when
given. ``quantities`` is emitted only when non-empty (empty round-trips to
``()`` = "all"). ``params`` keys that are ``None`` are dropped (an unset pixel
size etc.). ``load_config(write_config(path, ...))`` reproduces the inputs
(paths resolved against ``path.parent``). Returns *path*.
"""
path = Path(path)
lines: list[str] = [
f"catalog = {_toml_str(catalog)}",
]
if out_dir is not None:
lines.append(f"out_dir = {_toml_str(out_dir)}")
if quantities:
lines.append(f"quantities = {_toml_array(quantities)}")
if params:
kept = {k: v for k, v in params.items() if v is not None}
if kept:
lines.append("")
lines.append("[params]")
lines.extend(f"{k} = {_toml_value(v)}" for k, v in kept.items())
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
return path
def _toml_str(value: object) -> str:
"""A TOML basic string: backslash and double-quote escaped."""
text = str(value).replace("\\", "\\\\").replace('"', '\\"')
return f'"{text}"'
def _toml_array(values: Sequence[object]) -> str:
return "[" + ", ".join(_toml_str(v) for v in values) + "]"
def _toml_value(value: object) -> str:
"""Serialize a scalar for our closed schema (bool / int / float / str)."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
return _toml_str(value)