Files

362 lines
17 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
#!/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())