Source code for tooluniverse.ebi_sequence_tools
# ebi_sequence_tools.py
"""
EMBL-EBI Job Dispatcher sequence-analysis tools for ToolUniverse.
ToolUniverse already wraps three Job Dispatcher services: multiple sequence
alignment (EBI_msa_align, covering Clustal Omega, MUSCLE, MAFFT, Kalign and
T-Coffee), phylogeny (EBI_build_phylogenetic_tree), and InterProScan. This
module adds the remaining widely used services on the same submit -> poll ->
result protocol:
pairwise alignment emboss_needle, emboss_stretcher, emboss_matcher
sequence translation emboss_transeq, emboss_backtranseq, emboss_sixpack
domain scanning pfamscan
membrane topology phobius
profile search hmmer3_phmmer, psiblast
The submit/poll/result helpers are imported from ebi_alignment_tool rather
than duplicated, so all EBI services share one implementation of the
protocol.
API: https://www.ebi.ac.uk/Tools/services/rest
Public, no authentication.
"""
import json
import re
from typing import Dict, Any, List
from .base_tool import BaseTool
from .tool_registry import register_tool
from .ebi_alignment_tool import (
_DEFAULT_EMAIL,
_guarded_run,
_poll,
_result,
_result_types,
_submit,
)
# service -> (label, whether the service takes an stype parameter)
_PAIRWISE_METHODS = {
"needle": ("emboss_needle", "Needleman-Wunsch global alignment"),
"stretcher": ("emboss_stretcher", "memory-efficient global alignment"),
"matcher": ("emboss_matcher", "Smith-Waterman local alignment"),
}
_TRANSLATE_MODES = {
"dna_to_protein": ("emboss_transeq", "translate nucleotide to protein"),
"protein_to_dna": ("emboss_backtranseq", "back-translate protein to nucleotide"),
"six_frame": ("emboss_sixpack", "six-frame translation with ORF detection"),
}
_PROFILE_METHODS = {
"phmmer": (
"hmmer3_phmmer",
"HMMER3 profile search of a sequence against a database",
),
"psiblast": ("psiblast", "PSI-BLAST iterative profile search"),
}
_PHMMER_DATABASES = [
"swissprot", "uniprotkb", "uniprotrefprot", "rp75", "rp55", "rp15",
]
_PSIBLAST_DATABASES = [
"uniprotkb", "uniprotkb_swissprot", "uniprotkb_reference_proteomes",
]
def _first_result(service: str, job_id: str, timeout: int) -> str:
"""Fetch the primary text result, preferring the 'out' result type."""
types = _result_types(service, job_id, timeout)
for preferred in ("out", "aln", "sequence"):
if preferred in types:
return _result(service, job_id, preferred, timeout)
return _result(service, job_id, types[0], timeout) if types else ""
def _run_job(
service: str, params: Dict[str, Any], timeout: int
) -> Dict[str, Any]:
"""Submit, poll, and fetch a job. Returns {'text': ...} or an error dict."""
params.setdefault("email", _DEFAULT_EMAIL)
job_id, error = _submit(service, params, timeout)
if error:
return {"status": "error", "error": error}
_, error = _poll(service, job_id, timeout)
if error:
return {"status": "error", "error": error}
return {"text": _first_result(service, job_id, timeout), "job_id": job_id}
def _require_sequence(arguments: Dict[str, Any], name: str = "sequence") -> str:
return (arguments.get(name) or "").strip()
def _int(value: str) -> Any:
"""Parse an integer field, returning None when the column is malformed."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _float(value: str) -> Any:
"""Parse a float field, returning None when the column is malformed."""
try:
return float(value)
except (TypeError, ValueError):
return None
[docs]
@register_tool("EBIPairwiseAlignTool")
class EBIPairwiseAlignTool(BaseTool):
"""Align exactly two sequences via EMBL-EBI EMBOSS services."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return _guarded_run("pairwise alignment", self.timeout, self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
seq_a = _require_sequence(arguments, "sequence_a")
seq_b = _require_sequence(arguments, "sequence_b")
if not seq_a or not seq_b:
return {
"status": "error",
"error": "sequence_a and sequence_b are both required, each a "
"single sequence in FASTA or raw form.",
}
algorithm = (arguments.get("algorithm") or "needle").lower()
if algorithm not in _PAIRWISE_METHODS:
return {
"status": "error",
"error": f"Unknown algorithm '{algorithm}'. "
f"Choose one of: {', '.join(sorted(_PAIRWISE_METHODS))}.",
}
service, description = _PAIRWISE_METHODS[algorithm]
params: Dict[str, Any] = {
"asequence": seq_a,
"bsequence": seq_b,
"stype": arguments.get("sequence_type") or "protein",
}
for key, param in (("matrix", "matrix"), ("gap_open", "gapopen"),
("gap_extend", "gapext")):
if arguments.get(key) is not None:
params[param] = arguments[key]
outcome = _run_job(service, params, self.timeout)
if outcome.get("status") == "error":
return outcome
text = outcome["text"]
identity = re.search(r"# Identity:\s+\S+\s+\(\s*([\d.]+)%\)", text)
similarity = re.search(r"# Similarity:\s+\S+\s+\(\s*([\d.]+)%\)", text)
gaps = re.search(r"# Gaps:\s+\S+\s+\(\s*([\d.]+)%\)", text)
score = re.search(r"# Score:\s+([\d.-]+)", text)
return {
"status": "success",
"data": {
"algorithm": algorithm,
"alignment": text,
"identity_percent": float(identity.group(1)) if identity else None,
"similarity_percent": (
float(similarity.group(1)) if similarity else None
),
"gaps_percent": float(gaps.group(1)) if gaps else None,
"score": float(score.group(1)) if score else None,
},
"metadata": {
"service": service,
"description": description,
"job_id": outcome["job_id"],
"source": "EMBL-EBI Job Dispatcher",
},
}
[docs]
@register_tool("EBITranslateSequenceTool")
class EBITranslateSequenceTool(BaseTool):
"""Translate or back-translate sequences via EMBL-EBI EMBOSS services."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return _guarded_run("translation", self.timeout, self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
sequence = _require_sequence(arguments)
if not sequence:
return {
"status": "error",
"error": "sequence is required, in FASTA or raw form.",
}
mode = (arguments.get("mode") or "dna_to_protein").lower()
if mode not in _TRANSLATE_MODES:
return {
"status": "error",
"error": f"Unknown mode '{mode}'. "
f"Choose one of: {', '.join(sorted(_TRANSLATE_MODES))}.",
}
service, description = _TRANSLATE_MODES[mode]
params: Dict[str, Any] = {"sequence": sequence}
if arguments.get("codon_table") is not None:
params["codontable"] = arguments["codon_table"]
if mode == "dna_to_protein" and arguments.get("frame") is not None:
params["frame"] = arguments["frame"]
if mode == "six_frame" and arguments.get("min_orf_size") is not None:
params["orfminsize"] = arguments["min_orf_size"]
outcome = _run_job(service, params, self.timeout)
if outcome.get("status") == "error":
return outcome
text = outcome["text"]
return {
"status": "success",
"data": {
"mode": mode,
"result": text,
"record_count": len(re.findall(r"^>", text, flags=re.MULTILINE)),
},
"metadata": {
"service": service,
"description": description,
"job_id": outcome["job_id"],
"source": "EMBL-EBI Job Dispatcher",
},
}
[docs]
@register_tool("EBIPfamScanTool")
class EBIPfamScanTool(BaseTool):
"""Scan a protein sequence against Pfam HMMs via EMBL-EBI."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return _guarded_run("PfamScan", self.timeout, self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
sequence = _require_sequence(arguments)
if not sequence:
return {
"status": "error",
"error": "sequence is required: one protein sequence in FASTA "
"or raw form.",
}
params: Dict[str, Any] = {
"sequence": sequence,
"database": arguments.get("database") or "pfam-a",
"format": "json",
}
if arguments.get("evalue") is not None:
params["evalue"] = arguments["evalue"]
outcome = _run_job("pfamscan", params, self.timeout)
if outcome.get("status") == "error":
return outcome
text = outcome["text"]
# PfamScan returns a JSON array of hits, not a tabular report.
try:
hits = json.loads(text)
except (TypeError, ValueError):
hits = None
if not isinstance(hits, list):
return {
"status": "error",
"error": "PfamScan returned an unexpected result format. "
f"First 200 characters: {text[:200]}",
}
domains: List[Dict[str, Any]] = []
for hit in hits:
if not isinstance(hit, dict):
continue
seq = hit.get("seq") or {}
hmm = hit.get("hmm") or {}
domains.append(
{
"start": _int(seq.get("from")),
"end": _int(seq.get("to")),
"hmm_accession": hit.get("acc"),
"hmm_name": hit.get("name"),
"description": hit.get("desc"),
"type": hit.get("type"),
"clan": hit.get("clan"),
"bit_score": _float(hit.get("bits")),
"evalue": _float(hit.get("evalue")),
"significant": bool(hit.get("sig")),
"hmm_start": _int(hmm.get("from")),
"hmm_end": _int(hmm.get("to")),
}
)
return {
"status": "success",
"data": domains,
"metadata": {
"service": "pfamscan",
"database": params["database"],
"domain_count": len(domains),
"job_id": outcome["job_id"],
"source": "EMBL-EBI Job Dispatcher",
},
}
[docs]
@register_tool("EBIPhobiusTool")
class EBIPhobiusTool(BaseTool):
"""Predict transmembrane topology and signal peptides via Phobius."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return _guarded_run("Phobius", self.timeout, self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
sequence = _require_sequence(arguments)
if not sequence:
return {
"status": "error",
"error": "sequence is required: one protein sequence in FASTA "
"or raw form.",
}
outcome = _run_job(
"phobius",
{"sequence": sequence, "stype": "protein", "format": "long"},
self.timeout,
)
if outcome.get("status") == "error":
return outcome
text = outcome["text"]
features: List[Dict[str, Any]] = []
for line in text.splitlines():
if not line.startswith("FT "):
continue
cols = line.split(None, 4)
if len(cols) < 4:
continue
features.append(
{
"feature": cols[1],
"start": _int(cols[2]),
"end": _int(cols[3]),
"note": cols[4].strip() if len(cols) > 4 else None,
}
)
tm_count = sum(1 for f in features if f["feature"] == "TRANSMEM")
has_signal = any(f["feature"] == "SIGNAL" for f in features)
return {
"status": "success",
"data": {
"transmembrane_helix_count": tm_count,
"has_signal_peptide": has_signal,
"features": features,
"raw_output": text if not features else None,
},
"metadata": {
"service": "phobius",
"job_id": outcome["job_id"],
"note": "Sequence-based prediction. For structure-derived "
"topology see PDBTM and OPM resources.",
"source": "EMBL-EBI Job Dispatcher",
},
}
[docs]
@register_tool("EBIProfileSearchTool")
class EBIProfileSearchTool(BaseTool):
"""Iterative and profile-based sequence search via EMBL-EBI."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
return _guarded_run("profile search", self.timeout, self._run, arguments)
[docs]
def _run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
sequence = _require_sequence(arguments)
if not sequence:
return {
"status": "error",
"error": "sequence is required: one protein sequence in FASTA "
"or raw form.",
}
method = (arguments.get("method") or "phmmer").lower()
if method not in _PROFILE_METHODS:
return {
"status": "error",
"error": f"Unknown method '{method}'. "
f"Choose one of: {', '.join(sorted(_PROFILE_METHODS))}.",
}
service, description = _PROFILE_METHODS[method]
allowed = _PHMMER_DATABASES if method == "phmmer" else _PSIBLAST_DATABASES
database = arguments.get("database") or allowed[0]
if database not in allowed:
return {
"status": "error",
"error": f"Database '{database}' is not valid for {method}. "
f"Choose one of: {', '.join(allowed)}.",
}
params: Dict[str, Any] = {"sequence": sequence, "database": database}
if arguments.get("evalue") is not None:
params["E" if method == "phmmer" else "expthr"] = arguments["evalue"]
outcome = _run_job(service, params, self.timeout)
if outcome.get("status") == "error":
return outcome
return {
"status": "success",
"data": {
"method": method,
"database": database,
"result": outcome["text"],
},
"metadata": {
"service": service,
"description": description,
"job_id": outcome["job_id"],
"source": "EMBL-EBI Job Dispatcher",
},
}