From 023eae9ad19fa3be6c2087fdd80287db869f1f0b Mon Sep 17 00:00:00 2001 From: Elarwei Date: Wed, 24 Jun 2026 22:29:09 +0800 Subject: [PATCH 1/2] feat(blast): add taxonomy/organism filtering via taxid and entrez_query (#71) Add `taxid` and `entrez_query` arguments to `gget blast` (Python API and CLI) so searches can be restricted by organism, mirroring the "Organism" field of the NCBI web BLAST app. `taxid` accepts a single ID or a list (combined with OR as `txid[ORGN]`); `entrez_query` passes a raw NCBI Entrez query through to `ENTREZ_QUERY`. When both are given they are AND-combined. Both default to None, preserving backward compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/src/en/blast.md | 22 +++++++++++++ docs/src/en/updates.md | 3 ++ gget/gget_blast.py | 60 ++++++++++++++++++++++++++++++++++ gget/main.py | 25 ++++++++++++++ tests/fixtures/test_blast.json | 10 ++++++ tests/test_blast.py | 43 +++++++++++++++++++++++- 6 files changed, 162 insertions(+), 1 deletion(-) diff --git a/docs/src/en/blast.md b/docs/src/en/blast.md index 414019e25..b35767972 100644 --- a/docs/src/en/blast.md +++ b/docs/src/en/blast.md @@ -25,6 +25,12 @@ Limits number of hits to return. Default: 50. `-e` `--expect` Defines the [expect value](https://blast.ncbi.nlm.nih.gov/Blast.cgi?CMD=Web&PAGE_TYPE=BlastDocs&DOC_TYPE=FAQ#expect) cutoff. Default: 10.0. +`-tax` `--taxid` +One or more NCBI taxonomy IDs to restrict the search to, e.g. `9606` (human) or `txid9606`. On the command line, pass several space-separated IDs (`--taxid 9606 10090`); in Python, pass an `int`/`str` or a list (`taxid=[9606, 10090]`). Multiple IDs are combined with OR. This mirrors the "Organism" field of the [NCBI web BLAST](https://blast.ncbi.nlm.nih.gov/Blast.cgi) app. Default: None. + +`-eq` `--entrez_query` +Raw [NCBI Entrez query](https://www.ncbi.nlm.nih.gov/books/NBK3837/) used to limit the search, e.g. `"Homo sapiens[ORGN] NOT predicted[Title]"`. Combined with `--taxid` using AND when both are provided. 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. @@ -73,6 +79,22 @@ gget.blast("fasta.fa") ``` → Returns the BLAST results of the first sequence contained in the fasta.fa file. +

+**Restrict the search to one or more organisms (taxonomy filtering):** +```bash +# Limit to human (taxid 9606) +gget blast --taxid 9606 MKWMFKEDHSLEHRCVESAKIRAKYPDRVPVIVEKVSGSQIVDIDKRKYLVPSDITVAQFMWIIRKRIQLPSEKAIFLFVDKTVPQSR +``` +```python +# Python: limit to human and mouse, excluding predicted records +gget.blast( + "MKWMFKEDHSLEHRCVESAKIRAKYPDRVPVIVEKVSGSQIVDIDKRKYLVPSDITVAQFMWIIRKRIQLPSEKAIFLFVDKTVPQSR", + taxid=[9606, 10090], + entrez_query="NOT predicted[Title]", +) +``` +→ Returns only BLAST hits matching the requested organism(s)/Entrez query, mirroring the "Organism" filter of the NCBI web BLAST app. + #### [More examples](https://github.com/pachterlab/gget_examples) # References diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md index 89be22281..4882768b2 100644 --- a/docs/src/en/updates.md +++ b/docs/src/en/updates.md @@ -5,6 +5,9 @@ #### *gget* officially became part of [*scverse*](https://scverse.org/) on June 9, 2026. 🥳🥳🥳 **Version ≥ 0.30.9** (XXX XX, 2026): +- [`gget blast`](blast.md): Added taxonomy/organism filtering via the new `taxid` and `entrez_query` arguments (CLI: `--taxid`/`-tax` and `--entrez_query`/`-eq`), mirroring the "Organism" field of the NCBI web BLAST app (resolves [issue 71](https://github.com/scverse/gget/issues/71)). + - `taxid` accepts a single NCBI taxonomy ID or a list (e.g. `9606`, `"txid9606"`, or `[9606, 10090]`); multiple IDs are combined with OR and a `txid[ORGN]` Entrez term is built automatically. + - `entrez_query` passes a raw NCBI Entrez query through to BLAST (`ENTREZ_QUERY`); when both are given they are combined with AND. Backward compatible — both default to `None` (no filtering). **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/gget_blast.py b/gget/gget_blast.py index c072820d3..68620ee5f 100644 --- a/gget/gget_blast.py +++ b/gget/gget_blast.py @@ -25,6 +25,47 @@ ) +def _build_entrez_query( + taxid: int | str | list[int | str] | None = None, + entrez_query: str | None = None, +) -> str | None: + """Build an NCBI Entrez query string to limit a BLAST search by taxonomy. + + Args: + - taxid A single NCBI taxonomy ID or a list of taxonomy IDs to + restrict the search to (e.g. 9606 or "txid9606" for human). + Multiple IDs are combined with OR. + - entrez_query A raw NCBI Entrez query string passed through as-is + (e.g. "Homo sapiens[ORGN] NOT predicted[Title]"). + + When both are provided they are combined with AND. Returns None if neither + is provided. Raises ValueError if a taxid is not a valid (numeric) ID. + """ + entrez_terms = [] + + if taxid is not None: + # Accept a single taxid or an iterable of taxids + taxid_list = list(taxid) if isinstance(taxid, (list, tuple, set)) else [taxid] + taxid_terms = [] + for tid in taxid_list: + tid_str = str(tid).strip().lower().removeprefix("txid") + if not tid_str.isdigit(): + raise ValueError( + f"Invalid taxid {tid!r}. Expected a numeric NCBI taxonomy ID (e.g. 9606 or 'txid9606')." + ) + taxid_terms.append(f"txid{tid_str}[ORGN]") + + if len(taxid_terms) == 1: + entrez_terms.append(taxid_terms[0]) + else: + entrez_terms.append("(" + " OR ".join(taxid_terms) + ")") + + if entrez_query is not None and str(entrez_query).strip() != "": + entrez_terms.append(str(entrez_query).strip()) + + return " AND ".join(entrez_terms) if entrez_terms else None + + @overload def blast( sequence: str, @@ -39,6 +80,8 @@ def blast( *, json: Literal[True], save: bool = False, + entrez_query: str | None = None, + taxid: int | str | list[int | str] | None = None, ) -> list[dict[str, Any]] | None: ... @@ -55,6 +98,8 @@ def blast( wrap_text: bool = False, json: Literal[False] = False, save: bool = False, + entrez_query: str | None = None, + taxid: int | str | list[int | str] | None = None, ) -> pd.DataFrame | None: ... @@ -70,6 +115,8 @@ def blast( wrap_text: bool = False, json: bool = False, save: bool = False, + entrez_query: str | None = None, + taxid: int | str | list[int | str] | None = None, ) -> pd.DataFrame | list[dict[str, Any]] | None: """BLAST a nucleotide or amino acid sequence against any BLAST DB. @@ -89,6 +136,12 @@ def blast( - wrap_text If True, displays data frame with wrapped text for easy reading. Default: False. - json If True, returns results in json format instead of data frame. Default: False. - save If True, the data frame is saved as a csv in the current directory (default: False). + - entrez_query Raw NCBI Entrez query string used to limit the BLAST search + (e.g. "Homo sapiens[ORGN] NOT predicted[Title]"). Default: None. + - taxid NCBI taxonomy ID, or list of taxonomy IDs, to restrict the search to + (e.g. 9606 or "txid9606" for human; ["9606", "10090"] for human and mouse). + Multiple IDs are combined with OR. Combined with 'entrez_query' using AND + when both are provided. Default: None. - verbose True/False whether to print progress information. Default True. Returns a data frame with the BLAST results. @@ -230,6 +283,12 @@ def blast( else: megablast = "on" + ## Build Entrez query for taxonomy / organism filtering (issue #71) + # Validates taxid(s) and combines with any raw entrez_query before submission. + entrez_query_final = _build_entrez_query(taxid, entrez_query) + if entrez_query_final is not None and verbose: + logger.info(f"Limiting BLAST search to Entrez query: {entrez_query_final}") + ## Submit search # The following code was partly adapted from the Biopython BLAST NCBIWWW project written # by Jeffrey Chang (Copyright 1999), Brad Chapman, and Chris Wroe distributed under the @@ -247,6 +306,7 @@ def blast( ("EXPECT", expect), ("FILTER", low_comp_filt), ("MEGABLAST", megablast), + ("ENTREZ_QUERY", entrez_query_final), ("CMD", "Put"), ] diff --git a/gget/main.py b/gget/main.py index 7a2944b09..cac748611 100644 --- a/gget/main.py +++ b/gget/main.py @@ -892,6 +892,29 @@ def main() -> None: required=False, help="Returns results in csv format instead of json.", ) + parser_blast.add_argument( + "-tax", + "--taxid", + type=str, + nargs="+", + default=None, + required=False, + help=( + "One or more NCBI taxonomy IDs to restrict the search to, e.g. 9606 (human) " + "or 'txid9606'. Multiple IDs (space-separated) are combined with OR." + ), + ) + parser_blast.add_argument( + "-eq", + "--entrez_query", + type=str, + default=None, + required=False, + help=( + "Raw NCBI Entrez query to limit the search, e.g. \"Homo sapiens[ORGN] NOT predicted[Title]\". " + "Combined with --taxid using AND when both are provided." + ), + ) parser_blast.add_argument( "-o", "--out", @@ -3111,6 +3134,8 @@ def main() -> None: megablast=args.megablast_off, verbose=args.quiet, json=args.csv, + entrez_query=args.entrez_query, + taxid=args.taxid, ) # Check if the function returned something diff --git a/tests/fixtures/test_blast.json b/tests/fixtures/test_blast.json index 4c2e3292f..a1955fb26 100644 --- a/tests/fixtures/test_blast.json +++ b/tests/fixtures/test_blast.json @@ -70,5 +70,15 @@ "database": "banana" }, "expected_result": "ValueError" + }, + "test_blast_bad_taxid": { + "type": "error", + "args": { + "sequence": "MSKGEELFTGVVPILVELDGDVNGQKFSVSGEGEGDATYGKL", + "limit": 1, + "taxid": "banana" + }, + "expected_result": "ValueError", + "expected_msg": "Invalid taxid 'banana'. Expected a numeric NCBI taxonomy ID (e.g. 9606 or 'txid9606')." } } diff --git a/tests/test_blast.py b/tests/test_blast.py index 8fea295e6..0f7bbf7e0 100644 --- a/tests/test_blast.py +++ b/tests/test_blast.py @@ -1,7 +1,7 @@ import json import unittest -from gget.gget_blast import blast +from gget.gget_blast import _build_entrez_query, blast from .from_json import from_json @@ -12,3 +12,44 @@ class TestBlast(unittest.TestCase, metaclass=from_json(blast_dict, blast)): pass # all tests are loaded from json + + +class TestBuildEntrezQuery(unittest.TestCase): + """Network-free tests for the taxonomy/Entrez query builder (issue #71).""" + + def test_none(self): + self.assertIsNone(_build_entrez_query()) + self.assertIsNone(_build_entrez_query(taxid=None, entrez_query=None)) + self.assertIsNone(_build_entrez_query(entrez_query=" ")) + + def test_single_taxid_int(self): + self.assertEqual(_build_entrez_query(taxid=9606), "txid9606[ORGN]") + + def test_single_taxid_str_and_prefix(self): + self.assertEqual(_build_entrez_query(taxid="9606"), "txid9606[ORGN]") + self.assertEqual(_build_entrez_query(taxid="txid9606"), "txid9606[ORGN]") + self.assertEqual(_build_entrez_query(taxid="TXID9606"), "txid9606[ORGN]") + + def test_multiple_taxids_or(self): + self.assertEqual( + _build_entrez_query(taxid=[9606, "10090"]), + "(txid9606[ORGN] OR txid10090[ORGN])", + ) + + def test_entrez_query_only(self): + self.assertEqual( + _build_entrez_query(entrez_query="Homo sapiens[ORGN]"), + "Homo sapiens[ORGN]", + ) + + def test_taxid_and_entrez_query_combined(self): + self.assertEqual( + _build_entrez_query(taxid=9606, entrez_query="NOT predicted[Title]"), + "txid9606[ORGN] AND NOT predicted[Title]", + ) + + def test_invalid_taxid_raises(self): + with self.assertRaises(ValueError): + _build_entrez_query(taxid="banana") + with self.assertRaises(ValueError): + _build_entrez_query(taxid=[9606, "banana"]) From 16c294d038966f99100cf9a50ed79bbf379ba580 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:20:40 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- gget/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gget/main.py b/gget/main.py index cac748611..1bd317b7a 100644 --- a/gget/main.py +++ b/gget/main.py @@ -911,7 +911,7 @@ def main() -> None: default=None, required=False, help=( - "Raw NCBI Entrez query to limit the search, e.g. \"Homo sapiens[ORGN] NOT predicted[Title]\". " + 'Raw NCBI Entrez query to limit the search, e.g. "Homo sapiens[ORGN] NOT predicted[Title]". ' "Combined with --taxid using AND when both are provided." ), )