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:
1 parent
705138d70b
commit
e518ea03d5
40 files changed
+4742
No files matched your search
@@ -0,0 +1 @@
|
||||
"""SimurgArray cli package."""
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user