P1: file parsers, sample sheet, discovery, CLI inspect

Readers for IDAT v3, BPM (locus entries v4-v8), CSV manifest, EGT
(data blocks 5-9) and GTC v3-v5 (plus a layout-preserving GTC writer);
sample-sheet validation (IN-001..IN-014), IDAT discovery and pairing
(IN-020..IN-022), message catalogue and exit codes (CLI-014/016),
JSON Lines logging (CLI-007/008), atomic writes (CLI-011), run manifest,
and `simurg inspect`.

Validation (docs/validation/P1.md): GTC, BPM and EGT readers agree
exactly with Illumina BeadArrayFiles on GSA v4 / GDA v1 files; GTCs
round-trip byte-identically; BPM and CSV manifests are consistent.
This commit is contained in:
kemal committed 2026-09-27 16:56:39 +01:00
1 parent 705138d70b
commit e518ea03d5
40 files changed
+4742

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""SimurgArray cli package."""
+207
View File
@@ -0,0 +1,207 @@
"""``simurg`` command-line interface (spec §2, plan §14).
Phase P1 provides ``simurg version`` and ``simurg inspect``. Later phases add the other commands of CLI-001.
"""
from __future__ import annotations
import json
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any, TypeVar
import numpy as np
import typer
from simurg import PRODUCT_NAME, __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
from simurg.core.provenance import git_revision
app = typer.Typer(
name="simurg",
help=f"{PRODUCT_NAME}: analysis toolkit for Illumina Infinium genotyping BeadChip data.",
no_args_is_help=True,
add_completion=False,
pretty_exceptions_enable=False,
context_settings={"help_option_names": ["-h", "--help"]},
)
F = TypeVar("F", bound=Callable[..., Any])
def version_string() -> str:
rev = git_revision()
return f"{PRODUCT_NAME} {__version__}" + (f"+{rev}" if rev else "")
def _version_cb(value: bool) -> None:
if value:
typer.echo(version_string())
raise typer.Exit(0)
@app.callback()
def main(
version: Annotated[
bool, typer.Option("--version", callback=_version_cb, is_eager=True, help="Print the version and exit.")
] = False,
) -> None:
"""Top-level options."""
def run_guarded(reporter: Reporter, fn: Callable[[], int]) -> None:
"""Run a command body mapping errors to catalogue codes and exit codes (CLI-014)."""
reporter.open()
code = ExitCode.OK
try:
code = ExitCode(fn())
except SimurgError as e:
reporter.error(e.code, e.message + (f"\n hint: {e.hint}" if e.hint else ""), exc=e, file=e.path)
code = e.exit_code
except KeyboardInterrupt:
reporter.error("E_INTERNAL", "interrupted")
code = ExitCode.SIGINT
except Exception as e:
reporter.error(
"E_INTERNAL", f"internal error: {type(e).__name__}: {e}. Rerun with --debug and report it.", exc=e
)
code = ExitCode.INTERNAL
finally:
reporter.close()
raise typer.Exit(int(code))
# --------------------------------------------------------------------------------------------------------
@app.command("version")
def version_cmd() -> None:
"""Print the version."""
typer.echo(version_string())
# --------------------------------------------------------------------------------------------------------
def _jsonable(o: Any) -> Any:
if isinstance(o, np.generic):
return o.item()
if isinstance(o, np.ndarray):
return o.tolist()
if isinstance(o, Path):
return str(o)
if isinstance(o, bytes):
return o.hex()
if isinstance(o, float) and o != o: # NaN
return None
return str(o)
def inspect_file(path: Path) -> dict[str, Any]:
from simurg.formats.detect import detect_kind
kind = detect_kind(path)
info: dict[str, Any] = {"file": str(path), "kind": kind}
if kind == "idat":
from simurg.formats.idat import read_idat
md = read_idat(path).metadata()
md.pop("path", None)
info.update(md)
elif kind == "bpm":
from simurg.formats.bpm import read_bpm
info.update(read_bpm(path).summary())
elif kind == "csv_manifest":
from simurg.formats.csv_manifest import assay_format, read_csv_manifest
m = read_csv_manifest(path, load_sequences=False)
s = m.summary()
s.pop("normalization_bins", None)
s.pop("infinium_i_at", None)
s.pop("infinium_i_gc", None)
s["heading"] = m.heading
s["assay_format"] = assay_format(m)
info.update(s)
elif kind == "egt":
from simurg.formats.egt import read_egt
info.update(read_egt(path).summary())
elif kind == "gtc":
from simurg.formats.gtc import read_gtc, validate_gtc
g = read_gtc(path, check_write_complete=False)
info.update(g.summary())
info["problems"] = validate_gtc(g)
elif kind == "samplesheet":
from simurg.formats.samplesheet import parse_samplesheet
sh = parse_samplesheet(path)
info.update(
{
"rows": len(sh.rows),
"columns": sh.columns,
"key_columns": [sh.barcode_column, sh.position_column],
"root_folder": str(sh.root_folder) if sh.root_folder else None,
"header": sh.header,
"has_sample_name": sh.has_sample_name,
"warnings": [f"{c}: {m}" for c, m in sh.warnings],
}
)
else:
raise SimurgError("E_FILE_UNREADABLE", f"{path}: unrecognised file type", path=str(path))
info.pop("path", None)
return info
def _print_human(info: dict[str, Any]) -> None:
typer.echo(f"== {info['file']} ({info['kind']})")
for k, v in info.items():
if k in ("file", "kind"):
continue
if isinstance(v, list) and len(v) > 12:
v = [*v[:12], f"… ({len(v)} total)"]
if isinstance(v, (dict, list)):
v = json.dumps(v, default=_jsonable, ensure_ascii=False)
typer.echo(f" {k}: {v}")
@app.command("inspect")
def inspect_cmd(
files: Annotated[list[Path], typer.Argument(help="IDAT, BPM, CSV manifest, EGT, GTC or sample-sheet files.")],
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")] = False,
log_json: Annotated[bool, typer.Option("--log-json", "--json-log", help="JSON Lines log on stderr.")] = False,
debug: Annotated[bool, typer.Option("--debug", help="Debug logging with tracebacks.")] = False,
log_level: Annotated[str, typer.Option("--log-level", help="debug|info|warning|error")] = "info",
quiet: Annotated[bool, typer.Option("--quiet", "--no-progress", help="Suppress info messages.")] = False,
) -> None:
"""Print the header / table of contents of array files (spec CLI-020)."""
rep = Reporter(
command="inspect", level="debug" if debug else log_level, json_stderr=log_json, debug=debug, quiet=quiet
)
def body() -> int:
results = []
failures = 0
for f in files:
if not f.exists():
raise SimurgError("E_FILE_NOT_FOUND", f"{f}: no such file", path=str(f))
try:
info = inspect_file(f)
except SimurgError as e:
rep.error(e.code, e.message, file=str(f))
failures += 1
continue
results.append(info)
if not as_json:
_print_human(info)
if as_json:
out = results[0] if len(results) == 1 else results
sys.stdout.write(json.dumps(out, default=_jsonable, ensure_ascii=False, indent=2) + "\n")
if failures and not results:
return ExitCode.USAGE
return ExitCode.PARTIAL if failures else ExitCode.OK
run_guarded(rep, body)
if __name__ == "__main__": # pragma: no cover
app()