#!/usr/bin/env python3 """Run Stage 02 WGS reference indexing with samtools, GATK and BWA-MEM2.""" from __future__ import annotations import argparse import fcntl import json import os from pathlib import Path import shutil import subprocess import sys import uuid from datetime import datetime, timezone from typing import Any from gfa_core.wgs_scientific_identity import build_wgs_analysis_id, build_wgs_reference_id, sha256_file STAGE = "wgs_index" COMPONENTS = (".0123", ".amb", ".ann", ".bwt.2bit.64", ".pac") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Build the required WGS reference indices") parser.add_argument("--workspace", required=True) parser.add_argument("--job-id", required=True) parser.add_argument("--prefix", required=True) parser.add_argument("--reference-fasta", required=True) parser.add_argument("--samtools", default="samtools") parser.add_argument("--bwa-mem2", default="bwa-mem2") parser.add_argument("--gatk", default="gatk") parser.add_argument("--threads", type=int, default=1) return parser def _atomic_json(path: Path, payload: dict[str, Any]) -> None: temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") os.replace(temporary, path) def _authority_lock_path(manifest_path: Path) -> Path: return manifest_path.parent.parent / ".locks" / f"{manifest_path.stem}.lock" def _is_safe_path_component(value: Any) -> bool: return ( isinstance(value, str) and bool(value) and value not in {".", ".."} and "/" not in value and "\\" not in value and not any(ord(character) < 32 or ord(character) == 127 for character in value) ) def _is_generated_run_id(value: Any) -> bool: return ( _is_safe_path_component(value) and len(value) == 32 and all(character in "0123456789abcdef" for character in value) ) def _validate_prefix(prefix: Any) -> None: if not _is_safe_path_component(prefix): raise ValueError("prefix must be a non-empty safe path component") def _publish_transaction(bundle_work: Path, bundle_destination: Path, manifest_work: Path, manifest_path: Path, started_at: str) -> bool: """Publish a complete run bundle before switching the authority Manifest.""" lock_path = _authority_lock_path(manifest_path) lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+", encoding="utf-8") as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) if manifest_path.is_file(): try: existing = json.loads(manifest_path.read_text(encoding="utf-8")) if existing.get("status") == "success" and str(existing.get("start_time") or "") > started_at: return False except Exception: pass bundle_destination.parent.mkdir(parents=True, exist_ok=True) os.replace(bundle_work, bundle_destination) os.replace(manifest_work, manifest_path) return True def _tool_version(tool: str, *, bwa_mem2: bool = False) -> str: result = subprocess.run([tool, "version" if bwa_mem2 else "--version"], text=True, capture_output=True, check=False) if result.returncode: raise RuntimeError(f"{tool} version check failed with exit code {result.returncode}: {result.stderr.strip()}") lines = (result.stdout or result.stderr).strip().splitlines() if not lines: raise RuntimeError(f"{tool} version check produced no version") return lines[0] def _validate_reference(path: Path) -> list[tuple[str, int]]: if not path.is_file() or path.stat().st_size <= 0: raise ValueError("reference FASTA must be an existing non-empty regular file") seen: set[str] = set() contigs: list[tuple[str, int]] = [] current = "" length = 0 with path.open("r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): text = line.strip() if not text: continue if text.startswith(">"): if current: if length <= 0: raise ValueError(f"reference FASTA contig {current!r} has no sequence") contigs.append((current, length)) current = text[1:].split(maxsplit=1)[0] if not current: raise ValueError(f"reference FASTA header at line {line_number} has no contig name") if current in seen: raise ValueError(f"reference FASTA contig names must be unique: {current}") seen.add(current) length = 0 else: if not current: raise ValueError(f"reference FASTA sequence at line {line_number} precedes a header") length += len("".join(text.split())) if not current or length <= 0: raise ValueError("reference FASTA must contain at least one header with sequence") contigs.append((current, length)) return contigs def _validate_fai(path: Path, expected: list[tuple[str, int]]) -> None: observed: list[tuple[str, int]] = [] try: with path.open("r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): fields = line.rstrip("\n").split("\t") if len(fields) < 5 or not fields[0]: raise ValueError(f"invalid FAI record at line {line_number}") observed.append((fields[0], int(fields[1]))) except (OSError, UnicodeError, ValueError) as exc: raise RuntimeError(f"FAI content validation failed: {exc}") from exc if observed != expected: raise RuntimeError(f"FAI contig/length mismatch: expected {expected}, observed {observed}") def _validate_sequence_dictionary(path: Path, expected: list[tuple[str, int]]) -> None: observed: list[tuple[str, int]] = [] try: with path.open("r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): if not line.startswith("@SQ\t"): continue fields = line.rstrip("\n").split("\t")[1:] names = [field[3:] for field in fields if field.startswith("SN:")] lengths = [field[3:] for field in fields if field.startswith("LN:")] if len(names) != 1 or len(lengths) != 1: raise ValueError(f"invalid dictionary @SQ record at line {line_number}") observed.append((names[0], int(lengths[0]))) except (OSError, UnicodeError, ValueError) as exc: raise RuntimeError(f"sequence dictionary content validation failed: {exc}") from exc if observed != expected: raise RuntimeError(f"sequence dictionary contig/length mismatch: expected {expected}, observed {observed}") def _run_tool(command: list[str], name: str) -> None: result = subprocess.run(command, text=True, capture_output=True, check=False) if result.returncode: raise RuntimeError(f"{name} failed with exit code {result.returncode}: {result.stderr.strip()}") def _artifact(workspace: Path, source: Path, destination: Path, role: str, component: str = "") -> dict[str, Any]: item: dict[str, Any] = { "artifact": role, "role": role, "path": str(destination.relative_to(workspace)), "full_path": str(destination.resolve()), "type": source.suffix.lstrip(".") or "file", "size": source.stat().st_size, "size_bytes": source.stat().st_size, "sha256": sha256_file(source), } if component: item["component"] = component return item def _required_paths(reference: Path, dictionary: Path) -> dict[str, Path]: output = {"reference_fai": Path(f"{reference}.fai"), "reference_dict": dictionary} output.update({f"bwa_mem2_index_component.{component}": Path(f"{reference}{component}") for component in COMPONENTS}) return output def _has_symlink_component(path: Path, boundary: Path) -> bool: try: relative = path.relative_to(boundary) except ValueError: return True current = boundary for part in relative.parts: current = current / part if current.is_symlink(): return True return False def _valid_reuse(manifest_path: Path, *, workspace: Path, job_id: str, prefix: str, reference_id: str, analysis_id: str, parameters: dict[str, Any]) -> bool: try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except Exception: return False run_id = manifest.get("run_id") if not all((manifest.get("stage") == STAGE, manifest.get("status") == "success", manifest.get("job_id") == job_id, manifest.get("prefix") == prefix, manifest.get("wgs_reference_id") == reference_id, manifest.get("wgs_analysis_id") == analysis_id, manifest.get("parameters") == parameters, _is_safe_path_component(prefix), _is_generated_run_id(run_id))): return False workspace = workspace.resolve() bundle_root = workspace / "final" / ".wgs_index_runs" / prefix / run_id if _has_symlink_component(bundle_root, workspace): return False bundle_root_resolved = bundle_root.resolve() artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {} expected = {"reference_fasta", "reference_fai", "reference_dict", *{f"bwa_mem2_index_component.{component}" for component in COMPONENTS}} if set(artifacts) != expected: return False for key, item in artifacts.items(): if not isinstance(item, dict): return False expected_role = "bwa_mem2_index_component" if key.startswith("bwa_mem2_index_component.") else key if str(item.get("role") or item.get("artifact") or "") != expected_role: return False expected_component = key.removeprefix("bwa_mem2_index_component.") if expected_role == "bwa_mem2_index_component" else "" if str(item.get("component") or "") != expected_component: return False relative_path = Path(str(item.get("path") or "")) full_path = Path(str(item.get("full_path") or "")) if not str(relative_path) or relative_path.is_absolute() or ".." in relative_path.parts or not full_path.is_absolute() or ".." in full_path.parts: return False path_from_relative = workspace / relative_path if _has_symlink_component(path_from_relative, workspace) or _has_symlink_component(full_path, workspace): return False resolved_path = path_from_relative.resolve() resolved_full_path = full_path.resolve() if resolved_path != resolved_full_path: return False try: resolved_path.relative_to(bundle_root_resolved) except ValueError: return False if not resolved_path.is_file() or resolved_path.stat().st_size <= 0: return False actual_size = resolved_path.stat().st_size if item.get("size") != actual_size or item.get("size_bytes") != actual_size: return False declared_hash = str(item.get("sha256") or "").lower() if len(declared_hash) != 64 or any(char not in "0123456789abcdef" for char in declared_hash) or sha256_file(resolved_path) != declared_hash: return False return True def _refresh_reuse_authority( manifest_path: Path, *, workspace: Path, job_id: str, prefix: str, reference_id: str, analysis_id: str, parameters: dict[str, Any], reused_at: str, reuse_attempt_id: str, ) -> bool: lock_path = _authority_lock_path(manifest_path) lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+", encoding="utf-8") as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) if not _valid_reuse( manifest_path, workspace=workspace, job_id=job_id, prefix=prefix, reference_id=reference_id, analysis_id=analysis_id, parameters=parameters, ): return False manifest = json.loads(manifest_path.read_text(encoding="utf-8")) previous = manifest.get("reuse") if isinstance(manifest.get("reuse"), dict) else {} manifest["reuse"] = { "count": int(previous.get("count") or 0) + 1, "last_reused_at": reused_at, "last_attempt_id": reuse_attempt_id, } _atomic_json(manifest_path, manifest) return True def _failure_manifest(args: argparse.Namespace, error: str, run_id: str, started_at: str) -> dict[str, Any]: return {"manifest_version": "1.0", "stage": STAGE, "status": "failed", "prefix": args.prefix, "job_id": args.job_id, "run_id": run_id, "start_time": started_at, "artifacts": {}, "errors": [error]} def run(args: argparse.Namespace) -> int: workspace = Path(args.workspace).resolve() try: _validate_prefix(args.prefix) except ValueError as exc: print(f"wgs_index failed: {exc}", file=sys.stderr) return 1 final_dir = workspace / "final" final_dir.mkdir(parents=True, exist_ok=True) manifest_path = final_dir / f"{args.prefix}.{STAGE}.manifest.json" run_id, started_at = uuid.uuid4().hex, datetime.now(timezone.utc).isoformat() try: if args.threads < 1: raise ValueError("threads must be positive") reference_input = Path(args.reference_fasta).resolve() reference_contigs = _validate_reference(reference_input) reference_sha256 = sha256_file(reference_input) reference_id = build_wgs_reference_id(reference_sha256) versions = {"samtools": _tool_version(args.samtools), "bwa_mem2": _tool_version(args.bwa_mem2, bwa_mem2=True), "gatk": _tool_version(args.gatk)} parameters = {"samtools": args.samtools, "bwa_mem2": args.bwa_mem2, "gatk": args.gatk, "threads": args.threads, "components": list(COMPONENTS)} analysis_id = build_wgs_analysis_id(STAGE, [reference_sha256], parameters, versions, method="samtools_gatk_bwa_mem2") if _refresh_reuse_authority( manifest_path, workspace=workspace, job_id=args.job_id, prefix=args.prefix, reference_id=reference_id, analysis_id=analysis_id, parameters=parameters, reused_at=started_at, reuse_attempt_id=run_id, ): return 0 attempt = workspace / ".attempts" / run_id bundle_work = attempt / "bundle" bundle_work.mkdir(parents=True, exist_ok=False) reference = bundle_work / f"{args.prefix}.{STAGE}.reference.fasta" shutil.copy2(reference_input, reference) dictionary = bundle_work / f"{args.prefix}.{STAGE}.reference.dict" _run_tool([args.samtools, "faidx", str(reference)], "samtools faidx") _run_tool([args.gatk, "CreateSequenceDictionary", "-R", str(reference), "-O", str(dictionary)], "GATK CreateSequenceDictionary") _run_tool([args.bwa_mem2, "index", str(reference)], "bwa-mem2 index") outputs = _required_paths(reference, dictionary) missing = [key for key, path in outputs.items() if not path.is_file() or path.stat().st_size <= 0] if missing: raise RuntimeError(f"missing or empty required index output(s): {', '.join(missing)}") _validate_fai(outputs["reference_fai"], reference_contigs) _validate_sequence_dictionary(outputs["reference_dict"], reference_contigs) destination = final_dir / ".wgs_index_runs" / args.prefix / run_id artifacts = {"reference_fasta": _artifact(workspace, reference, destination / reference.name, "reference_fasta")} for key, source in outputs.items(): role = "bwa_mem2_index_component" if key.startswith("bwa_mem2_index_component.") else key component = key.removeprefix("bwa_mem2_index_component.") if role == "bwa_mem2_index_component" else "" artifacts[key] = _artifact(workspace, source, destination / source.name, role, component) manifest = {"manifest_version": "1.0", "stage": STAGE, "status": "success", "prefix": args.prefix, "job_id": args.job_id, "run_id": run_id, "start_time": started_at, "workspace": str(workspace), "wgs_reference_id": reference_id, "wgs_analysis_id": analysis_id, "parameters": parameters, "software": {name: {"path": getattr(args, name if name != "bwa_mem2" else "bwa_mem2"), "version": version} for name, version in versions.items()}, "metrics": {"required_index_outputs": 7, "components": list(COMPONENTS)}, "artifacts": artifacts} manifest_work = attempt / "manifest.json" _atomic_json(manifest_work, manifest) if not _publish_transaction(bundle_work, destination, manifest_work, manifest_path, started_at): raise RuntimeError("stale WGS index attempt cannot replace a newer successful run") return 0 except Exception as exc: failure = _failure_manifest(args, str(exc), run_id, started_at) failure_path = manifest_path if not manifest_path.exists() else final_dir / f"{args.prefix}.{STAGE}.failed.{run_id}.manifest.json" _atomic_json(failure_path, failure) print(f"wgs_index failed: {exc}", file=sys.stderr) return 1 def main(argv: list[str] | None = None) -> int: return run(build_parser().parse_args(argv)) if __name__ == "__main__": raise SystemExit(main())