Files
PlantAI-dev/gfa_core/mas/tools/executor.py
T
2026-08-03 18:42:47 +08:00

388 lines
13 KiB
Python
Executable File

import asyncio
import json
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
from gfa_core.mas.tools.registry import ToolMetadata, ToolRegistry, create_default_registry
class ExecutionStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
TIMEOUT = "timeout"
@dataclass
class ExecutionResult:
tool_id: str
status: ExecutionStatus
return_code: int = 0
stdout: str = ""
stderr: str = ""
outputs: dict[str, str] = field(default_factory=dict)
started_at: datetime | None = None
completed_at: datetime | None = None
error: str | None = None
class ToolExecutor:
def __init__(
self,
registry: ToolRegistry | None = None,
work_dir: str = "/tmp/gene-family-tools",
use_docker: bool = False,
simulation_mode: bool = False,
):
self.registry = registry or create_default_registry()
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.use_docker = use_docker
self.simulation_mode = simulation_mode
def get_tool(self, tool_id: str) -> ToolMetadata | None:
return self.registry.get(tool_id)
def list_tools(self, category: str | None = None) -> list[ToolMetadata]:
if category:
return self.registry.list_by_category(category)
return self.registry.list_all()
def execute(
self,
tool_id: str,
inputs: dict[str, str],
parameters: dict[str, Any] | None = None,
work_subdir: str | None = None,
) -> ExecutionResult:
tool = self.registry.get(tool_id)
if not tool:
return ExecutionResult(
tool_id=tool_id,
status=ExecutionStatus.FAILED,
error=f"Tool not found: {tool_id}",
)
parameters = parameters or {}
work_path = self.work_dir / (work_subdir or tool_id)
work_path.mkdir(parents=True, exist_ok=True)
started_at = datetime.now()
if self.simulation_mode:
return self._simulate_execution(tool, inputs, parameters, work_path, started_at)
try:
if self.use_docker and tool.docker_image:
cmd = self._build_docker_command(tool, inputs, parameters, work_path)
else:
cmd = self._build_command(tool, inputs, parameters, work_path)
result = subprocess.run(
cmd,
cwd=str(work_path),
capture_output=True,
text=True,
timeout=tool.timeout,
)
if tool.id == "mafft" and result.returncode == 0:
mafft_output = work_path / "aligned.fasta"
with open(mafft_output, "w") as f:
f.write(result.stdout)
outputs = self._collect_outputs(tool, work_path)
status = ExecutionStatus.COMPLETED if result.returncode == 0 else ExecutionStatus.FAILED
return ExecutionResult(
tool_id=tool_id,
status=status,
return_code=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
outputs=outputs,
started_at=started_at,
completed_at=datetime.now(),
)
except subprocess.TimeoutExpired:
return ExecutionResult(
tool_id=tool_id,
status=ExecutionStatus.TIMEOUT,
error=f"Tool timed out after {tool.timeout}s",
started_at=started_at,
completed_at=datetime.now(),
)
except FileNotFoundError:
return ExecutionResult(
tool_id=tool_id,
status=ExecutionStatus.FAILED,
error=f"Executable not found: {tool.executable}",
started_at=started_at,
completed_at=datetime.now(),
)
except Exception as e:
return ExecutionResult(
tool_id=tool_id,
status=ExecutionStatus.FAILED,
error=str(e),
started_at=started_at,
completed_at=datetime.now(),
)
async def aexecute(
self,
tool_id: str,
inputs: dict[str, str],
parameters: dict[str, Any] | None = None,
work_subdir: str | None = None,
) -> ExecutionResult:
return await asyncio.to_thread(
self.execute, tool_id, inputs, parameters, work_subdir
)
def _build_command(
self,
tool: ToolMetadata,
inputs: dict[str, str],
parameters: dict[str, Any],
work_path: Path,
) -> list[str]:
cmd = [tool.executable]
if tool.id == "hmmsearch":
output_tbl = work_path / "output.tbl"
dom_tbl = work_path / "dom.tbl"
cmd.extend([
"--tblout", str(output_tbl),
"--domtblout", str(dom_tbl),
"-E", str(parameters.get("e_value", 1e-5)),
"--cpu", str(parameters.get("cpu", 4)),
inputs.get("query", ""),
inputs.get("target", ""),
])
elif tool.id == "blastp":
output_file = work_path / "blast_results.txt"
cmd.extend([
"-query", inputs.get("query", ""),
"-db", inputs.get("db", ""),
"-evalue", str(parameters.get("e_value", 1e-5)),
"-max_target_seqs", str(parameters.get("max_target_seqs", 500)),
"-outfmt", str(parameters.get("outfmt", 6)),
"-out", str(output_file),
])
elif tool.id == "mafft":
strategy = parameters.get("strategy", "auto")
if strategy != "auto":
cmd.append(f"--{strategy}")
cmd.extend([
"--maxiterate", str(parameters.get("maxiterate", 1000)),
"--reorder",
"--thread", str(parameters.get("threads", 4)),
inputs.get("sequences", ""),
])
elif tool.id == "iqtree":
cmd.extend([
"-s", inputs.get("alignment", ""),
"-m", parameters.get("model", "MFP"),
"-bb", str(parameters.get("bootstrap", 1000)),
"-nt", str(parameters.get("threads", 4)),
"-pre", str(work_path / "iqtree"),
])
elif tool.id == "meme":
output_dir = work_path / "meme_out"
cmd.extend([
inputs.get("sequences", ""),
"-nmotifs", str(parameters.get("nmotifs", 10)),
"-minw", str(parameters.get("min_width", 6)),
"-maxw", str(parameters.get("max_width", 200)),
"-oc", str(output_dir),
"-mod", "anr",
"-protein",
])
elif tool.id == "mcscanx":
cmd.extend([
inputs.get("gff", "").replace(".gff", ""),
])
return cmd
def _build_docker_command(
self,
tool: ToolMetadata,
inputs: dict[str, str],
parameters: dict[str, Any],
work_path: Path,
) -> list[str]:
cmd = [
"docker", "run", "--rm",
"-v", f"{work_path}:/data",
"-w", "/data",
tool.docker_image,
tool.executable,
]
base_cmd = self._build_command(tool, inputs, parameters, work_path)
cmd.extend(base_cmd[1:])
return cmd
def _simulate_execution(
self,
tool: ToolMetadata,
inputs: dict[str, str],
parameters: dict[str, Any],
work_path: Path,
started_at: datetime,
) -> ExecutionResult:
import random
import time
time.sleep(0.5)
outputs = {}
stdout_lines = [f"[SIMULATION] Executing {tool.name}"]
stdout_lines.append(f"[SIMULATION] Input files: {list(inputs.values())}")
stdout_lines.append(f"[SIMULATION] Parameters: {parameters}")
if tool.id == "hmmsearch":
output_tbl = work_path / "output.tbl"
dom_tbl = work_path / "dom.tbl"
target_file = inputs.get("target", "")
num_hits = random.randint(3, 8)
with open(output_tbl, "w") as f:
f.write(f"# hmmsearch output (simulated)\n")
f.write(f"# target: {target_file}\n")
for i in range(num_hits):
evalue = 10 ** (-random.randint(5, 50))
score = random.uniform(50, 300)
f.write(f"gene{i+1}\t-\tSPL\t-\t{evalue:.2e}\t{score:.1f}\n")
with open(dom_tbl, "w") as f:
f.write(f"# domain hits (simulated)\n")
outputs["tblout"] = str(output_tbl)
outputs["domtblout"] = str(dom_tbl)
stdout_lines.append(f"[SIMULATION] Found {num_hits} hits with E-value < {parameters.get('e_value', 1e-5)}")
elif tool.id == "blastp":
output_file = work_path / "blast_results.txt"
num_hits = random.randint(5, 15)
with open(output_file, "w") as f:
for i in range(num_hits):
evalue = 10 ** (-random.randint(3, 30))
identity = random.uniform(30, 95)
f.write(f"query\tsubject{i+1}\t{identity:.1f}\t100\t85\t0\t1\t100\t{evalue:.2e}\t200\n")
outputs["results"] = str(output_file)
stdout_lines.append(f"[SIMULATION] Found {num_hits} similar sequences")
elif tool.id == "mafft":
output_file = work_path / "aligned.fasta"
seq_file = inputs.get("sequences", "")
aligned_seqs = ">gene1_aligned\nM-A-V-Q-K-L-I-R\n>gene2_aligned\nM-A-G-R-K-L-C-F\n>gene3_aligned\nM-S-D-R-Q-Q-L-V"
with open(output_file, "w") as f:
f.write(aligned_seqs)
outputs["alignment"] = str(output_file)
stdout_lines.append(f"[SIMULATION] Aligned sequences from {seq_file}")
elif tool.id == "iqtree":
tree_file = work_path / "iqtree.treefile"
log_file = work_path / "iqtree.log"
tree_content = "(gene1:0.1,gene2:0.2,(gene3:0.15,gene4:0.18):0.05);"
with open(tree_file, "w") as f:
f.write(tree_content)
with open(log_file, "w") as f:
f.write(f"IQ-TREE (simulated)\n")
f.write(f"Best-fit model: {parameters.get('model', 'MFP')}\n")
f.write(f"Bootstrap: {parameters.get('bootstrap', 1000)}\n")
outputs["tree"] = str(tree_file)
outputs["log"] = str(log_file)
stdout_lines.append(f"[SIMULATION] Built phylogenetic tree")
elif tool.id == "meme":
meme_dir = work_path / "meme_out"
meme_dir.mkdir(exist_ok=True)
xml_file = meme_dir / "meme.xml"
with open(xml_file, "w") as f:
f.write('<?xml version="1.0"?>\n<meme>\n')
for i in range(parameters.get("nmotifs", 10)):
f.write(f'<motif id="motif_{i+1}" width="{random.randint(6, 20)}" evalue="{10**-random.randint(5,20)}"/>\n')
f.write('</meme>\n')
outputs["motifs"] = str(xml_file)
stdout_lines.append(f"[SIMULATION] Found {parameters.get('nmotifs', 10)} motifs")
elif tool.id == "mcscanx":
collinearity = work_path / "output.collinearity"
tandem = work_path / "output.tandem"
with open(collinearity, "w") as f:
f.write("### Collinearity blocks (simulated)\n")
f.write("chr1\tgene1\tgene2\tchr2\tgene3\tgene4\n")
with open(tandem, "w") as f:
f.write("# Tandem duplications (simulated)\n")
outputs["collinearity"] = str(collinearity)
outputs["tandem"] = str(tandem)
stdout_lines.append(f"[SIMULATION] Analyzed synteny")
return ExecutionResult(
tool_id=tool.id,
status=ExecutionStatus.COMPLETED,
return_code=0,
stdout="\n".join(stdout_lines),
stderr="",
outputs=outputs,
started_at=started_at,
completed_at=datetime.now(),
)
def _collect_outputs(self, tool: ToolMetadata, work_path: Path) -> dict[str, str]:
outputs = {}
output_mappings = {
"tblout": "output.tbl",
"domtblout": "dom.tbl",
"tree": "iqtree.treefile",
"log": "iqtree.log",
"alignment": "aligned.fasta",
"results": "blast_results.txt",
"motifs": "meme_out/meme.xml",
"collinearity": "output.collinearity",
"tandem": "output.tandem",
}
for output_def in tool.outputs:
if output_def.name in output_mappings:
path = work_path / output_mappings[output_def.name]
if path.exists():
outputs[output_def.name] = str(path)
for f in work_path.glob("*.treefile"):
outputs["tree"] = str(f)
return outputs