From 06e8e1bbb0ad5d5a8ba83f73a3c3e1857d0acf79 Mon Sep 17 00:00:00 2001 From: Elarwei Date: Wed, 24 Jun 2026 22:34:12 +0800 Subject: [PATCH 1/3] feat(blast): expose NCBI web BLAST algorithm parameters (#58) Add word_size, gapcosts, matrix, nucl_reward, nucl_penalty, and perc_identity arguments to `gget blast` (Python API and CLI) so the tool more fully matches the "Algorithm parameters" panel of the NCBI web BLAST app. Values are validated and forwarded to the BLAST URL API (WORD_SIZE, GAPCOSTS, MATRIX, NUCL_REWARD, NUCL_PENALTY, PERC_IDENT). All default to None (server default), preserving existing behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/src/en/blast.md | 20 ++++++ docs/src/en/updates.md | 1 + gget/gget_blast.py | 114 +++++++++++++++++++++++++++++++++ gget/main.py | 55 ++++++++++++++++ tests/fixtures/test_blast.json | 20 ++++++ tests/test_blast.py | 53 ++++++++++++++- 6 files changed, 262 insertions(+), 1 deletion(-) diff --git a/docs/src/en/blast.md b/docs/src/en/blast.md index 414019e25..4a0bff58e 100644 --- a/docs/src/en/blast.md +++ b/docs/src/en/blast.md @@ -25,6 +25,26 @@ 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. +The following arguments expose the NCBI web BLAST ["Algorithm parameters"](https://blast.ncbi.nlm.nih.gov/Blast.cgi) so `gget blast` more fully matches the web app. Each defaults to `None`, in which case the NCBI server default is used. + +`-ws` `--word_size` +Length of the seed words used for the search (`WORD_SIZE`). Default: server default. + +`-gc` `--gapcosts` +Gap costs as `"open extend"`, e.g. `"11 1"` (`GAPCOSTS`). Default: server default. + +`-mx` `--matrix` +Protein scoring matrix (`MATRIX`): one of PAM30, PAM70, PAM250, BLOSUM80, BLOSUM62, BLOSUM50, BLOSUM45, BLOSUM90. Default: server default. + +`-nr` `--nucl_reward` +Reward for a nucleotide match (blastn only) (`NUCL_REWARD`). Default: server default. + +`-np` `--nucl_penalty` +Penalty for a nucleotide mismatch (blastn only) (`NUCL_PENALTY`). Default: server default. + +`-pi` `--perc_identity` +Percent identity cutoff between 0 and 100 (`PERC_IDENT`). Default: server default. + `-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. diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md index 89be22281..a462e3276 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 blast`](blast.md): Exposed the NCBI web BLAST "Algorithm parameters" so `gget blast` more fully matches the web app (resolves [issue 58](https://github.com/scverse/gget/issues/58)). New arguments `word_size`, `gapcosts`, `matrix`, `nucl_reward`, `nucl_penalty`, and `perc_identity` (CLI: `-ws`/`--word_size`, `-gc`/`--gapcosts`, `-mx`/`--matrix`, `-nr`/`--nucl_reward`, `-np`/`--nucl_penalty`, `-pi`/`--perc_identity`) are validated and forwarded to the BLAST URL API (`WORD_SIZE`, `GAPCOSTS`, `MATRIX`, `NUCL_REWARD`, `NUCL_PENALTY`, `PERC_IDENT`). All default to `None` (server default), so existing behavior is unchanged. **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..567ecdfa9 100644 --- a/gget/gget_blast.py +++ b/gget/gget_blast.py @@ -24,6 +24,79 @@ BLAST_URL, ) +# Protein scoring matrices supported by the NCBI web BLAST app +BLAST_MATRICES = [ + "PAM30", + "PAM70", + "PAM250", + "BLOSUM80", + "BLOSUM62", + "BLOSUM50", + "BLOSUM45", + "BLOSUM90", +] + + +def _build_algorithm_params( + word_size: int | None = None, + gapcosts: str | None = None, + matrix: str | None = None, + nucl_reward: int | None = None, + nucl_penalty: int | None = None, + perc_identity: float | None = None, +) -> list[tuple[str, Any]]: + """Validate and assemble extra NCBI BLAST "Algorithm parameters" (issue #58). + + Mirrors the algorithm-parameter panel of the NCBI web BLAST app. Returns a + list of (KEY, value) tuples for the BLAST URL API, omitting any unset + (None) parameter so default server behavior is preserved. + + Args: + - word_size Length of the seed words (WORD_SIZE). Positive integer. + - gapcosts Gap costs as "open extend", e.g. "11 1" (GAPCOSTS). + - matrix Protein scoring matrix, e.g. "BLOSUM62" (MATRIX). + - nucl_reward blastn match reward, positive integer (NUCL_REWARD). + - nucl_penalty blastn mismatch penalty, negative integer (NUCL_PENALTY). + - perc_identity Percent identity cutoff, 0-100 (PERC_IDENT). + + Raises ValueError on invalid values. + """ + params: list[tuple[str, Any]] = [] + + if word_size is not None: + if not isinstance(word_size, int) or isinstance(word_size, bool) or word_size < 2: + raise ValueError(f"Invalid word_size {word_size!r}. Expected an integer >= 2.") + params.append(("WORD_SIZE", word_size)) + + if gapcosts is not None: + parts = str(gapcosts).split() + if len(parts) != 2 or not all(p.lstrip("-").isdigit() for p in parts): + raise ValueError(f"Invalid gapcosts {gapcosts!r}. Expected two integers as 'open extend', e.g. '11 1'.") + params.append(("GAPCOSTS", f"{parts[0]} {parts[1]}")) + + if matrix is not None: + matrix_upper = str(matrix).upper() + if matrix_upper not in BLAST_MATRICES: + raise ValueError(f"Invalid matrix {matrix!r}. Expected one of: {', '.join(BLAST_MATRICES)}") + params.append(("MATRIX", matrix_upper)) + + if nucl_reward is not None: + if not isinstance(nucl_reward, int) or isinstance(nucl_reward, bool): + raise ValueError(f"Invalid nucl_reward {nucl_reward!r}. Expected an integer.") + params.append(("NUCL_REWARD", nucl_reward)) + + if nucl_penalty is not None: + if not isinstance(nucl_penalty, int) or isinstance(nucl_penalty, bool): + raise ValueError(f"Invalid nucl_penalty {nucl_penalty!r}. Expected an integer.") + params.append(("NUCL_PENALTY", nucl_penalty)) + + if perc_identity is not None: + if not isinstance(perc_identity, (int, float)) or isinstance(perc_identity, bool) or not (0 <= perc_identity <= 100): + raise ValueError(f"Invalid perc_identity {perc_identity!r}. Expected a number between 0 and 100.") + params.append(("PERC_IDENT", perc_identity)) + + return params + @overload def blast( @@ -39,6 +112,12 @@ def blast( *, json: Literal[True], save: bool = False, + word_size: int | None = None, + gapcosts: str | None = None, + matrix: str | None = None, + nucl_reward: int | None = None, + nucl_penalty: int | None = None, + perc_identity: float | None = None, ) -> list[dict[str, Any]] | None: ... @@ -55,6 +134,12 @@ def blast( wrap_text: bool = False, json: Literal[False] = False, save: bool = False, + word_size: int | None = None, + gapcosts: str | None = None, + matrix: str | None = None, + nucl_reward: int | None = None, + nucl_penalty: int | None = None, + perc_identity: float | None = None, ) -> pd.DataFrame | None: ... @@ -70,6 +155,12 @@ def blast( wrap_text: bool = False, json: bool = False, save: bool = False, + word_size: int | None = None, + gapcosts: str | None = None, + matrix: str | None = None, + nucl_reward: int | None = None, + nucl_penalty: int | None = None, + perc_identity: float | None = None, ) -> pd.DataFrame | list[dict[str, Any]] | None: """BLAST a nucleotide or amino acid sequence against any BLAST DB. @@ -89,8 +180,20 @@ 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). + - word_size int or None. Length of the seed words used for the search (WORD_SIZE). + Mirrors the "Word size" option of the NCBI web BLAST app. Default: None (server default). + - gapcosts str or None. Gap costs as "open extend" (e.g. "11 1") (GAPCOSTS). Default: None. + - matrix str or None. Protein scoring matrix (e.g. "BLOSUM62") (MATRIX). + One of: PAM30, PAM70, PAM250, BLOSUM80, BLOSUM62, BLOSUM50, BLOSUM45, BLOSUM90. Default: None. + - nucl_reward int or None. Reward for a nucleotide match (blastn only) (NUCL_REWARD). Default: None. + - nucl_penalty int or None. Penalty for a nucleotide mismatch (blastn only) (NUCL_PENALTY). Default: None. + - perc_identity float or None. Percent identity cutoff between 0 and 100 (PERC_IDENT). Default: None. - verbose True/False whether to print progress information. Default True. + The word_size, gapcosts, matrix, nucl_reward, nucl_penalty, and perc_identity + arguments expose the NCBI web BLAST "Algorithm parameters" so gget blast more + fully matches the web app (issue #58). + Returns a data frame with the BLAST results. NCBI server rule: @@ -230,6 +333,16 @@ def blast( else: megablast = "on" + ## Validate and assemble extra NCBI web BLAST "Algorithm parameters" (issue #58) + algorithm_params = _build_algorithm_params( + word_size=word_size, + gapcosts=gapcosts, + matrix=matrix, + nucl_reward=nucl_reward, + nucl_penalty=nucl_penalty, + perc_identity=perc_identity, + ) + ## 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 +360,7 @@ def blast( ("EXPECT", expect), ("FILTER", low_comp_filt), ("MEGABLAST", megablast), + *algorithm_params, ("CMD", "Put"), ] diff --git a/gget/main.py b/gget/main.py index 7a2944b09..232012668 100644 --- a/gget/main.py +++ b/gget/main.py @@ -860,6 +860,55 @@ def main() -> None: required=False, help="float or None. An expect value cutoff. Default 10.0.", ) + parser_blast.add_argument( + "-ws", + "--word_size", + type=int, + default=None, + required=False, + help="Length of the seed words for the search (WORD_SIZE in the NCBI web BLAST app). Default: server default.", + ) + parser_blast.add_argument( + "-gc", + "--gapcosts", + type=str, + default=None, + required=False, + help="Gap costs as 'open extend', e.g. '11 1' (GAPCOSTS). Default: server default.", + ) + parser_blast.add_argument( + "-mx", + "--matrix", + type=str, + default=None, + required=False, + choices=["PAM30", "PAM70", "PAM250", "BLOSUM80", "BLOSUM62", "BLOSUM50", "BLOSUM45", "BLOSUM90"], + help="Protein scoring matrix (MATRIX), e.g. BLOSUM62. Default: server default.", + ) + parser_blast.add_argument( + "-nr", + "--nucl_reward", + type=int, + default=None, + required=False, + help="Reward for a nucleotide match (blastn only) (NUCL_REWARD). Default: server default.", + ) + parser_blast.add_argument( + "-np", + "--nucl_penalty", + type=int, + default=None, + required=False, + help="Penalty for a nucleotide mismatch (blastn only) (NUCL_PENALTY). Default: server default.", + ) + parser_blast.add_argument( + "-pi", + "--perc_identity", + type=float, + default=None, + required=False, + help="Percent identity cutoff between 0 and 100 (PERC_IDENT). Default: server default.", + ) parser_blast.add_argument( "-lcf", "--low_comp_filt", @@ -3111,6 +3160,12 @@ def main() -> None: megablast=args.megablast_off, verbose=args.quiet, json=args.csv, + word_size=args.word_size, + gapcosts=args.gapcosts, + matrix=args.matrix, + nucl_reward=args.nucl_reward, + nucl_penalty=args.nucl_penalty, + perc_identity=args.perc_identity, ) # Check if the function returned something diff --git a/tests/fixtures/test_blast.json b/tests/fixtures/test_blast.json index 4c2e3292f..c29ab81f7 100644 --- a/tests/fixtures/test_blast.json +++ b/tests/fixtures/test_blast.json @@ -70,5 +70,25 @@ "database": "banana" }, "expected_result": "ValueError" + }, + "test_blast_bad_matrix": { + "type": "error", + "args": { + "sequence": "MSKGEELFTGVVPILVELDGDVNGQKFSVSGEGEGDATYGKL", + "limit": 1, + "matrix": "banana" + }, + "expected_result": "ValueError", + "expected_msg": "Invalid matrix 'banana'. Expected one of: PAM30, PAM70, PAM250, BLOSUM80, BLOSUM62, BLOSUM50, BLOSUM45, BLOSUM90" + }, + "test_blast_bad_word_size": { + "type": "error", + "args": { + "sequence": "MSKGEELFTGVVPILVELDGDVNGQKFSVSGEGEGDATYGKL", + "limit": 1, + "word_size": 1 + }, + "expected_result": "ValueError", + "expected_msg": "Invalid word_size 1. Expected an integer >= 2." } } diff --git a/tests/test_blast.py b/tests/test_blast.py index 8fea295e6..3af48d545 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_algorithm_params, blast from .from_json import from_json @@ -12,3 +12,54 @@ class TestBlast(unittest.TestCase, metaclass=from_json(blast_dict, blast)): pass # all tests are loaded from json + + +class TestBuildAlgorithmParams(unittest.TestCase): + """Network-free tests for the web-BLAST algorithm parameters (issue #58).""" + + def test_empty(self): + self.assertEqual(_build_algorithm_params(), []) + + def test_all_params(self): + params = _build_algorithm_params( + word_size=11, + gapcosts="11 1", + matrix="blosum62", + nucl_reward=1, + nucl_penalty=-2, + perc_identity=90.0, + ) + self.assertEqual( + params, + [ + ("WORD_SIZE", 11), + ("GAPCOSTS", "11 1"), + ("MATRIX", "BLOSUM62"), + ("NUCL_REWARD", 1), + ("NUCL_PENALTY", -2), + ("PERC_IDENT", 90.0), + ], + ) + + def test_matrix_uppercased(self): + self.assertEqual(_build_algorithm_params(matrix="pam30"), [("MATRIX", "PAM30")]) + + def test_invalid_word_size(self): + with self.assertRaises(ValueError): + _build_algorithm_params(word_size=1) + with self.assertRaises(ValueError): + _build_algorithm_params(word_size="big") + + def test_invalid_gapcosts(self): + with self.assertRaises(ValueError): + _build_algorithm_params(gapcosts="11") + with self.assertRaises(ValueError): + _build_algorithm_params(gapcosts="a b") + + def test_invalid_matrix(self): + with self.assertRaises(ValueError): + _build_algorithm_params(matrix="banana") + + def test_invalid_perc_identity(self): + with self.assertRaises(ValueError): + _build_algorithm_params(perc_identity=150) From f0f3c3143817ed759772387b59a41ab65529abac 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:22:42 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- gget/gget_blast.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gget/gget_blast.py b/gget/gget_blast.py index 567ecdfa9..b102431fd 100644 --- a/gget/gget_blast.py +++ b/gget/gget_blast.py @@ -91,7 +91,11 @@ def _build_algorithm_params( params.append(("NUCL_PENALTY", nucl_penalty)) if perc_identity is not None: - if not isinstance(perc_identity, (int, float)) or isinstance(perc_identity, bool) or not (0 <= perc_identity <= 100): + if ( + not isinstance(perc_identity, (int, float)) + or isinstance(perc_identity, bool) + or not (0 <= perc_identity <= 100) + ): raise ValueError(f"Invalid perc_identity {perc_identity!r}. Expected a number between 0 and 100.") params.append(("PERC_IDENT", perc_identity)) From 6e329681d7042c8319c560448595d128a8729827 Mon Sep 17 00:00:00 2001 From: Elarwei Date: Thu, 25 Jun 2026 00:07:46 +0800 Subject: [PATCH 3/3] test(blast): cover remaining lines for codecov (#58) Add unit tests for the invalid nucl_reward and nucl_penalty branches of _build_algorithm_params so the helper is fully covered network-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_blast.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_blast.py b/tests/test_blast.py index 3af48d545..e8cc8c691 100644 --- a/tests/test_blast.py +++ b/tests/test_blast.py @@ -63,3 +63,15 @@ def test_invalid_matrix(self): def test_invalid_perc_identity(self): with self.assertRaises(ValueError): _build_algorithm_params(perc_identity=150) + + def test_invalid_nucl_reward(self): + with self.assertRaises(ValueError): + _build_algorithm_params(nucl_reward="two") + with self.assertRaises(ValueError): + _build_algorithm_params(nucl_reward=True) + + def test_invalid_nucl_penalty(self): + with self.assertRaises(ValueError): + _build_algorithm_params(nucl_penalty="minus three") + with self.assertRaises(ValueError): + _build_algorithm_params(nucl_penalty=False)