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
368c97b9aa
commit
8cd092460f
40 files changed
+4742
No files matched your search
@@ -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="")
|
||||
Reference in new issue
Block a user