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()