diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
index b112fd0f2..668bbc326 100644
--- a/docs/src/SUMMARY.md
+++ b/docs/src/SUMMARY.md
@@ -29,6 +29,8 @@
* [gget opentargets](en/opentargets.md)
* [gget pdb](en/pdb.md)
* [gget ref](en/ref.md)
+* [gget rummagene](en/rummagene.md)
+* [gget rummageo](en/rummageo.md)
* [gget search](en/search.md)
* [gget setup](en/setup.md)
* [gget seq](en/seq.md)
diff --git a/docs/src/en/rummagene.md b/docs/src/en/rummagene.md
new file mode 100644
index 000000000..d63fc4d86
--- /dev/null
+++ b/docs/src/en/rummagene.md
@@ -0,0 +1,53 @@
+[ View page source on GitHub ](https://github.com/scverse/gget/blob/main/docs/src/en/rummagene.md)
+
+> Python arguments are equivalent to long-option arguments (`--arg`), unless otherwise specified. Flags are True/False arguments in Python. The manual for any gget tool can be called from the command-line using the `-h` `--help` flag.
+# gget rummagene 📚
+Find gene sets from PubMed Central (PMC) supplementary tables that overlap a query gene set using [Rummagene](https://rummagene.com/).
+Rummagene automatically extracted ~1 million gene sets from the supplementary tables of open-access PMC articles. `gget rummagene` submits your gene list and returns the gene sets with the most significant overlap (Fisher's exact test).
+Return format: JSON (command-line) or data frame/CSV (Python).
+
+**Positional argument**
+`genes`
+One or more gene symbols (HGNC), e.g. `STAT1 IRF1 OAS1`.
+
+**Optional arguments**
+`-l` `--limit`
+Maximum number of enriched gene sets to return. Default: 50.
+
+`-ft` `--filter_term`
+Only return gene sets whose term contains this (case-insensitive) substring. Default: None.
+
+`-o` `--out`
+Path to the file the results will be saved in, e.g. path/to/directory/results.csv (or .json). Default: Standard out.
+Python: `save=True` will save the output in the current working directory.
+
+**Flags**
+`-csv` `--csv`
+Command-line only. Returns results in CSV format.
+Python: Use `json=True` to return output in JSON format.
+
+`-q` `--quiet`
+Command-line only. Prevents progress information from being displayed.
+Python: Use `verbose=False` to prevent progress information from being displayed.
+
+### Example
+```bash
+gget rummagene STAT1 STAT2 IRF1 IRF9 OAS1 MX1 ISG15 IFIT1 IFIT3 GBP1
+```
+```python
+# Python
+gget.rummagene(["STAT1", "STAT2", "IRF1", "IRF9", "OAS1", "MX1", "ISG15", "IFIT1", "IFIT3", "GBP1"])
+```
+→ Returns the PMC-derived gene sets with the most significant overlap with the query genes, ranked by p-value.
+
+| rank | term | n_overlap | n_genes_in_set | odds_ratio | pval | adj_pval |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 | PMC7617869-...-Type_I_IFN_signalling... | 10 | 34 | 602.6 | 3.7e-29 | 3.1e-23 |
+| . . . | . . . | . . . | . . . | . . . | . . . | . . . |
+
+# References
+If you use `gget rummagene` in a publication, please cite the following articles:
+
+- Luebbert, L., & Pachter, L. (2023). Efficient querying of genomic reference databases with gget. Bioinformatics. [https://doi.org/10.1093/bioinformatics/btac836](https://doi.org/10.1093/bioinformatics/btac836)
+
+- Clarke, D. J. B., et al. (2024). Rummagene: massive mining of gene sets from supporting materials of biomedical research publications. Communications Biology. [https://doi.org/10.1038/s42003-024-06177-7](https://doi.org/10.1038/s42003-024-06177-7)
diff --git a/docs/src/en/rummageo.md b/docs/src/en/rummageo.md
new file mode 100644
index 000000000..5d3cb5b42
--- /dev/null
+++ b/docs/src/en/rummageo.md
@@ -0,0 +1,53 @@
+[ View page source on GitHub ](https://github.com/scverse/gget/blob/main/docs/src/en/rummageo.md)
+
+> Python arguments are equivalent to long-option arguments (`--arg`), unless otherwise specified. Flags are True/False arguments in Python. The manual for any gget tool can be called from the command-line using the `-h` `--help` flag.
+# gget rummageo 🧬
+Find gene sets from Gene Expression Omnibus (GEO) studies that overlap a query gene set using [RummaGEO](https://rummageo.com/).
+RummaGEO automatically computed differential-expression gene sets ("signatures") from hundreds of thousands of human and mouse GEO studies. `gget rummageo` submits your gene list and returns the gene sets with the most significant overlap (Fisher's exact test).
+Return format: JSON (command-line) or data frame/CSV (Python).
+
+**Positional argument**
+`genes`
+One or more gene symbols, e.g. `STAT1 IRF1 OAS1`.
+
+**Optional arguments**
+`-l` `--limit`
+Maximum number of enriched gene sets to return. Default: 50.
+
+`-ft` `--filter_term`
+Only return gene sets whose term contains this (case-insensitive) substring. Default: None.
+
+`-o` `--out`
+Path to the file the results will be saved in, e.g. path/to/directory/results.csv (or .json). Default: Standard out.
+Python: `save=True` will save the output in the current working directory.
+
+**Flags**
+`-csv` `--csv`
+Command-line only. Returns results in CSV format.
+Python: Use `json=True` to return output in JSON format.
+
+`-q` `--quiet`
+Command-line only. Prevents progress information from being displayed.
+Python: Use `verbose=False` to prevent progress information from being displayed.
+
+### Example
+```bash
+gget rummageo STAT1 STAT2 IRF1 IRF9 OAS1 MX1 ISG15 IFIT1 IFIT3 GBP1
+```
+```python
+# Python
+gget.rummageo(["STAT1", "STAT2", "IRF1", "IRF9", "OAS1", "MX1", "ISG15", "IFIT1", "IFIT3", "GBP1"])
+```
+→ Returns the GEO-derived gene sets with the most significant overlap with the query genes, ranked by p-value.
+
+| rank | term | species | n_overlap | n_genes_in_set | odds_ratio | pval | adj_pval |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| 1 | GSE223635-8-vs-6-human up | human | 10 | 112 | 555.7 | 2.4e-28 | 4.1e-23 |
+| . . . | . . . | . . . | . . . | . . . | . . . | . . . | . . . |
+
+# References
+If you use `gget rummageo` in a publication, please cite the following articles:
+
+- Luebbert, L., & Pachter, L. (2023). Efficient querying of genomic reference databases with gget. Bioinformatics. [https://doi.org/10.1093/bioinformatics/btac836](https://doi.org/10.1093/bioinformatics/btac836)
+
+- Marino, G. B., et al. (2024). RummaGEO: Automatic Mining of Human and Mouse Gene Sets from GEO. Patterns. [https://doi.org/10.1016/j.patter.2024.101072](https://doi.org/10.1016/j.patter.2024.101072)
diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md
index 89be22281..a0b9500a5 100644
--- a/docs/src/en/updates.md
+++ b/docs/src/en/updates.md
@@ -5,6 +5,7 @@
#### *gget* officially became part of [*scverse*](https://scverse.org/) on June 9, 2026. 🥳🥳🥳
**Version ≥ 0.30.9** (XXX XX, 2026):
+- [`gget rummagene`](rummagene.md) and [`gget rummageo`](rummageo.md): **New modules** for gene set enrichment against gene sets automatically mined from PMC supplementary tables ([Rummagene](https://rummagene.com/)) and from GEO study signatures ([RummaGEO](https://rummageo.com/)). Submit a list of gene symbols to retrieve the most significantly overlapping literature/GEO gene sets (Fisher's exact test), ranked by p-value, via each service's public GraphQL API. Both share a single enrichment helper and support the `limit`, `filter_term`, `json`, and `save` options in the Python API and on the command line. Resolves [issue 164](https://github.com/scverse/gget/issues/164).
**Version ≥ 0.30.8** (Jun 28, 2026):
- [`gget g2p`](g2p.md): Either `gene` or `--uniprot_id` is now sufficient — whichever is missing is resolved via UniProt and cached. Gene→UniProt picks the canonical reviewed human Swiss-Prot entry; the resolution and its limitations are logged. The canonical pair is **always** prepended to the result as `gene_name` / `uniprot_id` columns (and stored on `df.attrs`), so the output schema is invariant regardless of input mode. Existing call sites continue to work.
diff --git a/gget/__init__.py b/gget/__init__.py
index f56cbcddc..6ae2015a5 100644
--- a/gget/__init__.py
+++ b/gget/__init__.py
@@ -23,6 +23,7 @@
from .gget_opentargets import opentargets
from .gget_pdb import pdb
from .gget_ref import ref
+from .gget_rummage import rummagene, rummageo
from .gget_search import search
from .gget_seq import seq
from .gget_setup import setup
diff --git a/gget/constants.py b/gget/constants.py
index 38f90119f..9f3bd05ad 100644
--- a/gget/constants.py
+++ b/gget/constants.py
@@ -7,6 +7,11 @@
# strategy avoid hanging indefinitely on slow upstreams.
DEFAULT_REQUESTS_TIMEOUT = (10, 60)
+# Rummagene & RummaGEO GraphQL API endpoints (gene set enrichment against gene
+# sets automatically extracted from PMC supplementary tables / GEO studies)
+RUMMAGENE_GRAPHQL_URL = "https://rummagene.com/graphql"
+RUMMAGEO_GRAPHQL_URL = "https://rummageo.com/graphql"
+
# Ensembl REST API server for gget seq and info
ENSEMBL_REST_API = "http://rest.ensembl.org/"
ENSEMBL_FTP_URL = "http://ftp.ensembl.org/pub/"
diff --git a/gget/gget_rummage.py b/gget/gget_rummage.py
new file mode 100644
index 000000000..8fd012e08
--- /dev/null
+++ b/gget/gget_rummage.py
@@ -0,0 +1,310 @@
+from __future__ import annotations
+
+import json as json_package
+from typing import Any, Literal, overload
+
+import pandas as pd
+import requests
+
+from .constants import (
+ DEFAULT_REQUESTS_TIMEOUT,
+ RUMMAGENE_GRAPHQL_URL,
+ RUMMAGEO_GRAPHQL_URL,
+)
+from .utils import set_up_logger
+
+logger = set_up_logger()
+
+# Column order for the returned data frames
+_RUMMAGENE_COLUMNS = [
+ "rank",
+ "term",
+ "n_overlap",
+ "n_genes_in_set",
+ "odds_ratio",
+ "pval",
+ "adj_pval",
+]
+_RUMMAGEO_COLUMNS = [
+ "rank",
+ "term",
+ "species",
+ "n_overlap",
+ "n_genes_in_set",
+ "odds_ratio",
+ "pval",
+ "adj_pval",
+]
+
+
+def _clean_genes(genes: str | list[Any]) -> list[str]:
+ """Normalize the genes argument into a clean list of gene symbol strings."""
+ if isinstance(genes, str):
+ genes = [genes]
+
+ genes_clean = []
+ for gene in genes:
+ # Skip NaNs/Nones/empty strings
+ if gene is None or (isinstance(gene, float)):
+ continue
+ gene_str = str(gene).strip()
+ if gene_str == "" or gene_str.lower() == "nan":
+ continue
+ genes_clean.append(gene_str)
+
+ if len(genes_clean) == 0:
+ raise ValueError("Please provide at least one gene symbol in the 'genes' argument.")
+
+ return genes_clean
+
+
+def _rummage_enrich(
+ source: str,
+ url: str,
+ genes: str | list[Any],
+ limit: int = 50,
+ filter_term: str | None = None,
+ json: bool = False,
+ save: bool = False,
+ verbose: bool = True,
+) -> pd.DataFrame | list[dict[str, Any]] | None:
+ """Shared enrichment helper for the Rummagene and RummaGEO GraphQL APIs.
+
+ Both services expose the same `currentBackground { enrich(...) }` entry point;
+ they differ only in the per-result gene set selection (Rummagene returns a
+ `geneSets` connection, RummaGEO a single `geneSet` with a `species` field).
+ """
+ genes_clean = _clean_genes(genes)
+
+ # The two APIs differ in how each enrichment result exposes its gene set(s)
+ if source == "rummagene":
+ result_selection = "geneSets { nodes { term nGeneIds } }"
+ columns = _RUMMAGENE_COLUMNS
+ else:
+ result_selection = "geneSet { term nGeneIds species }"
+ columns = _RUMMAGEO_COLUMNS
+
+ query = (
+ "query ($genes: [String]!, $first: Int, $filterTerm: String) {"
+ " currentBackground {"
+ " enrich(genes: $genes, first: $first, filterTerm: $filterTerm) {"
+ " totalCount"
+ f" nodes {{ pvalue adjPvalue oddsRatio nOverlap {result_selection} }}"
+ " }"
+ " }"
+ "}"
+ )
+ variables: dict[str, Any] = {"genes": genes_clean}
+ if limit is not None:
+ variables["first"] = int(limit)
+ if filter_term is not None and str(filter_term).strip() != "":
+ variables["filterTerm"] = str(filter_term)
+
+ if verbose:
+ logger.info(f"Querying {source} with {len(genes_clean)} genes...")
+
+ try:
+ response = requests.post(
+ url,
+ json={"query": query, "variables": variables},
+ timeout=DEFAULT_REQUESTS_TIMEOUT,
+ )
+ except requests.exceptions.RequestException as exc:
+ raise RuntimeError(f"The {source} server request failed: {exc}") from exc
+
+ if not response.ok:
+ raise RuntimeError(
+ f"The {source} server returned error status code {response.status_code}. "
+ "Please try again later and/or report this issue if it persists."
+ )
+
+ payload = response.json()
+ if payload.get("errors"):
+ raise RuntimeError(f"The {source} GraphQL API returned an error: {payload['errors']}")
+
+ enrich = ((payload.get("data") or {}).get("currentBackground") or {}).get("enrich") or {}
+ nodes = enrich.get("nodes") or []
+
+ rows = []
+ for node in nodes:
+ base = {
+ "n_overlap": node.get("nOverlap"),
+ "odds_ratio": node.get("oddsRatio"),
+ "pval": node.get("pvalue"),
+ "adj_pval": node.get("adjPvalue"),
+ }
+ if source == "rummagene":
+ gene_sets = (node.get("geneSets") or {}).get("nodes") or []
+ for gene_set in gene_sets:
+ rows.append(
+ {
+ "term": gene_set.get("term"),
+ "n_genes_in_set": gene_set.get("nGeneIds"),
+ **base,
+ }
+ )
+ else:
+ gene_set = node.get("geneSet") or {}
+ rows.append(
+ {
+ "term": gene_set.get("term"),
+ "species": gene_set.get("species"),
+ "n_genes_in_set": gene_set.get("nGeneIds"),
+ **base,
+ }
+ )
+
+ if len(rows) == 0:
+ logger.warning(
+ f"No {source} gene sets found for the provided genes. "
+ "Please double-check the gene symbols (HGNC symbols are expected)."
+ )
+ return None
+
+ # Honor 'limit' as the number of returned results
+ if limit is not None:
+ rows = rows[: int(limit)]
+
+ results_df = pd.DataFrame(rows)
+ # Add 1-based rank and enforce column order
+ results_df.insert(0, "rank", range(1, len(results_df) + 1))
+ results_df = results_df[columns]
+
+ if json:
+ results_dict = json_package.loads(results_df.to_json(orient="records"))
+ if save:
+ with open(f"gget_{source}_results.json", "w", encoding="utf-8") as f:
+ json_package.dump(results_dict, f, ensure_ascii=False, indent=4)
+ return results_dict
+
+ if save:
+ results_df.to_csv(f"gget_{source}_results.csv", index=False)
+
+ return results_df
+
+
+@overload
+def rummagene(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ *,
+ json: Literal[True],
+) -> list[dict[str, Any]] | None: ...
+
+
+@overload
+def rummagene(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ json: Literal[False] = False,
+) -> pd.DataFrame | None: ...
+
+
+def rummagene(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ json: bool = False,
+) -> pd.DataFrame | list[dict[str, Any]] | None:
+ """Find gene sets from PMC supplementary tables that overlap a query gene set using Rummagene.
+
+ Performs gene set enrichment against the ~1M gene sets that Rummagene
+ automatically extracted from supplementary tables of PubMed Central (PMC)
+ articles (https://rummagene.com/).
+
+ Args:
+ - genes List of gene symbols (HGNC) to query, e.g. ["STAT1", "IRF1"].
+ A single gene may be passed as a string.
+ - limit Maximum number of enriched gene sets to return. Default: 50.
+ - filter_term If provided, only return gene sets whose term contains this
+ (case-insensitive) substring. Default: None.
+ - save If True, save the results in the current working directory. Default: False.
+ - verbose True/False whether to print progress information. Default: True.
+ - json If True, returns results in json format instead of data frame. Default: False.
+
+ Returns a data frame (or list of dicts if json=True) with the matching gene sets
+ ranked by p-value, including the gene set term, overlap size, odds ratio, and
+ (adjusted) p-values from a Fisher's exact test. Returns None if no overlap is found.
+ """
+ return _rummage_enrich(
+ source="rummagene",
+ url=RUMMAGENE_GRAPHQL_URL,
+ genes=genes,
+ limit=limit,
+ filter_term=filter_term,
+ json=json,
+ save=save,
+ verbose=verbose,
+ )
+
+
+@overload
+def rummageo(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ *,
+ json: Literal[True],
+) -> list[dict[str, Any]] | None: ...
+
+
+@overload
+def rummageo(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ json: Literal[False] = False,
+) -> pd.DataFrame | None: ...
+
+
+def rummageo(
+ genes: str | list[str],
+ limit: int = 50,
+ filter_term: str | None = None,
+ save: bool = False,
+ verbose: bool = True,
+ json: bool = False,
+) -> pd.DataFrame | list[dict[str, Any]] | None:
+ """Find gene sets from GEO studies that overlap a query gene set using RummaGEO.
+
+ Performs gene set enrichment against the gene sets that RummaGEO automatically
+ extracted from differential-expression signatures of Gene Expression Omnibus
+ (GEO) studies (https://rummageo.com/).
+
+ Args:
+ - genes List of gene symbols (HGNC/MGI) to query, e.g. ["STAT1", "IRF1"].
+ A single gene may be passed as a string.
+ - limit Maximum number of enriched gene sets to return. Default: 50.
+ - filter_term If provided, only return gene sets whose term contains this
+ (case-insensitive) substring. Default: None.
+ - save If True, save the results in the current working directory. Default: False.
+ - verbose True/False whether to print progress information. Default: True.
+ - json If True, returns results in json format instead of data frame. Default: False.
+
+ Returns a data frame (or list of dicts if json=True) with the matching gene sets
+ ranked by p-value, including the gene set term, the species, overlap size, odds
+ ratio, and (adjusted) p-values from a Fisher's exact test. Returns None if no
+ overlap is found.
+ """
+ return _rummage_enrich(
+ source="rummageo",
+ url=RUMMAGEO_GRAPHQL_URL,
+ genes=genes,
+ limit=limit,
+ filter_term=filter_term,
+ json=json,
+ save=save,
+ verbose=verbose,
+ )
diff --git a/gget/main.py b/gget/main.py
index 7a2944b09..dd172dea5 100644
--- a/gget/main.py
+++ b/gget/main.py
@@ -38,6 +38,7 @@
from .gget_opentargets import OPENTARGETS_RESOURCES, opentargets # noqa: E402
from .gget_pdb import pdb # noqa: E402
from .gget_ref import ref # noqa: E402
+from .gget_rummage import rummagene, rummageo # noqa: E402
from .gget_search import search # noqa: E402
from .gget_seq import seq # noqa: E402
from .gget_setup import setup # noqa: E402
@@ -1133,6 +1134,129 @@ def main() -> None:
help="DEPRECATED - json is now the default output format (convert to csv using flag [--csv]).",
)
+ ## gget rummagene subparser
+ rummagene_desc = (
+ "Find gene sets from PMC supplementary tables that overlap a query gene set using Rummagene "
+ "(https://rummagene.com/)."
+ )
+ parser_rummagene = parent_subparsers.add_parser(
+ "rummagene",
+ parents=[parent],
+ description=rummagene_desc,
+ help=rummagene_desc,
+ add_help=True,
+ formatter_class=CustomHelpFormatter,
+ )
+ parser_rummagene.add_argument(
+ "genes",
+ type=str,
+ nargs="+",
+ help="List of gene symbols (HGNC) to perform the gene set search on.",
+ )
+ parser_rummagene.add_argument(
+ "-l",
+ "--limit",
+ type=int,
+ default=50,
+ required=False,
+ help="Maximum number of enriched gene sets to return. Default: 50.",
+ )
+ parser_rummagene.add_argument(
+ "-ft",
+ "--filter_term",
+ type=str,
+ default=None,
+ required=False,
+ help="Only return gene sets whose term contains this (case-insensitive) substring. Default: None.",
+ )
+ parser_rummagene.add_argument(
+ "-csv",
+ "--csv",
+ default=True,
+ action="store_false",
+ required=False,
+ help="Returns results in csv format instead of json.",
+ )
+ parser_rummagene.add_argument(
+ "-o",
+ "--out",
+ type=str,
+ required=False,
+ help=(
+ "Path to the file the results will be saved in, e.g. path/to/directory/results.csv (or .json).\n"
+ "Default: Standard out."
+ ),
+ )
+ parser_rummagene.add_argument(
+ "-q",
+ "--quiet",
+ default=True,
+ action="store_false",
+ required=False,
+ help="Does not print progress information.",
+ )
+
+ ## gget rummageo subparser
+ rummageo_desc = (
+ "Find gene sets from GEO studies that overlap a query gene set using RummaGEO (https://rummageo.com/)."
+ )
+ parser_rummageo = parent_subparsers.add_parser(
+ "rummageo",
+ parents=[parent],
+ description=rummageo_desc,
+ help=rummageo_desc,
+ add_help=True,
+ formatter_class=CustomHelpFormatter,
+ )
+ parser_rummageo.add_argument(
+ "genes",
+ type=str,
+ nargs="+",
+ help="List of gene symbols to perform the gene set search on.",
+ )
+ parser_rummageo.add_argument(
+ "-l",
+ "--limit",
+ type=int,
+ default=50,
+ required=False,
+ help="Maximum number of enriched gene sets to return. Default: 50.",
+ )
+ parser_rummageo.add_argument(
+ "-ft",
+ "--filter_term",
+ type=str,
+ default=None,
+ required=False,
+ help="Only return gene sets whose term contains this (case-insensitive) substring. Default: None.",
+ )
+ parser_rummageo.add_argument(
+ "-csv",
+ "--csv",
+ default=True,
+ action="store_false",
+ required=False,
+ help="Returns results in csv format instead of json.",
+ )
+ parser_rummageo.add_argument(
+ "-o",
+ "--out",
+ type=str,
+ required=False,
+ help=(
+ "Path to the file the results will be saved in, e.g. path/to/directory/results.csv (or .json).\n"
+ "Default: Standard out."
+ ),
+ )
+ parser_rummageo.add_argument(
+ "-q",
+ "--quiet",
+ default=True,
+ action="store_false",
+ required=False,
+ help="Does not print progress information.",
+ )
+
## gget archs4 subparser
archs4_desc = "Find the most correlated genes or the tissue expression atlas of a gene using data from the human and mouse RNA-seq database ARCHS4 (https://maayanlab.cloud/archs4/)."
parser_archs4 = parent_subparsers.add_parser(
@@ -2957,6 +3081,8 @@ def main() -> None:
"blast": parser_blast,
"blat": parser_blat,
"enrichr": parser_enrichr,
+ "rummagene": parser_rummagene,
+ "rummageo": parser_rummageo,
"archs4": parser_archs4,
"setup": parser_setup,
"alphafold": parser_alphafold,
@@ -3578,6 +3704,47 @@ def main() -> None:
if not args.out and args.csv:
print(json.dumps(enrichr_results, ensure_ascii=False, indent=4))
+ ## rummagene / rummageo return
+ if args.command in ("rummagene", "rummageo"):
+ # Clean up args.genes (split any comma-separated values; spaces handled by nargs="+")
+ genes_clean = []
+ for gene in args.genes:
+ genes_clean.append(gene.split(","))
+ genes_clean_final = [item for sublist in genes_clean for item in sublist]
+ while "" in genes_clean_final:
+ genes_clean_final.remove("")
+
+ rummage_func = rummagene if args.command == "rummagene" else rummageo
+ rummage_results = rummage_func(
+ genes=genes_clean_final,
+ limit=args.limit,
+ filter_term=args.filter_term,
+ json=args.csv,
+ verbose=args.quiet,
+ )
+
+ # Check if the function returned something
+ if rummage_results is not None:
+ # Save results if args.out specified
+ if args.out and not args.csv:
+ directory = "/".join(args.out.split("/")[:-1])
+ if directory != "":
+ os.makedirs(directory, exist_ok=True)
+ rummage_results.to_csv(args.out, index=False)
+
+ if args.out and args.csv:
+ directory = "/".join(args.out.split("/")[:-1])
+ if directory != "":
+ os.makedirs(directory, exist_ok=True)
+ with open(args.out, "w", encoding="utf-8") as f:
+ json.dump(rummage_results, f, ensure_ascii=False, indent=4)
+
+ # Print results if no directory specified
+ if not args.out and not args.csv:
+ rummage_results.to_csv(sys.stdout, index=False)
+ if not args.out and args.csv:
+ print(json.dumps(rummage_results, ensure_ascii=False, indent=4))
+
## info return
if args.command == "info":
# Handle deprecated flags for backwards compatibility
diff --git a/tests/fixtures/test_rummagene.json b/tests/fixtures/test_rummagene.json
new file mode 100644
index 000000000..e0e3572f8
--- /dev/null
+++ b/tests/fixtures/test_rummagene.json
@@ -0,0 +1,18 @@
+{
+ "test_rummagene_no_genes": {
+ "type": "error",
+ "args": {
+ "genes": []
+ },
+ "expected_result": "ValueError",
+ "expected_msg": "Please provide at least one gene symbol in the 'genes' argument."
+ },
+ "test_rummagene_only_invalid_genes": {
+ "type": "error",
+ "args": {
+ "genes": ["", "nan", null]
+ },
+ "expected_result": "ValueError",
+ "expected_msg": "Please provide at least one gene symbol in the 'genes' argument."
+ }
+}
diff --git a/tests/fixtures/test_rummageo.json b/tests/fixtures/test_rummageo.json
new file mode 100644
index 000000000..b2bd4b303
--- /dev/null
+++ b/tests/fixtures/test_rummageo.json
@@ -0,0 +1,10 @@
+{
+ "test_rummageo_no_genes": {
+ "type": "error",
+ "args": {
+ "genes": []
+ },
+ "expected_result": "ValueError",
+ "expected_msg": "Please provide at least one gene symbol in the 'genes' argument."
+ }
+}
diff --git a/tests/test_rummage.py b/tests/test_rummage.py
new file mode 100644
index 000000000..7663a5390
--- /dev/null
+++ b/tests/test_rummage.py
@@ -0,0 +1,191 @@
+import json
+import os
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import gget.gget_rummage as gget_rummage
+import requests
+from gget.gget_rummage import _clean_genes, rummagene, rummageo
+
+from .from_json import from_json
+
+# Load dictionaries containing arguments and expected results
+with open("./tests/fixtures/test_rummagene.json") as json_file:
+ rummagene_dict = json.load(json_file)
+
+with open("./tests/fixtures/test_rummageo.json") as json_file:
+ rummageo_dict = json.load(json_file)
+
+
+class TestRummagene(unittest.TestCase, metaclass=from_json(rummagene_dict, rummagene)):
+ pass # tests loaded from json
+
+
+class TestRummageo(unittest.TestCase, metaclass=from_json(rummageo_dict, rummageo)):
+ pass # tests loaded from json
+
+
+class _FakeResponse:
+ """Minimal stand-in for a requests.Response used to test parsing offline."""
+
+ def __init__(self, payload, ok=True, status_code=200):
+ self._payload = payload
+ self.ok = ok
+ self.status_code = status_code
+
+ def json(self):
+ return self._payload
+
+
+# Canned GraphQL payloads mirroring the live Rummagene / RummaGEO responses
+_RUMMAGENE_PAYLOAD = {
+ "data": {
+ "currentBackground": {
+ "enrich": {
+ "totalCount": 2,
+ "nodes": [
+ {
+ "pvalue": 1e-10,
+ "adjPvalue": 1e-8,
+ "oddsRatio": 500.0,
+ "nOverlap": 8,
+ "geneSets": {"nodes": [{"term": "PMC123-table1-up", "nGeneIds": 30}]},
+ },
+ {
+ "pvalue": 1e-5,
+ "adjPvalue": 1e-3,
+ "oddsRatio": 100.0,
+ "nOverlap": 5,
+ "geneSets": {"nodes": [{"term": "PMC456-table2-down", "nGeneIds": 40}]},
+ },
+ ],
+ }
+ }
+ }
+}
+
+_RUMMAGEO_PAYLOAD = {
+ "data": {
+ "currentBackground": {
+ "enrich": {
+ "totalCount": 1,
+ "nodes": [
+ {
+ "pvalue": 2e-9,
+ "adjPvalue": 2e-7,
+ "oddsRatio": 400.0,
+ "nOverlap": 7,
+ "geneSet": {"term": "GSE123-2-vs-1-human up", "nGeneIds": 50, "species": "human"},
+ }
+ ],
+ }
+ }
+ }
+}
+
+
+class TestRummageParsing(unittest.TestCase):
+ """Network-free tests of the shared enrichment parsing logic (issue #164)."""
+
+ def test_clean_genes(self):
+ self.assertEqual(_clean_genes("STAT1"), ["STAT1"])
+ self.assertEqual(_clean_genes([" STAT1 ", "IRF1", "", None, "nan"]), ["STAT1", "IRF1"])
+ with self.assertRaises(ValueError):
+ _clean_genes([])
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_rummagene_parsing(self, mock_post):
+ mock_post.return_value = _FakeResponse(_RUMMAGENE_PAYLOAD)
+ df = rummagene(["STAT1", "IRF1"], verbose=False)
+ self.assertEqual(
+ list(df.columns),
+ ["rank", "term", "n_overlap", "n_genes_in_set", "odds_ratio", "pval", "adj_pval"],
+ )
+ self.assertEqual(df.shape[0], 2)
+ self.assertEqual(df.iloc[0]["rank"], 1)
+ self.assertEqual(df.iloc[0]["term"], "PMC123-table1-up")
+ self.assertEqual(df.iloc[0]["n_overlap"], 8)
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_rummagene_json_and_limit(self, mock_post):
+ mock_post.return_value = _FakeResponse(_RUMMAGENE_PAYLOAD)
+ result = rummagene(["STAT1"], limit=1, json=True, verbose=False)
+ self.assertIsInstance(result, list)
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["term"], "PMC123-table1-up")
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_rummageo_parsing(self, mock_post):
+ mock_post.return_value = _FakeResponse(_RUMMAGEO_PAYLOAD)
+ df = rummageo(["STAT1", "IRF1"], verbose=False)
+ self.assertEqual(
+ list(df.columns),
+ ["rank", "term", "species", "n_overlap", "n_genes_in_set", "odds_ratio", "pval", "adj_pval"],
+ )
+ self.assertEqual(df.iloc[0]["species"], "human")
+ self.assertEqual(df.iloc[0]["term"], "GSE123-2-vs-1-human up")
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_no_results_returns_none(self, mock_post):
+ empty = {"data": {"currentBackground": {"enrich": {"totalCount": 0, "nodes": []}}}}
+ mock_post.return_value = _FakeResponse(empty)
+ self.assertIsNone(rummagene(["STAT1"], verbose=False))
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_graphql_error_raises(self, mock_post):
+ mock_post.return_value = _FakeResponse({"errors": [{"message": "boom"}]})
+ with self.assertRaises(RuntimeError):
+ rummagene(["STAT1"], verbose=False)
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_http_error_raises(self, mock_post):
+ mock_post.return_value = _FakeResponse({}, ok=False, status_code=500)
+ with self.assertRaises(RuntimeError):
+ rummageo(["STAT1"], verbose=False)
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_filter_term_and_verbose(self, mock_post):
+ # Covers the filter_term variable branch and the verbose logging line.
+ mock_post.return_value = _FakeResponse(_RUMMAGENE_PAYLOAD)
+ rummagene(["STAT1"], filter_term="cancer", verbose=True)
+ sent_variables = mock_post.call_args.kwargs["json"]["variables"]
+ self.assertEqual(sent_variables["filterTerm"], "cancer")
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_request_exception_raises(self, mock_post):
+ # Covers the requests.RequestException -> RuntimeError branch.
+ mock_post.side_effect = requests.exceptions.ConnectionError("no network")
+ with self.assertRaises(RuntimeError):
+ rummagene(["STAT1"], verbose=False)
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_save_csv(self, mock_post):
+ # Covers the save-to-CSV branch.
+ mock_post.return_value = _FakeResponse(_RUMMAGENE_PAYLOAD)
+ with tempfile.TemporaryDirectory() as tmp:
+ cwd = os.getcwd()
+ os.chdir(tmp)
+ try:
+ rummagene(["STAT1"], save=True, verbose=False)
+ self.assertTrue(os.path.exists("gget_rummagene_results.csv"))
+ finally:
+ os.chdir(cwd)
+
+ @patch.object(gget_rummage.requests, "post")
+ def test_save_json(self, mock_post):
+ # Covers the json + save branch.
+ mock_post.return_value = _FakeResponse(_RUMMAGENE_PAYLOAD)
+ with tempfile.TemporaryDirectory() as tmp:
+ cwd = os.getcwd()
+ os.chdir(tmp)
+ try:
+ result = rummagene(["STAT1"], save=True, json=True, verbose=False)
+ self.assertIsInstance(result, list)
+ self.assertTrue(os.path.exists("gget_rummagene_results.json"))
+ finally:
+ os.chdir(cwd)
+
+
+if __name__ == "__main__":
+ unittest.main()