diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index b112fd0f2..c2bb0d6b5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ # Manual * [gget 8cube](en/8cube.md) +* [gget alliance](en/alliance.md) * [gget alphafold](en/alphafold.md) * [gget archs4](en/archs4.md) * [gget bgee](en/bgee.md) diff --git a/docs/src/en/alliance.md b/docs/src/en/alliance.md new file mode 100644 index 000000000..841b7f51e --- /dev/null +++ b/docs/src/en/alliance.md @@ -0,0 +1,72 @@ +[ View page source on GitHub ](https://github.com/scverse/gget/blob/main/docs/src/en/alliance.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 alliance 🧬 +Query the [Alliance of Genome Resources](https://www.alliancegenome.org/), which integrates data from the major model organism databases (human, mouse, rat, zebrafish, fly, worm, yeast, and more). +`gget alliance` has two modes, chosen automatically from the input: +- If the input is an Alliance **gene ID** (e.g. `HGNC:1101`, `MGI:109337`, `RGD:2219`, `ZFIN:...`, `FB:...`, `WB:...`, `SGD:...`), the gene's details are returned. +- Otherwise, the input is used as a **free-text search** and the matching objects of the given `--category` are returned. + +Return format: JSON (command-line) or data frame/CSV (Python). + +**Positional argument** +`search_term` +Alliance gene ID (e.g. `HGNC:1101`) or a free-text search term. + +**Optional arguments** +`-c` `--category` +Category for free-text searches: one of `gene`, `allele`, `disease`, `go`, `variant`, `model`, or `all` for no filter. Default: `gene`. + +`-l` `--limit` +Maximum number of results for free-text searches. Default: 10. + +`-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. + +### Examples +**Search for genes across model organisms:** +```bash +gget alliance brca2 +``` +```python +# Python +gget.alliance("brca2") +``` +→ Returns a table of genes matching the search term across Alliance member databases. + +| id | symbol | name | species | category | so_term_name | +| --- | --- | --- | --- | --- | --- | +| HGNC:1101 | BRCA2 | BRCA2 DNA repair associated | Homo sapiens | gene_search_result | protein_coding_gene | +| MGI:109337 | Brca2 | breast cancer 2 | Mus musculus | gene_search_result | protein_coding_gene | + +

+**Fetch a single gene by its Alliance ID:** +```bash +gget alliance HGNC:1101 +``` +```python +# Python +gget.alliance("HGNC:1101") +``` +→ Returns the gene's details. + +| id | symbol | name | species | taxon | gene_type | synonyms | data_provider | +| --- | --- | --- | --- | --- | --- | --- | --- | +| HGNC:1101 | BRCA2 | BRCA2 DNA repair associated | Homo sapiens | NCBITaxon:9606 | protein_coding_gene | ['FAD', ...] | RGD | + +# References +If you use `gget alliance` 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) + +- Alliance of Genome Resources Consortium. (2024). Updates to the Alliance of Genome Resources central infrastructure. Genetics. [https://doi.org/10.1093/genetics/iyae049](https://doi.org/10.1093/genetics/iyae049) diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md index 89be22281..6cddac2a3 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 alliance`](alliance.md): **New module** to query the [Alliance of Genome Resources](https://www.alliancegenome.org/). Pass an Alliance gene ID (e.g. `HGNC:1101`, `MGI:109337`, `RGD:2219`) to fetch the gene's details, or a free-text term to search genes/alleles/diseases/GO/variants/models across the member model-organism databases (selectable with `category`). Available in the Python API and on the command line. Resolves [issue 162](https://github.com/scverse/gget/issues/162). **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..5b336dc11 100644 --- a/gget/__init__.py +++ b/gget/__init__.py @@ -4,6 +4,7 @@ from importlib.metadata import PackageNotFoundError, version from .gget_8cube import gene_expression, psi_block, specificity +from .gget_alliance import alliance from .gget_alphafold import alphafold from .gget_archs4 import archs4 from .gget_bgee import bgee diff --git a/gget/constants.py b/gget/constants.py index 38f90119f..a0226f59f 100644 --- a/gget/constants.py +++ b/gget/constants.py @@ -7,6 +7,9 @@ # strategy avoid hanging indefinitely on slow upstreams. DEFAULT_REQUESTS_TIMEOUT = (10, 60) +# Alliance of Genome Resources REST API for gget alliance +ALLIANCE_URL = "https://www.alliancegenome.org/api" + # 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_alliance.py b/gget/gget_alliance.py new file mode 100644 index 000000000..9227371b1 --- /dev/null +++ b/gget/gget_alliance.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json as json_package +from typing import Any, Literal, overload + +import pandas as pd +import requests + +from .constants import ALLIANCE_URL, DEFAULT_REQUESTS_TIMEOUT +from .utils import set_up_logger + +logger = set_up_logger() + +# Columns returned when fetching a single gene by its Alliance ID +_GENE_COLUMNS = [ + "id", + "symbol", + "name", + "species", + "taxon", + "gene_type", + "synonyms", + "data_provider", +] +# Columns returned for a free-text search +_SEARCH_COLUMNS = [ + "id", + "symbol", + "name", + "species", + "category", + "so_term_name", +] + +# Friendly category names -> Alliance search API category values +_CATEGORY_MAP = { + "gene": "gene_search_result", + "allele": "allele_search_result", + "disease": "disease_search_result", + "go": "go_search_result", + "variant": "variant_search_result", + "model": "model", +} + +# Recognized Alliance/model-organism-database gene ID (curie) prefixes +_GENE_ID_PREFIXES = { + "HGNC", + "MGI", + "RGD", + "ZFIN", + "FB", + "WB", + "SGD", + "ENSEMBL", + "XENBASE", + "WORMBASE", + "FLYBASE", +} + + +def _is_gene_id(term: str) -> bool: + """Return True if 'term' looks like an Alliance gene ID (e.g. HGNC:1101).""" + if ":" not in term: + return False + prefix = term.split(":", 1)[0].upper() + return prefix in _GENE_ID_PREFIXES + + +def _alliance_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """GET a JSON object from the Alliance of Genome Resources REST API.""" + url = f"{ALLIANCE_URL}{path}" + try: + response = requests.get( + url, + params=params, + headers={"Accept": "application/json"}, + timeout=DEFAULT_REQUESTS_TIMEOUT, + ) + except requests.exceptions.RequestException as exc: + raise RuntimeError(f"The Alliance server request failed: {exc}") from exc + + if response.status_code == 404: + raise ValueError(f"Alliance returned 404 (not found) for '{path}'. Please double-check the gene ID.") + if not response.ok: + raise RuntimeError( + f"The Alliance server returned error status code {response.status_code}. Please try again later." + ) + + return response.json() + + +def _text(value: Any) -> Any: + """Extract display text from an Alliance {displayText/formatText} object.""" + if isinstance(value, dict): + return value.get("displayText") or value.get("formatText") + return value + + +def _gene_row(gene: dict[str, Any]) -> dict[str, Any]: + """Flatten an Alliance gene object into a row of scalar values.""" + taxon = gene.get("taxon") or {} + species = taxon.get("name") if isinstance(taxon, dict) else None + taxon_curie = taxon.get("curie") if isinstance(taxon, dict) else None + + gene_type = gene.get("geneType") + gene_type_name = gene_type.get("name") if isinstance(gene_type, dict) else gene_type + + data_provider = gene.get("dataProvider") + provider = data_provider.get("abbreviation") if isinstance(data_provider, dict) else data_provider + + synonyms = [_text(s) for s in (gene.get("geneSynonyms") or [])] + + return { + "id": gene.get("primaryExternalId"), + "symbol": _text(gene.get("geneSymbol")), + "name": _text(gene.get("geneFullName")), + "species": species, + "taxon": taxon_curie, + "gene_type": gene_type_name, + "synonyms": synonyms, + "data_provider": provider, + } + + +def _search_row(r: dict[str, Any]) -> dict[str, Any]: + """Flatten an Alliance search result into a row of scalar values.""" + return { + "id": r.get("id"), + "symbol": r.get("symbol"), + "name": r.get("name"), + "species": r.get("species"), + "category": r.get("category"), + "so_term_name": r.get("soTermName"), + } + + +@overload +def alliance( + search_term: str, + category: str = "gene", + limit: int = 10, + save: bool = False, + verbose: bool = True, + *, + json: Literal[True], +) -> list[dict[str, Any]] | None: ... + + +@overload +def alliance( + search_term: str, + category: str = "gene", + limit: int = 10, + save: bool = False, + verbose: bool = True, + json: Literal[False] = False, +) -> pd.DataFrame | None: ... + + +def alliance( + search_term: str, + category: str = "gene", + limit: int = 10, + save: bool = False, + verbose: bool = True, + json: bool = False, +) -> pd.DataFrame | list[dict[str, Any]] | None: + """Query the Alliance of Genome Resources (https://www.alliancegenome.org/). + + Two modes, chosen automatically from 'search_term': + - If 'search_term' is an Alliance gene ID (e.g. "HGNC:1101", "MGI:109337", + "RGD:2219", "ZFIN:...", "FB:...", "WB:...", "SGD:..."), the gene is fetched + and its details are returned. + - Otherwise, 'search_term' is used as a free-text query against the Alliance + search endpoint and the matching objects of the given 'category' are returned. + + Args: + - search_term Alliance gene ID (curie) or a free-text search term. + - category Category for free-text searches: one of "gene", "allele", + "disease", "go", "variant", "model", or None/"all" for no filter. + Default: "gene". + - limit Maximum number of results for free-text searches. Default: 10. + - save If True, save the results table as csv/json in the 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) of gene details or search + results. Returns None if no results are found. + """ + if search_term is None or str(search_term).strip() == "": + raise ValueError("Please provide a gene ID or search term in 'search_term'.") + + term = str(search_term).strip() + source = "alliance_search" + + if _is_gene_id(term): + if verbose: + logger.info(f"Fetching Alliance gene {term}...") + obj = _alliance_get(f"/gene/{term}") + gene = obj.get("gene") if isinstance(obj, dict) else None + if not gene: + logger.warning(f"No Alliance gene found for '{term}'.") + return None + source = "alliance_gene" + results_df = pd.DataFrame([_gene_row(gene)], columns=_GENE_COLUMNS) + else: + params: dict[str, Any] = {"q": term, "limit": limit} + if category is not None and str(category).lower() not in ("all", ""): + params["category"] = _CATEGORY_MAP.get(str(category).lower(), category) + if verbose: + logger.info(f"Searching Alliance for '{term}' (category={category})...") + data = _alliance_get("/search", params=params) + rows = [_search_row(r) for r in data.get("results", [])] + results_df = pd.DataFrame(rows, columns=_SEARCH_COLUMNS) + + if len(results_df) == 0: + logger.warning(f"No Alliance results found for '{term}'.") + return None + + 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 diff --git a/gget/main.py b/gget/main.py index 7a2944b09..d8ff9f54a 100644 --- a/gget/main.py +++ b/gget/main.py @@ -19,6 +19,7 @@ logger = set_up_logger() from .__init__ import __version__ # noqa: E402 +from .gget_alliance import alliance # noqa: E402 from .gget_alphafold import alphafold # noqa: E402 from .gget_archs4 import archs4 # noqa: E402 from .gget_bgee import bgee # noqa: E402 @@ -1002,6 +1003,67 @@ def main() -> None: help="DEPRECATED - json is now the default output format (convert to csv using flag [--csv]).", ) + ## gget alliance subparser + alliance_desc = "Query the Alliance of Genome Resources (https://www.alliancegenome.org/)." + parser_alliance = parent_subparsers.add_parser( + "alliance", + parents=[parent], + description=alliance_desc, + help=alliance_desc, + add_help=True, + formatter_class=CustomHelpFormatter, + ) + parser_alliance.add_argument( + "search_term", + type=str, + help="Alliance gene ID (e.g. HGNC:1101, MGI:109337, RGD:2219) or a free-text search term.", + ) + parser_alliance.add_argument( + "-c", + "--category", + type=str, + default="gene", + required=False, + help=( + "Category for free-text searches: 'gene', 'allele', 'disease', 'go', 'variant', 'model', " + "or 'all' for no filter. Default: 'gene'." + ), + ) + parser_alliance.add_argument( + "-l", + "--limit", + type=int, + default=10, + required=False, + help="Maximum number of results for free-text searches. Default: 10.", + ) + parser_alliance.add_argument( + "-csv", + "--csv", + default=True, + action="store_false", + required=False, + help="Returns results in csv format instead of json.", + ) + parser_alliance.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_alliance.add_argument( + "-q", + "--quiet", + default=True, + action="store_false", + required=False, + help="Does not print progress information.", + ) + ## gget enrichr subparser enrichr_desc = "Perform an enrichment analysis on a list of genes using Enrichr." parser_enrichr = parent_subparsers.add_parser( @@ -2956,6 +3018,7 @@ def main() -> None: "muscle": parser_muscle, "blast": parser_blast, "blat": parser_blat, + "alliance": parser_alliance, "enrichr": parser_enrichr, "archs4": parser_archs4, "setup": parser_setup, @@ -3502,6 +3565,38 @@ def main() -> None: if not args.out and args.csv: print(json.dumps(gget_results, ensure_ascii=False, indent=4)) + ## alliance return + if args.command == "alliance": + alliance_results = alliance( + search_term=args.search_term, + category=args.category, + limit=args.limit, + json=args.csv, + verbose=args.quiet, + ) + + # Check if the function returned something + if alliance_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) + alliance_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(alliance_results, f, ensure_ascii=False, indent=4) + + # Print results if no directory specified + if not args.out and not args.csv: + alliance_results.to_csv(sys.stdout, index=False) + if not args.out and args.csv: + print(json.dumps(alliance_results, ensure_ascii=False, indent=4)) + ## enrichr return if args.command == "enrichr": # Handle deprecated flags for backwards compatibility diff --git a/tests/fixtures/test_alliance.json b/tests/fixtures/test_alliance.json new file mode 100644 index 000000000..ff2e93f5f --- /dev/null +++ b/tests/fixtures/test_alliance.json @@ -0,0 +1,10 @@ +{ + "test_alliance_no_term": { + "type": "error", + "args": { + "search_term": "" + }, + "expected_result": "ValueError", + "expected_msg": "Please provide a gene ID or search term in 'search_term'." + } +} diff --git a/tests/test_alliance.py b/tests/test_alliance.py new file mode 100644 index 000000000..3be458b0c --- /dev/null +++ b/tests/test_alliance.py @@ -0,0 +1,184 @@ +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +import gget.gget_alliance as gget_alliance +import requests +from gget.gget_alliance import _gene_row, _is_gene_id, _search_row, alliance + +from .from_json import from_json + +with open("./tests/fixtures/test_alliance.json") as json_file: + alliance_dict = json.load(json_file) + + +class TestAlliance(unittest.TestCase, metaclass=from_json(alliance_dict, alliance)): + 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 + + +_GENE_PAYLOAD = { + "category": "gene_summary", + "gene": { + "primaryExternalId": "HGNC:1101", + "geneSymbol": {"displayText": "BRCA2", "formatText": "BRCA2"}, + "geneFullName": {"displayText": "BRCA2 DNA repair associated"}, + "geneType": {"name": "protein_coding_gene"}, + "geneSynonyms": [{"displayText": "FAD"}, {"displayText": "FACD"}], + "taxon": {"curie": "NCBITaxon:9606", "name": "Homo sapiens"}, + "dataProvider": {"abbreviation": "RGD"}, + }, +} + +_SEARCH_PAYLOAD = { + "results": [ + { + "id": "HGNC:1101", + "symbol": "BRCA2", + "name": "BRCA2 DNA repair associated", + "species": "Homo sapiens", + "category": "gene_search_result", + "soTermName": "protein_coding_gene", + }, + { + "id": "MGI:109337", + "symbol": "Brca2", + "name": "breast cancer 2", + "species": "Mus musculus", + "category": "gene_search_result", + "soTermName": "protein_coding_gene", + }, + ] +} + + +class TestAllianceHelpers(unittest.TestCase): + """Network-free tests of the Alliance helpers (issue #162).""" + + def test_is_gene_id(self): + self.assertTrue(_is_gene_id("HGNC:1101")) + self.assertTrue(_is_gene_id("MGI:109337")) + self.assertTrue(_is_gene_id("rgd:2219")) + self.assertFalse(_is_gene_id("brca2")) + self.assertFalse(_is_gene_id("breast cancer")) + + def test_gene_row(self): + row = _gene_row(_GENE_PAYLOAD["gene"]) + self.assertEqual(row["id"], "HGNC:1101") + self.assertEqual(row["symbol"], "BRCA2") + self.assertEqual(row["name"], "BRCA2 DNA repair associated") + self.assertEqual(row["species"], "Homo sapiens") + self.assertEqual(row["taxon"], "NCBITaxon:9606") + self.assertEqual(row["gene_type"], "protein_coding_gene") + self.assertEqual(row["synonyms"], ["FAD", "FACD"]) + self.assertEqual(row["data_provider"], "RGD") + + def test_search_row(self): + row = _search_row(_SEARCH_PAYLOAD["results"][0]) + self.assertEqual(row["id"], "HGNC:1101") + self.assertEqual(row["symbol"], "BRCA2") + self.assertEqual(row["species"], "Homo sapiens") + + @patch.object(gget_alliance.requests, "get") + def test_gene_id_mode(self, mock_get): + mock_get.return_value = _FakeResponse(_GENE_PAYLOAD) + df = alliance("HGNC:1101", verbose=False) + self.assertEqual(list(df.columns), gget_alliance._GENE_COLUMNS) + self.assertEqual(df.shape[0], 1) + self.assertEqual(df.iloc[0]["symbol"], "BRCA2") + + @patch.object(gget_alliance.requests, "get") + def test_search_mode_json(self, mock_get): + mock_get.return_value = _FakeResponse(_SEARCH_PAYLOAD) + result = alliance("brca2", json=True, verbose=False) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], "HGNC:1101") + + @patch.object(gget_alliance.requests, "get") + def test_category_mapping(self, mock_get): + mock_get.return_value = _FakeResponse(_SEARCH_PAYLOAD) + alliance("brca2", category="gene", verbose=False) + # Confirm the friendly category was mapped to the API value + _, kwargs = mock_get.call_args + self.assertEqual(kwargs["params"]["category"], "gene_search_result") + + @patch.object(gget_alliance.requests, "get") + def test_no_results_returns_none(self, mock_get): + mock_get.return_value = _FakeResponse({"results": []}) + self.assertIsNone(alliance("nonexistent xyz", verbose=False)) + + @patch.object(gget_alliance.requests, "get") + def test_http_error_raises(self, mock_get): + mock_get.return_value = _FakeResponse({}, ok=False, status_code=500) + with self.assertRaises(RuntimeError): + alliance("brca2", verbose=False) + + def test_empty_search_term_raises(self): + # Covers the empty/None search_term ValueError branch. + with self.assertRaises(ValueError): + alliance(" ", verbose=False) + + def test_text_passthrough(self): + # Covers the non-dict passthrough branch of _text. + self.assertEqual(gget_alliance._text("plain"), "plain") + self.assertIsNone(gget_alliance._text(None)) + + @patch.object(gget_alliance.requests, "get") + def test_request_exception_raises(self, mock_get): + # Covers the requests.RequestException -> RuntimeError branch in _alliance_get. + mock_get.side_effect = requests.exceptions.ConnectionError("no network") + with self.assertRaises(RuntimeError): + alliance("brca2", verbose=False) + + @patch.object(gget_alliance.requests, "get") + def test_404_raises(self, mock_get): + # Covers the 404 -> ValueError branch in _alliance_get. + mock_get.return_value = _FakeResponse({}, ok=False, status_code=404) + with self.assertRaises(ValueError): + alliance("HGNC:1101", verbose=False) + + @patch.object(gget_alliance.requests, "get") + def test_gene_not_found_verbose(self, mock_get): + # Covers the gene-path verbose log and the "no gene found" branch. + mock_get.return_value = _FakeResponse({"gene": None}) + self.assertIsNone(alliance("HGNC:9999", verbose=True)) + + @patch.object(gget_alliance.requests, "get") + def test_search_verbose(self, mock_get): + # Covers the search-path verbose log line. + mock_get.return_value = _FakeResponse(_SEARCH_PAYLOAD) + df = alliance("brca2", verbose=True) + self.assertEqual(df.shape[0], 2) + + @patch.object(gget_alliance.requests, "get") + def test_save_csv_and_json(self, mock_get): + # Covers the save-to-CSV and json+save branches. + mock_get.return_value = _FakeResponse(_GENE_PAYLOAD) + with tempfile.TemporaryDirectory() as tmp: + cwd = os.getcwd() + os.chdir(tmp) + try: + alliance("HGNC:1101", save=True, verbose=False) + self.assertTrue(any(f.endswith(".csv") for f in os.listdir("."))) + alliance("HGNC:1101", save=True, json=True, verbose=False) + self.assertTrue(any(f.endswith(".json") for f in os.listdir("."))) + finally: + os.chdir(cwd) + + +if __name__ == "__main__": + unittest.main()