#!/usr/bin/env python3 """Run Stage 09 SNP/INDEL selection from the authoritative Stage08 VCF.""" from __future__ import annotations import argparse from datetime import datetime, timezone import fcntl import gzip import json import os from pathlib import Path import re import shutil import subprocess import sys from typing import Any import uuid from gfa_core.wgs_scientific_identity import ( build_wgs_analysis_id, sha256_file, ) from gfa_core.wgs_upstream_manifest import resolve_wgs_artifacts STAGE = "wgs_variant_select" ROLES = ( "selected_snp_vcf", "selected_snp_vcf_index", "selected_indel_vcf", "selected_indel_vcf_index", "variant_select_report", ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Select SNPs and INDELs from authoritative Stage08 VCF" ) parser.add_argument("--workspace", required=True) parser.add_argument("--job-id", required=True) parser.add_argument("--prefix", required=True) parser.add_argument("--joint-manifest", required=True) parser.add_argument( "--method", choices=("gatk", "bcftools"), default="gatk" ) parser.add_argument("--gatk", default="gatk") parser.add_argument("--bcftools", default="bcftools") 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 _safe_component(value: str, label: str) -> None: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value or ""): raise ValueError(f"{label} must be a non-empty safe path component") def _authority_header( path: str | Path, *, workspace: Path, prefix: str, job_id: str, ) -> dict[str, Any]: try: manifest = json.loads(Path(path).read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValueError( "wgs_joint_genotyping authority Manifest is unreadable" ) from exc if ( not isinstance(manifest, dict) or manifest.get("stage") != "wgs_joint_genotyping" or manifest.get("status") != "success" ): raise ValueError( "wgs_joint_genotyping authority Manifest is not successful" ) if ( manifest.get("prefix") != prefix or manifest.get("job_id") != job_id or manifest.get("workspace") != str(workspace) ): raise ValueError( "wgs_joint_genotyping authority Manifest identity mismatch" ) return manifest def _invocation_path(command: str) -> Path: resolved = shutil.which(command) if not resolved: raise RuntimeError(f"executable is unavailable: {command}") return Path(resolved).absolute() def _executable_path(command: str) -> Path: return _invocation_path(command).resolve() def _environment(command: str) -> dict[str, str]: return { **os.environ, "PATH": str(_invocation_path(command).parent) + os.pathsep + os.environ.get("PATH", ""), } def _checked( command: list[str], label: str ) -> subprocess.CompletedProcess[str]: result = subprocess.run( command, text=True, capture_output=True, check=False, env=_environment(command[0]), ) if result.returncode: detail = (result.stderr or result.stdout).strip() raise RuntimeError( f"{label} failed with exit code {result.returncode}: {detail}" ) return result def _run_logged(command: list[str], log: Any, label: str) -> None: log.write(f"{label} argv: {json.dumps(command)}\n") log.flush() result = subprocess.run( command, stdout=log, stderr=subprocess.STDOUT, text=True, check=False, env=_environment(command[0]), ) log.write(f"{label} exit={result.returncode}\n") log.flush() if result.returncode: raise RuntimeError(f"{label} failed with exit code {result.returncode}") def _tool_version(command: str, label: str) -> str: result = _checked([command, "--version"], f"{label} version check") lines = (result.stdout or result.stderr).strip().splitlines() if not lines: raise RuntimeError(f"{label} version check produced no version") return lines[0] def _vcf_summary( path: Path, expected_kind: str | None = None, *, allow_empty: bool = False, ) -> tuple[list[str], str, int, str]: if not path.is_file() or path.stat().st_size <= 0: raise RuntimeError("VCF is missing or empty") samples: list[str] | None = None interval = "" fallback_interval = "" region_row = "" records = 0 try: with gzip.open(path, "rt", encoding="utf-8") as handle: for raw in handle: line = raw.rstrip("\n") if line.startswith("#CHROM\t"): fields = line.split("\t") if len(fields) < 10: raise RuntimeError("VCF has no sample columns") samples = fields[9:] elif line.startswith("##contig= None: regions = scratch / f".{label}.{uuid.uuid4().hex}.regions.tsv" regions.write_text(region_row, encoding="utf-8") try: _run_logged( [bcftools, "view", "-H", "-R", str(regions), str(vcf)], log, f"{label} indexed query", ) finally: regions.unlink(missing_ok=True) def _select_gatk( gatk: str, source: Path, output: Path, kind: str, log: Any, ) -> None: _run_logged( [ gatk, "SelectVariants", "-V", str(source), "-O", str(output), "-select-type-to-include", kind, ], log, f"GATK SelectVariants {kind}", ) _run_logged( [gatk, "IndexFeatureFile", "-I", str(output)], log, f"GATK IndexFeatureFile {kind}", ) def _select_bcftools( bcftools: str, source: Path, output: Path, kind: str, log: Any, ) -> None: variant_type = "snps" if kind == "SNP" else "indels" _run_logged( [ bcftools, "view", "-v", variant_type, "-Oz", "-o", str(output), str(source), ], log, f"bcftools select {kind}", ) _run_logged( [bcftools, "index", "--tbi", str(output)], log, f"bcftools index {kind}", ) def _postflight( *, method: str, gatk: str, bcftools: str, output: Path, index: Path, kind: str, expected_samples: list[str], log: Any, ) -> int: if index != Path(f"{output}.tbi"): raise RuntimeError(f"{kind} index is not its VCF sidecar") if not index.is_file() or index.stat().st_size <= 0: raise RuntimeError(f"{kind} index is missing or empty") samples, interval, records, region_row = _vcf_summary( output, kind, allow_empty=True ) if samples != expected_samples: raise RuntimeError(f"{kind} sample header mismatch") if method == "gatk": command = [ gatk, "ValidateVariants", "-V", str(output), "-L", interval, ] command.extend( [ "--validation-type-to-exclude", "ALLELES", "--validation-type-to-exclude", "REF", ] ) _run_logged( command, log, f"GATK ValidateVariants {kind}", ) else: _query_index( bcftools, output, region_row, output.parent, log, kind ) _run_logged( [bcftools, "stats", str(output)], log, f"bcftools stats {kind}", ) return records def _artifact( workspace: Path, source: Path, destination: Path, role: str, ) -> dict[str, Any]: size = source.stat().st_size return { "artifact": role, "role": role, "path": str(destination.relative_to(workspace)), "full_path": str(destination.resolve()), "type": source.suffix.lstrip(".") or "file", "size": size, "size_bytes": size, "sha256": sha256_file(source), } def _publish( bundle: Path, destination: Path, manifest_work: Path, authority: Path, started_at: str, ) -> bool: lock_path = authority.parent.parent / ".locks" / f"{authority.stem}.lock" 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 authority.is_file(): try: current = json.loads(authority.read_text(encoding="utf-8")) if ( current.get("status") == "success" and str(current.get("start_time") or "") >= started_at ): return False except Exception: pass destination.parent.mkdir(parents=True, exist_ok=True) os.replace(bundle, destination) os.replace(manifest_work, authority) return True def _failure( args: argparse.Namespace, run_id: str, started_at: str, error: str ) -> dict[str, Any]: payload = { "manifest_version": "1.0", "stage": STAGE, "status": "failed", "prefix": args.prefix, "job_id": args.job_id, "run_id": run_id, "start_time": started_at, "method": args.method, "artifacts": {}, "errors": [error], } if ( args.method == "gatk" and bool(getattr(args, "_fallback_eligible", False)) ): payload["fallback_method"] = "bcftools" return payload def _publish_failure( authority: Path, args: argparse.Namespace, run_id: str, started_at: str, error: str, ) -> None: lock_path = authority.parent.parent / ".locks" / f"{authority.stem}.lock" 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) failure_path = ( authority if not authority.exists() else authority.parent / ( f"{args.prefix}.{STAGE}.{args.method}.failed." f"{run_id}.manifest.json" ) ) _atomic_json( failure_path, _failure(args, run_id, started_at, error) ) def run(args: argparse.Namespace) -> int: args._fallback_eligible = False workspace = Path(args.workspace).resolve() run_id = uuid.uuid4().hex started_at = datetime.now(timezone.utc).isoformat() final = workspace / "final" final.mkdir(parents=True, exist_ok=True) try: _safe_component(args.prefix, "prefix") except ValueError as exc: _atomic_json( final / f"{STAGE}.{args.method}.failed.{run_id}.manifest.json", _failure(args, run_id, started_at, str(exc)), ) print(f"{STAGE} failed: {exc}", file=sys.stderr) return 1 authority = ( final / f"{args.prefix}.{STAGE}.{args.method}.manifest.json" ) try: header = _authority_header( args.joint_manifest, workspace=workspace, prefix=args.prefix, job_id=args.job_id, ) sample_set_id = str(header.get("wgs_sample_set_id") or "") reference_id = str(header.get("wgs_reference_id") or "") upstream_analysis_id = str(header.get("wgs_analysis_id") or "") if not sample_set_id or not reference_id or not upstream_analysis_id: raise ValueError( "wgs_joint_genotyping scientific lineage is incomplete" ) resolved = resolve_wgs_artifacts( args.joint_manifest, "wgs_joint_genotyping", ("joint_vcf", "joint_vcf_index"), sample_set_id, reference_id, upstream_analysis_id, ) if any(len(resolved[role]) != 1 for role in resolved): raise RuntimeError( "authoritative joint VCF and TBI must be singletons" ) joint_vcf = resolved["joint_vcf"][0].path joint_index = resolved["joint_vcf_index"][0].path if joint_index != Path(f"{joint_vcf}.tbi"): raise RuntimeError("joint VCF index is not its declared sidecar") expected_samples = (header.get("metrics") or {}).get( "output_sample_ids" ) if ( not isinstance(expected_samples, list) or not expected_samples or expected_samples != sorted(set(expected_samples)) or (header.get("metrics") or {}).get("input_sample_ids") != expected_samples ): raise RuntimeError("joint VCF sample set is incomplete") ( observed_samples, joint_interval, _, joint_region_row, ) = _vcf_summary(joint_vcf) if observed_samples != expected_samples: raise RuntimeError("joint VCF sample header mismatch") executable = args.gatk if args.method == "gatk" else args.bcftools label = "GATK" if args.method == "gatk" else "bcftools" try: version = _tool_version(executable, label) except Exception: args._fallback_eligible = args.method == "gatk" raise parameters = { "method": args.method, "executable": executable, "type_rules": ( {"SNP": "GATK SNP", "INDEL": "GATK INDEL"} if args.method == "gatk" else {"SNP": "bcftools snps", "INDEL": "bcftools indels"} ), } analysis_id = build_wgs_analysis_id( STAGE, [item.sha256 for values in resolved.values() for item in values], parameters, {args.method: version}, method=args.method, ) attempt = workspace / ".attempts" / run_id bundle = attempt / "bundle" bundle.mkdir(parents=True, exist_ok=False) base = f"{args.prefix}.{STAGE}.cohort.selected" snp = bundle / f"{base}.snp.vcf.gz" indel = bundle / f"{base}.indel.vcf.gz" snp_index = Path(f"{snp}.tbi") indel_index = Path(f"{indel}.tbi") report = bundle / f"{args.prefix}.{STAGE}.cohort.report.md" log_path = attempt / "variant-select.log" with log_path.open("w", encoding="utf-8") as log: args._fallback_eligible = args.method == "gatk" if args.method == "gatk": _run_logged( [ args.gatk, "ValidateVariants", "-V", str(joint_vcf), "-L", joint_interval, "--validation-type-to-exclude", "ALLELES", "--validation-type-to-exclude", "REF", ], log, "GATK ValidateVariants input", ) else: _query_index( args.bcftools, joint_vcf, joint_region_row, attempt, log, "input", ) for output, kind in ((snp, "SNP"), (indel, "INDEL")): if args.method == "gatk": _select_gatk( args.gatk, joint_vcf, output, kind, log ) else: _select_bcftools( args.bcftools, joint_vcf, output, kind, log ) snp_records = _postflight( method=args.method, gatk=args.gatk, bcftools=args.bcftools, output=snp, index=snp_index, kind="SNP", expected_samples=expected_samples, log=log, ) indel_records = _postflight( method=args.method, gatk=args.gatk, bcftools=args.bcftools, output=indel, index=indel_index, kind="INDEL", expected_samples=expected_samples, log=log, ) args._fallback_eligible = False report.write_text( "# WGS variant selection\n\n" f"- method: {args.method}\n" f"- SNP records: {snp_records}\n" f"- INDEL records: {indel_records}\n" "- Methods are recorded as executed; scientific equivalence is " "not asserted.\n", encoding="utf-8", ) destination = ( final / ".wgs_variant_select_runs" / args.prefix / args.method / run_id ) sources = { "selected_snp_vcf": snp, "selected_snp_vcf_index": snp_index, "selected_indel_vcf": indel, "selected_indel_vcf_index": indel_index, "variant_select_report": report, } artifacts = { role: _artifact( workspace, source, destination / source.name, role, ) for role, source in sources.items() } 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), "method": args.method, "wgs_sample_set_id": sample_set_id, "wgs_reference_id": reference_id, "upstream_wgs_analysis_ids": { "wgs_joint_genotyping": upstream_analysis_id }, "wgs_analysis_id": analysis_id, "parameters": parameters, "software": { args.method: { "path": str(_executable_path(executable)), "version": version, } }, "metrics": { "input_sample_ids": expected_samples, "output_sample_ids": expected_samples, "total_samples": len(expected_samples), "snp_records": snp_records, "indel_records": indel_records, }, "artifacts": artifacts, } manifest_work = attempt / "manifest.json" _atomic_json(manifest_work, manifest) if not _publish( bundle, destination, manifest_work, authority, started_at ): raise RuntimeError( "stale variant-select attempt cannot replace newer method run" ) return 0 except Exception as exc: _publish_failure( authority, args, run_id, started_at, str(exc) ) print(f"{STAGE} 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())