Files
PlantAI-rag-new/bioflow_kernel/cli/run_rnaseq_enrich.py

1974 lines
78 KiB
Python
Executable File

#!/usr/bin/env python3
"""RNA-seq enrichment runner.
This runner implements the fast, deterministic path for uploaded gene2go
background files. Full-genome annotation is intentionally kept outside this
interactive step because it can take hours and should be submitted as a
separate background task.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import re
import subprocess
import sys
import time
import os
import hashlib
import urllib.request
import tempfile
from html import escape
from collections import defaultdict
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).absolute().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from typing import Iterable
from cli.rnaseq_env import rnaseq_rscript
from gfa_core.rnaseq_context import apply_rnaseq_context, build_rnaseq_context
GO_RE = re.compile(r"GO:\d{7}")
KO_RE = re.compile(r"K\d{5}")
PATHWAY_RE = re.compile(r"(?:ko|map|path:map)(\d{5})")
RNASEQ_RSCRIPT = rnaseq_rscript()
SPECIES_DB = {
"ath": "org.At.tair.db",
"hsa": "org.Hs.eg.db",
"mmu": "org.Mm.eg.db",
"rno": "org.Rn.eg.db",
"dme": "org.Dm.eg.db",
"sce": "org.Sc.sgd.db",
}
SPECIES_KEYTYPE_HINTS = {
"ath": ["TAIR", "ENTREZID", "ENSEMBL", "ALIAS"],
"hsa": ["ENTREZID", "ENSEMBL", "SYMBOL", "ALIAS"],
"mmu": ["ENTREZID", "ENSEMBL", "SYMBOL", "ALIAS"],
"rno": ["ENTREZID", "ENSEMBL", "SYMBOL", "ALIAS"],
"dme": ["FLYBASE", "ENTREZID", "SYMBOL", "ALIAS"],
"sce": ["ORF", "ENTREZID", "GENENAME", "ALIAS"],
}
GO_NAMESPACE_LABELS = {
"biological_process": "Biological process",
"cellular_component": "Cellular component",
"molecular_function": "Molecular function",
}
def now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def compute_file_hash(path: Path) -> str:
sha256 = hashlib.sha256()
try:
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
sha256.update(chunk)
return sha256.hexdigest()
except Exception:
return ""
def abs_text(path: Path | str | None) -> str:
return str(Path(path).absolute()) if path else ""
def split_terms(value: str) -> list[str]:
return sorted(set(GO_RE.findall(value)))
def split_kos(value: str) -> list[str]:
return sorted(set(KO_RE.findall(value)))
GENE_ID_SUFFIX_PATTERNS = [
re.compile(r"(?i)([._-](?:mrna|rna|transcript|cds|protein|pep|peptide)\d+[A-Za-z]?)$"),
re.compile(r"(?i)([._-][tp]\d+[A-Za-z]?)$"),
re.compile(r"(?i)([._-][rp][A-Z])$"),
re.compile(r"(\.\d+)$"),
]
RAP_TRANSCRIPT_RE = re.compile(r"^(Os\d{2})t(\d+)(?:-\d+)?$", re.IGNORECASE)
def normalize_gene_id(gene_id: str) -> str:
"""Normalize common transcript/protein/version suffixes to gene-level IDs."""
value = (gene_id or "").strip().strip('"').lstrip(">")
value = re.split(r"\s+", value, maxsplit=1)[0]
previous = None
while value and value != previous:
previous = value
for pattern in GENE_ID_SUFFIX_PATTERNS:
value = pattern.sub("", value)
rap_match = RAP_TRANSCRIPT_RE.match(value)
if rap_match:
value = f"{rap_match.group(1)}g{rap_match.group(2)}"
return value
def parse_gene2go(path: Path) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
gene_to_terms: dict[str, set[str]] = defaultdict(set)
term_to_genes: dict[str, set[str]] = defaultdict(set)
with path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
fields = re.split(r"[\t, ]+", line)
if not fields:
continue
first = normalize_gene_id(fields[0])
if first.lower() in {"gene", "gene_id", "geneid", "id"}:
continue
terms = split_terms(line)
if not first or not terms:
continue
gene_to_terms[first].update(terms)
for term in terms:
term_to_genes[term].add(first)
return dict(gene_to_terms), dict(term_to_genes)
def load_go_ontology(path: Path) -> dict[str, dict[str, str]]:
"""Parse go-basic.obo into GO ID -> name/ontology namespace."""
if not path.exists():
return {}
terms: dict[str, dict[str, str]] = {}
current: dict[str, str] = {}
in_term = False
def flush() -> None:
if not current:
return
go_id = current.get("id", "")
if not go_id or current.get("is_obsolete") == "true":
return
namespace = current.get("namespace", "")
terms[go_id] = {
"Description": current.get("name", go_id),
"Ontology": GO_NAMESPACE_LABELS.get(namespace, namespace or "Unknown"),
}
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.rstrip("\n")
if line == "[Term]":
flush()
current = {}
in_term = True
continue
if line.startswith("[") and line != "[Term]":
flush()
current = {}
in_term = False
continue
if not in_term or not line or ": " not in line:
continue
key, value = line.split(": ", 1)
if key in {"id", "name", "namespace", "is_obsolete"}:
current[key] = value
flush()
return terms
def parse_gene2ko(path: Path) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
gene_to_terms: dict[str, set[str]] = defaultdict(set)
term_to_genes: dict[str, set[str]] = defaultdict(set)
with path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
fields = re.split(r"[\t, ]+", line)
if not fields:
continue
first = normalize_gene_id(fields[0])
if first.lower() in {"gene", "gene_id", "geneid", "id", "gid"}:
continue
terms = split_kos(line)
if not first or not terms:
continue
gene_to_terms[first].update(terms)
for term in terms:
term_to_genes[term].add(first)
return dict(gene_to_terms), dict(term_to_genes)
def normalize_pathway_id(value: str, organism: str = "") -> str:
text = (value or "").strip()
match = PATHWAY_RE.search(text)
if not match:
return text
prefix = organism if organism and re.fullmatch(r"[a-z]{3,4}", organism) else "ko"
return f"{prefix}{match.group(1)}"
def generic_pathway_id(pathway_id: str) -> str:
match = re.search(r"(\d{5})", pathway_id or "")
return f"ko{match.group(1)}" if match else pathway_id
def kegg_cache_dir(args: argparse.Namespace, output_dir: Path) -> Path:
raw = Path(args.kegg_cache_dir) if getattr(args, "kegg_cache_dir", "") else Path("data/kegg_pathway_cache")
if not raw.is_absolute():
raw = Path.cwd() / raw
if raw.parent.exists() or str(raw).startswith(str(Path.cwd())):
return raw
return output_dir / "kegg_pathway_cache"
def fetch_kegg_rest(endpoint: str, cache_file: Path, allow_network: bool) -> str:
cache_file.parent.mkdir(parents=True, exist_ok=True)
if cache_file.exists() and cache_file.stat().st_size > 0:
return cache_file.read_text(encoding="utf-8", errors="replace")
if not allow_network:
return ""
url = f"https://rest.kegg.jp/{endpoint.lstrip('/')}"
with urllib.request.urlopen(url, timeout=60) as response:
text = response.read().decode("utf-8", errors="replace")
cache_file.write_text(text, encoding="utf-8")
return text
def load_kegg_pathway_maps(
cache_dir: Path,
organism: str = "",
allow_network: bool = True,
) -> tuple[dict[str, set[str]], dict[str, dict[str, str]], dict[str, str]]:
"""Load cached or downloaded KO -> KEGG pathway mapping and metadata."""
ko_to_pathways: dict[str, set[str]] = defaultdict(set)
pathway_meta: dict[str, dict[str, str]] = {}
ko_names: dict[str, str] = {}
list_text = fetch_kegg_rest("list/pathway/ko", cache_dir / "list_pathway_ko.tsv", allow_network)
for raw_line in list_text.splitlines():
parts = raw_line.split("\t", 1)
if len(parts) != 2:
continue
pid = normalize_pathway_id(parts[0])
pathway_meta[pid] = {
"Description": parts[1].strip(),
"Class": "Unclassified",
"Subclass": "Unclassified",
"GenericPathwayID": generic_pathway_id(pid),
}
link_text = fetch_kegg_rest("link/pathway/ko", cache_dir / "link_pathway_ko.tsv", allow_network)
for raw_line in link_text.splitlines():
parts = raw_line.split("\t")
if len(parts) != 2:
continue
kos = split_kos(raw_line)
pathway_hits = [normalize_pathway_id(part) for part in parts if PATHWAY_RE.search(part)]
if not kos or not pathway_hits:
continue
for pathway_id in pathway_hits:
ko_to_pathways[kos[0]].add(pathway_id)
brite_text = fetch_kegg_rest("get/br:ko00001", cache_dir / "ko00001.keg", allow_network)
current_class = "Unclassified"
current_subclass = "Unclassified"
for raw_line in brite_text.splitlines():
line = raw_line.rstrip()
if not line or line.startswith("!") or line.startswith("+"):
continue
level = line[0]
value = line[1:].strip()
if level == "A":
current_class = re.sub(r"^\d+\s+", "", value).strip() or "Unclassified"
elif level == "B":
current_subclass = re.sub(r"^\d+\s+", "", value).strip() or "Unclassified"
elif level == "C":
match = re.search(r"(\d{5})\s+(.+?)(?:\s+\[PATH:ko\d{5}\])?$", value)
if not match:
continue
pathway_id = f"ko{match.group(1)}"
meta = pathway_meta.setdefault(
pathway_id,
{"Description": match.group(2).strip(), "GenericPathwayID": pathway_id},
)
meta["Description"] = meta.get("Description") or match.group(2).strip()
meta["Class"] = current_class
meta["Subclass"] = current_subclass
meta["GenericPathwayID"] = pathway_id
ko_text = fetch_kegg_rest("list/ko", cache_dir / "ko_list.tsv", allow_network)
for raw_line in ko_text.splitlines():
parts = raw_line.split("\t", 1)
if len(parts) != 2:
continue
kos = split_kos(parts[0])
if kos:
ko_names[kos[0]] = parts[1].strip()
organism = (organism or "").strip().lower()
if organism and re.fullmatch(r"[a-z][a-z0-9]{2,4}", organism):
species_list_text = fetch_kegg_rest(
f"list/pathway/{organism}",
cache_dir / f"list_pathway_{organism}.tsv",
allow_network,
)
species_pathway_meta: dict[str, dict[str, str]] = {}
for raw_line in species_list_text.splitlines():
parts = raw_line.split("\t", 1)
if len(parts) != 2:
continue
species_pid = normalize_pathway_id(parts[0], organism)
generic_pid = generic_pathway_id(species_pid)
species_pathway_meta[generic_pid] = {
"Description": parts[1].strip(),
"Class": pathway_meta.get(generic_pid, {}).get("Class", "Unclassified"),
"Subclass": pathway_meta.get(generic_pid, {}).get("Subclass", "Unclassified"),
"GenericPathwayID": generic_pid,
"SpeciesPathwayID": species_pid,
"KeggOrganism": organism,
}
if species_pathway_meta:
allowed = set(species_pathway_meta)
ko_to_pathways = {
ko: {pid for pid in pathways if generic_pathway_id(pid) in allowed}
for ko, pathways in ko_to_pathways.items()
}
ko_to_pathways = {ko: paths for ko, paths in ko_to_pathways.items() if paths}
pathway_meta.update(species_pathway_meta)
return dict(ko_to_pathways), pathway_meta, ko_names
def build_gene_pathway_maps(
gene_to_kos: dict[str, set[str]],
ko_to_pathways: dict[str, set[str]],
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
gene_to_pathways: dict[str, set[str]] = defaultdict(set)
pathway_to_genes: dict[str, set[str]] = defaultdict(set)
for gene, kos in gene_to_kos.items():
for ko in kos:
for pathway_id in ko_to_pathways.get(ko, set()):
gene_to_pathways[gene].add(pathway_id)
pathway_to_genes[pathway_id].add(gene)
return dict(gene_to_pathways), dict(pathway_to_genes)
def parse_de_genes(path: Path) -> list[str]:
genes: list[str] = []
seen: set[str] = set()
with path.open("r", encoding="utf-8") as handle:
reader = csv.reader(handle, delimiter="\t")
for row in reader:
if not row:
continue
gene = normalize_gene_id(row[0])
if not gene or gene.startswith("#"):
continue
if gene.lower() in {"gene", "gene_id", "geneid", "id", "x"}:
continue
if gene not in seen:
genes.append(gene)
seen.add(gene)
return genes
def find_de_files(input_dir: Path, prefix: str, foreground_mode: str = "global", family_name: str = "") -> list[Path]:
candidates = sorted(input_dir.glob("*DE_genes.txt"))
if foreground_mode == "family":
candidates = [p for p in candidates if "family_DE_genes" in p.name]
if family_name:
family_token = re.sub(r"[^A-Za-z0-9_.-]+", "_", family_name)
preferred_family = [p for p in candidates if f".rnaseq_de.{family_token}_" in p.name]
if preferred_family:
candidates = preferred_family
else:
candidates = [p for p in candidates if "family_DE_genes" not in p.name]
if prefix:
preferred = [p for p in candidates if p.name.startswith(f"{prefix}.rnaseq_de.")]
if preferred:
candidates = preferred
return candidates
def comparison_name(path: Path, prefix: str) -> str:
name = path.name
for token in (f"{prefix}.rnaseq_de.", f"{prefix}.", "rnaseq_de."):
if token and name.startswith(token):
name = name[len(token):]
break
if name.endswith(".family_DE_genes.txt"):
name = name[: -len(".family_DE_genes.txt")]
elif name.endswith(".DE_genes.txt"):
name = name[: -len(".DE_genes.txt")]
return re.sub(r"[^A-Za-z0-9_.-]+", "_", name) or "comparison"
def hypergeom_tail(successes: int, term_size: int, foreground_size: int, universe_size: int) -> float:
max_i = min(term_size, foreground_size)
if successes <= 0:
return 1.0
if successes > max_i or universe_size <= 0 or foreground_size <= 0 or term_size <= 0:
return 1.0
min_i = max(successes, foreground_size - (universe_size - term_size))
if min_i > max_i:
return 1.0
def log_choose(n: int, k: int) -> float:
if k < 0 or k > n:
return float("-inf")
return math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
log_denom = log_choose(universe_size, foreground_size)
log_terms: list[float] = []
for i in range(successes, max_i + 1):
if foreground_size - i > universe_size - term_size:
continue
log_terms.append(
log_choose(term_size, i)
+ log_choose(universe_size - term_size, foreground_size - i)
- log_denom
)
if not log_terms:
return 1.0
max_log = max(log_terms)
total = math.exp(max_log) * sum(math.exp(value - max_log) for value in log_terms)
return min(total, 1.0)
def bh_adjust(pvalues: list[float]) -> list[float]:
if not pvalues:
return []
indexed = sorted(enumerate(pvalues), key=lambda item: item[1])
adjusted = [1.0] * len(pvalues)
running = 1.0
total = len(pvalues)
for rank, (index, pvalue) in enumerate(reversed(indexed), start=1):
original_rank = total - rank + 1
running = min(running, pvalue * total / original_rank)
adjusted[index] = min(running, 1.0)
return adjusted
def enrich_go(
foreground_genes: Iterable[str],
gene_to_terms: dict[str, set[str]],
term_to_genes: dict[str, set[str]],
) -> list[dict[str, object]]:
universe = set(gene_to_terms)
foreground = set(foreground_genes) & universe
universe_size = len(universe)
foreground_size = len(foreground)
if universe_size == 0 or foreground_size == 0:
return []
rows: list[dict[str, object]] = []
pvalues: list[float] = []
for term, genes in sorted(term_to_genes.items()):
annotated_genes = genes & universe
hits = foreground & annotated_genes
if not hits:
continue
pvalue = hypergeom_tail(len(hits), len(annotated_genes), foreground_size, universe_size)
pvalues.append(pvalue)
rows.append(
{
"ID": term,
"Description": term,
"GeneRatio": f"{len(hits)}/{foreground_size}",
"BgRatio": f"{len(annotated_genes)}/{universe_size}",
"pvalue": pvalue,
"Count": len(hits),
"geneID": "/".join(sorted(hits)),
}
)
for row, padj in zip(rows, bh_adjust(pvalues)):
row["p.adjust"] = padj
rows.sort(key=lambda row: (float(row["p.adjust"]), float(row["pvalue"]), str(row["ID"])))
return rows
def enrich_term_table(
foreground_genes: Iterable[str],
gene_to_terms: dict[str, set[str]],
term_to_genes: dict[str, set[str]],
) -> list[dict[str, object]]:
return enrich_go(foreground_genes, gene_to_terms, term_to_genes)
def foreground_background_overlap(
foreground_genes: Iterable[str],
gene_to_terms: dict[str, set[str]],
) -> dict[str, int]:
foreground = set(foreground_genes)
background = set(gene_to_terms)
return {
"foreground_genes": len(foreground),
"annotated_background_genes": len(background),
"overlap_genes": len(foreground & background),
}
def annotate_go_rows(rows: list[dict[str, object]], go_ontology: dict[str, dict[str, str]]) -> int:
mapped = 0
for row in rows:
info = go_ontology.get(str(row.get("ID", "")))
if not info:
continue
row["Description"] = info.get("Description") or row.get("Description") or row.get("ID")
row["Ontology"] = info.get("Ontology", "Unknown")
mapped += 1
return mapped
def write_go_table(path: Path, rows: list[dict[str, object]], top_pathways: int) -> None:
columns = ["ID", "Description"]
if any("Ontology" in row for row in rows):
columns.append("Ontology")
columns += ["GeneRatio", "BgRatio", "pvalue", "p.adjust", "Count", "geneID"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns, delimiter="\t")
writer.writeheader()
for row in rows[:top_pathways]:
formatted = dict(row)
formatted["pvalue"] = f"{float(row['pvalue']):.6g}"
formatted["p.adjust"] = f"{float(row['p.adjust']):.6g}"
writer.writerow(formatted)
def write_kegg_table(path: Path, rows: list[dict[str, object]], top_pathways: int) -> None:
columns = ["ID", "Description"]
for optional in ("Class", "Subclass", "GenericPathwayID"):
if any(optional in row for row in rows):
columns.append(optional)
columns += ["GeneRatio", "BgRatio", "pvalue", "p.adjust", "Count", "geneID"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns, delimiter="\t", extrasaction="ignore")
writer.writeheader()
for row in rows[:top_pathways]:
formatted = dict(row)
formatted["pvalue"] = f"{float(row['pvalue']):.6g}"
formatted["p.adjust"] = f"{float(row['p.adjust']):.6g}"
writer.writerow(formatted)
def annotate_kegg_pathway_rows(rows: list[dict[str, object]], pathway_meta: dict[str, dict[str, str]]) -> int:
mapped = 0
for row in rows:
pathway_id = str(row.get("ID", ""))
info = pathway_meta.get(pathway_id) or pathway_meta.get(generic_pathway_id(pathway_id))
if not info:
continue
row["Description"] = info.get("Description") or row.get("Description") or pathway_id
row["Class"] = info.get("Class", "Unclassified")
row["Subclass"] = info.get("Subclass", "Unclassified")
row["GenericPathwayID"] = info.get("GenericPathwayID", generic_pathway_id(pathway_id))
mapped += 1
return mapped
PLANT_COMPATIBLE_KEGG_CLASSES = {
"Metabolism",
"Genetic Information Processing",
"Environmental Information Processing",
"Cellular Processes",
}
PLANT_COMPATIBLE_ORGANISMAL_SUBCLASSES = {"Environmental adaptation"}
ANIMAL_OR_DISEASE_KEGG_PATTERNS = re.compile(
r"\b(human|cancer|carcinoma|leukemia|diabetes|alzheimer|parkinson|huntington|alcoholism|"
r"lupus|infection|viral|bacterial invasion|cardiomyopathy|addiction|bile secretion|"
r"salivary|gastric|pancreatic|endocrine|immune|neutrophil|platelet|synaptic|axon|"
r"olfactory|taste|thermogenesis|phagosome|longevity regulating pathway - worm|longevity regulating pathway - fly)\b",
re.IGNORECASE,
)
PLANT_KEEP_KEYWORDS = re.compile(r"\b(plant|photosynthesis|flavonoid|phenylpropanoid|terpenoid|chlorophyll)\b", re.IGNORECASE)
def kegg_is_plant_compatible(row: dict[str, object]) -> bool:
pathway_class = str(row.get("Class", "") or "Unclassified")
subclass = str(row.get("Subclass", "") or "Unclassified")
description = str(row.get("Description", "") or row.get("ID", ""))
if PLANT_KEEP_KEYWORDS.search(description):
return True
if pathway_class == "Human Diseases":
return False
if ANIMAL_OR_DISEASE_KEGG_PATTERNS.search(description):
return False
if pathway_class in PLANT_COMPATIBLE_KEGG_CLASSES:
return True
if pathway_class == "Organismal Systems" and subclass in PLANT_COMPATIBLE_ORGANISMAL_SUBCLASSES:
return True
return pathway_class == "Unclassified"
def filter_kegg_rows(rows: list[dict[str, object]], scope: str) -> list[dict[str, object]]:
if scope == "all":
return rows
return [row for row in rows if kegg_is_plant_compatible(row)]
def write_kegg_hit_table(
path: Path,
rows: list[dict[str, object]],
gene_to_kos: dict[str, set[str]],
ko_to_pathways: dict[str, set[str]],
pathway_meta: dict[str, dict[str, str]],
ko_names: dict[str, str],
top_pathways: int,
) -> None:
columns = ["PathwayID", "PathwayName", "Class", "Subclass", "KO", "KOName", "GeneCount", "Genes"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns, delimiter="\t")
writer.writeheader()
for row in rows[:top_pathways]:
pathway_id = str(row.get("ID", ""))
meta = pathway_meta.get(pathway_id) or pathway_meta.get(generic_pathway_id(pathway_id), {})
hit_genes = [gene for gene in str(row.get("geneID", "")).split("/") if gene]
ko_to_hit_genes: dict[str, list[str]] = defaultdict(list)
for gene in hit_genes:
for ko in sorted(gene_to_kos.get(gene, set())):
if pathway_id in ko_to_pathways.get(ko, set()) or generic_pathway_id(pathway_id) in ko_to_pathways.get(ko, set()):
ko_to_hit_genes[ko].append(gene)
for ko, genes in sorted(ko_to_hit_genes.items()):
writer.writerow(
{
"PathwayID": pathway_id,
"PathwayName": meta.get("Description", row.get("Description", pathway_id)),
"Class": meta.get("Class", row.get("Class", "Unclassified")),
"Subclass": meta.get("Subclass", row.get("Subclass", "Unclassified")),
"KO": ko,
"KOName": ko_names.get(ko, ko),
"GeneCount": len(set(genes)),
"Genes": "/".join(sorted(set(genes))),
}
)
def write_kegg_mapper_files(
path: Path,
rows: list[dict[str, object]],
gene_to_kos: dict[str, set[str]],
ko_to_pathways: dict[str, set[str]],
pathway_meta: dict[str, dict[str, str]],
organism: str,
top_pathways: int,
) -> None:
mode = "organism" if organism else "ko"
columns = ["PathwayID", "PathwayName", "HighlightMode", "KEGGPathwayURL", "KOs", "Genes", "MapperColorData"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns, delimiter="\t")
writer.writeheader()
for row in rows[:top_pathways]:
pathway_id = str(row.get("ID", ""))
display_id = normalize_pathway_id(pathway_id, organism) if organism else generic_pathway_id(pathway_id)
meta = pathway_meta.get(pathway_id) or pathway_meta.get(generic_pathway_id(pathway_id), {})
hit_genes = [gene for gene in str(row.get("geneID", "")).split("/") if gene]
hit_kos: set[str] = set()
for gene in hit_genes:
for ko in gene_to_kos.get(gene, set()):
if pathway_id in ko_to_pathways.get(ko, set()) or generic_pathway_id(pathway_id) in ko_to_pathways.get(ko, set()):
hit_kos.add(ko)
writer.writerow(
{
"PathwayID": display_id,
"PathwayName": meta.get("Description", row.get("Description", pathway_id)),
"HighlightMode": mode,
"KEGGPathwayURL": f"https://www.kegg.jp/pathway/{display_id}",
"KOs": "/".join(sorted(hit_kos)),
"Genes": "/".join(sorted(set(hit_genes))),
"MapperColorData": ";".join(f"{ko} red" for ko in sorted(hit_kos)),
}
)
def write_enrichment_bar_svg(
path: Path,
rows: list[dict[str, object]],
title: str,
top_pathways: int,
fill: str,
enrich_type: str = "",
) -> None:
"""Write a dependency-free SVG bar plot for top enriched terms."""
def clean_kegg_plot_label(label: str) -> str:
if enrich_type.upper() != "KEGG":
return label
return re.sub(
r"\s+-\s+[A-Z][A-Za-z]+(?:\s+[a-z][A-Za-z0-9._-]*){1,4}\s+\([^)]*\)\s*$",
"",
label.strip(),
)
def wrap_svg_label(label: str, width: int = 34, max_lines: int = 2) -> list[str]:
words = re.split(r"\s+", label.strip())
lines: list[str] = []
current = ""
for word in words:
candidate = word if not current else f"{current} {word}"
if len(candidate) <= width:
current = candidate
continue
if current:
lines.append(current)
current = word
if len(lines) >= max_lines:
break
if current and len(lines) < max_lines:
lines.append(current)
if not lines:
lines = [label]
if len(lines) > max_lines:
lines = lines[:max_lines]
if len(" ".join(words)) > len(" ".join(lines)):
lines[-1] = lines[-1].rstrip(".") + "..."
return lines
selected = rows[:top_pathways]
width = 1100
row_height = 38
plot_left = 330
plot_right = 1040
top = 80
bottom = 56
height = max(220, top + bottom + max(len(selected), 1) * row_height)
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
'<rect width="100%" height="100%" fill="white"/>',
f'<text x="{width / 2:.0f}" y="34" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" font-weight="700">{escape(title)}</text>',
]
if not selected:
parts.extend(
[
f'<text x="{width / 2:.0f}" y="{height / 2:.0f}" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#555">No enriched terms passed the current filters</text>',
"</svg>",
]
)
path.write_text("\n".join(parts) + "\n", encoding="utf-8")
return
scores: list[float] = []
for row in selected:
padj = float(row.get("p.adjust", 1.0) or 1.0)
scores.append(max(-math.log10(max(padj, 1e-300)), 0.0))
max_score = max(scores) or 1.0
axis_y = top + len(selected) * row_height + 6
parts.append(f'<line x1="{plot_left}" y1="{axis_y}" x2="{plot_right}" y2="{axis_y}" stroke="#333" stroke-width="1"/>')
for tick in range(0, 6):
value = max_score * tick / 5
x = plot_left + (plot_right - plot_left) * tick / 5
parts.append(f'<line x1="{x:.1f}" y1="{axis_y}" x2="{x:.1f}" y2="{axis_y + 5}" stroke="#333" stroke-width="1"/>')
parts.append(f'<text x="{x:.1f}" y="{axis_y + 22}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#333">{value:.1f}</text>')
parts.append(f'<text x="{(plot_left + plot_right) / 2:.0f}" y="{height - 12}" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" fill="#333">-log10(adjusted p-value)</text>')
for index, (row, score) in enumerate(zip(selected, scores)):
y = top + index * row_height
bar_width = max(1.0, (plot_right - plot_left) * score / max_score)
term_id = str(row.get("ID", ""))
desc = str(row.get("Description", "")) or term_id
desc = clean_kegg_plot_label(desc)
label = desc if desc == term_id else f"{term_id} {desc}"
label_lines = wrap_svg_label(label)
count = row.get("Count", "")
padj = float(row.get("p.adjust", 1.0) or 1.0)
parts.append(f'<text x="{plot_left - 12}" y="{y + 13}" text-anchor="end" font-family="Arial, sans-serif" font-size="12" fill="#222">')
for line_index, label_line in enumerate(label_lines):
dy = 0 if line_index == 0 else 14
parts.append(f'<tspan x="{plot_left - 12}" dy="{dy}">{escape(label_line)}</tspan>')
parts.append("</text>")
parts.append(f'<rect x="{plot_left}" y="{y + 6}" width="{bar_width:.1f}" height="16" rx="2" fill="{fill}"/>')
parts.append(f'<text x="{plot_left + bar_width + 6:.1f}" y="{y + 18}" font-family="Arial, sans-serif" font-size="12" fill="#222">Count={escape(str(count))}, FDR={padj:.2g}</text>')
parts.append("</svg>")
path.write_text("\n".join(parts) + "\n", encoding="utf-8")
def write_enrichment_r_plot(
enrich_file: Path,
output_dir: Path,
comp: str,
enrich_type: str,
rscript: Path,
top_n: int = 20,
prefix: str = "",
stage: str = "",
) -> list[Path]:
"""Generate enrichment plots.
The PlantAI shared script is used only for GO plots so this fix does not
change KEGG plot naming or behavior.
"""
if enrich_type.upper() != "GO":
return write_enrichment_r_plot_legacy(
enrich_file,
output_dir,
comp,
enrich_type,
rscript,
top_n,
prefix,
stage,
)
if not enrich_file.exists():
return []
try:
with enrich_file.open("r", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter="\t")
if not list(reader):
return []
except Exception:
return []
script = Path("scripts/plot_rnaseq_go_enrichment.R")
if not script.exists():
print(f"[WARN] R enrichment plotting script not found: {script}", file=sys.stderr)
return []
if not rscript.exists():
print(f"[WARN] Rscript not found: {rscript}", file=sys.stderr)
return []
output_prefix = enrich_file.with_suffix("")
cmd = [
str(rscript),
str(script),
str(enrich_file),
str(output_prefix),
enrich_type,
str(top_n),
]
try:
result = subprocess.run(
cmd,
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
except Exception as exc:
print(f"[WARN] R enrichment plotting failed for {enrich_file}: {exc}", file=sys.stderr)
return []
log_prefix = output_dir / f"plot_{comp}_{enrich_type}"
try:
log_prefix.with_suffix(".stdout.log").write_text(result.stdout, encoding="utf-8")
log_prefix.with_suffix(".stderr.log").write_text(result.stderr, encoding="utf-8")
except Exception:
pass
if result.returncode != 0:
print(
f"[WARN] R enrichment plotting failed for {enrich_file}. "
f"See {log_prefix.with_suffix('.stderr.log')}",
file=sys.stderr,
)
return []
plot_files: list[Path] = []
seen: set[str] = set()
for line in result.stdout.splitlines():
item = line.strip()
if not item:
continue
path = Path(item)
if path.exists() and str(path) not in seen:
plot_files.append(path)
seen.add(str(path))
for suffix in (
"barplot.png",
"barplot.pdf",
"dotplot.png",
"dotplot.pdf",
"ontology_barplot.png",
"ontology_barplot.pdf",
):
path = output_prefix.with_name(output_prefix.name + f".{suffix}")
if path.exists() and str(path) not in seen:
plot_files.append(path)
seen.add(str(path))
return plot_files
def write_enrichment_r_plot_legacy(
enrich_file: Path,
output_dir: Path,
comp: str,
enrich_type: str,
rscript: Path,
top_n: int = 20,
prefix: str = "",
stage: str = "",
) -> list[Path]:
"""Keep the pre-PlantAI R plotting behavior for non-GO enrichment."""
if not enrich_file.exists():
return []
try:
with enrich_file.open("r", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter="\t")
if not list(reader):
return []
except Exception:
return []
r_code = f"""
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(dplyr))
enrich_file <- {r_string(abs_text(enrich_file))}
output_dir <- {r_string(abs_text(output_dir))}
comp <- {r_string(comp)}
enrich_type <- {r_string(enrich_type)}
top_n <- {top_n}
prefix <- {r_string(prefix)}
stage <- {r_string(stage)}
ego <- read.table(enrich_file, header=TRUE, sep="\\t", check.names=FALSE, stringsAsFactors=FALSE)
if (nrow(ego) == 0) {{
message("No enrichment data to plot for ", comp)
quit(status=0)
}}
ego_df <- as.data.frame(ego)
parse_ratio <- function(x) {{
parts <- strsplit(as.character(x), "/")
sapply(parts, function(p) if (length(p) == 2) as.numeric(p[1]) / as.numeric(p[2]) else 0)
}}
wrap_axis_label <- function(x, width=42, max_lines=2) {{
vapply(as.character(x), function(label) {{
if (is.na(label) || label == "") return("")
lines <- strwrap(label, width=width)
if (length(lines) == 0) return(label)
if (length(lines) > max_lines) {{
lines <- lines[seq_len(max_lines)]
lines[[max_lines]] <- paste0(sub("\\\\s+$", "", lines[[max_lines]]), "...")
}}
paste(lines, collapse="\\n")
}}, character(1))
}}
clean_kegg_plot_label <- function(x) {{
if (toupper(enrich_type) != "KEGG") return(as.character(x))
sub("\\\\s+-\\\\s+[A-Z][A-Za-z]+(?:\\\\s+[a-z][A-Za-z0-9._-]*){{1,4}}\\\\s+\\\\([^)]*\\\\)\\\\s*$", "", as.character(x), perl=TRUE)
}}
ego_df$GeneRatioNum <- parse_ratio(ego_df$GeneRatio)
ego_df <- ego_df[order(ego_df$p.adjust), ]
ego_df <- head(ego_df, top_n)
ego_df$PlotDescription <- clean_kegg_plot_label(ego_df$Description)
ego_df$DisplayDescription <- wrap_axis_label(ego_df$PlotDescription, width=44, max_lines=2)
ego_df$DisplayDescription <- make.unique(ego_df$DisplayDescription, sep=" ")
ego_df$DisplayDescription <- factor(ego_df$DisplayDescription, levels=rev(ego_df$DisplayDescription))
p_bar <- ggplot(ego_df, aes(x=GeneRatioNum, y=DisplayDescription, fill=-log10(p.adjust))) +
geom_bar(stat="identity") +
scale_fill_gradient(low="#4a90d9", high="#d94a4a") +
labs(x="Gene Ratio", y="", title=paste(comp, enrich_type, "enrichment"),
fill="-log10(FDR)") +
theme_minimal() +
theme(axis.text.y = element_text(size=8, lineheight=0.9),
plot.title = element_text(hjust=0.5, size=12, face="bold"))
pdf_file <- file.path(output_dir, paste0(prefix, ".", stage, ".", comp, "_", enrich_type, "_enrich.pdf"))
pdf(pdf_file, width=13.5, height=max(8, nrow(ego_df) * 0.46))
print(p_bar)
dev.off()
png_file <- file.path(output_dir, paste0(prefix, ".", stage, ".", comp, "_", enrich_type, "_enrich.png"))
png(png_file, width=2025, height=max(1200, nrow(ego_df) * 69), res=150)
print(p_bar)
dev.off()
p_dot <- ggplot(ego_df, aes(x=GeneRatioNum, y=reorder(DisplayDescription, -log10(p.adjust)),
size=Count, color=-log10(p.adjust))) +
geom_point() +
scale_color_gradient(low="#4a90d9", high="#d94a4a") +
scale_size(range=c(3, 8)) +
labs(x="Gene Ratio", y="", title=paste(comp, enrich_type, "dot plot"),
color="-log10(FDR)", size="Count") +
theme_minimal() +
theme(axis.text.y = element_text(size=8, lineheight=0.9),
plot.title = element_text(hjust=0.5, size=12, face="bold"))
pdf_file_dot <- file.path(output_dir, paste0(prefix, ".", stage, ".", comp, "_", enrich_type, "_dotplot.pdf"))
pdf(pdf_file_dot, width=13.5, height=max(8, nrow(ego_df) * 0.46))
print(p_dot)
dev.off()
png_file_dot <- file.path(output_dir, paste0(prefix, ".", stage, ".", comp, "_", enrich_type, "_dotplot.png"))
png(png_file_dot, width=2025, height=max(1200, nrow(ego_df) * 69), res=150)
print(p_dot)
dev.off()
"""
r_script = output_dir / f"plot_{comp}_{enrich_type}.R"
r_script.write_text(r_code, encoding="utf-8")
result = subprocess.run(
[str(rscript), abs_text(r_script)],
cwd=output_dir,
text=True,
capture_output=True,
check=False,
)
try:
(output_dir / f"plot_{comp}_{enrich_type}.stdout.log").write_text(result.stdout, encoding="utf-8")
(output_dir / f"plot_{comp}_{enrich_type}.stderr.log").write_text(result.stderr, encoding="utf-8")
except Exception:
pass
if result.returncode != 0:
print(f"[WARN] R enrichment plotting failed for {enrich_file}", file=sys.stderr)
return []
plot_files: list[Path] = []
seen: set[str] = set()
for ext in ("pdf", "png"):
for plot_type in ("enrich", "dotplot"):
path = output_dir / f"{prefix}.{stage}.{comp}_{enrich_type}_{plot_type}.{ext}"
if path.exists() and str(path) not in seen:
plot_files.append(path)
seen.add(str(path))
return plot_files
def parse_de_results_log2fc(path: Path) -> dict[str, float]:
"""Parse a DESeq2 results table (TSV) and return gene_id -> log2FoldChange."""
if not path.exists():
return {}
with path.open("r", encoding="utf-8", errors="replace") as handle:
reader = csv.DictReader(handle, delimiter="\t")
if not reader.fieldnames:
return {}
key_col = "gene_id" if "gene_id" in reader.fieldnames else reader.fieldnames[0]
fc_col = "log2FoldChange" if "log2FoldChange" in reader.fieldnames else None
if fc_col is None:
return {}
out: dict[str, float] = {}
for row in reader:
gene = normalize_gene_id(str(row.get(key_col, "")).strip())
if not gene:
continue
raw_fc = str(row.get(fc_col, "")).strip()
if not raw_fc or raw_fc.upper() == "NA":
continue
try:
out[gene] = float(raw_fc)
except ValueError:
continue
return out
def build_ko_log2fc(
genes: list[str],
gene_to_kos: dict[str, set[str]],
gene_log2fc: dict[str, float],
) -> dict[str, float]:
"""Aggregate gene log2FC values to KO level (mean across genes mapped to that KO)."""
ko_values: dict[str, list[float]] = defaultdict(list)
for gene in genes:
fc = gene_log2fc.get(normalize_gene_id(gene))
if fc is None:
continue
for ko in gene_to_kos.get(gene, set()):
if ko:
ko_values[ko].append(fc)
return {ko: (sum(vals) / len(vals)) for ko, vals in ko_values.items() if vals}
def strip_pathway_prefix(pathway_id: str) -> str:
pid = pathway_id.strip()
match = re.search(r"(\d{5})$", pid)
return match.group(1) if match else pid
def run_pathview_kegg_plots(
rows: list[dict[str, object]],
genes: list[str],
de_results_file: Path,
gene_to_kos: dict[str, set[str]],
output_dir: Path,
prefix: str,
output_tag: str,
comp: str,
rscript: str,
top_pathways: int,
) -> list[Path]:
"""
Generate KEGG pathway maps with pathview using KO-level log2FC.
Output follows KEGG naming conventions:
{prefix}.{stage}.{comparison}.KEGG_pathway_map.koXXXXX.png
"""
if not rows:
return []
gene_log2fc = parse_de_results_log2fc(de_results_file)
ko_log2fc = build_ko_log2fc(genes, gene_to_kos, gene_log2fc)
if not ko_log2fc:
return []
output_dir.mkdir(parents=True, exist_ok=True)
section_tag = "KEGG_pathway_map"
ko_fc_path = output_dir / f"{prefix}.{output_tag}.{comp}.{section_tag}.ko_log2fc.tsv"
with ko_fc_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter="\t")
writer.writerow(["KO", "log2FoldChange"])
for ko, fc in sorted(ko_log2fc.items()):
writer.writerow([ko, f"{fc:.6g}"])
plot_ids = [strip_pathway_prefix(str(row.get("ID", ""))) for row in rows[:top_pathways] if row.get("ID")]
plot_ids = [pid for pid in dict.fromkeys(plot_ids) if pid]
if not plot_ids:
return []
r_code = f"""
suppressPackageStartupMessages({{
library(pathview)
}})
df <- read.table({json.dumps(str(ko_fc_path))}, header=TRUE, sep="\\t", quote="", comment.char="")
gene.data <- df$log2FoldChange
names(gene.data) <- df$KO
out.dir <- {json.dumps(str(output_dir))}
dir.create(out.dir, recursive=TRUE, showWarnings=FALSE)
setwd(out.dir)
plot_ids <- c({", ".join(json.dumps(pid) for pid in plot_ids)})
for (pid in plot_ids) {{
tryCatch({{
pathview(
gene.data = gene.data,
pathway.id = pid,
species = "ko",
kegg.native = TRUE,
gene.idtype = "kegg",
out.suffix = {json.dumps(section_tag)}
)
}}, error=function(e) {{
message("pathview failed for ", pid, ": ", e$message)
}})
}}
cat("PATHVIEW_DONE\\n")
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".R", delete=False) as handle:
script_path = Path(handle.name)
handle.write(r_code)
try:
proc = subprocess.run(
[rscript, str(script_path)],
capture_output=True,
text=True,
timeout=1800,
check=False,
)
(output_dir / f"{prefix}.{output_tag}.{comp}.{section_tag}.stdout.log").write_text(proc.stdout, encoding="utf-8")
(output_dir / f"{prefix}.{output_tag}.{comp}.{section_tag}.stderr.log").write_text(proc.stderr, encoding="utf-8")
if proc.returncode != 0:
return []
finally:
try:
script_path.unlink(missing_ok=True)
except Exception:
pass
produced: list[Path] = []
for ext in ("png", "pdf"):
for raw in sorted(output_dir.glob(f"ko*.{section_tag}.{ext}")):
match = re.match(rf"^(ko\d{{5}})\.{re.escape(section_tag)}\.{ext}$", raw.name)
if not match:
continue
pathway_id = match.group(1)
target = output_dir / f"{prefix}.{output_tag}.{comp}.{section_tag}.{pathway_id}.{ext}"
try:
if target.exists():
target.unlink()
raw.rename(target)
produced.append(target)
except Exception as exc:
print(f"[WARN] rename pathview output failed: {raw.name} -> {target.name}: {exc}", file=sys.stderr)
produced.append(raw)
return produced
def r_string(value: Path | str) -> str:
return json.dumps(str(value))
def build_orgdb_r_script(
r_script: Path,
de_files: list[Path],
output_dir: Path,
args: argparse.Namespace,
) -> dict[str, Path]:
org_db = SPECIES_DB[args.species]
keytype_hints = SPECIES_KEYTYPE_HINTS.get(args.species, ["ENTREZID", "ENSEMBL", "SYMBOL", "ALIAS"])
output_files: dict[str, Path] = {}
de_entries = []
for de_file in de_files:
comp = comparison_name(de_file, args.prefix)
go_file = output_dir / f"{args.prefix}.{result_prefix(args)}.{comp}.GO_enrich.txt"
output_files[comp] = go_file
de_entries.append(
"list(name=%s, path=%s, output=%s)"
% (r_string(comp), r_string(de_file), r_string(go_file))
)
r_code = f"""
suppressPackageStartupMessages(library(clusterProfiler))
suppressPackageStartupMessages(library(AnnotationDbi))
suppressPackageStartupMessages(library({org_db}))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(enrichplot))
orgdb <- get("{org_db}")
de_files <- list({", ".join(de_entries)})
pvalue_cutoff <- {args.pvalue_cutoff}
qvalue_cutoff <- {args.qvalue_cutoff}
top_n <- {args.top_pathways}
requested_keytype <- {r_string(args.key_type)}
keytype_hints <- c({", ".join(r_string(item) for item in keytype_hints)})
read_de_genes <- function(path) {{
table <- read.table(path, header=TRUE, sep="\\t", quote="", check.names=FALSE, stringsAsFactors=FALSE)
genes <- table[[1]]
genes <- unique(as.character(genes[!is.na(genes) & genes != ""]))
genes
}}
choose_keytype <- function(genes) {{
available <- AnnotationDbi::keytypes(orgdb)
if (requested_keytype != "auto") {{
if (!(requested_keytype %in% available)) {{
stop(paste("Requested keyType not available:", requested_keytype))
}}
return(requested_keytype)
}}
candidates <- unique(c(keytype_hints, available))
candidates <- candidates[candidates %in% available]
best_keytype <- candidates[[1]]
best_hits <- -1
sample_genes <- head(genes, 200)
for (candidate in candidates) {{
hit_count <- tryCatch({{
mapped <- suppressWarnings(AnnotationDbi::select(orgdb, keys=sample_genes, columns=c("GO"), keytype=candidate))
length(unique(mapped[!is.na(mapped$GO), 1]))
}}, error=function(e) -1)
if (hit_count > best_hits) {{
best_hits <- hit_count
best_keytype <- candidate
}}
}}
best_keytype
}}
write_empty_result <- function(path) {{
empty <- data.frame(ID=character(), Description=character(), GeneRatio=character(), BgRatio=character(),
pvalue=numeric(), p.adjust=numeric(), qvalue=numeric(), geneID=character(), Count=integer())
write.table(empty, file=path, sep="\\t", quote=FALSE, row.names=FALSE)
}}
for (entry in de_files) {{
genes <- read_de_genes(entry$path)
if (length(genes) == 0) {{
write_empty_result(entry$output)
next
}}
keytype <- choose_keytype(genes)
message("Processing ", entry$name, " genes=", length(genes), " keyType=", keytype)
ego <- tryCatch({{
enrichGO(
gene=genes,
OrgDb=orgdb,
keyType=keytype,
ont="ALL",
pvalueCutoff=pvalue_cutoff,
pAdjustMethod="BH",
qvalueCutoff=qvalue_cutoff,
readable=FALSE
)
}}, error=function(e) {{
message("enrichGO failed for ", entry$name, ": ", e$message)
NULL
}})
if (is.null(ego) || nrow(as.data.frame(ego)) == 0) {{
write_empty_result(entry$output)
next
}}
result <- head(as.data.frame(ego), top_n)
write.table(result, file=entry$output, sep="\\t", quote=FALSE, row.names=FALSE)
}}
"""
r_script.write_text(r_code, encoding="utf-8")
return output_files
def run_orgdb_enrichment(
de_files: list[Path],
final_dir: Path,
output_dir: Path,
args: argparse.Namespace,
) -> tuple[list[Path], int, int]:
if args.species not in SPECIES_DB:
supported = ", ".join(sorted(SPECIES_DB))
raise ValueError(f"Unsupported built-in species '{args.species}'. Supported species: {supported}")
rscript = Path(args.rscript)
if not rscript.exists():
raise FileNotFoundError(f"Rscript not found: {rscript}")
r_script = output_dir / "run_enrichment_orgdb.R"
output_files = build_orgdb_r_script(r_script, de_files, final_dir, args)
result = subprocess.run(
[str(rscript), str(r_script)],
cwd=output_dir,
text=True,
capture_output=True,
check=False,
)
(output_dir / "run_enrichment_orgdb.stdout.log").write_text(result.stdout, encoding="utf-8")
(output_dir / "run_enrichment_orgdb.stderr.log").write_text(result.stderr, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(
f"OrgDb enrichment failed with exit code {result.returncode}. "
f"See {output_dir / 'run_enrichment_orgdb.stderr.log'}"
)
go_files = list(output_files.values())
term_count = 0
for go_file in go_files:
if not go_file.exists():
raise FileNotFoundError(f"Expected GO enrichment output not found: {go_file}")
with go_file.open("r", encoding="utf-8") as handle:
term_count += max(sum(1 for _ in handle) - 1, 0)
return go_files, term_count, len(de_files)
def rel_artifact(final_dir: Path, file_path: Path, artifact_type: str) -> dict[str, str]:
return {
"path": f"final/{file_path.name}",
"full_path": abs_text(file_path),
"type": artifact_type,
}
def cleanup_previous_outputs(final_dir: Path, output_dir: Path, prefix: str, stage_id: str) -> None:
for pattern in (
f"{prefix}.{stage_id}.*",
f"{prefix}.{stage_id}_report.md",
f"plot_*_GO.*.log",
f"plot_*_KEGG.*.log",
):
for path in final_dir.glob(pattern):
if path.is_file():
path.unlink()
for pattern in ("commands.sh", "environment.yml", "checksums.sha256"):
path = output_dir / pattern
if path.is_file():
path.unlink()
def stage_id_from_args(args: argparse.Namespace) -> str:
if getattr(args, "stage", ""):
return args.stage
if args.enrich_type == "go":
return "rnaseq_go_enrich"
if args.enrich_type == "kegg":
return "rnaseq_kegg_enrich"
return "rnaseq_enrich"
def result_prefix(args: argparse.Namespace) -> str:
return stage_id_from_args(args)
def write_reproducibility(
output_dir: Path,
cmd: list[str],
files: list[Path],
environment_name: str = "rnaseq",
) -> tuple[Path, Path, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
commands_file = output_dir / "commands.sh"
with commands_file.open("w", encoding="utf-8") as handle:
handle.write("#!/bin/bash\nset -euo pipefail\n\n")
handle.write(" ".join(subprocess.list2cmdline([part]) for part in cmd) + "\n")
os.chmod(commands_file, 0o755)
env_file = output_dir / "environment.yml"
env_file.write_text(
f"name: {environment_name}\n"
"channels:\n"
" - bioconda\n"
" - conda-forge\n"
"dependencies:\n"
" - python\n"
" - r-base\n"
" - bioconductor-clusterprofiler\n",
encoding="utf-8",
)
checksums_file = output_dir / "checksums.sha256"
with checksums_file.open("w", encoding="utf-8") as handle:
for path in files:
if path and path.exists() and path.is_file():
handle.write(f"{compute_file_hash(path)} {path}\n")
return commands_file, env_file, checksums_file
def build_manifest(
args: argparse.Namespace,
status: str,
start_time: str,
elapsed_sec: float,
final_dir: Path,
artifacts: dict[str, dict[str, str]],
metrics: dict[str, object],
error: str | None = None,
) -> dict[str, object]:
output_dir = Path(args.output).absolute()
job_workspace = output_dir.parent if output_dir.name == stage_id_from_args(args) else final_dir.parent
sample_table = job_workspace / "rnaseq_sample_qc" / "sample_table.tsv"
count_matrix = final_dir / f"{args.prefix}.count_matrix.txt"
manifest = {
"manifest_version": "1.0",
"stage": stage_id_from_args(args),
"status": status,
"prefix": args.prefix,
"job_id": args.job_id,
"timestamp": now_iso(),
"start_time": start_time,
"end_time": now_iso(),
"elapsed_sec": round(elapsed_sec, 3),
"workspace_root": abs_text(job_workspace.parent),
"workspace": abs_text(job_workspace),
"input_dir": abs_text(job_workspace.parent.parent / "input" / args.job_id),
"parameters": {
"cpu": args.cpu,
"prefix": args.prefix,
"species": args.species,
"top_pathways": args.top_pathways,
"gene2go": abs_text(args.gene2go) if args.gene2go else "",
"background_source": args.background_source_effective,
"org_db": SPECIES_DB.get(args.species, ""),
"key_type": args.key_type,
"pvalue_cutoff": args.pvalue_cutoff,
"qvalue_cutoff": args.qvalue_cutoff,
"auto_annotate_requested": bool(args.auto_annotate),
"foreground_mode": args.foreground_mode,
"family_name": args.family_name,
"enrich_type": args.enrich_type,
"kegg_organism": args.kegg_organism,
"kegg_level": args.kegg_level,
"kegg_filter_scope": args.kegg_filter_scope,
"kegg_highlight_mode": args.kegg_highlight_mode,
"no_kegg_network": bool(args.no_kegg_network),
"skip_pathview": bool(args.skip_pathview),
"plot_type": args.plot_type,
"kegg_cache_dir": abs_text(kegg_cache_dir(args, Path(args.output))) if args.enrich_type in {"kegg", "both"} else "",
"gene2ko": abs_text(args.gene2ko) if args.gene2ko else "",
"go_obo": abs_text(args.go_obo) if args.go_obo else "",
"input_final_dir": abs_text(final_dir),
"analysis_dir": abs_text(output_dir),
"stage": stage_id_from_args(args),
},
"metrics": metrics,
"artifacts": artifacts,
"cmd": sys.argv,
}
context = build_rnaseq_context(
prefix=args.prefix,
count_matrix=count_matrix if count_matrix.exists() else None,
sample_table=sample_table if sample_table.exists() else None,
foreground_mode=args.foreground_mode,
family_label=args.family_name,
stage=stage_id_from_args(args),
)
apply_rnaseq_context(manifest, context)
if error:
manifest["error"] = error
return manifest
def write_report(
path: Path,
args: argparse.Namespace,
metrics: dict[str, object],
go_files: list[Path],
kegg_files: list[Path],
plot_files: list[Path],
) -> None:
lines = [
f"# {stage_id_from_args(args)} Report",
"",
f"- Prefix: {args.prefix}",
f"- Job ID: {args.job_id}",
f"- Species code: {args.species}",
f"- Background source: {args.background_source_effective}",
f"- Foreground mode: {args.foreground_mode}",
f"- Family name: {args.family_name}",
f"- Enrich type: {args.enrich_type}",
f"- OrgDb: {SPECIES_DB.get(args.species, '')}",
f"- KEGG organism code: {args.kegg_organism}",
f"- Comparisons: {metrics.get('comparisons', 0)}",
f"- DE genes input: {metrics.get('de_genes_input', 0)}",
f"- GO enriched terms reported: {metrics.get('go_enriched_terms', 0)}",
f"- KEGG enriched pathways reported: {metrics.get('kegg_enriched_pathways', 0)}",
"",
"## Output Files",
]
for file_path in go_files:
lines.append(f"- {file_path.name}")
for file_path in kegg_files:
lines.append(f"- {file_path.name}")
for file_path in plot_files:
lines.append(f"- {file_path.name}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run RNA-seq GO enrichment from DE genes and gene2go background.")
parser.add_argument("-i", "--input", required=True, help="Input final directory containing *.DE_genes.txt")
parser.add_argument("-s", "--species", default="", help="Built-in OrgDb species code")
parser.add_argument("-o", "--output", required=True, help="Intermediate output directory")
parser.add_argument("-p", "--prefix", required=True, help="Analysis prefix")
parser.add_argument("-j", "--job-id", required=True, help="Job ID")
parser.add_argument("-t", "--cpu", type=int, default=4, help="CPU cores")
parser.add_argument("--top-pathways", type=int, default=20, help="Maximum terms to report per comparison")
parser.add_argument("-g", "--gene2go", default="", help="Uploaded gene2go background file")
parser.add_argument("--gene2ko", default="", help="Uploaded gene2ko background file")
parser.add_argument("--go-obo", default="data/go/go-basic.obo", help="GO OBO file used to add term name and BP/CC/MF ontology")
parser.add_argument(
"--background-source",
choices=["auto", "gene2go", "gene2ko", "orgdb"],
default="auto",
help="Background source: uploaded gene2go/gene2ko or built-in OrgDb",
)
parser.add_argument("--key-type", default="auto", help="OrgDb keyType, or auto")
parser.add_argument("--pvalue-cutoff", type=float, default=0.05, help="GO enrichment p-value cutoff")
parser.add_argument("--qvalue-cutoff", type=float, default=0.05, help="GO enrichment q-value cutoff")
parser.add_argument("--rscript", default=RNASEQ_RSCRIPT, help="Rscript executable for OrgDb enrichment")
parser.add_argument("--kegg-organism", default="", help="KEGG organism code (e.g. ath, hsa)")
parser.add_argument(
"--kegg-level",
choices=["pathway", "ko"],
default="pathway",
help="KEGG enrichment level. pathway uses KO-to-pathway mapping; ko keeps legacy KO-term enrichment.",
)
parser.add_argument(
"--kegg-filter-scope",
choices=["plant", "all"],
default="plant",
help="Default KEGG display filter. plant removes human/animal disease pathways but writes raw results separately.",
)
parser.add_argument(
"--kegg-highlight-mode",
choices=["auto", "ko", "organism"],
default="auto",
help="Pathway highlight output mode. auto uses organism mode only when --kegg-organism is provided.",
)
parser.add_argument("--kegg-cache-dir", default="data/kegg_pathway_cache", help="Local cache for KEGG REST pathway mappings")
parser.add_argument("--no-kegg-network", action="store_true", help="Do not download missing KEGG pathway mapping cache files")
parser.add_argument("--skip-pathview", action="store_true", help="Skip pathview native KEGG pathway map rendering")
parser.add_argument("--auto-annotate", action="store_true", help="Accepted for compatibility; not run here")
parser.add_argument(
"--enrich-type",
choices=["go", "kegg", "both"],
default="go",
help="Run GO enrichment, KEGG enrichment, or both",
)
parser.add_argument(
"--foreground-mode",
choices=["global", "family"],
default="global",
help="Foreground genes: global DE genes or gene-family-specific DE genes",
)
parser.add_argument("--family-name", default="", help="Family name used to select family_DE_genes files")
parser.add_argument(
"--stage",
choices=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_enrich"],
default="",
help="Manifest/output stage name. Defaults from --enrich-type.",
)
parser.add_argument(
"--plot-type",
choices=["svg", "r_plot"],
default="r_plot",
help="Plot type: svg (Python-generated) or r_plot (R enrichplot)",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
args.background_source_effective = "missing"
start = time.time()
start_time = now_iso()
input_dir = Path(args.input)
output_dir = Path(args.output)
final_dir = input_dir
output_dir.mkdir(parents=True, exist_ok=True)
final_dir.mkdir(parents=True, exist_ok=True)
stage_id = stage_id_from_args(args)
output_tag = result_prefix(args)
cleanup_previous_outputs(final_dir, output_dir, args.prefix, stage_id)
manifest_path = final_dir / f"{args.prefix}.{stage_id}.manifest.json"
artifacts: dict[str, dict[str, str]] = {}
metrics: dict[str, object] = {
"de_genes_input": 0,
"go_enriched_terms": 0,
"kegg_enriched_pathways": 0,
"comparisons": 0,
"annotated_background_genes": 0,
"foreground_mode": args.foreground_mode,
"family_name": args.family_name,
"go_ontology_terms": 0,
"go_ontology_mapped_terms": 0,
"kegg_pathway_mapped_terms": 0,
"kegg_raw_pathways": 0,
"kegg_filtered_pathways": 0,
"kegg_level": args.kegg_level,
"kegg_filter_scope": args.kegg_filter_scope,
"pathview_skipped": False,
"go_foreground_background_overlap": {},
}
try:
de_files = find_de_files(input_dir, args.prefix, args.foreground_mode, args.family_name)
if not de_files:
if args.foreground_mode == "family":
raise FileNotFoundError(
f"No family-specific DE genes files found under {input_dir}. "
"Run RNA-seq DE with foreground mode 'family' first, or choose foreground mode 'global'."
)
raise FileNotFoundError(f"No global DE genes files found under {input_dir}")
metrics["de_source_files"] = {
comparison_name(path, args.prefix): abs_text(path)
for path in de_files
}
go_files: list[Path] = []
kegg_files: list[Path] = []
plot_files: list[Path] = []
total_de_genes = 0
total_terms = 0
total_kegg_terms = 0
run_go = args.enrich_type in {"go", "both"}
run_kegg = args.enrich_type in {"kegg", "both"}
go_ontology = load_go_ontology(Path(args.go_obo)) if run_go else {}
metrics["go_ontology_terms"] = len(go_ontology)
use_gene2go = args.background_source in {"auto", "gene2go", "gene2ko"} and (
bool(args.gene2go) or (run_kegg and bool(args.gene2ko))
)
use_orgdb = args.background_source == "orgdb" or (
args.background_source == "auto" and not args.gene2go and args.species in SPECIES_DB
)
if use_gene2go:
args.background_source_effective = "uploaded_gene2go"
gene_to_terms: dict[str, set[str]] = {}
term_to_genes: dict[str, set[str]] = {}
annotated_background_genes = 0
if run_go:
gene2go_path = Path(args.gene2go)
if not gene2go_path.exists():
raise FileNotFoundError(f"gene2go file not found: {gene2go_path}")
gene_to_terms, term_to_genes = parse_gene2go(gene2go_path)
if not gene_to_terms:
raise ValueError(f"No valid GO annotations found in {gene2go_path}")
annotated_background_genes = len(gene_to_terms)
for de_file in de_files:
genes = parse_de_genes(de_file)
total_de_genes += len(genes)
comp = comparison_name(de_file, args.prefix)
if run_go:
overlap_stats = foreground_background_overlap(genes, gene_to_terms)
metrics["go_foreground_background_overlap"][comp] = overlap_stats
if genes and gene_to_terms and overlap_stats["overlap_genes"] == 0:
examples = {
"foreground_examples": genes[:5],
"background_examples": sorted(gene_to_terms)[:5],
}
raise ValueError(
"GO foreground/background ID mismatch: no DE genes overlap uploaded gene2go "
f"background for {comp}. examples={json.dumps(examples, ensure_ascii=False)}"
)
rows = enrich_term_table(genes, gene_to_terms, term_to_genes)
metrics["go_ontology_mapped_terms"] = int(metrics.get("go_ontology_mapped_terms", 0)) + annotate_go_rows(rows, go_ontology)
go_file = final_dir / f"{args.prefix}.{output_tag}.{comp}.GO_enrich.txt"
write_go_table(go_file, rows, args.top_pathways)
go_files.append(go_file)
# Generate plots based on plot_type
if args.plot_type == "r_plot":
rscript = Path(args.rscript) if args.rscript else Path(RNASEQ_RSCRIPT)
stage_name = "rnaseq_go_enrich" if "go" in args.enrich_type else output_tag
go_plot_files = write_enrichment_r_plot(
go_file, final_dir, comp, "GO", rscript, args.top_pathways, args.prefix, stage_name
)
plot_files.extend(go_plot_files)
for pf in go_plot_files:
suffix = "pdf" if pf.suffix == ".pdf" else "png"
artifacts[f"go_enrich_plot_{comp}_{pf.stem}_{pf.suffix.lstrip('.')}"] = rel_artifact(final_dir, pf, suffix)
else:
go_plot = final_dir / f"{args.prefix}.{output_tag}.{comp}.GO_enrich.svg"
write_enrichment_bar_svg(go_plot, rows, f"{comp} GO enrichment", args.top_pathways, "#2f7ed8")
plot_files.append(go_plot)
artifacts[f"go_enrich_plot_{comp}"] = rel_artifact(final_dir, go_plot, "svg")
total_terms += min(len(rows), args.top_pathways)
artifacts[f"go_enrich_{comp}"] = rel_artifact(final_dir, go_file, "tsv")
if run_kegg:
if not args.gene2ko:
raise ValueError(
"KEGG enrichment requires gene2ko background file. Please upload gene2ko "
"or run annotation to generate gene2ko."
)
gene2ko_path = Path(args.gene2ko)
if not gene2ko_path.exists():
raise FileNotFoundError(f"gene2ko file not found: {gene2ko_path}")
gene_to_kos, ko_to_genes = parse_gene2ko(gene2ko_path)
if not gene_to_kos:
raise ValueError(f"No valid KO annotations found in {gene2ko_path}")
if not annotated_background_genes:
annotated_background_genes = len(gene_to_kos)
ko_to_pathways: dict[str, set[str]] = {}
pathway_meta: dict[str, dict[str, str]] = {}
ko_names: dict[str, str] = {}
gene_to_kegg_terms = gene_to_kos
kegg_term_to_genes = ko_to_genes
if args.kegg_level == "pathway":
ko_to_pathways, pathway_meta, ko_names = load_kegg_pathway_maps(
kegg_cache_dir(args, output_dir),
organism=args.kegg_organism,
allow_network=not args.no_kegg_network,
)
if not ko_to_pathways:
raise ValueError(
"KEGG pathway mapping is unavailable. Allow KEGG REST cache download or provide an existing "
"data/kegg_pathway_cache/link_pathway_ko.tsv cache file."
)
gene_to_kegg_terms, kegg_term_to_genes = build_gene_pathway_maps(gene_to_kos, ko_to_pathways)
if not gene_to_kegg_terms:
raise ValueError("gene2ko has KOs, but none map to KEGG pathways.")
for de_file in de_files:
genes = parse_de_genes(de_file)
comp = comparison_name(de_file, args.prefix)
krows = enrich_term_table(genes, gene_to_kegg_terms, kegg_term_to_genes)
if args.kegg_level == "pathway":
metrics["kegg_pathway_mapped_terms"] = int(metrics.get("kegg_pathway_mapped_terms", 0)) + annotate_kegg_pathway_rows(krows, pathway_meta)
metrics["kegg_raw_pathways"] = int(metrics.get("kegg_raw_pathways", 0)) + len(krows)
raw_kegg_file = final_dir / f"{args.prefix}.{output_tag}.{comp}.KEGG_enrich.raw.txt"
write_kegg_table(raw_kegg_file, krows, max(args.top_pathways, len(krows)))
kegg_files.append(raw_kegg_file)
artifacts[f"kegg_enrich_{comp}_raw"] = rel_artifact(final_dir, raw_kegg_file, "tsv")
krows = filter_kegg_rows(krows, args.kegg_filter_scope)
metrics["kegg_filtered_pathways"] = int(metrics.get("kegg_filtered_pathways", 0)) + len(krows)
kegg_file = final_dir / f"{args.prefix}.{output_tag}.{comp}.KEGG_enrich.txt"
write_kegg_table(kegg_file, krows, args.top_pathways)
kegg_files.append(kegg_file)
# Generate plots based on plot_type
if args.plot_type == "r_plot":
rscript = Path(args.rscript) if args.rscript else Path(RNASEQ_RSCRIPT)
stage_name = "rnaseq_kegg_enrich" if "kegg" in args.enrich_type else output_tag
kegg_plot_files = write_enrichment_r_plot(
kegg_file, final_dir, comp, "KEGG", rscript, args.top_pathways, args.prefix, stage_name
)
plot_files.extend(kegg_plot_files)
for pf in kegg_plot_files:
suffix = "pdf" if pf.suffix == ".pdf" else "png"
artifacts[f"kegg_enrich_plot_{comp}_{pf.stem}_{pf.suffix.lstrip('.')}"] = rel_artifact(final_dir, pf, suffix)
else:
kegg_plot = final_dir / f"{args.prefix}.{output_tag}.{comp}.KEGG_enrich.svg"
write_enrichment_bar_svg(kegg_plot, krows, f"{comp} KEGG enrichment", args.top_pathways, "#36a166", "KEGG")
plot_files.append(kegg_plot)
artifacts[f"kegg_enrich_plot_{comp}"] = rel_artifact(final_dir, kegg_plot, "svg")
total_kegg_terms += min(len(krows), args.top_pathways)
artifacts[f"kegg_enrich_{comp}"] = rel_artifact(final_dir, kegg_file, "tsv")
if args.kegg_level == "pathway":
hit_file = final_dir / f"{args.prefix}.{output_tag}.{comp}.KEGG_pathway_gene_hits.txt"
write_kegg_hit_table(hit_file, krows, gene_to_kos, ko_to_pathways, pathway_meta, ko_names, args.top_pathways)
kegg_files.append(hit_file)
artifacts[f"kegg_enrich_{comp}_pathway_gene_hits"] = rel_artifact(final_dir, hit_file, "tsv")
mapper_organism = args.kegg_organism if args.kegg_highlight_mode in {"auto", "organism"} else ""
if args.kegg_highlight_mode == "auto" and not args.kegg_organism:
mapper_organism = ""
mapper_file = final_dir / f"{args.prefix}.{output_tag}.{comp}.KEGG_pathway_mapper.tsv"
write_kegg_mapper_files(
mapper_file, krows, gene_to_kos, ko_to_pathways, pathway_meta, mapper_organism, args.top_pathways
)
kegg_files.append(mapper_file)
artifacts[f"kegg_enrich_{comp}_pathway_mapper"] = rel_artifact(final_dir, mapper_file, "tsv")
# pathview renders native KEGG pathway images and may access KEGG online assets.
# In no-network validation, keep deterministic table/mapper outputs and skip it.
if args.skip_pathview or args.no_kegg_network:
metrics["pathview_skipped"] = True
else:
de_results_file = Path(
str(de_file)
.replace(".family_DE_genes.txt", ".family_DE_results.txt")
.replace(".DE_genes.txt", ".DE_results.txt")
)
for pv_file in run_pathview_kegg_plots(
krows,
genes,
de_results_file,
gene_to_kos,
final_dir,
args.prefix,
output_tag,
comp,
args.rscript,
min(args.top_pathways, 10),
):
plot_files.append(pv_file)
ko_match = re.search(r"\.(ko\d{5})$", pv_file.stem)
ko_tag = ko_match.group(1) if ko_match else pv_file.stem
artifacts[f"kegg_enrich_{comp}_pathway_map_{pv_file.suffix.lstrip('.')}__{ko_tag}"] = rel_artifact(
final_dir, pv_file, "image" if pv_file.suffix == ".png" else "pdf"
)
elif use_orgdb:
if run_kegg:
raise ValueError(
"KEGG enrichment uses KEGG organism code and KEGG ID mapping, not OrgDb keyType directly. "
"Please use gene2ko background (uploaded or from annotation) for KEGG, or run GO only."
)
args.background_source_effective = "builtin_orgdb"
for de_file in de_files:
total_de_genes += len(parse_de_genes(de_file))
if run_go:
go_files, total_terms, _ = run_orgdb_enrichment(de_files, final_dir, output_dir, args)
for go_file in go_files:
comp = go_file.name
comp = comp.replace(f"{args.prefix}.{output_tag}.", "").replace(".GO_enrich.txt", "")
artifacts[f"go_enrich_{comp}"] = rel_artifact(final_dir, go_file, "tsv")
try:
rows = []
with go_file.open("r", encoding="utf-8") as handle:
reader = csv.DictReader(handle, delimiter="\t")
rows = [dict(row) for row in reader]
# Generate plots based on plot_type
if args.plot_type == "r_plot":
rscript = Path(args.rscript) if args.rscript else Path(RNASEQ_RSCRIPT)
stage_name = output_tag # Use output_tag for OrgDb mode
go_plot_files = write_enrichment_r_plot(
go_file, final_dir, comp, "GO", rscript, args.top_pathways, args.prefix, stage_name
)
plot_files.extend(go_plot_files)
for pf in go_plot_files:
suffix = "pdf" if pf.suffix == ".pdf" else "png"
artifacts[f"go_enrich_plot_{comp}_{pf.stem}_{pf.suffix.lstrip('.')}"] = rel_artifact(final_dir, pf, suffix)
else:
go_plot = final_dir / f"{args.prefix}.{output_tag}.{comp}.GO_enrich.svg"
write_enrichment_bar_svg(go_plot, rows, f"{comp} GO enrichment", args.top_pathways, "#2f7ed8")
plot_files.append(go_plot)
artifacts[f"go_enrich_plot_{comp}"] = rel_artifact(final_dir, go_plot, "svg")
except Exception as plot_exc:
print(f"Warning: failed to draw GO plot for {comp}: {plot_exc}", file=sys.stderr)
annotated_background_genes = 0
else:
raise ValueError(
"Background annotation is required. Upload a gene2go file, explicitly choose a supported built-in "
"OrgDb species, or run a separate background annotation task."
)
report_path = final_dir / f"{args.prefix}.{stage_id}_report.md"
metrics.update(
{
"de_genes_input": total_de_genes,
"go_enriched_terms": total_terms,
"kegg_enriched_pathways": total_kegg_terms,
"comparisons": len(de_files),
"annotated_background_genes": annotated_background_genes,
}
)
write_report(report_path, args, metrics, go_files, kegg_files, plot_files)
artifacts["report"] = rel_artifact(final_dir, report_path, "markdown")
commands_file, env_file, checksums_file = write_reproducibility(
output_dir,
sys.argv,
[*de_files, *go_files, *kegg_files, Path(args.gene2go) if args.gene2go else Path(), Path(args.gene2ko) if args.gene2ko else Path(), report_path],
)
artifacts["commands"] = {
"path": str(commands_file.relative_to(final_dir.parent)),
"full_path": abs_text(commands_file),
"type": "shell",
}
artifacts["environment"] = {
"path": str(env_file.relative_to(final_dir.parent)),
"full_path": abs_text(env_file),
"type": "yaml",
}
artifacts["checksums"] = {
"path": str(checksums_file.relative_to(final_dir.parent)),
"full_path": abs_text(checksums_file),
"type": "sha256",
}
artifacts["manifest"] = {
"path": f"final/{manifest_path.name}",
"full_path": abs_text(manifest_path),
"type": "json",
}
manifest = build_manifest(args, "success", start_time, time.time() - start, final_dir, artifacts, metrics)
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"RNA-seq enrichment completed: {manifest_path}")
return 0
except Exception as exc:
error = str(exc)
manifest = build_manifest(
args,
"failed",
start_time,
time.time() - start,
final_dir,
artifacts,
metrics,
error=error,
)
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"RNA-seq enrichment failed: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())