Files
PlantAI-dev/apps/cli/run_wgs_joint_genotyping.py
T
2026-08-03 18:42:47 +08:00

613 lines
20 KiB
Python

#!/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=<ID=NON_REF,"):
non_ref_declared = True
elif line.startswith("#CHROM\t"):
fields = line.split("\t")
if len(fields) < 10:
raise RuntimeError("GVCF has no sample columns")
samples = fields[9:]
elif line and not line.startswith("#"):
fields = line.split("\t")
if len(fields) < 10:
raise RuntimeError("VCF record is invalid")
if require_non_ref and "<NON_REF>" 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())