#!/usr/bin/env python3 """Run Stage 08 joint genotyping from authoritative WGS Manifests.""" 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_joint_genotyping" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Joint-genotype the authoritative Stage07 cohort GVCF" ) parser.add_argument("--workspace", required=True) parser.add_argument("--job-id", required=True) parser.add_argument("--prefix", required=True) parser.add_argument("--combine-manifest", required=True) parser.add_argument("--index-manifest", required=True) parser.add_argument("--gatk", default="gatk") parser.add_argument("--max-alternate-alleles", type=int, default=6) parser.add_argument("--calling-threshold", type=int, default=10) parser.add_argument( "--include-non-variant-sites", type=lambda value: str(value).strip().lower() == "true", default=False, ) 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, *, stage: str, 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(f"{stage} authority Manifest is unreadable") from exc if ( not isinstance(manifest, dict) or manifest.get("stage") != stage or manifest.get("status") != "success" ): raise ValueError( f"{stage} authority Manifest is not a successful producer" ) if ( manifest.get("prefix") != prefix or manifest.get("job_id") != job_id or manifest.get("workspace") != str(workspace) ): raise ValueError(f"{stage} authority Manifest identity mismatch") return manifest def _invocation_path(command: str) -> Path: resolved = shutil.which(command) if not resolved: raise RuntimeError(f"GATK executable is unavailable: {command}") return Path(resolved).absolute() def _executable_path(command: str) -> Path: return _invocation_path(command).resolve() def _checked(command: list[str], name: str) -> subprocess.CompletedProcess[str]: environment = os.environ.copy() executable_parent = str(_invocation_path(command[0]).parent) environment["PATH"] = ( executable_parent + os.pathsep + environment.get("PATH", "") ) result = subprocess.run( command, text=True, capture_output=True, check=False, env=environment, ) if result.returncode: raise RuntimeError( f"{name} failed with exit code {result.returncode}: " f"{result.stderr.strip()}" ) return result def _tool_version(gatk: str) -> str: result = _checked([gatk, "--version"], "GATK version check") lines = (result.stdout or result.stderr).strip().splitlines() if not lines: raise RuntimeError("GATK version check produced no version") return lines[0] 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={ **os.environ, "PATH": str(_invocation_path(command[0]).parent) + os.pathsep + os.environ.get("PATH", ""), }, ) log.write(f"{label} exit={result.returncode}\n") log.flush() if result.returncode: raise RuntimeError(f"{label} failed with exit code {result.returncode}") def _vcf_header( path: Path, *, require_non_ref: bool = False ) -> tuple[list[str], str]: if not path.is_file() or path.stat().st_size <= 0: raise RuntimeError("GVCF is missing or empty") samples: list[str] | None = None interval = "" non_ref_declared = False records = 0 try: with gzip.open(path, "rt", encoding="utf-8") as handle: for raw in handle: line = raw.rstrip("\n") if line.startswith("##ALT=" not in fields[4].split(","): raise RuntimeError("combined GVCF record is invalid") try: position = int(fields[1]) except ValueError as exc: raise RuntimeError( "GVCF record position is invalid" ) from exc if not fields[0] or position < 1: raise RuntimeError("GVCF record position is invalid") if not interval: interval = f"{fields[0]}:{position}-{position}" records += 1 except (OSError, UnicodeError) as exc: raise RuntimeError("GVCF is unreadable") from exc if ( samples is None or not samples or len(samples) != len(set(samples)) or (require_non_ref and not non_ref_declared) or records < 1 or not interval ): raise RuntimeError("VCF content is incomplete") return samples, interval def _validate_with_index( gatk: str, reference: Path, gvcf: Path, index: Path, expected_samples: list[str], log: Any, label: str, *, require_non_ref: bool = False, ) -> None: if index != Path(f"{gvcf}.tbi"): raise RuntimeError(f"{label} index is not the declared VCF sidecar") if not index.is_file() or index.stat().st_size <= 0: raise RuntimeError(f"{label} index is missing or empty") samples, interval = _vcf_header( gvcf, require_non_ref=require_non_ref ) if samples != expected_samples: raise RuntimeError(f"{label} sample header mismatch") _run_logged( [ gatk, "ValidateVariants", "-R", str(reference), "-V", str(gvcf), "-L", interval, "--validation-type-to-exclude", "ALLELES", ], log, label, ) 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]: 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 _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}.failed.{run_id}.manifest.json" ) _atomic_json( failure_path, _failure(args, run_id, started_at, error) ) def run(args: argparse.Namespace) -> int: 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}.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}.manifest.json" try: combine_header = _authority_header( args.combine_manifest, stage="wgs_gvcf_combine", workspace=workspace, prefix=args.prefix, job_id=args.job_id, ) index_header = _authority_header( args.index_manifest, stage="wgs_index", workspace=workspace, prefix=args.prefix, job_id=args.job_id, ) sample_set_id = str( combine_header.get("wgs_sample_set_id") or "" ) reference_id = str(combine_header.get("wgs_reference_id") or "") upstream_ids = { "wgs_gvcf_combine": str( combine_header.get("wgs_analysis_id") or "" ), "wgs_index": str(index_header.get("wgs_analysis_id") or ""), } if ( not sample_set_id or not reference_id or not all(upstream_ids.values()) or index_header.get("wgs_reference_id") != reference_id ): raise ValueError("two-way upstream scientific lineage mismatch") producer_upstream = combine_header.get("upstream_wgs_analysis_ids") if ( not isinstance(producer_upstream, dict) or producer_upstream.get("wgs_index") != upstream_ids["wgs_index"] ): raise ValueError( "wgs_gvcf_combine index lineage does not match " "the current wgs_index authority" ) combine = resolve_wgs_artifacts( args.combine_manifest, "wgs_gvcf_combine", ("combined_gvcf", "combined_gvcf_index"), sample_set_id, reference_id, upstream_ids["wgs_gvcf_combine"], ) index = resolve_wgs_artifacts( args.index_manifest, "wgs_index", ("reference_fasta", "reference_fai", "reference_dict"), expected_reference_id=reference_id, expected_analysis_id=upstream_ids["wgs_index"], ) if any(len(index[role]) != 1 for role in index): raise RuntimeError( "authoritative index must contain one FASTA, FAI and dictionary" ) if any(len(combine[role]) != 1 for role in combine): raise RuntimeError( "authoritative combine must contain one GVCF and index" ) metrics = combine_header.get("metrics") or {} expected_samples = metrics.get("output_sample_ids") if ( not isinstance(expected_samples, list) or not expected_samples or any( not isinstance(sample, str) or not sample for sample in expected_samples ) or expected_samples != sorted(set(expected_samples)) or metrics.get("input_sample_ids") != expected_samples ): raise RuntimeError("upstream combined GVCF sample set is incomplete") if ( args.max_alternate_alleles != 6 or args.calling_threshold != 10 or args.include_non_variant_sites is not False ): raise ValueError( "joint genotyping parameters must be exactly 6, 10 and false" ) version = _tool_version(args.gatk) parameters = { "gatk": args.gatk, "operation": "GenotypeGVCFs", "max_alternate_alleles": args.max_alternate_alleles, "calling_threshold": args.calling_threshold, "include_non_variant_sites": args.include_non_variant_sites, } consumed = [ item.sha256 for group in (combine, index) for values in group.values() for item in values ] analysis_id = build_wgs_analysis_id( STAGE, consumed, parameters, {"gatk": version}, method="gatk_genotypegvcfs", ) attempt = workspace / ".attempts" / run_id bundle = attempt / "bundle" bundle.mkdir(parents=True, exist_ok=False) base = f"{args.prefix}.{STAGE}.cohort" joint_vcf = bundle / f"{base}.joint.vcf.gz" joint_vcf_index = Path(f"{joint_vcf}.tbi") summary = bundle / f"{base}.summary.tsv" log_path = bundle / f"{base}.joint.log" reference = index["reference_fasta"][0].path combined = combine["combined_gvcf"][0].path combined_index = combine["combined_gvcf_index"][0].path with log_path.open("w", encoding="utf-8") as log: _validate_with_index( args.gatk, reference, combined, combined_index, expected_samples, log, "ValidateVariants input", require_non_ref=True, ) genotype = [ args.gatk, "GenotypeGVCFs", "-R", str(reference), "-V", str(combined), "-O", str(joint_vcf), "--max-alternate-alleles", str(args.max_alternate_alleles), "--standard-min-confidence-threshold-for-calling", str(args.calling_threshold), "--include-non-variant-sites", str(args.include_non_variant_sites).lower(), ] _run_logged(genotype, log, "GenotypeGVCFs") _run_logged( [args.gatk, "IndexFeatureFile", "-I", str(joint_vcf)], log, "IndexFeatureFile", ) _validate_with_index( args.gatk, reference, joint_vcf, joint_vcf_index, expected_samples, log, "ValidateVariants", ) _run_logged( [ args.gatk, "VariantsToTable", "-V", str(joint_vcf), "-F", "CHROM", "-F", "POS", "-F", "REF", "-F", "ALT", "-F", "QUAL", "-O", str(summary), ], log, "VariantsToTable", ) try: summary_lines = summary.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeError) as exc: raise RuntimeError("joint summary is unreadable") from exc if ( len(summary_lines) < 2 or summary_lines[0] != "CHROM\tPOS\tREF\tALT\tQUAL" or any(len(line.split("\t")) != 5 for line in summary_lines[1:]) ): raise RuntimeError("joint summary content is invalid") destination = ( final / ".wgs_joint_genotyping_runs" / args.prefix / run_id ) artifacts = { "joint_vcf": _artifact( workspace, joint_vcf, destination / joint_vcf.name, "joint_vcf", ), "joint_vcf_index": _artifact( workspace, joint_vcf_index, destination / joint_vcf_index.name, "joint_vcf_index", ), "joint_summary": _artifact( workspace, summary, destination / summary.name, "joint_summary", ), "joint_log": _artifact( workspace, log_path, destination / log_path.name, "joint_log", ), } 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_sample_set_id": sample_set_id, "wgs_reference_id": reference_id, "upstream_wgs_analysis_ids": upstream_ids, "wgs_analysis_id": analysis_id, "parameters": parameters, "software": { "gatk": { "path": str(_executable_path(args.gatk)), "version": version, } }, "metrics": { "input_sample_ids": expected_samples, "output_sample_ids": expected_samples, "total_samples": len(expected_samples), }, "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 combine attempt cannot replace a newer successful 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())