Files
PlantAI-dev/apps/cli/run_ncbi_fetch_sequences.py
T

1142 lines
46 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
#!/usr/bin/env python3
"""Fetch protein and CDS sequences from NCBI by gene IDs or gene/family query.
This runner is intentionally conservative:
* GeneID inputs can be fetched directly.
* Symbol/locus/family queries must be scoped by --taxid or --species.
* Ambiguous and unresolved queries are written to TSV instead of being guessed.
* Family-mode downloads require --allow-family-download; otherwise a candidate
table is produced for user confirmation.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import re
import shlex
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from io import StringIO
from pathlib import Path
from typing import Any, Callable, Iterable
from Bio import Entrez, SeqIO
from Bio.SeqRecord import SeqRecord
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_EMAIL = os.environ.get("NCBI_EMAIL", "bbav5-user@example.org")
DEFAULT_ATH_PEP = Path("/home/xzq/wutx/data/raw_genome/ath/Arabidopsis_thaliana.TAIR10.pep.all.fa")
DEFAULT_ATH_CDS = Path("/home/xzq/wutx/data/raw_genome/ath/Arabidopsis_thaliana.TAIR10.cds.all.fa")
LOCUS_TAG_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*g\d+)", re.IGNORECASE)
TAIR_GENE_RE = re.compile(r"gene:(AT[0-9]G\d+)", re.IGNORECASE)
GENE_ID_RE = re.compile(r"(?:GeneID:|Acc:)(\d+)", re.IGNORECASE)
def now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
try:
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
except Exception:
return ""
def safe_name(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip())
return value.strip("._") or "query"
def read_query_file(path: Path) -> list[str]:
ids: list[str] = []
with path.open("r", encoding="utf-8", errors="ignore") as handle:
for raw in handle:
text = raw.strip()
if not text or text.startswith("#"):
continue
ids.append(re.split(r"[\s,]+", text)[0])
return ids
def write_tsv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t", extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({key: row.get(key, "") for key in fieldnames})
def wrap_fasta(seq: str, width: int = 70) -> str:
return "\n".join(seq[i:i + width] for i in range(0, len(seq), width))
def write_fasta(path: Path, records: list[dict[str, Any]], seq_key: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for row in records:
seq = str(row.get(seq_key, "") or "").replace("\n", "").replace(" ", "")
if not seq:
continue
header_parts = [
row.get("input_id") or row.get("query") or row.get("gene_id"),
f"GeneID:{row.get('gene_id', '')}",
row.get("symbol", ""),
row.get("transcript_accession", ""),
row.get("protein_accession", ""),
]
header = " ".join(str(item) for item in header_parts if item)
handle.write(f">{header}\n{wrap_fasta(seq)}\n")
def create_family_reference_link(prefix: str, protein_fa: Path) -> Path | None:
"""Create the legacy family-reference name Species.Family.pep.fa for NCBI protein output."""
parts = [p for p in safe_name(prefix).split(".") if p]
if len(parts) < 2 or not protein_fa.exists() or protein_fa.stat().st_size == 0:
return None
link_path = protein_fa.parent / f"{parts[0]}.{parts[1]}.pep.fa"
if link_path.resolve() == protein_fa.resolve():
return link_path
try:
if link_path.exists() or link_path.is_symlink():
if link_path.is_symlink() and link_path.resolve() == protein_fa.resolve():
return link_path
link_path.unlink()
os.symlink(protein_fa.name, link_path)
return link_path
except Exception as exc:
print(f"WARNING: failed to create family reference symlink {link_path}: {exc}")
return None
def normalize_locus_tag(value: str) -> str:
match = LOCUS_TAG_RE.match(str(value or "").strip())
return match.group(1).upper() if match else ""
def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
candidate = Path(path)
if not candidate.exists():
continue
key = str(candidate.resolve())
if key in seen:
continue
seen.add(key)
unique.append(candidate.resolve())
return unique
def _paths_from_genome_prepare_manifests(workspace: Path) -> tuple[list[Path], list[Path]]:
pep_paths: list[Path] = []
cds_paths: list[Path] = []
final_dir = workspace / "final"
if not final_dir.exists():
return pep_paths, cds_paths
for manifest_path in sorted(final_dir.glob("*.genome_prepare*.manifest.json")):
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[WARN] Failed to read genome_prepare manifest {manifest_path}: {exc}")
continue
for artifact in (data.get("artifacts") or {}).values():
raw_path = artifact.get("full_path") or artifact.get("path") or ""
if not raw_path:
continue
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = (PROJECT_ROOT / candidate).resolve()
if not candidate.exists():
continue
name = candidate.name.lower()
kind = str(artifact.get("type") or artifact.get("artifact") or "").lower()
if kind in {"pep", "all_pep", "protein", "fasta"} or "pep" in name or name.endswith(".faa"):
pep_paths.append(candidate)
if kind == "cds" or "cds" in name:
cds_paths.append(candidate)
return pep_paths, cds_paths
def discover_local_reference_files(workspace: Path) -> tuple[list[Path], list[Path]]:
"""Discover job-local and bundled reference proteome/CDS files (preferred over NCBI web)."""
job_id = workspace.name
input_dir = PROJECT_ROOT / "input" / job_id
pep_paths: list[Path] = []
cds_paths: list[Path] = []
search_roots = [input_dir, workspace / "input", workspace / "genome_prepare"]
for root in search_roots:
if not root.exists():
continue
for candidate in root.rglob("*"):
if not candidate.is_file():
continue
name = candidate.name.lower()
suffix = candidate.suffix.lower()
if suffix not in {".fa", ".fasta", ".faa", ".fna"} and ".pep." not in name and ".cds." not in name:
continue
if "pep" in name or name.endswith(".faa") or ".protein." in name:
pep_paths.append(candidate)
if "cds" in name or name.endswith(".fna") or ".cds." in name:
cds_paths.append(candidate)
manifest_pep, manifest_cds = _paths_from_genome_prepare_manifests(workspace)
pep_paths.extend(manifest_pep)
cds_paths.extend(manifest_cds)
for env_key, bucket in (
("NCBI_LOCAL_PEP_PATH", pep_paths),
("NCBI_LOCAL_CDS_PATH", cds_paths),
):
env_path = os.environ.get(env_key, "").strip()
if env_path:
for item in env_path.split(":"):
bucket.append(Path(item))
if DEFAULT_ATH_PEP.exists():
pep_paths.append(DEFAULT_ATH_PEP)
if DEFAULT_ATH_CDS.exists():
cds_paths.append(DEFAULT_ATH_CDS)
return _dedupe_paths(pep_paths), _dedupe_paths(cds_paths)
def _index_local_record(
index: dict[str, list[dict[str, Any]]],
key: str,
row: dict[str, Any],
) -> None:
if not key:
return
index.setdefault(key, []).append(row)
def build_local_sequence_index(paths: list[Path]) -> dict[str, list[dict[str, Any]]]:
index: dict[str, list[dict[str, Any]]] = {}
for path in paths:
try:
for record in SeqIO.parse(str(path), "fasta"):
header = f"{record.id} {record.description or ''}"
locus = normalize_locus_tag(record.id) or normalize_locus_tag(record.description)
tair_match = TAIR_GENE_RE.search(header)
if tair_match:
locus = tair_match.group(1).upper()
gene_id_match = GENE_ID_RE.search(header)
row = {
"accession": str(record.id),
"description": str(record.description or ""),
"sequence": str(record.seq),
"source": str(path),
"gene_id": gene_id_match.group(1) if gene_id_match else "",
"isoform_rank": int(record.id.split(".")[-1]) if "." in record.id and record.id.split(".")[-1].isdigit() else 0,
}
if locus:
_index_local_record(index, f"locus:{locus}", row)
if row["gene_id"]:
_index_local_record(index, f"gene_id:{row['gene_id']}", row)
_index_local_record(index, f"accession:{record.id}", row)
base_accession = record.id.split(".")[0]
if base_accession != record.id:
_index_local_record(index, f"accession:{base_accession}", row)
except Exception as exc:
print(f"[WARN] Failed to index local reference file {path}: {exc}")
return index
def build_local_reference_store(workspace: Path) -> LocalReferenceStore:
pep_paths, cds_paths = discover_local_reference_files(workspace)
return LocalReferenceStore(
pep_index=build_local_sequence_index(pep_paths),
cds_index=build_local_sequence_index(cds_paths),
pep_sources=[str(path) for path in pep_paths],
cds_sources=[str(path) for path in cds_paths],
)
def lookup_local_candidates(
index: dict[str, list[dict[str, Any]]],
gene: dict[str, Any],
) -> list[dict[str, Any]]:
keys: list[str] = []
locus = normalize_locus_tag(gene.get("input_id") or gene.get("locus_tag") or "")
if locus:
keys.append(f"locus:{locus}")
gene_id = str(gene.get("gene_id") or "")
if gene_id:
keys.append(f"gene_id:{gene_id}")
input_id = str(gene.get("input_id") or "").strip()
if input_id:
keys.append(f"accession:{input_id}")
keys.append(f"accession:{input_id.split('.')[0]}")
merged: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for key in keys:
for row in index.get(key, []):
marker = (row.get("accession", ""), row.get("source", ""))
if marker in seen:
continue
seen.add(marker)
merged.append(row)
if gene_id:
matched = [row for row in merged if row.get("gene_id") == gene_id]
if matched:
return matched
return merged
def _select_local_records(
candidates: list[dict[str, Any]],
transcript_policy: str,
) -> list[dict[str, Any]]:
if not candidates:
return []
if transcript_policy == "all":
return candidates
if transcript_policy == "longest":
return sorted(candidates, key=lambda row: len(str(row.get("sequence") or "")), reverse=True)[:1]
return sorted(
candidates,
key=lambda row: (
0 if str(row.get("accession", "")).endswith(".1") else 1,
int(row.get("isoform_rank") or 999),
-len(str(row.get("sequence") or "")),
),
)[:1]
def fetch_gene_from_local_reference(
store: LocalReferenceStore,
gene: dict[str, Any],
*,
seq_types: str,
transcript_policy: str,
) -> tuple[list[dict[str, Any]], str]:
"""Extract protein/CDS from local reference genome/proteome before NCBI web download."""
if not store.pep_sources and not store.cds_sources:
return [], "no local reference genome/proteome found in job input or bundled ref_data"
need_protein = seq_types in {"protein", "both"}
need_cds = seq_types in {"cds", "both"}
pep_candidates = lookup_local_candidates(store.pep_index, gene) if need_protein else []
cds_candidates = lookup_local_candidates(store.cds_index, gene) if need_cds else []
if need_protein and not pep_candidates and need_cds and not cds_candidates:
locus = normalize_locus_tag(gene.get("input_id") or gene.get("locus_tag") or "")
hint = locus or gene.get("gene_id") or gene.get("input_id") or "query"
return [], f"no local reference match for {hint}"
selected_pep = _select_local_records(pep_candidates, transcript_policy) if pep_candidates else []
selected_cds = _select_local_records(cds_candidates, transcript_policy) if cds_candidates else []
if need_protein and not selected_pep and not (need_cds and selected_cds):
return [], "local reference indexed but no matching protein/CDS record"
if need_cds and not selected_cds and not (need_protein and selected_pep):
return [], "local reference indexed but no matching CDS record"
if need_protein and not selected_pep and need_cds and selected_cds:
# Protein-only fallback from CDS record is not supported; caller may still use CDS.
if seq_types == "cds":
selected_pep = []
else:
return [], "local CDS found but protein sequence unavailable without NCBI"
locus = normalize_locus_tag(gene.get("input_id") or gene.get("locus_tag") or "")
pep_item = selected_pep[0] if selected_pep else None
cds_item = selected_cds[0] if selected_cds else None
if pep_item and cds_item and pep_item.get("accession") != cds_item.get("accession"):
# Prefer paired .1 isoforms when both indexes exist.
pass
source_name = Path((pep_item or cds_item or {}).get("source", "")).name
row = {
**gene,
"transcript_accession": (pep_item or cds_item or {}).get("accession", ""),
"protein_accession": (pep_item or {}).get("accession", ""),
"product": (pep_item or cds_item or {}).get("description", ""),
"locus_tag": locus,
"cds_length": len((cds_item or {}).get("sequence", "") or ""),
"protein_length": len((pep_item or {}).get("sequence", "") or ""),
"cds_sequence": (cds_item or {}).get("sequence", "") if need_cds else "",
"protein_sequence": (pep_item or {}).get("sequence", "") if need_protein else "",
"status": "resolved",
"fetch_route": f"local_reference:{source_name or 'reference'}",
"transcript_policy": transcript_policy,
"transcript_candidates": max(len(pep_candidates), len(cds_candidates)),
}
if need_protein and not row["protein_sequence"]:
return [], "local reference match missing protein sequence"
if need_cds and not row["cds_sequence"]:
return [], "local reference match missing CDS sequence"
return [row], ""
@dataclass
class LocalReferenceStore:
pep_index: dict[str, list[dict[str, Any]]]
cds_index: dict[str, list[dict[str, Any]]]
pep_sources: list[str]
cds_sources: list[str]
@dataclass
class EntrezClient:
email: str
api_key: str = ""
cache_dir: Path | None = None
delay_sec: float = 0.34
def __post_init__(self) -> None:
Entrez.email = self.email
if self.api_key:
Entrez.api_key = self.api_key
self.delay_sec = min(self.delay_sec, 0.11)
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _cache_path(self, kind: str, key: str, suffix: str) -> Path | None:
if not self.cache_dir:
return None
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:16]
return self.cache_dir / f"{kind}.{safe_name(key)[:60]}.{digest}.{suffix}"
def _with_retries(self, label: str, call: Callable[[], Any], attempts: int = 5) -> Any:
last_error: Exception | None = None
for attempt in range(attempts):
try:
time.sleep(self.delay_sec)
return call()
except Exception as exc:
last_error = exc
if attempt == attempts - 1:
break
backoff = min(30.0, (2.0 ** attempt) + self.delay_sec * (attempt + 1))
time.sleep(backoff)
raise RuntimeError(f"{label} failed after {attempts} attempts: {last_error}") from last_error
def esearch(self, db: str, term: str, retmax: int = 50) -> dict[str, Any]:
key = f"{db}|{term}|{retmax}"
cache = self._cache_path("esearch", key, "json")
if cache and cache.exists():
return json.loads(cache.read_text(encoding="utf-8"))
def call() -> Any:
with Entrez.esearch(db=db, term=term, retmax=retmax, retmode="xml") as handle:
return Entrez.read(handle)
data = self._with_retries(f"esearch:{db}", call)
payload = json.loads(json.dumps(data))
if cache:
cache.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return payload
def esummary_gene(self, gene_ids: Iterable[str]) -> dict[str, dict[str, Any]]:
ids = [str(i) for i in gene_ids if str(i).strip()]
if not ids:
return {}
key = ",".join(ids)
cache = self._cache_path("esummary_gene", key, "json")
if cache and cache.exists():
return json.loads(cache.read_text(encoding="utf-8"))
def call() -> Any:
with Entrez.esummary(db="gene", id=key, retmode="xml") as handle:
return Entrez.read(handle)
data = self._with_retries("esummary:gene", call)
result: dict[str, dict[str, Any]] = {}
for item in data.get("DocumentSummarySet", {}).get("DocumentSummary", []):
gene_id = str(item.attributes.get("uid") or item.get("uid") or "")
result[gene_id] = json.loads(json.dumps(item))
if cache:
cache.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
return result
def elink_ids(self, dbfrom: str, db: str, uid: str, preferred_terms: list[str] | None = None) -> list[dict[str, str]]:
key = f"{dbfrom}|{db}|{uid}"
cache = self._cache_path("elink", key, "json")
if cache and cache.exists():
linksets = json.loads(cache.read_text(encoding="utf-8"))
else:
def call() -> Any:
with Entrez.elink(dbfrom=dbfrom, db=db, id=str(uid), retmode="xml") as handle:
return Entrez.read(handle)
data = self._with_retries(f"elink:{dbfrom}->{db}", call)
linksets = json.loads(json.dumps(data))
if cache:
cache.write_text(json.dumps(linksets, indent=2, ensure_ascii=False), encoding="utf-8")
rows: list[dict[str, str]] = []
for linkset in linksets:
for linkdb in linkset.get("LinkSetDb", []) or []:
linkname = str(linkdb.get("LinkName", ""))
for item in linkdb.get("Link", []) or []:
rows.append({"id": str(item.get("Id", "")), "linkname": linkname})
if preferred_terms:
preferred = [row for row in rows if any(term.lower() in row["linkname"].lower() for term in preferred_terms)]
if preferred:
return preferred
return rows
def efetch_text(self, db: str, ids: Iterable[str], rettype: str, retmode: str = "text") -> str:
clean_ids = [str(i) for i in ids if str(i).strip()]
if not clean_ids:
return ""
key = f"{db}|{rettype}|{retmode}|{','.join(clean_ids)}"
cache = self._cache_path("efetch", key, "txt")
if cache and cache.exists():
return cache.read_text(encoding="utf-8", errors="ignore")
def call() -> str:
with Entrez.efetch(db=db, id=",".join(clean_ids), rettype=rettype, retmode=retmode) as handle:
return handle.read()
text = self._with_retries(f"efetch:{db}:{rettype}", call)
if cache:
cache.write_text(text, encoding="utf-8")
return text
def taxon_clause(taxid: str = "", species: str = "") -> str:
if taxid:
return f"txid{taxid}[Organism:exp]"
if species:
return f'"{species}"[Organism]'
return ""
def build_gene_search_term(query: str, id_type: str, taxid: str = "", species: str = "", mode: str = "gene") -> str:
taxon = taxon_clause(taxid, species)
if id_type == "geneid" or re.fullmatch(r"\d+", query.strip()):
term = f"{query.strip()}[uid]"
elif id_type == "symbol":
term = f"{query.strip()}[Gene Name]"
elif id_type == "locus_tag":
term = f"{query.strip()}[Locus Tag]"
elif mode == "family":
cleaned = query.strip()
term = f"({cleaned}[All Fields] OR {cleaned} family[All Fields])"
else:
cleaned = query.strip()
term = f"({cleaned}[Gene Name] OR {cleaned}[Locus Tag] OR {cleaned}[All Fields])"
return f"({term}) AND {taxon}" if taxon else term
def gene_summary_taxid(summary: dict[str, Any]) -> str:
organism = summary.get("Organism")
if isinstance(organism, dict):
return str(organism.get("TaxID") or organism.get("TaxId") or organism.get("taxid") or "")
return str(summary.get("TaxID") or summary.get("TaxId") or summary.get("taxid") or "")
def gene_summary_organism(summary: dict[str, Any]) -> str:
organism = summary.get("Organism")
if isinstance(organism, dict):
return str(organism.get("ScientificName") or organism.get("CommonName") or "")
return str(organism or "")
def summary_exactly_matches_query(summary: dict[str, Any], query: str) -> bool:
query_norm = str(query or "").strip().upper()
if not query_norm:
return False
values = [
summary.get("Name", ""),
summary.get("NomenclatureSymbol", ""),
]
for alias in re.split(r"[,;]\s*", str(summary.get("OtherAliases", "") or "")):
values.append(alias)
return any(str(value).strip().upper() == query_norm for value in values if str(value).strip())
def resolve_gene_queries(
client: EntrezClient,
queries: list[str],
*,
id_type: str,
mode: str,
taxid: str,
species: str,
retmax: int,
allow_family_download: bool,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
resolved: list[dict[str, Any]] = []
ambiguous: list[dict[str, Any]] = []
unresolved: list[dict[str, Any]] = []
for query in queries:
query = str(query).strip()
if not query:
continue
if (id_type in {"auto", "symbol", "locus_tag"} or mode == "family") and not (taxid or species) and not re.fullmatch(r"\d+", query):
unresolved.append({
"input_id": query,
"reason": "symbol/locus/family query requires --taxid or --species",
})
continue
term = build_gene_search_term(query, id_type, taxid, species, mode)
search = client.esearch("gene", term, retmax=retmax)
ids = [str(i) for i in search.get("IdList", [])]
if not ids:
unresolved.append({"input_id": query, "reason": "no NCBI Gene match", "search_term": term})
continue
if mode != "family" and len(ids) > 1:
summaries = client.esummary_gene(ids)
exact_ids = [gid for gid in ids if summary_exactly_matches_query(summaries.get(gid, {}), query)]
if len(exact_ids) == 1:
ids = exact_ids
else:
ambiguous.append({
"input_id": query,
"reason": f"matched {len(ids)} NCBI Gene records",
"search_term": term,
"candidate_gene_ids": ",".join(ids[:20]),
"candidate_symbols": ";".join(f"{gid}:{summaries.get(gid, {}).get('Name', '')}" for gid in ids[:20]),
})
continue
if mode == "family" and len(ids) > 1 and not allow_family_download:
summaries = client.esummary_gene(ids[:retmax])
for gid in ids:
summary = summaries.get(gid, {})
ambiguous.append({
"input_id": query,
"reason": "family candidate requires user confirmation",
"search_term": term,
"candidate_gene_id": gid,
"candidate_symbol": summary.get("Name", ""),
"candidate_description": summary.get("Description", ""),
"candidate_taxid": gene_summary_taxid(summary),
"candidate_organism": gene_summary_organism(summary),
})
continue
summaries = client.esummary_gene(ids)
for gid in ids:
summary = summaries.get(gid, {})
resolved.append({
"input_id": query,
"gene_id": gid,
"symbol": summary.get("Name", ""),
"description": summary.get("Description", ""),
"taxid": gene_summary_taxid(summary),
"organism": gene_summary_organism(summary),
"search_term": term,
})
return resolved, ambiguous, unresolved
def cds_matches_gene(feature: Any, gene_id: str) -> bool:
qualifiers = getattr(feature, "qualifiers", {}) or {}
db_xrefs = qualifiers.get("db_xref", []) or []
if any(str(x) == f"GeneID:{gene_id}" for x in db_xrefs):
return True
return False
def transcript_rank(row: dict[str, Any]) -> tuple[int, int, int]:
tx = str(row.get("transcript_accession", ""))
protein = str(row.get("protein_accession", ""))
refseq_score = 0
if tx.startswith(("NM_", "NR_")):
refseq_score += 4
elif tx.startswith("XM_"):
refseq_score += 2
if protein.startswith("NP_"):
refseq_score += 4
elif protein.startswith("XP_"):
refseq_score += 2
cds_len = int(row.get("cds_length") or 0)
return (refseq_score, cds_len, -len(tx))
def select_transcripts(rows: list[dict[str, Any]], policy: str) -> list[dict[str, Any]]:
if policy == "all":
return rows
if not rows:
return []
ordered = sorted(rows, key=transcript_rank, reverse=True)
if policy == "longest":
ordered = sorted(rows, key=lambda r: int(r.get("cds_length") or 0), reverse=True)
return ordered[:1]
def protein_rank(row: dict[str, Any]) -> tuple[int, int]:
acc = str(row.get("protein_accession", ""))
score = 0
if acc.startswith("NP_"):
score += 4
elif acc.startswith("XP_"):
score += 2
elif acc.startswith("WP_"):
score += 1
return (score, int(row.get("protein_length") or 0))
def fetch_gene_protein_via_elink(
client: EntrezClient,
gene: dict[str, Any],
*,
transcript_policy: str,
) -> tuple[list[dict[str, Any]], str]:
"""Fetch protein sequences via gene->protein elink when nuccore is unavailable."""
gene_id = str(gene["gene_id"])
links = client.elink_ids(
"gene",
"protein",
gene_id,
preferred_terms=["gene_protein_refseq", "refseq", "gene_protein"],
)
protein_ids: list[str] = []
seen: set[str] = set()
for row in links:
uid = row.get("id", "")
if uid and uid not in seen:
protein_ids.append(uid)
seen.add(uid)
if not protein_ids:
return [], "no linked protein records"
fasta_text = client.efetch_text("protein", protein_ids, rettype="fasta", retmode="text")
parsed = list(SeqIO.parse(StringIO(fasta_text), "fasta"))
candidates: list[dict[str, Any]] = []
for record in parsed:
accession = str(record.id)
candidates.append({
**gene,
"transcript_accession": "",
"protein_accession": accession,
"product": str(record.description or ""),
"locus_tag": str(gene.get("input_id") or ""),
"cds_length": 0,
"protein_length": len(record.seq),
"cds_sequence": "",
"protein_sequence": str(record.seq),
"status": "resolved",
"fetch_route": "gene_protein_elink",
})
if not candidates:
return [], f"protein efetch returned no records for GeneID:{gene_id}"
if transcript_policy == "all":
selected = candidates
elif transcript_policy == "longest":
selected = sorted(candidates, key=lambda row: int(row.get("protein_length") or 0), reverse=True)[:1]
else:
selected = sorted(candidates, key=protein_rank, reverse=True)[:1]
for row in selected:
row["transcript_policy"] = transcript_policy
row["transcript_candidates"] = len(candidates)
return selected, ""
def fetch_gene_sequences(
client: EntrezClient,
gene: dict[str, Any],
*,
transcript_policy: str,
max_nuccore: int,
seq_types: str = "both",
) -> tuple[list[dict[str, Any]], str]:
if seq_types == "protein":
return fetch_gene_protein_via_elink(client, gene, transcript_policy=transcript_policy)
gene_id = str(gene["gene_id"])
try:
links = client.elink_ids(
"gene",
"nuccore",
gene_id,
preferred_terms=["gene_nuccore_refseqrna", "refseqrna", "rna"],
)
except Exception as exc:
if seq_types in {"protein", "both"}:
rows, reason = fetch_gene_protein_via_elink(client, gene, transcript_policy=transcript_policy)
if rows:
return rows, ""
return [], f"nuccore elink failed ({exc}); protein fallback failed ({reason})"
return [], str(exc)
nuccore_ids: list[str] = []
seen: set[str] = set()
for row in links:
uid = row.get("id", "")
if uid and uid not in seen:
nuccore_ids.append(uid)
seen.add(uid)
if len(nuccore_ids) >= max_nuccore:
break
if not nuccore_ids:
if seq_types in {"protein", "both"}:
rows, reason = fetch_gene_protein_via_elink(client, gene, transcript_policy=transcript_policy)
if rows:
return rows, ""
return [], f"no linked RefSeq RNA/nuccore records; protein fallback failed ({reason})"
return [], "no linked RefSeq RNA/nuccore records"
gb_text = client.efetch_text("nuccore", nuccore_ids, rettype="gb", retmode="text")
records = list(SeqIO.parse(StringIO(gb_text), "genbank"))
candidates: list[dict[str, Any]] = []
for record in records:
accession = str(record.id)
for feature in record.features:
if feature.type != "CDS":
continue
if not cds_matches_gene(feature, gene_id):
continue
qualifiers = feature.qualifiers or {}
cds_seq = str(feature.extract(record.seq))
protein_seq = str((qualifiers.get("translation") or [""])[0])
protein_id = str((qualifiers.get("protein_id") or [""])[0])
product = str((qualifiers.get("product") or [""])[0])
locus_tag = str((qualifiers.get("locus_tag") or [""])[0])
if not cds_seq and not protein_seq:
continue
row = {
**gene,
"transcript_accession": accession,
"protein_accession": protein_id,
"product": product,
"locus_tag": locus_tag,
"cds_length": len(cds_seq),
"protein_length": len(protein_seq),
"cds_sequence": cds_seq,
"protein_sequence": protein_seq,
"status": "resolved",
}
candidates.append(row)
if not candidates:
if seq_types in {"protein", "both"}:
rows, reason = fetch_gene_protein_via_elink(client, gene, transcript_policy=transcript_policy)
if rows:
return rows, ""
return [], f"linked nuccore records had no CDS feature with db_xref GeneID:{gene_id}; protein fallback failed ({reason})"
return [], f"linked nuccore records had no CDS feature with db_xref GeneID:{gene_id}"
selected = select_transcripts(candidates, transcript_policy)
for row in selected:
row["transcript_policy"] = transcript_policy
row["transcript_candidates"] = len(candidates)
return selected, ""
def artifact(workspace: Path, path: Path, typ: str) -> dict[str, Any]:
try:
rel = str(path.relative_to(workspace))
except ValueError:
rel = str(path)
return {
"path": rel,
"full_path": str(path.resolve()),
"type": typ,
"size": path.stat().st_size if path.exists() else 0,
"sha256": file_sha256(path) if path.exists() else "",
}
def write_report(path: Path, manifest: dict[str, Any]) -> None:
metrics = manifest.get("metrics", {})
params = manifest.get("parameters", {})
lines = [
"# NCBI Sequence Fetch Report",
"",
f"- Stage: `{manifest.get('stage')}`",
f"- Status: `{manifest.get('status')}`",
f"- Prefix: `{manifest.get('prefix')}`",
f"- Query mode: `{params.get('mode')}`",
f"- ID type: `{params.get('id_type')}`",
f"- Taxid/species: `{params.get('taxid') or 'N/A'}` / `{params.get('species') or 'N/A'}`",
f"- Resolved genes: {metrics.get('resolved_genes')}",
f"- Protein records: {metrics.get('protein_records')}",
f"- CDS records: {metrics.get('cds_records')}",
f"- Ambiguous records: {metrics.get('ambiguous_records')}",
f"- Unresolved records: {metrics.get('unresolved_records')}",
f"- Local reference records: {metrics.get('local_reference_records', 0)}",
f"- NCBI web records: {metrics.get('ncbi_web_records', 0)}",
"",
"## Boundary",
"",
"Reference-genome priority: sequences are extracted from job input / genome_prepare / bundled "
"reference FASTA first; NCBI E-utilities is used only when no local match exists. "
"Family-mode candidates are not downloaded unless `--allow-family-download` is set. "
"Ambiguous and unresolved IDs are written to TSV files for user review.",
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Fetch protein/CDS sequences from NCBI Gene records.")
parser.add_argument("--workspace", required=True, help="Job workspace directory")
parser.add_argument("-p", "--prefix", default="NCBI.fetch", help="Output prefix")
parser.add_argument("-j", "--job-id", default="", help="Job ID")
parser.add_argument("--gene-ids", help="Text file with one query/GeneID per line")
parser.add_argument("--id", dest="ids", action="append", default=[], help="Single query/GeneID; can be repeated")
parser.add_argument("--query", default="", help="Gene symbol/locus/family query")
parser.add_argument("--mode", choices=["gene", "family"], default="gene")
parser.add_argument("--id-type", choices=["auto", "geneid", "symbol", "locus_tag"], default="auto")
parser.add_argument("--taxid", default="", help="NCBI taxonomy ID; recommended for symbol/family queries")
parser.add_argument("--species", default="", help="Species scientific name; used if taxid is absent")
parser.add_argument("--seq-types", choices=["protein", "cds", "both"], default="both")
parser.add_argument("--transcript-policy", choices=["refseq_preferred", "canonical", "longest", "all"], default="refseq_preferred")
parser.add_argument("--allow-family-download", action="store_true", help="Download all family search candidates without further confirmation")
parser.add_argument("--retmax", type=int, default=50, help="Max NCBI Gene candidates for search queries")
parser.add_argument("--max-nuccore", type=int, default=30, help="Max linked nuccore records to inspect per GeneID")
parser.add_argument("--email", default=DEFAULT_EMAIL, help="NCBI Entrez email")
parser.add_argument("--api-key", default=os.environ.get("NCBI_API_KEY", ""), help="NCBI API key")
return parser.parse_args()
def main() -> int:
args = parse_args()
start = datetime.now()
workspace = Path(args.workspace).resolve()
analysis_dir = workspace / "ncbi_fetch_sequences" / "analysis"
final_dir = workspace / "final"
cache_dir = workspace / "cache" / "ncbi"
analysis_dir.mkdir(parents=True, exist_ok=True)
final_dir.mkdir(parents=True, exist_ok=True)
policy = "refseq_preferred" if args.transcript_policy == "canonical" else args.transcript_policy
queries = list(args.ids or [])
if args.gene_ids:
queries.extend(read_query_file(Path(args.gene_ids)))
if args.query:
queries.append(args.query)
queries = [q for q in queries if str(q).strip()]
prefix = safe_name(args.prefix)
protein_fa = final_dir / f"{prefix}.ncbi_fetch.protein.fa"
cds_fa = final_dir / f"{prefix}.ncbi_fetch.cds.fa"
mapping_tsv = final_dir / f"{prefix}.ncbi_fetch.mapping.tsv"
unresolved_tsv = final_dir / f"{prefix}.ncbi_fetch.unresolved.tsv"
ambiguous_tsv = final_dir / f"{prefix}.ncbi_fetch.ambiguous.tsv"
report_path = final_dir / f"{prefix}.ncbi_fetch.report.md"
manifest_path = final_dir / f"{prefix}.ncbi_fetch.manifest.json"
commands_path = analysis_dir / "commands.sh"
commands_path.write_text(" ".join(shlex.quote(part) for part in sys.argv) + "\n", encoding="utf-8")
os.chmod(commands_path, 0o755)
resolved: list[dict[str, Any]] = []
ambiguous: list[dict[str, Any]] = []
unresolved: list[dict[str, Any]] = []
sequence_rows: list[dict[str, Any]] = []
status = "success"
error = ""
local_reference = LocalReferenceStore({}, {}, [], [])
try:
if not queries:
raise ValueError("No query provided. Use --gene-ids, --id, or --query.")
local_reference = build_local_reference_store(workspace)
if local_reference.pep_sources or local_reference.cds_sources:
print(
"Local reference priority enabled: "
f"{len(local_reference.pep_sources)} protein source(s), "
f"{len(local_reference.cds_sources)} CDS source(s)"
)
remaining_queries: list[str] = []
for query in queries:
local_gene = {
"input_id": query,
"gene_id": query if re.fullmatch(r"\d+", str(query).strip()) else "",
"symbol": "",
"locus_tag": normalize_locus_tag(query) or "",
"description": "",
"taxid": args.taxid,
"organism": args.species,
}
rows, _reason = fetch_gene_from_local_reference(
local_reference,
local_gene,
seq_types=args.seq_types,
transcript_policy=policy,
)
if rows:
sequence_rows.extend(rows)
resolved.append(local_gene)
else:
remaining_queries.append(query)
if remaining_queries:
client = EntrezClient(email=args.email, api_key=args.api_key, cache_dir=cache_dir)
web_resolved, ambiguous, unresolved = resolve_gene_queries(
client,
remaining_queries,
id_type=args.id_type,
mode=args.mode,
taxid=args.taxid,
species=args.species,
retmax=args.retmax,
allow_family_download=args.allow_family_download,
)
resolved.extend(web_resolved)
else:
web_resolved = []
seen_gene_ids: set[str] = set()
unique_resolved: list[dict[str, Any]] = []
for row in resolved:
marker = str(row.get("gene_id") or row.get("input_id") or "")
if marker and marker not in seen_gene_ids:
unique_resolved.append(row)
seen_gene_ids.add(marker)
resolved = unique_resolved
for gene in web_resolved:
rows: list[dict[str, Any]] = []
reason = ""
rows, reason = fetch_gene_from_local_reference(
local_reference,
gene,
seq_types=args.seq_types,
transcript_policy=policy,
)
if not rows:
try:
rows, reason = fetch_gene_sequences(
client,
gene,
transcript_policy=policy,
max_nuccore=args.max_nuccore,
seq_types=args.seq_types,
)
except Exception as exc:
rows = []
reason = str(exc)
if rows:
sequence_rows.extend(rows)
else:
unresolved.append({
"input_id": gene.get("input_id", ""),
"gene_id": gene.get("gene_id", ""),
"symbol": gene.get("symbol", ""),
"reason": reason,
})
if not sequence_rows and (ambiguous or unresolved):
status = "needs_user_confirmation" if ambiguous else "failed"
elif ambiguous or unresolved:
status = "partial_success"
except Exception as exc:
status = "failed"
error = str(exc)
unresolved.append({"input_id": ",".join(queries), "reason": str(exc)})
mapping_fields = [
"input_id", "gene_id", "symbol", "description", "taxid", "organism",
"transcript_accession", "protein_accession", "product", "locus_tag",
"transcript_policy", "transcript_candidates", "cds_length", "protein_length", "status",
]
unresolved_fields = ["input_id", "gene_id", "symbol", "reason", "search_term"]
ambiguous_fields = [
"input_id", "reason", "search_term", "candidate_gene_id", "candidate_gene_ids",
"candidate_symbol", "candidate_symbols", "candidate_description", "candidate_taxid", "candidate_organism",
]
write_tsv(mapping_tsv, sequence_rows, mapping_fields)
write_tsv(unresolved_tsv, unresolved, unresolved_fields)
write_tsv(ambiguous_tsv, ambiguous, ambiguous_fields)
if args.seq_types in {"protein", "both"}:
write_fasta(protein_fa, sequence_rows, "protein_sequence")
else:
protein_fa.write_text("", encoding="utf-8")
if args.seq_types in {"cds", "both"}:
write_fasta(cds_fa, sequence_rows, "cds_sequence")
else:
cds_fa.write_text("", encoding="utf-8")
end = datetime.now()
artifacts: dict[str, dict[str, Any]] = {
"mapping": artifact(workspace, mapping_tsv, "tsv"),
"unresolved": artifact(workspace, unresolved_tsv, "tsv"),
"ambiguous": artifact(workspace, ambiguous_tsv, "tsv"),
"commands": artifact(workspace, commands_path, "shell"),
}
if protein_fa.exists():
artifacts["protein_fasta"] = artifact(workspace, protein_fa, "fasta")
family_ref_link = create_family_reference_link(prefix, protein_fa)
if family_ref_link:
artifacts["family_reference_pep"] = artifact(workspace, family_ref_link, "fasta")
if cds_fa.exists():
artifacts["cds_fasta"] = artifact(workspace, cds_fa, "fasta")
manifest: dict[str, Any] = {
"manifest_version": "1.0",
"stage": "ncbi_fetch_sequences",
"status": status,
"prefix": prefix,
"job_id": args.job_id,
"created_at": start.isoformat(),
"completed_at": end.isoformat(),
"elapsed_sec": round((end - start).total_seconds(), 3),
"parameters": {
"mode": args.mode,
"id_type": args.id_type,
"taxid": args.taxid,
"species": args.species,
"seq_types": args.seq_types,
"transcript_policy": policy,
"allow_family_download": args.allow_family_download,
"retmax": args.retmax,
"max_nuccore": args.max_nuccore,
"email": args.email,
"api_key_used": bool(args.api_key),
"queries": queries,
"fetch_policy": "local_reference_first",
"local_reference_protein_sources": local_reference.pep_sources,
"local_reference_cds_sources": local_reference.cds_sources,
},
"metrics": {
"queries": len(queries),
"resolved_genes": len(resolved),
"sequence_rows": len(sequence_rows),
"protein_records": sum(1 for row in sequence_rows if row.get("protein_sequence")),
"cds_records": sum(1 for row in sequence_rows if row.get("cds_sequence")),
"ambiguous_records": len(ambiguous),
"unresolved_records": len(unresolved),
"local_fallback_records": sum(
1 for row in sequence_rows if str(row.get("fetch_route", "")).startswith("local_reference:")
),
"local_reference_records": sum(
1 for row in sequence_rows if str(row.get("fetch_route", "")).startswith("local_reference:")
),
"ncbi_web_records": sum(
1 for row in sequence_rows if str(row.get("fetch_route", "")).startswith(("gene_protein_elink", "nuccore"))
or "ncbi" in str(row.get("fetch_route", "")).lower()
),
},
"artifacts": artifacts,
"error": error,
"tool_versions": {
"biopython": getattr(sys.modules.get("Bio"), "__version__", "unknown"),
"entrez": "NCBI E-utilities",
},
"policy": {
"ambiguous_queries_are_not_downloaded": True,
"family_download_requires_confirmation_flag": True,
"no_silent_gene_symbol_guessing": True,
},
}
write_report(report_path, manifest)
artifacts["report"] = artifact(workspace, report_path, "markdown")
manifest["artifacts"] = artifacts
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
manifest["artifacts"]["manifest"] = artifact(workspace, manifest_path, "json")
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"NCBI fetch status: {status}")
print(f"Resolved genes: {len(resolved)}")
print(f"Sequence rows: {len(sequence_rows)}")
print(f"Protein FASTA: {protein_fa}")
print(f"CDS FASTA: {cds_fa}")
print(f"Manifest: {manifest_path}")
return 0 if status in {"success", "partial_success", "needs_user_confirmation"} else 1
if __name__ == "__main__":
raise SystemExit(main())