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..b102431fd 100644 --- a/gget/gget_blast.py +++ b/gget/gget_blast.py @@ -24,6 +24,83 @@ 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 +116,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 +138,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 +159,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 +184,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 +337,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 +364,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..e8cc8c691 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,66 @@ 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) + + 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)