Files

327 lines
13 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
#!/usr/bin/env python3
"""Small genome data preparation substeps.
This module exposes reusable pieces of run_genome_prepare.py as independent
stages so agents can call them directly from natural language.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from apps.cli.run_genome_prepare import (
MultiSpeciesDetector,
MultiSpeciesGenomePreparer,
compute_file_hash,
generate_species_config,
get_tool_versions,
merge_species_files,
organize_files_by_config,
prepare_directories,
)
STAGE_TO_OPERATION = {
"prepare_organize": "organize",
"prepare_normalize_gff": "normalize_gff",
"prepare_longest_transcript": "longest_transcript",
"prepare_extract_pep": "extract_pep",
"prepare_extract_cds": "extract_cds",
"prepare_filter_fasta": "filter_fasta",
"prepare_simplify_ncbi": "simplify_ncbi",
}
OPERATION_TO_STAGE = {value: key for key, value in STAGE_TO_OPERATION.items()}
def _artifact(path: Path, name: str, artifact_type: str = "file") -> dict[str, Any]:
return {
"artifact": name,
"path": str(path.name),
"full_path": str(path.absolute()),
"type": artifact_type,
"size": path.stat().st_size if path.exists() else 0,
"sha256": compute_file_hash(path) if path.exists() else "",
"mtime": path.stat().st_mtime if path.exists() else None,
}
def _write_manifest(
workspace: Path,
input_dir: Path,
prefix: str,
stage: str,
operation: str,
start_time: datetime,
artifacts: dict[str, dict[str, Any]],
metrics: dict[str, Any],
parameters: dict[str, Any],
status: str = "success",
error: str = "",
) -> Path:
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
end_time = datetime.now()
manifest = {
"manifest_version": "1.0",
"stage": stage,
"status": status,
"prefix": prefix,
"operation": operation,
"timestamp": end_time.isoformat(),
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"elapsed_sec": round((end_time - start_time).total_seconds(), 2),
"workspace_root": str(workspace.resolve()),
"workspace": str(final_dir.resolve()),
"input_dir": str(input_dir.resolve()),
"parameters": parameters,
"metrics": metrics,
"artifacts": artifacts,
"tool_versions": get_tool_versions(),
}
if error:
manifest["error"] = error
manifest_path = final_dir / f"{prefix}.{stage}.manifest.json"
manifest["artifacts"]["manifest"] = {
"artifact": "manifest",
"path": f"final/{manifest_path.name}",
"full_path": str(manifest_path.absolute()),
"type": "json",
"size": 0,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
input_final = input_dir / "final"
input_final.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(manifest_path, input_final / manifest_path.name)
except Exception:
pass
return manifest_path
def _preparer(input_dir: Path, workspace: Path) -> MultiSpeciesGenomePreparer:
paths = prepare_directories(input_dir)
paths["work_dir"] = input_dir / "genome_prepare"
paths["work_dir"].mkdir(exist_ok=True)
return MultiSpeciesGenomePreparer(input_dir, workspace, Path("scripts"), paths)
def _iter_species(input_dir: Path):
return MultiSpeciesDetector.scan_input_dir(input_dir).values()
def _run_organize(input_dir: Path, workspace: Path) -> tuple[dict, dict, dict]:
org = organize_files_by_config(input_dir)
artifacts: dict[str, dict[str, Any]] = {}
metrics = {
"organized": bool(org.get("organized")),
"moved_files": len(org.get("moved_files", [])),
"missing_files": len(org.get("missing_files", [])),
"target_species": org.get("target_species", []),
"ref_species": org.get("ref_species", []),
}
genome_prepare_dir = input_dir / "genome_prepare"
genome_prepare_dir.mkdir(exist_ok=True)
if org.get("organized"):
merge = merge_species_files(input_dir, genome_prepare_dir, org)
cfg = generate_species_config(input_dir, genome_prepare_dir, org)
artifacts["species_config"] = _artifact(cfg, "species_config", "json")
for key, value in merge.get("merged_files", {}).items():
path = Path(value)
if path.exists():
artifacts[key] = _artifact(path, key, "fasta")
metrics["merged_files"] = list(merge.get("merged_files", {}))
return artifacts, metrics, org
def _run_normalize_gff(input_dir: Path, workspace: Path) -> tuple[dict, dict]:
prep = _preparer(input_dir, workspace)
artifacts: dict[str, dict[str, Any]] = {}
count = 0
for species in _iter_species(input_dir):
if not species.gff:
continue
gff = prep.decompress_if_needed(species.gff)
db_type = prep.detect_db_type_from_gff(gff)
out = input_dir / f"{species.species_code}.normalized.gff3"
prep._preprocess_gff(gff, out, db_type)
artifacts[f"{species.species_code}_normalized_gff"] = _artifact(out, f"{species.species_code}_normalized_gff", "gff3")
count += 1
return artifacts, {"normalized_gff": count}
def _run_longest_transcript(input_dir: Path, workspace: Path) -> tuple[dict, dict]:
prep = _preparer(input_dir, workspace)
longest_script = prep._ensure_script("gff_filter_longest.py")
artifacts: dict[str, dict[str, Any]] = {}
count = 0
for species in _iter_species(input_dir):
if not species.gff:
continue
gff = prep.decompress_if_needed(species.gff)
db_type = prep.detect_db_type_from_gff(gff)
gff_tmp = prep.work_dir / f"{species.species_code}.gff3.tmp"
prep._preprocess_gff(gff, gff_tmp, db_type)
table = prep.work_dir / f"{species.species_code}_gene_mrna_cds.ids"
out_gff = input_dir / f"{species.species_code}.longest.gff3"
rc, _, stderr = prep.run_command(
[sys.executable, str(longest_script), str(gff_tmp.resolve()), str(table.resolve()), str(out_gff.resolve())],
f"Select longest transcript for {species.species_code}",
)
if rc != 0:
raise RuntimeError(f"Longest transcript failed for {species.species_code}: {stderr}")
ids = input_dir / f"{species.species_code}.longest.ids"
prep._write_ids_from_longest_table(table, ids)
artifacts[f"{species.species_code}_longest_gff"] = _artifact(out_gff, f"{species.species_code}_longest_gff", "gff3")
artifacts[f"{species.species_code}_longest_ids"] = _artifact(ids, f"{species.species_code}_longest_ids", "txt")
count += 1
return artifacts, {"species_with_longest_transcript": count}
def _run_gffread(input_dir: Path, workspace: Path, mode: str) -> tuple[dict, dict]:
prep = _preparer(input_dir, workspace)
prep._ensure_tool("gffread")
artifacts: dict[str, dict[str, Any]] = {}
count = 0
flag = "-y" if mode == "pep" else "-x"
suffix = "pep.fasta" if mode == "pep" else "cds.fasta"
for species in _iter_species(input_dir):
if not species.gff or not species.genome:
continue
gff = prep.decompress_if_needed(species.gff)
genome = prep.decompress_if_needed(species.genome)
out = input_dir / f"{species.species_code}.{suffix}"
rc, _, stderr = prep.run_command(
["gffread", flag, str(out), "-g", str(genome), str(gff)],
f"Extract {mode.upper()} for {species.species_code}",
)
if rc != 0 or not out.exists() or out.stat().st_size == 0:
raise RuntimeError(f"gffread {mode} extraction failed for {species.species_code}: {stderr}")
artifacts[f"{species.species_code}_{mode}"] = _artifact(out, f"{species.species_code}_{mode}", "fasta")
count += 1
return artifacts, {f"extracted_{mode}_species": count}
def _run_filter_fasta(input_dir: Path, workspace: Path) -> tuple[dict, dict]:
prep = _preparer(input_dir, workspace)
prep._ensure_tool("seqtk")
artifacts: dict[str, dict[str, Any]] = {}
count = 0
for ids in sorted(input_dir.glob("*.ids")) + sorted(input_dir.glob("*_mRNA.id")):
species_code = ids.name.split(".", 1)[0].split("_", 1)[0]
candidates = sorted(input_dir.glob(f"{species_code}*.fasta")) + sorted(input_dir.glob(f"{species_code}*.fa"))
for fasta in candidates:
if ids.resolve() == fasta.resolve() or ".subset." in fasta.name:
continue
out = input_dir / f"{fasta.stem}.subset.fasta"
rc, _, stderr = prep.run_command_to_file(["seqtk", "subseq", str(fasta), str(ids)], out, f"Filter {fasta.name} by {ids.name}")
if rc != 0:
raise RuntimeError(f"seqtk subseq failed for {fasta.name}: {stderr}")
artifacts[f"{out.stem}"] = _artifact(out, out.stem, "fasta")
count += 1
return artifacts, {"filtered_fasta_files": count}
def _run_simplify_ncbi(input_dir: Path, workspace: Path) -> tuple[dict, dict]:
prep = _preparer(input_dir, workspace)
artifacts: dict[str, dict[str, Any]] = {}
count = 0
for species in _iter_species(input_dir):
if species.gff:
gff = prep.decompress_if_needed(species.gff)
out_gff = input_dir / f"{species.species_code}.ncbi_simplified.gff3"
shutil.copy2(gff, out_gff)
prep._simplify_ncbi_gff_attrs(out_gff)
artifacts[f"{species.species_code}_ncbi_simplified_gff"] = _artifact(out_gff, f"{species.species_code}_ncbi_simplified_gff", "gff3")
count += 1
if species.cds:
cds = prep.decompress_if_needed(species.cds)
out_cds = input_dir / f"{species.species_code}.ncbi_normalized.cds.fasta"
prep._normalize_ncbi_cds_headers(cds, out_cds)
artifacts[f"{species.species_code}_ncbi_normalized_cds"] = _artifact(out_cds, f"{species.species_code}_ncbi_normalized_cds", "fasta")
return artifacts, {"simplified_ncbi_species": count}
def run_operation(operation: str, input_dir: Path, workspace: Path) -> tuple[dict, dict, dict]:
if operation == "organize":
artifacts, metrics, params = _run_organize(input_dir, workspace)
elif operation == "normalize_gff":
artifacts, metrics = _run_normalize_gff(input_dir, workspace)
params = {}
elif operation == "longest_transcript":
artifacts, metrics = _run_longest_transcript(input_dir, workspace)
params = {}
elif operation == "extract_pep":
artifacts, metrics = _run_gffread(input_dir, workspace, "pep")
params = {}
elif operation == "extract_cds":
artifacts, metrics = _run_gffread(input_dir, workspace, "cds")
params = {}
elif operation == "filter_fasta":
artifacts, metrics = _run_filter_fasta(input_dir, workspace)
params = {}
elif operation == "simplify_ncbi":
artifacts, metrics = _run_simplify_ncbi(input_dir, workspace)
params = {}
else:
raise ValueError(f"Unknown operation: {operation}")
return artifacts, metrics, params
def main() -> int:
parser = argparse.ArgumentParser(description="Run a single genome data preparation substep")
parser.add_argument("--input-dir", required=True, type=Path)
parser.add_argument("--workspace", required=True, type=Path)
parser.add_argument("--prefix", default="data_prep")
parser.add_argument("--stage", choices=sorted(STAGE_TO_OPERATION), default="")
parser.add_argument("--operation", choices=sorted(OPERATION_TO_STAGE), default="")
parser.add_argument("--cpu", default=4)
args = parser.parse_args()
operation = args.operation or STAGE_TO_OPERATION.get(args.stage)
if not operation:
parser.error("--operation or --stage is required")
stage = args.stage or OPERATION_TO_STAGE[operation]
start = datetime.now()
try:
args.input_dir.mkdir(parents=True, exist_ok=True)
args.workspace.mkdir(parents=True, exist_ok=True)
artifacts, metrics, params = run_operation(operation, args.input_dir, args.workspace)
params.update({"operation": operation, "cpu": args.cpu})
manifest = _write_manifest(args.workspace, args.input_dir, args.prefix, stage, operation, start, artifacts, metrics, params)
print(f"Completed {stage}; manifest: {manifest}")
return 0
except Exception as exc:
manifest = _write_manifest(
args.workspace,
args.input_dir,
args.prefix,
stage,
operation,
start,
{},
{},
{"operation": operation, "cpu": args.cpu},
status="failed",
error=str(exc),
)
print(f"Failed {stage}: {exc}", file=sys.stderr)
print(f"Manifest: {manifest}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())