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 368c97b9aa
commit 8cd092460f
40 files changed
+4742

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
"""Stable message catalogue and exception types (spec CLI-014, CLI-016).
Every error or warning SimurgArray emits has a stable ``code``. The human-readable text may change between
versions; the code may not. ``docs/messages.md`` is generated from :data:`CATALOGUE`
(``python -m simurg.core.errors``).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import Literal
Level = Literal["error", "warning", "info"]
class ExitCode(IntEnum):
"""Process exit codes (spec CLI-014)."""
OK = 0
INTERNAL = 1
USAGE = 2 # usage or pre-flight validation error; no per-sample work started
PARTIAL = 3 # >= 1 sample failed and >= 1 succeeded
ALL_FAILED = 4 # run completed but no sample succeeded
SIGINT = 130
SIGTERM = 143
@dataclass(frozen=True)
class MessageDef:
code: str
level: Level
summary: str
spec: str
def _m(code: str, level: Level, summary: str, spec: str) -> tuple[str, MessageDef]:
return code, MessageDef(code, level, summary, spec)
CATALOGUE: dict[str, MessageDef] = dict(
[
# --- CLI / run control -------------------------------------------------------------------
_m("E_NO_INPUT", "error", "Neither an input folder nor a sample sheet was given", "CLI-006"),
_m("E_NO_INPUT_FOLDER", "error", "Sample sheet has no RootFolder and no folder was given", "CLI-006"),
_m("E_OUTPUT_EXISTS", "error", "Outputs already exist; use --force or --resume", "CLI-010"),
_m("E_CONFIG_KEY", "error", "Unknown key in --config YAML", "CLI-019"),
_m("E_CONFIG_PARSE", "error", "--config file is not valid YAML", "CLI-019"),
_m("E_FILE_NOT_FOUND", "error", "Input file or folder does not exist", "CLI-016"),
_m("E_FILE_UNREADABLE", "error", "Input file cannot be read", "CLI-016"),
_m("E_INTERNAL", "error", "Internal error (bug); rerun with --debug and report", "CLI-014"),
# --- sample sheet (IN-001 .. IN-017) -------------------------------------------------------
_m("E_SHEET_DELIMITER", "error", "Sample sheet is not comma-delimited CSV", "IN-001"),
_m("E_SHEET_EMPTY", "error", "Sample sheet has no data rows", "IN-001"),
_m("E_SHEET_NO_KEY_COLUMNS", "error", "Sample sheet lacks SentrixBarcode_A/SentrixPosition_A", "IN-002"),
_m("E_SHEET_DUP_COLUMN", "error", "Duplicate column (column names are case-insensitive)", "IN-003"),
_m("W_SHEET_EMPTY_COLUMN", "warning", "Columns with empty headers are ignored", "IN-004"),
_m("E_SHEET_RESERVED", "error", "Sample sheet column collides with an output column", "IN-005"),
_m("E_SHEET_TOO_LONG", "error", "Sample sheet name or value longer than 500 characters", "IN-006"),
_m("E_SHEET_BARCODE_FORMAT", "error", "Invalid SentrixBarcode_A / SentrixPosition_A value", "IN-007"),
_m("W_SHEET_BARCODE_REPAIRED", "warning", "Barcode written as a decimal was repaired", "IN-007"),
_m("W_SHEET_ROOTFOLDER_OVERRIDDEN", "warning", "CLI folder overrides the sheet RootFolder", "IN-008"),
_m("E_SHEET_DUP_SAMPLE", "error", "The same barcode/position appears in several rows", "IN-009"),
_m("E_SHEET_NAME_EMPTY", "error", "Sample_Name is empty in at least one row", "IN-010"),
_m("E_SHEET_NAME_COLLISION", "error", "Sample names are not unique or clash with sample IDs", "IN-010"),
_m("W_SHEET_SEX_CONFLICT", "warning", "Reported-sex columns disagree", "IN-014"),
# --- IDAT / GTC discovery & parsing (IN-020 .. IN-033) --------------------------------------
_m("E_DUP_IDAT", "error", "Several IDAT pairs resolve to the same sample", "IN-022"),
_m("W_DUP_IDAT_RESOLVED", "warning", "Duplicate IDAT pair resolved by --on-duplicate-idat", "IN-022"),
_m("W_IDAT_UNPAIRED", "warning", "IDAT channel file without its partner", "IN-021"),
_m("E_IDAT_FORMAT", "error", "IDAT file is malformed", "IN-023"),
_m("E_IDAT_ENCRYPTED", "error", "Encrypted (version 1) IDATs are not supported", "IN-023"),
_m("E_IDAT_PAIR_MISMATCH", "error", "Red and Grn IDATs of a sample do not match", "IN-023"),
_m("W_IDAT_NAME_MISMATCH", "warning", "IDAT barcode/position disagrees with its file name", "IN-023"),
_m("W_IDAT_NO_SCAN_TIME", "warning", "IDAT has no scan entry in its run info", "IN-024"),
_m("I_UNLISTED_IDATS", "info", "IDAT pairs not listed in the sample sheet are ignored", "IN-016"),
_m("E_DUP_GTC", "error", "Several GTC files resolve to the same sample", "IN-030"),
_m("E_GTC_CORRUPT", "warning", "GTC file failed validation; sample skipped", "IN-032"),
_m("W_GTC_FOREIGN", "warning", "GTC was produced by another tool", "IN-031"),
_m("E_GTC_UNSUITABLE", "error", "GTC lacks fields required by this command", "IN-031"),
# --- manifests & cluster files (IN-040 .. IN-047) -------------------------------------------
_m("E_BPM_FORMAT", "error", "BPM manifest is malformed or unsupported", "IN-040"),
_m("E_CSV_MANIFEST_FORMAT", "error", "CSV manifest is malformed", "IN-041"),
_m("E_BPM_CSV_MISMATCH", "error", "BPM and CSV manifests describe different loci", "IN-042"),
_m("E_EGT_LOAD", "error", "Cluster (EGT) file cannot be loaded", "IN-044"),
_m("W_EGT_NAME", "warning", "Cluster file name is not portable", "IN-045"),
_m("W_MANIFEST_NO_REFSTRAND", "warning", "Manifest lacks RefStrand annotation", "IN-040"),
]
)
class SimurgError(Exception):
"""An error with a stable catalogue code and an exit code."""
def __init__(
self,
code: str,
message: str,
*,
exit_code: ExitCode = ExitCode.USAGE,
hint: str | None = None,
path: str | None = None,
) -> None:
if code not in CATALOGUE:
raise ValueError(f"unknown message code {code!r}")
self.code = code
self.message = message
self.exit_code = exit_code
self.hint = hint
self.path = path
super().__init__(f"[{code}] {message}" + (f" (hint: {hint})" if hint else ""))
class FormatError(SimurgError):
"""Malformed binary/text input. ``code`` names the file type (E_IDAT_FORMAT, E_GTC_CORRUPT, ...)."""
def render_markdown() -> str:
"""Render the catalogue as ``docs/messages.md``."""
lines = [
"# SimurgArray message catalogue",
"",
"Generated from `src/simurg/core/errors.py` — do not edit by hand.",
"",
"| Code | Level | Meaning | Spec |",
"|---|---|---|---|",
]
for d in sorted(CATALOGUE.values(), key=lambda m: m.code):
lines.append(f"| `{d.code}` | {d.level} | {d.summary} | {d.spec} |")
return "\n".join(lines) + "\n"
if __name__ == "__main__": # pragma: no cover
print(render_markdown(), end="")