Repository: pachterlab/gget (language: python)

Issue:
fix cbio search test



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17158cb4..673b86fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python: ['3.9', '3.10']
+        python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
         os: ['ubuntu-22.04']
     name: Test on Python ${{ matrix.python }}
     steps:
diff --git a/.gitignore b/.gitignore
index 83650229..0645fb62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ share/python-wheels/
 .installed.cfg
 *.egg
 MANIFEST
+/gget/elm_files/
 
 # PyInstaller
 #  Usually these files are written by a python script from a template
@@ -130,3 +131,12 @@ dmypy.json
 
 # Pyre type checker
 .pyre/
+
+# PyCharm
+/.idea/
+
+# mdbook documentation
+/docs/book/
+
+# Scratch directory
+/scratch/
diff --git a/README.md b/README.md
index 52235000..b504705c 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ conda install -c bioconda gget
 
 For use in Jupyter Lab / Google Colab:
 ```python
+# Python
 import gget
 ```
 # [🔗 Manual](https://pachterlab.github.io/gget) 
diff --git a/dev-requirements.txt b/dev-requirements.txt
index ee7df76c..3eace89b 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -3,3 +3,4 @@ pytest>=7.0.0
 openai<=0.28.1
 cellxgene-census
 parameterized==0.9.0
+bravado==11.0.3
diff --git a/docs/assets/gget_cbio_figure_1.png b/docs/assets/gget_cbio_figure_1.png
new file mode 100644
index 00000000..f470e27c
Binary files /dev/null and b/docs/assets/gget_cbio_figure_1.png differ
diff --git a/docs/assets/gget_cbio_figure_2.png b/docs/assets/gget_cbio_figure_2.png
new file mode 100644
index 00000000..2171e0a1
Binary files /dev/null and b/docs/assets/gget_cbio_figure_2.png differ
diff --git a/docs/assets/gget_cbio_figure_3.png b/docs/assets/gget_cbio_figure_3.png
new file mode 100644
index 00000000..f770e5c9
Binary files /dev/null and b/docs/assets/gget_cbio_figure_3.png differ
diff --git a/docs/assets/gget_cbio_figure_4.png b/docs/assets/gget_cbio_figure_4.png
new file mode 100644
index 00000000..c71c4a5c
Binary files /dev/null and b/docs/assets/gget_cbio_figure_4.png differ
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
index 527abe85..ef091474 100644
--- a/docs/src/SUMMARY.md
+++ b/docs/src/SUMMARY.md
@@ -26,6 +26,9 @@
 * [gget search](en/search.md)  
 * [gget setup](en/setup.md)  
 * [gget seq](en/seq.md)  
+* [gget opentargets](en/opentargets.md)  
+* [gget cbio](en/cbio.md)  
+* [gget bgee](en/bgee.md)
 
 ---
 
diff --git a/docs/src/en/bgee.md b/docs/src/en/bgee.md
new file mode 100644
index 00000000..4f9d1834
--- /dev/null
+++ b/docs/src/en/bgee.md
@@ -0,0 +1,79 @@
+> 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 bgee 🐝
+Fetch orthology and gene expression data from [Bgee](https://www.bgee.org/) using Ensembl IDs.   
+Return format: JSON/CSV (command-line) or data frame (Python).
+
+> If you are specifically interested in human gene expression data, consider using [gget opentargets](./opentargets.md) instead.
+> **gget bgee** has less data, but supports more species.
+
+**Positional argument**  
+`ens_id`  
+Ensembl gene ID, e.g. ENSG00000169194 or ENSSSCG00000014725.
+
+**Required arguments**  
+`-t` `--type`  
+Type of data to fetch. Options: `orthologs`, `expression`.  
+
+**Optional arguments**  
+`-o` `--out`    
+Path to the JSON file the results will be saved in, e.g. path/to/directory/results.json. Default: Standard out.
+
+**Flags**   
+`-csv` `--csv`  
+Command-line only. Returns the output in CSV format, instead of JSON 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
+
+**Get orthologs for a gene**
+
+```bash
+gget bgee ENSSSCG00000014725 -t orthologs
+```
+
+```python
+import gget
+gget.bgee("ENSSSCG00000014725", type="orthologs")
+```
+
+&rarr; Returns orthologs for the gene with Ensembl ID ENSSSCG00000014725.
+
+| gene_id            | gene_name    | species_id | genus   | species    |
+|--------------------|--------------|------------|---------|------------|
+| 734881             | hbb1         | 8355       | Xenopus | laevis     |
+| ENSFCAG00000038029 | LOC101098159 | 9685       | Felis   | catus      |
+| ENSBTAG00000047356 | LOC107131172 | 9913       | Bos     | taurus     |
+| ENSOARG00000019163 | LOC101105437 | 9940       | Ovis    | aries      |
+| ENSXETG00000025667 | hbg1         | 8364       | Xenopus | tropicalis |
+| ...                | ...          | ...        | ...     | ...        |
+
+<br/><br/>
+
+**Get gene expression data for a gene**
+
+```bash
+gget bgee ENSSSCG00000014725 -t expression
+```
+```python
+import gget
+gget.bgee("ENSSSCG00000014725", type="expression")
+```
+
+&rarr; Returns gene expression data for the gene with Ensembl ID ENSSSCG00000014725.
+
+| anat_entity_id | anat_entity_name            | score | score_confidence | expression_state |
+|----------------|-----------------------------|-------|------------------|------------------|
+| UBERON:0000178 | blood                       | 99.98 | high             | expressed        |
+| UBERON:0002106 | spleen                      | 99.96 | high             | expressed        |
+| UBERON:0002190 | subcutaneous adipose tissue | 99.70 | high             | expressed        |
+| UBERON:0005316 | endocardial endothelium     | 99.61 | high             | expressed        |
+| UBERON:0002107 | liver                       | 99.27 | high             | expressed        |
+| ...            | ...                         | ...   | ...              | ...              |
+
+    
+#### [More examples](https://github.com/pachterlab/gget_examples)
diff --git a/docs/src/en/cbio.md b/docs/src/en/cbio.md
new file mode 100644
index 00000000..d663548f
--- /dev/null
+++ b/docs/src/en/cbio.md
@@ -0,0 +1,220 @@
+> 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 cbio 📖
+Plot cancer genomics heatmaps using data from [cBioPortal](https://www.cbioportal.org/) using Ensembl IDs or gene names.
+
+**Positional argument**  
+`subcommand`  
+Either `search` or `plot`
+
+### `search` subcommand (Python: `gget.cbio_search`)
+Find cBioPortal study IDs by keyword.   
+Return format: JSON (command-line) or string list (Python).     
+**Note: This does not return studies with mixed cancer types.**
+
+**Positional argument**     
+`keywords`  
+Space-separated list of keywords to search for, e.g. <code>breast&nbsp;lung</code>.
+
+### `plot` subcommand (Python: `gget.cbio_plot`)
+Plot cancer genomics heatmaps using data from cBioPortal.
+Return format: PNG (command-line and Python)
+
+**Required arguments**  
+`-s` `--study_ids`    
+Space-separated list of cBioPortal study IDs, e.g. <code>msk_impact_2017&nbsp;egc_msk_2023</code>.
+
+`-g` `--genes`  
+Space-separated list of gene names or Ensembl IDs, e.g. <code>NOTCH3&nbsp;ENSG00000108375</code>.
+
+**Optional arguments**  
+`-st` `--stratification`    
+Column to stratify the data by. Default: `tissue`.  
+Options:
+- `tissue`
+- `cancer_type`
+- `cancer_type_detailed`
+- `study_id`
+- `sample`
+
+`-vt` `--variation_type`    
+Type of variation to plot. Default: `mutation_occurrences`.     
+Options:
+- `mutation_occurrences`
+- `cna_nonbinary` Note: `stratification` must be `sample` for this option.
+- `sv_occurrences`
+- `cna_occurrences`
+- `Consequence` Note: `stratification` must be `sample` for this option.
+
+`-f` `--filter`     
+Filter the data by a specific value in a specific column, e.g. `study_id:msk_impact_2017`   
+Python: `filter=(column, value)`
+
+`-dd` `--data_dir`  
+Directory to store data files. Default: `./gget_cbio_cache`.
+
+`-fd` `--figure_dir`    
+Directory to output figures. Default: `./gget_cbio_figures`.
+
+`-fn` `--filename`  
+Filename for the output figure, relative to `figure_dir`. Default: auto-generated    
+Python: `figure_filename`
+
+`-t` `--title`  
+Title for the output figure. Default: auto-generated  
+Python: `figure_title`
+
+`-dpi` `--dpi`  
+DPI of the output figure. Default: 100.
+
+**Flags**   
+
+`-q` `--quiet`   
+Command-line only. Prevents progress information from being displayed.  
+Python: Use `verbose=False` to prevent progress information from being displayed. 
+
+`-nc` `--no_confirm`     
+Command-line only. Skip download confirmation prompts.  
+Python: Use `confirm_download=True` to enable download confirmation prompts.
+
+`-sh` `--show`  
+Show the plot in a window (automatic in Jupyter notebooks).
+  
+  
+### Examples
+
+**Find all cBioPortal studies with cancer types matching specific keywords:**   
+```bash
+gget cbio search esophag ovary ovarian
+```
+```python
+# Python
+import gget
+gget.cbio_search('esophag', 'ovary', 'ovarian')
+```
+&rarr; Returns a list of studies with cancer types matching the keywords `esophag`, `ovary`, or `ovarian`.
+
+```
+['egc_tmucih_2015', 'egc_msk_2017', ..., 'msk_spectrum_tme_2022']
+```
+
+<br/><br/>
+
+**Plot a heatmap of mutation occurrences for specific genes in a specific study:**   
+```bash
+gget cbio plot \
+    -s msk_impact_2017 \
+    -g AKT1 ALK FLT4 MAP3K1 MLL2 MLL3 NOTCH3 NOTCH4 PDCD1 RNF43 \
+    -st tissue \
+    -vt mutation_occurrences \
+    -dpi 200 \
+    -y
+```
+```python
+# Python
+import gget
+gget.cbio_plot(
+    ['msk_impact_2017'],
+    ['AKT1', 'ALK', 'FLT4', 'MAP3K1', 'MLL2', 'MLL3', 'NOTCH3', 'NOTCH4', 'PDCD1', 'RNF43'],
+    stratification='tissue',
+    variation_type='mutation_occurrences',
+    dpi=200
+)
+```
+
+&rarr; Saves a heatmap of mutation occurrences for the specified genes in the specified study to `./gget_cbio_figures/Heatmap_tissue.png`.
+
+![Heatmap](https://raw.githubusercontent.com/pachterlab/gget/f6b4465eecae0f07c71558f8f6e7b60d36c8d41a/docs/assets/gget_cbio_figure_1.png)
+
+<br/><br/>
+
+**Plot a heatmap of mutation types for specific genes in a specific study:**   
+```bash
+gget cbio plot \
+    -s msk_impact_2017 \
+    -g AKT1 ALK FLT4 MAP3K1 MLL2 MLL3 NOTCH3 NOTCH4 PDCD1 RNF43 \
+    -st sample \
+    -vt Consequence \
+    -dpi 200 \
+    -y
+```
+```python
+# Python
+import gget
+gget.cbio_plot(
+    ['msk_impact_2017'],
+    ['AKT1', 'ALK', 'FLT4', 'MAP3K1', 'MLL2', 'MLL3', 'NOTCH3', 'NOTCH4', 'PDCD1', 'RNF43'],
+    stratification='sample',
+    variation_type='Consequence',
+    dpi=200,
+)
+```
+
+&rarr; Saves a heatmap of mutation types for the specified genes in the specified study to `./gget_cbio_figures/Heatmap_sample.png`.
+
+![Heatmap](https://raw.githubusercontent.com/pachterlab/gget/f6b4465eecae0f07c71558f8f6e7b60d36c8d41a/docs/assets/gget_cbio_figure_2.png)
+
+<br/><br/>
+
+**Plot a heatmap of mutation types for specific genes in a specific study, filtering by tissue:**
+```bash
+gget cbio plot \
+    -s msk_impact_2017 \
+    -g AKT1 ALK FLT4 MAP3K1 MLL2 MLL3 NOTCH3 NOTCH4 PDCD1 RNF43 \
+    -st sample \
+    -vt Consequence \
+    -f tissue:intestine \
+    -dpi 200 \
+    -y
+```
+```python
+# Python
+import gget
+gget.cbio_plot(
+    ['msk_impact_2017'],
+    ['AKT1', 'ALK', 'FLT4', 'MAP3K1', 'MLL2', 'MLL3', 'NOTCH3', 'NOTCH4', 'PDCD1', 'RNF43'],
+    stratification='sample',
+    variation_type='Consequence',
+    filter=('tissue', 'intestine'),
+    dpi=200,
+)
+```
+
+&rarr; Saves a heatmap of mutation types for the specified genes in the specified study, filtered by tissue, to `./gget_cbio_figures/Heatmap_sample_intestine.png`.
+
+![Heatmap](https://raw.githubusercontent.com/pachterlab/gget/ef0e8334d87214c17cbac70713028e7b823bba49/docs/assets/gget_cbio_figure_3.png)
+
+<br/><br/>
+
+**Plot a heatmap with a custom title and filename:**
+```bash
+gget cbio plot \
+    -s msk_impact_2017 \
+    -g AKT1 ALK FLT4 MAP3K1 MLL2 MLL3 NOTCH3 NOTCH4 PDCD1 RNF43 \
+    -st sample \
+    -vt Consequence \
+    -f tissue:intestine \
+    -dpi 200 \
+    -y \
+    -t "Intestinal Mutations" \
+    -fn intestinal_mutations.png
+```
+```python
+# Python
+import gget
+gget.cbio_plot(
+    ['msk_impact_2017'],
+    ['AKT1', 'ALK', 'FLT4', 'MAP3K1', 'MLL2', 'MLL3', 'NOTCH3', 'NOTCH4', 'PDCD1', 'RNF43'],
+    stratification='sample',
+    variation_type='Consequence',
+    filter=('tissue', 'intestine'),
+    dpi=200,
+    figure_title='Intestinal Mutations',
+    figure_filename='intestinal_mutations.png'
+)
+```
+
+&rarr; Saves a heatmap of mutation types for the specified genes in the specified study, filtered by tissue, with the title "Intestinal Mutations" to `./gget_cbio_figures/intestinal_mutations.png`.
+
+![Heatmap](https://raw.githubusercontent.com/pachterlab/gget/b32c01efefd55d37c19034ce96a86826e30ae3e5/docs/assets/gget_cbio_figure_4.png)
+    
+#### [More examples](https://github.com/pachterlab/gget_examples)
diff --git a/docs/src/en/cosmic.md b/docs/src/en/cosmic.md
index 32a8dccb..743bd4de 100644
--- a/docs/src/en/cosmic.md
+++ b/docs/src/en/cosmic.md
@@ -44,8 +44,8 @@ TURNS OFF creation of a modified version of the database for use with gget mutat
 Python: `gget_mutate` is True by default. Set `gget_mutate=False` to disable.  
 
 **Optional arguments (for downloading COSMIC databases)**  
-`-mc` `--mutation_class`
-'cancer' (default), 'cell_line', 'census', 'resistance', 'screen', or 'cancer_example'  
+`-mc` `--mutation_class`  
+'cancer' (default), 'cell_line', 'census', 'resistance', 'genome_screen', 'targeted_screen', or 'cancer_example'  
 Type of COSMIC database to download.  
 
 `-cv` `--cosmic_version`  
@@ -54,6 +54,12 @@ Version of the COSMIC database. Default: None -> Defaults to latest version.
 `-gv` `--grch_version`  
 Version of the human GRCh reference genome the COSMIC database was based on (37 or 38). Default: 37  
 
+`--keep_genome_info`
+Whether to keep genome information in the modified database for use with gget mutate. Default: False
+
+`--remove_duplicates`
+Whether to remove duplicate rows from the modified database for use with gget mutate. Default: False
+
 **Optional arguments (general)**  
 `-o` `--out`   
 Path to the file (or folder when downloading databases with the `download_cosmic` flag) the results will be saved in, e.g. 'path/to/results.json'.  
diff --git a/docs/src/en/enrichr.md b/docs/src/en/enrichr.md
index 66448cdb..3cf6bd3d 100644
--- a/docs/src/en/enrichr.md
+++ b/docs/src/en/enrichr.md
@@ -1,6 +1,6 @@
 > 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 enrichr 💰
-Perform an enrichment analysis on a list of genes using [Enrichr](https://maayanlab.cloud/Enrichr/).  
+Perform an enrichment analysis on a list of genes using [Enrichr](https://maayanlab.cloud/Enrichr/) or [modEnrichr](https://maayanlab.cloud/modEnrichr/).  
 Return format: JSON (command-line) or data frame/CSV (Python).
   
 **Positional argument**  
@@ -10,7 +10,7 @@ Alternatively: use flag `--ensembl` to input a list of Ensembl gene IDs, e.g. EN
 
 **Other required arguments**  
 `-db` `--database`  
-Database to use as reference for the enrichment analysis.  
+Database database to use as reference for the enrichment analysis.  
 Supports any database listed [here](https://maayanlab.cloud/Enrichr/#libraries) under 'Gene-set Library' or one of the following shortcuts:  
 'pathway'       (KEGG_2021_Human)  
 'transcription'     (ChEA_2016)  
@@ -19,7 +19,22 @@ Supports any database listed [here](https://maayanlab.cloud/Enrichr/#libraries)
 'celltypes'      (PanglaoDB_Augmented_2021)  
 'kinase_interactions'   (KEA_2015)  
   
+NOTE: database shortcuts are not supported for species other than 'human' or 'mouse'. Click on the species databases listed below under `species` to view a list of databases available for each species.  	  
+  
 **Optional arguments**  
+`-s` `--species`  
+Species to use as reference for the enrichment analysis. (Default: human)
+Options:
+
+| Species  | Database list                                                     |
+|----------|-------------------------------------------------------------------|
+| `human`  | [Enrichr](https://maayanlab.cloud/Enrichr/#libraries)             |
+| `mouse`  | [Equivalent to human](https://maayanlab.cloud/Enrichr/#libraries) |
+| `fly`    | [FlyEnrichr](https://maayanlab.cloud/FlyEnrichr/#stats)       |
+| `yeast`  | [YeastEnrichr](https://maayanlab.cloud/YeastEnrichr/#stats)   |
+| `worm`   | [WormEnrichr](https://maayanlab.cloud/WormEnrichr/#stats)     |
+| `fish`   | [FishEnrichr](https://maayanlab.cloud/FishEnrichr/#stats)     |
+
 `-bkg_l` `--background_list`  
 Short names (gene symbols) of background genes to perform enrichment analysis on, e.g. NSUN3 POLRMT NLRX1.  
 Alternatively: use flag `--ensembl_background` to input a list of Ensembl gene IDs.  
diff --git a/docs/src/en/mutate.md b/docs/src/en/mutate.md
index 598b5afa..f175da88 100644
--- a/docs/src/en/mutate.md
+++ b/docs/src/en/mutate.md
@@ -3,13 +3,12 @@
 Takes in nucleotide sequences and mutations (in [standard mutation annotation](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1867422/) and returns mutated versions of the input sequences according to the provided mutations.  
 Return format: Saves mutated sequences in FASTA format (or returns a list containing the mutated sequences if `out=None`).  
 
-This module was co-written by [Joseph Rich](https://github.com/josephrich98).
+This module was written by [Joseph Rich](https://github.com/josephrich98).
 
 **Positional argument**  
 `sequences`   
 Path to the FASTA file containing the sequences to be mutated, e.g., 'path/to/seqs.fa'.  
 Sequence identifiers following the '>' character must correspond to the identifiers in the seq_ID column of `mutations`.  
-NOTE: Only the string following the '>' until the first space or dot will be used as a sequence identifier. - Version numbers of Ensembl IDs will be ignored.  
 
 Example format of the FASTA file:  
 ```
@@ -21,12 +20,15 @@ AGATCGCTAG
 
 Alternatively: Input sequence(s) as a string or list, e.g. 'AGCTAGCT'.
 
+NOTE: Only the letters until the first space or dot will be used as sequence identifiers - Version numbers of Ensembl IDs will be ignored.  
+NOTE: When the `sequences` input is a genome fasta file, also see the `gtf` argument below.
+
 **Required arguments**  
 `-m` `--mutations`  
-Path to the csv or tsv file (e.g., 'path/to/mutations.csv') or data frame (DataFrame object) containing information about the mutations in the following format (the 'notes' column is not necessary):  
+Path to the csv or tsv file (e.g., 'path/to/mutations.csv') or data frame (DataFrame object) containing information about the mutations in the following format (the 'notes' and 'mut_ID' columns are optional):  
 
 | mutation         | mut_ID | seq_ID | notes |
-|------------------|--------|--------|-|
+|------------------|--------|--------|-------|
 | c.2C>T           | mut1   | seq1   | -> Apply mutation 1 to sequence 1 |
 | c.9_13inv        | mut2   | seq2   | -> Apply mutation 2 to sequence 2 |
 | c.9_13inv        | mut2   | seq4   | -> Apply mutation 2 to sequence 4 |
@@ -39,28 +41,80 @@ Path to the csv or tsv file (e.g., 'path/to/mutations.csv') or data frame (DataF
 
 Alternatively: Input mutation(s) as a string or list, e.g., 'c.2C>T'.  
 If a list is provided, the number of mutations must equal the number of input sequences.  
+
 For use from the terminal (bash): Enclose individual mutation annotations in quotation marks to prevent parsing errors.  
 
-**Optional arguments**  
+**Optional input-related arguments**  
+`-mc` `--mut_column`  
+Name of the column containing the mutations to be performed in `mutations`. Default: 'mutation'.  
+
+`-sic` `--seq_id_column`  
+Name of the column containing the IDs of the sequences to be mutated in `mutations`. Default: 'seq_ID'.
+
+`-mic` `--mut_id_column`  
+Name of the column containing the IDs of each mutation in `mutations`. Default: Same as `mut_column`.
+
+`-gtf` `--gtf`  
+Path to a .gtf file. When providing a genome fasta file as input for 'sequences', you can provide a .gtf file here and the input sequences will be defined according to the transcript boundaries, e.g. 'path/to/genome_annotation.gtf'. Default: None
+
+`-gtic` `--gtf_transcript_id_column`  
+Column name in the input `mutations` file containing the transcript ID. In this case, column `seq_id_column` should contain the chromosome number.  
+Required when `gtf` is provided. Default: None  
+  
+**Optional mutant sequence generation/filtering arguments**  
 `-k` `--k`  
 Length of sequences flanking the mutation. Default: 30.  
 If k > total length of the sequence, the entire sequence will be kept.  
 
-`-mc` `--mut_column`  
-Name of the column containing the mutations to be performed in `mutations`. Default: 'mutation'.  
+`-msl` `--min_seq_len`  
+Minimum length of the mutant output sequence, e.g. 100. Mutant sequences smaller than this will be dropped. Default: None
 
-`-mic` `--mut_id_column`  
-Name of the column containing the IDs of each mutation in `mutations`. Default: 'mut_ID'.  
+`-ma` `--max_ambiguous`                
+Maximum number of 'N' (or 'n') characters allowed in the output sequence, e.g. 10. Default: None (no ambiguous character filter will be applied)
 
-`-sic` `--seq_id_column`  
-Name of the column containing the IDs of the sequences to be mutated in `mutations`. Default: 'seq_ID'.  
+**Optional mutant sequence generation/filtering flags**  
+`-ofr` `--optimize_flanking_regions`  
+Removes nucleotides from either end of the mutant sequence to ensure (when possible) that the mutant sequence does not contain any k-mers also found in the wildtype/input sequence. 
+
+`-rswk` `--remove_seqs_with_wt_kmers`  
+Removes output sequences where at least one k-mer is also present in the wildtype/input sequence in the same region.  
+When used with `--optimize_flanking_regions`, only sequences for which a wildtpye kmer is still present after optimization will be removed.
+
+`-mio` `--merge_identical_off`          
+Do not merge identical mutant sequences in the output (by default, identical sequences will be merged by concatenating the sequence headers for all identical sequences).
+
+**Optional arguments to generate additional output**   
+This output is activated using the  `--update_df` flag and will be stored in a copy of the `mutations` DataFrame.  
+
+`-udf_o` `--update_df_out`               
+Path to output csv file containing the updated DataFrame, e.g. 'path/to/mutations_updated.csv'. Only valid when used with `--update_df`.  
+Default: None -> the new csv file will be saved in the same directory as the `mutations` DataFrame with appendix '_updated'  
+
+`-ts` `--translate_start`              
+(int or str) The position in the input nucleotide sequence to start translating, e.g. 5. If a string is provided, it should correspond to a column name in `mutations` containing the open reading frame start positions for each sequence/mutation. Only valid when used with `--translate`.  
+Default: translates from the beginning of each sequence  
 
+`-te` `--translate_end`                
+(int or str) The position in the input nucleotide sequence to end translating, e.g. 35. If a string is provided, it should correspond to a column name in `mutations` containing the open reading frame end positions for each sequence/mutation. Only valid when used with `--translate`.  
+Default: translates until the end of each sequence  
+
+**Optional flags to modify additional output**  
+`-udf` `--update_df`   
+Updates the input `mutations` DataFrame to include additional columns with the mutation type, wildtype nucleotide sequence, and mutant nucleotide sequence (only valid if `mutations` is a .csv or .tsv file).  
+
+`-sfs` `--store_full_sequences`         
+Includes the complete wildtype and mutant sequences in the updated `mutations` DataFrame (not just the sub-sequence with k-length flanks). Only valid when used with `--update_df`.   
+
+`-tr` `--translate`                  
+Adds additional columns to the updated `mutations` DataFrame containing the wildtype and mutant amino acid sequences. Only valid when used with `--store_full_sequences`.   
+                                  
+**Optional general arguments**  
 `-o` `--out`   
 Path to output FASTA file containing the mutated sequences, e.g., 'path/to/output_fasta.fa'.  
 Default: None -> returns a list of the mutated sequences to standard out.    
-The identifiers (following the '>') of the mutated sequences in the output FASTA will be '>[seq_ID]_[mut_ID]'.  
+The identifiers (following the '>') of the mutated sequences in the output FASTA will be '>[seq_ID]_[mut_ID]'. 
 
-**Flags**  
+**Optional general flags**  
 `-q` `--quiet`   
 Command-line only. Prevents progress information from being displayed.  
 Python: Use `verbose=False` to prevent progress information from being displayed.  
@@ -104,3 +158,41 @@ gget mutate ATCGCTAAGCT TAGCTA -m 'c.1_3inv' -k 3
 gget.mutate(["ATCGCTAAGCT", "TAGCTA"], "c.1_3inv", k=3)
 ```
 &rarr; Returns ['CTAGCT', 'GATCTA'].  
+
+
+<br/><br/>
+
+**Pass in the genome mutation information as a `mutations` CSV (by having `seq_id_column` contain chromosome information, and `mut_column` contain mutation information with respect to genome coordinates), as well as the genome as the `sequences` file. Respect the transcript boundaries by merging in transcript start and end positions with the `gtf` argument set to the path to the gtf file, as well as the `gtf_transcript_id_column` specifying the name of the column containing transcript ID's corresponding to the gtf in the input `mutations` file. Optimize the length to maximize length while maintaining specificity of all k-mers with the `optimize_flanking_regions` argument. Create a CSV file with updated information including mutation type and output sequences with the `update_df argument`, stored to the path designated by `update_df_out`. Store the full sequences (i.e., the mutation in the context of the entire sequence of the corresponding `sequences` fasta file entry) with the `store_full_sequences` argument. Store translated amino acid sequences for each full mutation with the `translate` argument, with `translate_start` and `translate_end` specifying the names of the column in the input `mutations` file that contain the start and end sequence positions of the open reading frame, respectively:**  
+```bash
+gget mutate genome_reference.fa -m mutations_input.csv -o mut_fasta.fa -k 4 -sic Chromosome -mic Mutation -gtf genome_annotation.gtf -gtic Ensembl_Transcript_ID -ofr -update_df -udf_o mutations_updated.csv -sfs -tr -ts Translate_Start -te Translate_End
+```
+```python
+# Python
+gget.mutate(sequences="genome_reference.fa", mutations="mutations_input.csv", out="mut_fasta.fa", k=4, seq_id_column="Chromosome", mut_column="Mutation", gtf="genome_annotation.gtf", gtf_transcript_id_column="Ensembl_Transcript_ID", optimize_flanking_regions=True, update_df=True, update_df_out="mutations_updated.csv", store_full_sequences=True, translate=True, translate_start="Translate_Start", translate_end="Translate_End")
+```
+&rarr; Takes as input 'mutations_input.csv' file containing: 
+```
+| Chromosome | Mutation          | Ensembl_Transcript_ID  | Translate_Start | Translate_End |
+|------------|-------------------|------------------------|-----------------|---------------|
+| 1          | g.224411A>C       | ENST00000193812        | 0               | 100           |
+| 8          | g.25111del        | ENST00000174411        | 0               | 294           |
+| X          | g.1011_1012insAA  | ENST00000421914        | 9               | 1211          |
+``` 
+&rarr; Saves 'mut_fasta.fa' file containing: 
+```
+>1:g.224411A>C  
+TGCTCTGCT  
+>8:g.25111del  
+GAGTCGAT
+>X:g.1011_1012insAA
+TTAGAACTT
+``` 
+&rarr; Saves 'mutations_updated.csv' file containing: 
+```
+
+| Chromosome | Mutation          | Ensembl_Transcript_ID  | mutation_type | wt_sequence | mutant_sequence | wt_sequence_full  | mutant_sequence_full | wt_sequence_aa_full | mutant_sequence_aa_full |
+|------------|-------------------|------------------------|---------------|-------------|-----------------|-------------------|----------------------|---------------------|-------------------------|
+| 1          | g.224411A>C       | ENSMUST00000193812     | Substitution  | TGCTATGCT   | TGCTCTGCT       | ...TGCTATGCT...   | ...TGCTCTGCT...      | ...CYA...           | ...CSA...               |
+| 8          | g.25111del        | ENST00000174411        | Deletion      | GAGTCCGAT   | GAGTCGAT        | ...GAGTCCGAT...   | ...GAGTCGAT...       | ...ESD...           | ...ES...                |
+| X          | g.1011_1012insAA  | ENST00000421914        | Insertion     | TTAGCTT     | TTAGAACTT       | ...TTAGCTT...     | ...TTAGAACTT...      | ...A...             | ...EL...                |
+
diff --git a/docs/src/en/opentargets.md b/docs/src/en/opentargets.md
new file mode 100644
index 00000000..676fddf8
--- /dev/null
+++ b/docs/src/en/opentargets.md
@@ -0,0 +1,270 @@
+> 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 opentargets 🎯
+Fetch associated diseases or drugs from [OpenTargets](https://platform.opentargets.org/) using Ensembl IDs.   
+Return format: JSON/CSV (command-line) or data frame (Python).
+
+**Positional argument**  
+`ens_id`  
+Ensembl gene ID, e.g ENSG00000169194.
+
+**Optional arguments**  
+`-r` `--resource`   
+Defines the type of information to return in the output. Default: 'diseases'.   
+Possible resources are:
+
+| Resource           | Return Value                                                      | Valid Filters                                     | Sources                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
+|--------------------|-------------------------------------------------------------------|---------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `diseases`         | Associated diseases                                               | None                                              | Various:<ul><li>[Open&nbsp;Targets](https://genetics.opentargets.org/)</li><li>[ChEMBL](https://www.ebi.ac.uk/chembl/)</li><li>[Europe&nbsp;PMC](http://europepmc.org/)</li></ul>etc.                                                                                                                                                                                                                                                                                                              |
+| `drugs`            | Associated drugs                                                  | `disease_id`                                      | [ChEMBL](https://www.ebi.ac.uk/chembl/)                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
+| `tractability`     | Tractability data                                                 | None                                              | [Open&nbsp;Targets](https://platform-docs.opentargets.org/target/tractability)                                                                                                                                                                                                                                                                                                                                                                                                                     |
+| `pharmacogenetics` | Pharmacogenetic responses                                         | `drug_id`                                         | [PharmGKB](https://www.pharmgkb.org/)                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
+| `expression`       | Gene expression data (by tissues, organs, and anatomical systems) | `tissue_id`<br/>`anatomical_system`<br/>`organ`   | <ul><li>[ExpressionAtlas](https://www.ebi.ac.uk/gxa/home)</li><li>[HPA](https://www.proteinatlas.org/)</li><li>[GTEx](https://www.gtexportal.org/home/)</li></ul>                                                                                                                                                                                                                                                                                                                                  |
+| `depmap`           | DepMap gene&rarr;disease-effect data.                             | `tissue_id`                                       | [DepMap&nbsp;Portal](https://depmap.org/portal/)                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
+| `interactions`     | Protein&rlarr;protein interactions                                | `protein_a_id`<br/>`protein_b_id`<br/>`gene_b_id` | <ul><li>[Open&nbsp;Targets](https://platform-docs.opentargets.org/target/molecular-interactions)</li><li>[IntAct](https://platform-docs.opentargets.org/target/molecular-interactions#intact)</li><li>[Signor](https://platform-docs.opentargets.org/target/molecular-interactions#signor)</li><li>[Reactome](https://platform-docs.opentargets.org/target/molecular-interactions#reactome)</li><li>[String](https://platform-docs.opentargets.org/target/molecular-interactions#string)</li></ul> |
+
+`-l` `--limit`  
+Limit the number of results, e.g 10. Default: No limit.     
+Note: Not compatible with the `tractability` and `depmap` resources.
+
+`-o` `--out`    
+Path to the JSON file the results will be saved in, e.g. path/to/directory/results.json. Default: Standard out.  
+Python: `save=True` will save the output in the current working directory.
+
+**Optional filter arguments**
+
+`-fd` `--filter_disease` `disease_id`  
+Filter by disease ID, e.g. 'EFO_0000274'. *Only valid for the `drugs` resource.*
+
+`-fc` `--filter_drug` `drug_id`  
+Filter by drug ID, e.g. 'CHEMBL1743081'. *Only valid for the `pharmacogenetics` resource.*
+
+`-ft` `--filter_tissue` `tissue_id`  
+Filter by tissue ID, e.g. 'UBERON_0000473'. *Only valid for the `expression` and `depmap` resources.*
+
+`-fa` `--filter_anat_sys`  
+Filter by anatomical system, e.g. 'nervous system'. *Only valid for the `expression` resource.*
+
+`-fo` `--filter_organ` `anatomical_system`  
+Filter by organ, e.g. 'brain'. *Only valid for the `expression` resource.*
+
+`-fpa` `--filter_protein_a` `protein_a_id`  
+Filter by the protein ID of the first protein in the interaction, e.g. 'ENSP00000304915'. *Only valid for the `interactions` resource.*
+
+`-fpb` `--filter_protein_b` `protein_b_id`  
+Filter by the protein ID of the second protein in the interaction, e.g. 'ENSP00000379111'. *Only valid for the `interactions` resource.*
+
+`-fgb` `--filter_gene_b` `gene_b_id`  
+Filter by the gene ID of the second protein in the interaction, e.g. 'ENSG00000077238'. *Only valid for the `interactions` resource.*
+
+`filters`  
+Python only. A dictionary of filters, e.g.
+```python
+{'disease_id': ['EFO_0000274', 'HP_0000964']}
+```
+
+`filter_mode`  
+Python only. `filter_mode='or'` combines filters of different IDs with OR logic.
+`filter_mode='and'` combines filters of different IDs with AND logic (default).
+
+**Flags**   
+`-csv` `--csv`  
+Command-line only. Returns the output in CSV format, instead of JSON 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. 
+
+`-or` `--or`  
+Command-line only. Filters are combined with OR logic. Default: AND logic.
+
+`wrap_text`  
+Python only. `wrap_text=True` displays data frame with wrapped text for easy reading (default: False).  
+  
+  
+### Examples
+
+**Get associated diseases for a specific gene:**   
+```bash
+gget opentargets ENSG00000169194 -r diseases -l 1
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='diseases', limit=1)
+```
+&rarr; Returns the top disease associated with the gene ENSG00000169194.
+
+| id            | name               | description                                                           | score            |
+|---------------|--------------------|-----------------------------------------------------------------------|------------------|
+| EFO_0000274   | atopic&nbsp;eczema | A chronic inflammatory genetically determined disease of the skin ... | 0.66364347241831 |
+
+<br/><br/>
+
+**Get associated drugs for a specific gene:**   
+```bash
+gget opentargets ENSG00000169194 -r drugs -l 2
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='drugs', limit=2)
+```
+
+&rarr; Returns the top 2 drugs associated with the gene ENSG00000169194.
+
+| id            | name         | type     | action_mechanism                    | description                                                  | synonyms                                           | trade_names           | disease_id  | disease_name                  | trial_phase | trial_status | trial_ids     | approved |
+|---------------|--------------|----------|-------------------------------------|--------------------------------------------------------------|----------------------------------------------------|-----------------------|-------------|-------------------------------|-------------|--------------|---------------|----------|
+| CHEMBL1743081 | TRALOKINUMAB | Antibody | Interleukin&#8209;13&nbsp;inhibitor | Antibody drug with a maximum clinical trial phase of IV ...  | ['CAT-354', 'Tralokinumab']                        | ['Adbry', 'Adtralza'] | EFO_0000274 | atopic&nbsp;eczema            | 4           |              | []            | True     |
+| CHEMBL4297864 | CENDAKIMAB   | Antibody | Interleukin&#8209;13&nbsp;inhibitor | Antibody drug with a maximum clinical trial phase of III ... | [ABT-308, Abt-308, CC-93538, Cendakimab, RPC-4046] | []                    | EFO_0004232 | eosinophilic&nbsp;esophagitis | 3           | Recruiting   | [NCT04991935] | False    |
+
+*Note: Returned `trial_ids` are [ClinicalTrials.gov](https://clinicaltrials.gov) identifiers*
+
+<br/><br/>
+
+**Get tractability data for a specific gene:**   
+```bash
+gget opentargets ENSG00000169194 -r tractability
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='tractability')
+```
+
+&rarr; Returns tractability data for the gene ENSG00000169194.
+
+| label                 | modality       |
+|-----------------------|----------------|
+| High-Quality Pocket   | Small molecule |
+| Approved Drug         | Antibody       |
+| GO CC high conf       | Antibody       |
+| UniProt loc med conf  | Antibody       |
+| UniProt SigP or TMHMM | Antibody       |
+
+<br/><br/>
+
+**Get pharmacogenetic responses for a specific gene:**
+```bash
+gget opentargets ENSG00000169194 -r pharmacogenetics -l 1
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='pharmacogenetics', limit=1)
+```
+
+&rarr; Returns pharmacogenetic responses for the gene ENSG00000169194.
+
+| rs_id     | genotype_id       | genotype | variant_consequence_id | variant_consequence_label | drugs                                                                                                                                   | phenotype                                                               | genotype_annotation                                                                                          | response_category | direct_target | evidence_level | source   | literature |
+|-----------|-------------------|----------|------------------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-------------------|---------------|----------------|----------|------------|
+| rs1295686 | 5_132660151_T_T,T | TT       | SO:0002073             | no_sequence_alteration    | &nbsp;&nbsp;&nbsp;&nbsp;id&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;name<br/>0&nbsp;&nbsp;None&nbsp;&nbsp;hepatitis&nbsp;vaccines | increased risk for non&#8209;immune response to the hepatitis B vaccine | Patients with the TT genotype may be at increased risk for non-immune response to the hepatitis B vaccine... | efficacy          | False         | 3              | pharmgkb | [21111021] |
+
+*Note: Returned `literature` ids are [Europe PMC](https://europepmc.org/article/med/) identifiers*
+
+<br/><br/>
+
+**Get tissues where a gene is most expressed:**
+```bash
+gget opentargets ENSG00000169194 -r expression -l 2
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='expression', limit=2)
+```
+
+&rarr; Returns the top 2 tissues where the gene ENSG00000169194 is most expressed.
+
+| tissue_id      | tissue_name                           | rna_zscore | rna_value | rna_unit | rna_level | anatomical_systems                                                   | organs                                                 |
+|----------------|---------------------------------------|------------|-----------|----------|-----------|----------------------------------------------------------------------|--------------------------------------------------------|
+| UBERON_0000473 | testis                                | 5          | 1026      |          | 3         | [reproductive&nbsp;system]                                           | [reproductive&nbsp;organ, reproductive&nbsp;structure] |
+| CL_0000542     | EBV&#8209;transformed&nbsp;lymphocyte | 1          | 54        |          | 2         | [hemolymphoid&nbsp;system, immune&nbsp;system, lymphoid&nbsp;system] | [immune organ]                                         |
+
+<br/><br/>
+
+**Get DepMap gene-disease effect data for a specific gene:**
+```bash
+gget opentargets ENSG00000169194 -r depmap
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='depmap')
+```
+
+&rarr; Returns DepMap gene-disease effect data for the gene ENSG00000169194.
+
+| depmap_id        | expression | effect   | tissue_id      | tissue_name | cell_line_name | disease_cell_line_id | disease_name         | mutation |
+|------------------|------------|----------|----------------|-------------|----------------|----------------------|----------------------|----------|
+| ACH&#8209;001532 | 0.176323   | 0.054950 | UBERON_0002113 | kidney      | JMU-RTK-2      | None                 | Rhabdoid&nbsp;Cancer | None     |
+
+<br/><br/>
+
+**Get protein-protein interactions for a specific gene:**
+```bash
+gget opentargets ENSG00000169194 -r interactions -l 2
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='interactions', limit=2)
+```
+
+&rarr; Returns the top 2 protein-protein interactions for the gene ENSG00000169194.
+
+| evidence_score | evidence_count | source_db | protein_a_id    | gene_a_id       | gene_a_symbol | role_a                | taxon_a | protein_b_id    | gene_b_id       | gene_b_symbol | role_b                | taxon_b |
+|----------------|----------------|-----------|-----------------|-----------------|---------------|-----------------------|---------|-----------------|-----------------|---------------|-----------------------|---------|
+| 0.999          | 3              | string    | ENSP00000304915 | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | ENSP00000379111 | ENSG00000077238 | IL4R          | unspecified&nbsp;role | 9606    |
+| 0.999          | 3              | string    | ENSP00000304915 | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | ENSP00000360730 | ENSG00000131724 | IL13RA1       | unspecified&nbsp;role | 9606    |
+
+<br/><br/>
+
+**Get protein-protein interactions for a specific gene, filtering by protein and gene IDs:**
+```bash
+gget opentargets ENSG00000169194 -r interactions -fpa P35225 --filter_gene_b ENSG00000077238
+```
+```python
+# Python
+import gget
+gget.opentargets('ENSG00000169194', resource='interactions', filters={'protein_a_id': 'P35225', 'gene_b_id': 'ENSG00000077238'})
+```
+
+&rarr; Returns protein-protein interactions for the gene ENSG00000169194, where the first protein is P35225 **and** the second gene is ENSG00000077238.
+
+| evidence_score | evidence_count | source_db | protein_a_id | gene_a_id       | gene_a_symbol | role_a                | taxon_a | protein_b_id | gene_b_id       | gene_b_symbol | role_b                | taxon_b |
+|----------------|----------------|-----------|--------------|-----------------|---------------|-----------------------|---------|--------------|-----------------|---------------|-----------------------|---------|
+| None           | 3              | reactome  | P35225       | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | P24394       | ENSG00000077238 | IL4R          | unspecified&nbsp;role | 9606    |
+| None           | 2              | signor    | P35225       | ENSG00000169194 | IL13          | regulator             | 9606    | P24394       | ENSG00000077238 | IL4R          | regulator&nbsp;target | 9606    |
+
+<br/><br/>
+
+**Get protein-protein interactions for a specific gene, filtering by protein or gene IDs:**
+```bash
+gget opentargets ENSG00000169194 -r interactions -fpa P35225 --filter_gene_b ENSG00000077238 ENSG00000111537 --or -l 5
+```
+```python
+# Python
+import gget
+gget.opentargets(
+    'ENSG00000169194',
+    resource='interactions',
+    filters={'protein_a_id': 'P35225', 'gene_b_id': ['ENSG00000077238', 'ENSG00000111537']},
+    filter_mode='or',
+    limit=5
+)
+```
+
+&rarr; Returns protein-protein interactions for the gene ENSG00000169194, where the first protein is P35225 **or** the second gene is either ENSG00000077238 or ENSG00000111537.
+
+| evidence_score | evidence_count | source_db | protein_a_id    | gene_a_id       | gene_a_symbol | role_a                | taxon_a | protein_b_id    | gene_b_id       | gene_b_symbol | role_b                | taxon_b |
+|----------------|----------------|-----------|-----------------|-----------------|---------------|-----------------------|---------|-----------------|-----------------|---------------|-----------------------|---------|
+| 0.999          | 3              | string    | ENSP00000304915 | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | ENSP00000379111 | ENSG00000077238 | IL4R          | unspecified&nbsp;role | 9606    |
+| 0.961          | 2              | string    | ENSP00000304915 | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | ENSP00000229135 | ENSG00000111537 | IFNG          | unspecified&nbsp;role | 9606    |
+| 0.800          | 9              | intact    | P35225          | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | Q14627          | ENSG00000123496 | IL13RA2       | unspecified&nbsp;role | 9606    |
+| 0.740          | 6              | intact    | P35225          | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | P78552          | ENSG00000131724 | IL13RA1       | unspecified&nbsp;role | 9606    |
+| 0.400          | 1              | intact    | P35225          | ENSG00000169194 | IL13          | unspecified&nbsp;role | 9606    | Q86XT9          | ENSG00000149932 | TMEM219       | stimulator            | 9606    |
+
+
+    
+#### [More examples](https://github.com/pachterlab/gget_examples)
diff --git a/docs/src/en/ref.md b/docs/src/en/ref.md
index 9d95d49c..e81bbccf 100644
--- a/docs/src/en/ref.md
+++ b/docs/src/en/ref.md
@@ -24,6 +24,9 @@ Possible entries are one or a combination (as comma-separated list) of the follo
 `-r` `--release`  
 Defines the Ensembl release number from which the files are fetched, e.g. 104. Default: latest Ensembl release.  
 
+`-od` `--out_dir`   
+Path to the directory where the FTPs will be saved, e.g. path/to/directory/. Default: Current working directory.
+
 `-o` `--out`    
 Path to the JSON file the results will be saved in, e.g. path/to/directory/results.json. Default: Standard out.  
 Python: `save=True` will save the output in the current working directory.
@@ -39,7 +42,7 @@ Lists all available invertebrate species. (Python: combine with `species=None`.)
 Returns only the requested FTP links.  
 
 `-d` `--download`   
-Command-line only. Downloads the requested FTPs to the current directory (requires [curl](https://curl.se/docs/) to be installed).
+Command-line only. Downloads the requested FTPs to the directory specified by `out_dir` (requires [curl](https://curl.se/docs/) to be installed).
 
 `-q` `--quiet`   
 Command-line only. Prevents progress information from being displayed.  
diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md
index 1617a5d1..324ce011 100644
--- a/docs/src/en/updates.md
+++ b/docs/src/en/updates.md
@@ -1,4 +1,12 @@
 ## ✨ What's new  
+**Version ≥ 0.29.0** (XXX):  
+- **New modules: [`gget cbio`](cbio.md) and [`gget opentargets`](opentargets.md) and [`gget bgee`](bgee.md)**
+- [`gget mutate`](./mutate.md): `gget mutate` will now merge identical sequences in the final file by default. Mutation creation was vectorized to decrease runtime. Improved flanking sequence check for non-substitution mutations to make sure no wildtype kmer is retained in the mutation-containing sequence. Addition of several new arguments to customize sequence generation and output.
+- [`gget cosmic`](./cosmic.md): Added support for targeted as well as gene screens. The CSV file created for gget mutate now also contains protein mutation info.
+- [`gget ref`](./ref.md): Added out file option.
+- [`gget info`](./info.md) and [`gget seq`](./seq.md): Switched to Ensembl POST API to increase speed (nothing changes in front end).
+- [`gget enrichr`](./enrichr.md) now also supports species other than human (fly, yeast, worm, and fish) via [modEnrichR](https://maayanlab.cloud/modEnrichr/)
+
 **Version ≥ 0.28.6** (Jun 2, 2024):  
 - **New module: [`gget mutate`](./mutate.md)**
 - [`gget cosmic`](./cosmic.md): You can now download entire COSMIC databases using the argument `download_cosmic` argument
diff --git a/docs/src/es/cosmic.md b/docs/src/es/cosmic.md
index e51f2da3..e6360414 100644
--- a/docs/src/es/cosmic.md
+++ b/docs/src/es/cosmic.md
@@ -44,8 +44,8 @@ DESACTIVA la creación de una versión modificada de la base de datos para usar
 Python: `gget_mutate` es Verdadero por defecto. Establezca `gget_mutate=False` para deshabilitar.
 
 **Parámetros opcionales (para descargar bases de datos COSMIC)**  
-`-mc` `--mutation_class`
-'cáncer' (predeterminado), 'línea_celular', 'censo', 'resistencia', 'pantalla' o 'ejemplo_cáncer'  
+`-mc` `--mutation_class`  
+'cancer' (predeterminado), 'cell_line', 'census', 'resistance', 'genome_screen', 'targeted_screen', o 'cancer_example'  
 Tipo de base de datos COSMIC para descargar.
 
 `-cv` `--cosmic_version`  
@@ -62,7 +62,7 @@ Predeterminado: None
 -> Cuando download_cosmic=True: La base de datos se descargará en el directorio de trabajo actual  
 
 **Banderas (generales)**  
-`-csv` `--csv`
+`-csv` `--csv`  
 Solo para Terminal. Produce los resultados en formato CSV.  
 Para Python, usa json=True para producir los resultados en formato JSON.
 
diff --git a/gget/__init__.py b/gget/__init__.py
index 8baf60b2..ecac1bcd 100644
--- a/gget/__init__.py
+++ b/gget/__init__.py
@@ -16,11 +16,14 @@
 from .gget_diamond import diamond
 from .gget_cosmic import cosmic
 from .gget_mutate import mutate
+from .gget_opentargets import opentargets
+from .gget_cbio import cbio_plot, cbio_search
+from .gget_bgee import bgee
 
 import logging
 # Mute numexpr threads info
 logging.getLogger("numexpr").setLevel(logging.WARNING)
 
-__version__ = "0.28.6"
+__version__ = "0.29.0"
 __author__ = "Laura Luebbert"
 __email__ = "lauralubbert@gmail.com"
diff --git a/gget/constants.py b/gget/constants.py
index b62ca5d6..00327c06 100644
--- a/gget/constants.py
+++ b/gget/constants.py
@@ -34,6 +34,17 @@
 POST_BACKGROUND_ID_ENRICHR_URL = "https://maayanlab.cloud/speedrichr/api/addbackground"
 GET_BACKGROUND_ENRICHR_URL = "https://maayanlab.cloud/speedrichr/api/backgroundenrich"
 
+POST_ENRICHR_URLS = {
+    f"{typ}": f"https://maayanlab.cloud/{typ.capitalize()}Enrichr/addList"
+    for typ in ["fly", "yeast", "worm", "fish"]
+}
+POST_ENRICHR_URLS["human"] = POST_ENRICHR_URL
+GET_ENRICHR_URLS = {
+    f"{typ}": f"https://maayanlab.cloud/{typ.capitalize()}Enrichr/enrich"
+    for typ in ["fly", "yeast", "worm", "fish"]
+}
+GET_ENRICHR_URLS["human"] = GET_ENRICHR_URL
+
 # ARCHS4 API endpoints
 GENECORR_URL = "https://maayanlab.cloud/matrixapi/coltop"
 EXPRESSION_URL = "https://maayanlab.cloud/archs4/search/loadExpressionTissue.php?"
@@ -51,3 +62,264 @@
 # COSMIC API endpoint
 COSMIC_GET_URL = "https://cancer.sanger.ac.uk/cosmic/search/"
 COSMIC_RELEASE_URL = "https://cancer.sanger.ac.uk/cosmic/release_notes"
+
+# OpenTargets API endpoint
+OPENTARGETS_GRAPHQL_API = "https://api.platform.opentargets.org/api/v4/graphql"
+
+# CBIO data
+CBIO_CANCER_TYPE_TO_TISSUE_DICTIONARY = {
+    'Acute Leukemias of Ambiguous Lineage': 'leukemia',
+    'Acute Myeloid Leukemia': 'leukemia',
+    'Acute myeloid leukemia': 'leukemia',
+    'Adenosarcoma': 'mixed',
+    'Adrenal Tumor': 'adrenal_gland',
+    'Adrenocortical Adenoma': 'adrenal_gland',
+    'Adrenocortical Carcinoma': 'adrenal_gland',
+    'Adrenocortical carcinoma': 'adrenal_gland',
+    'Ampullary Cancer': 'ampulla',
+    'Ampullary Carcinoma': 'ampulla',
+    'Anal Cancer': 'intestine',
+    'Appendiceal Cancer': 'appendix',
+    'B-Lymphoblastic Leukemia/Lymphoma': 'lymphoma',
+    'Biliary Tract': 'biliary_tract',
+    'Biliary Tract Cancer, NOS': 'biliary_tract',
+    'Bladder Cancer': 'bladder',
+    'Bladder/Urinary Tract Cancer, NOS': 'bladder',
+    'Blastic Plasmacytoid Dendritic Cell Neoplasm': 'immune',
+    'Blood Cancer, NOS': 'leukemia',
+    'Bone Cancer': 'bone',
+    'Bone Sarcoma': 'bone',
+    'Bowel Cancer, NOS': 'intestine',
+    'Brain Cancer': 'brain',
+    'Breast Cancer': 'breast',
+    'Breast Carcinoma': 'breast',
+    'Breast Sarcoma': 'breast',
+    'CNS Cancer': 'brain',
+    'Cancer of Unknown Primary': 'mixed',
+    'Carcinoma of Uterine Cervix': 'cervix',
+    'Cervical Cancer': 'cervix',
+    'Cholangiocarcinoma': 'biliary_tract',
+    'Choroid Plexus Tumor': 'brain',
+    'Colorectal Cancer': 'intestine',
+    'Colorectal Carcinoma': 'intestine',
+    'Cutaneous malignancy of hair matrix cells': 'skin',
+    'Diffuse Glioma': 'brain',
+    'Embryonal Tumor': 'mixed',
+    'Encapsulated Glioma': 'brain',
+    'Endometrial Cancer': 'uterus',
+    'Endometrial Carcinoma': 'uterus',
+    'Ependymomal Tumor': 'brain',
+    'Esophageal Carcinoma': 'esophagus',
+    'Esophagogastric Cancer': 'esophagus',
+    'Essential Thrombocythemia': 'plasma',
+    'Extrahepatic Cholangiocarcinoma': 'biliary_tract',
+    'Fibrosarcoma': 'soft_tissue',
+    'Gallbladder Carcinoma': 'gallbladder',
+    'Gastric Cancer': 'stomach',
+    'Gastrointestinal Neuroendocrine Tumor': 'intestine',
+    'Gastrointestinal Stromal Tumor': 'intestine',
+    'Germ Cell Tumor': 'testicle',
+    'Gestational Trophoblastic Disease': 'uterus',
+    'Glioblastoma': 'brain',
+    'Glioma': 'brain',
+    'Head and Neck Cancer': 'head_neck',
+    'Head and Neck Cancer, NOS': 'head_neck',
+    'Head and Neck Carcinoma': 'head_neck',
+    'Hepatobiliary Cancer': 'liver',
+    'High-grade glioma/astrocytoma': 'brain',
+    'Histiocytosis': 'immune',
+    'Hodgkin Lymphoma': 'lymphoma',
+    'Hodgkin Lymphoma-like PTLD': 'lymphoma',
+    'Intraductal Papillary Mucinous Neoplasm': 'pancreas',
+    'Intrahepatic Cholangiocarcinoma': 'biliary_tract',
+    'Invasive Breast Carcinoma': 'breast',
+    'Kidney Renal Cell Carcinoma': 'kidney',
+    'Leukemia': 'leukemia',
+    'Liver Hepatocellular Carcinoma': 'liver',
+    'Liver Tumor': 'liver',
+    'Low-grade glioma/astrocytoma': 'brain',
+    'Lung Adenocarcinoma': 'lung',
+    'Lung Cancer': 'lung',
+    'Lung Cancer, NOS': 'lung',
+    'Lung cancer': 'lung',
+    'Lymphoid Neoplasm': 'lymph',
+    'Malignant Rhabdoid Tumor of the Liver': 'liver',
+    'Mastocytosis': 'immune',
+    'Mature B-Cell Neoplasms': 'lymphoma',
+    'Mature B-cell lymphoma': 'lymphoma',
+    'Mature T and NK Neoplasms': 'immune',
+    'Medulloblastoma': 'brain',
+    'Melanoma': 'skin',
+    'Meningioma': 'brain',
+    'Mesothelioma': 'soft_tissue',
+    'Miscellaneous Brain Tumor': 'brain',
+    'Miscellaneous Neuroepithelial Tumor': 'brain',
+    'Mucinous Adenocarcinoma Lymph Node': 'lymph',
+    'Myelodysplastic Syndromes': 'plasma',
+    'Myelodysplastic/Myeloproliferative Neoplasms': 'plasma',
+    'Myeloproliferative Neoplasms': 'plasma',
+    'Nerve Sheath Tumor': 'soft_tissue',
+    'Nested stromal epithelial tumor of the liver': 'liver',
+    'Non Small Cell Lung Cancer': 'lung',
+    'Non-Germinomatous Germ Cell Tumor': 'testicle',
+    'Non-Hodgkin Lymphoma': 'lymphoma',
+    'Non-Seminomatous Germ Cell Tumor': 'testicle',
+    'Non-Small Cell Lung Cancer': 'lung',
+    'Ocular Melanoma': 'eye',
+    'Other': 'mixed',
+    'Ovarian Cancer': 'ovary',
+    'Ovarian Carcinoma': 'ovary',
+    'Ovarian Epithelial Tumor': 'ovary',
+    'Ovarian Germ Cell Tumor': 'ovary',
+    'Ovarian/Fallopian Tube Cancer, NOS': 'ovary',
+    'Pancreatic Cancer': 'pancreas',
+    'Penile Cancer': 'intestine',
+    'Peripheral Nervous System': 'soft_tissue',
+    'Pheochromocytoma': 'adrenal_gland',
+    'Pineal Tumor': 'brain',
+    'Pleural Mesothelioma': 'soft_tissue',
+    'Posttransplant Lymphoproliferative Disorders': 'lymphoma',
+    'Prostate Cancer': 'prostate',
+    'Prostate Cancer, NOS': 'prostate',
+    'Renal Cell Carcinoma': 'kidney',
+    'Renal Clear Cell Carcinoma': 'kidney',
+    'Renal Non-Clear Cell Carcinoma': 'kidney',
+    'Renal cancer': 'kidney',
+    'Retinoblastoma': 'eye',
+    'Rhabdoid Cancer': 'soft_tissue',
+    'Salivary Cancer': 'head_neck',
+    'Salivary Gland Cancer': 'head_neck',
+    'Salivary Gland-Type Tumor of the Lung': 'lung',
+    'Sarcoma': 'soft_tissue',
+    'Sellar Tumor': 'brain',
+    'Seminoma': 'testicle',
+    'Sex Cord Stromal Tumor': 'testicle',
+    'Skin Cancer, Non-Melanoma': 'skin',
+    'Small Bowel Cancer': 'intestine',
+    'Small Bowel Carcinoma': 'intestine',
+    'Small Cell Lung Cancer': 'lung',
+    'Soft Tissue Myoepithelial Carcinoma': 'soft_tissue',
+    'Soft Tissue Sarcoma': 'soft_tissue',
+    'Soft Tissue Tumor': 'soft_tissue',
+    'T-Lymphoblastic Leukemia/Lymphoma': 'leukemia',
+    'Teratoma with Malignant Transformation': 'testicle',
+    'Thymic Epithelial Tumor': 'thymus',
+    'Thymic Tumor': 'thymus',
+    'Thyroid Cancer': 'thyroid',
+    'Thyroid Carcinoma': 'thyroid',
+    'Urothelial Carcinoma': 'bladder',
+    'Uterine Corpus Endometrial Carcinoma': 'uterus',
+    'Uterine Endometrioid Carcinoma': 'uterus',
+    'Uterine Sarcoma': 'uterus',
+    'Vaginal Cancer': 'intestine',
+    'Wilms Tumor': 'kidney'
+}
+
+CBIO_CANCER_TYPE_ACRONYM_TO_TISSUE_DICTIONARY = {
+    'acbc': 'breast',
+    'acc': 'adrenal_gland',
+    'acyc': 'adenoid',
+    'aml': 'leukemia',
+    'ampca': 'ampulla',
+    'angs': 'endothelial',
+    'apad': 'appendix',
+    'bcc': 'skin',
+    'bfn': 'breast',
+    'biliary_tract': 'biliary_tract',
+    'blca': 'bladder',
+    'bll': 'leukemia',
+    'bowel': 'intestine',
+    'brain': 'brain',
+    'brca': 'breast',
+    'breast': 'breast',
+    'ccrcc': 'kidney',
+    'cervix': 'cervix',
+    'cesc': 'cervix',
+    'chol': 'biliary_tract',
+    'chrcc': 'kidney',
+    'cllsll': 'leukemia',
+    'coad': 'intestine',
+    'coadread': 'intestine',
+    'cscc': 'skin',
+    'desm': 'skin',
+    'difg': 'brain',
+    'dlbclnos': 'lymphoma',
+    'egc': 'esophagus',
+    'es': 'bone',
+    'esca': 'esophagus',
+    'escc': 'esophagus',
+    'gbc': 'gallbladder',
+    'gbm': 'brain',
+    'gist': 'stomach',
+    'hcc': 'liver',
+    'hccihch': 'liver',
+    'hdcn': 'immune',
+    'head_neck': 'head_neck',
+    'hgsoc': 'ovary',
+    'hnsc': 'head_neck',
+    'ihch': 'liver',
+    'lgsoc': 'ovary',
+    'liad': 'liver',
+    'luad': 'lung',
+    'lung': 'lung',
+    'lusc': 'lung',
+    'lymph': 'lymph',
+    'mbc': 'breast',
+    'mbl': 'brain',
+    'mbn': 'lymphoma',
+    'mcl': 'lymphoma',
+    'mds': 'leukemia',
+    'mel': 'skin',
+    'mixed': 'mixed',
+    'mng': 'head_neck',
+    'mnm': 'leukemia',
+    'mpn': 'leukemia',
+    'mpnst': 'soft_tissue',
+    'mrt': 'kidney',
+    'mtnn': 'lymphoma',
+    'myeloid': 'leukemia',
+    'nbl': 'adrenal_gland',
+    'nccrcc': 'kidney',
+    'nhl': 'lymphoma',
+    'npc': 'head_neck',
+    'nsclc': 'lung',
+    'nsgct': 'testicle',
+    'nst': 'brain',
+    'odg': 'brain',
+    'ovary': 'ovary',
+    'paac': 'pancreas',
+    'paad': 'pancreas',
+    'pact': 'pancreas',
+    'pancreas': 'pancreas',
+    'panet': 'pancreas',
+    'past': 'brain',
+    'pcm': 'plasma',
+    'pcnsl': 'lymphoma',  # brain?
+    'plmeso': 'lung',
+    'prad': 'prostate',
+    'prcc': 'kidney',
+    'prostate': 'prostate',
+    'rbl': 'eye',
+    'rms': 'muscle',
+    'scco': 'ovary',
+    'sclc': 'lung',
+    'skcm': 'skin',
+    'soft_tissue': 'soft_tissue',
+    'stad': 'stomach',
+    'stmyec': 'soft_tissue',
+    'stomach': 'stomach',
+    'testis': 'testicle',
+    'tet': 'thymus',
+    'thpa': 'thyroid',
+    'thym': 'thymus',
+    'thyroid': 'thyroid',
+    'uccc': 'uterus',
+    'ucec': 'uterus',
+    'ucs': 'uterus',
+    'um': 'eye',
+    'urcc': 'kidney',
+    'usarc': 'uterus',
+    'utuc': 'bladder',
+    'vsc': 'cervix',
+    'wt': 'kidney'
+}
diff --git a/gget/gget_bgee.py b/gget/gget_bgee.py
new file mode 100644
index 00000000..54e323c4
--- /dev/null
+++ b/gget/gget_bgee.py
@@ -0,0 +1,190 @@
+import pandas as pd
+import json as json_
+from typing import Literal, List, Dict, Union
+
+import requests
+
+from .utils import set_up_logger, json_list_to_df
+
+logger = set_up_logger()
+
+
+def _bgee_species(gene_id: str, verbose: bool = True) -> int:
+    """
+    Get species ID from Bgee
+    :param gene_id: Ensembl gene ID
+    :param verbose: log progress
+    :return: species ID
+    """
+
+    if verbose:
+        logger.info(f"Getting species ID for gene {gene_id} from Bgee")
+
+    response = requests.get(
+        "https://bgee.org/api/",
+        params={
+            "display_type": "json",
+            "page": "gene",
+            "action": "general_info",
+            "gene_id": gene_id,
+        },
+    )
+
+    if not response.ok:
+        raise RuntimeError(
+            f"Bgee API request returned with error code: {response.status_code}. "
+            "Please double-check the arguments and try again.\n"
+        )
+
+    genes_data: list[dict[str, ...]] = response.json()["data"]["genes"]
+    assert len(genes_data) == 1
+    gene_data: dict[str, ...] = genes_data[0]
+
+    species: int = gene_data["species"]["genomeSpeciesId"]
+    return species
+
+
+def _bgee_orthologs(
+    gene_id: str, json: bool = False, verbose: bool = True
+) -> Union[pd.DataFrame, List[Dict[str, object]]]:
+    """
+    Get orthologs for a gene from Bgee
+
+    Args:
+
+    :param gene_id: Ensembl gene ID
+    :param json:    return JSON instead of DataFrame
+    :param verbose: log progress
+
+    Returns requested information as a DataFrame or JSON
+    """
+
+    # must first obtain species
+    species = _bgee_species(gene_id, verbose=verbose)
+
+    if verbose:
+        logger.info(f"Getting orthologs for gene {gene_id} from Bgee")
+
+    # then obtain homologs
+    response = requests.get(
+        f"https://bgee.org/api/",
+        params={
+            "display_type": "json",
+            "page": "gene",
+            "action": "homologs",
+            "gene_id": gene_id,
+            "species_id": species,
+        },
+    )
+
+    if not response.ok:
+        raise RuntimeError(
+            f"Bgee API request returned with error code: {response.status_code}. "
+            "Please double-check the arguments and try again.\n"
+        )
+
+    homologs_data: list[dict[str, ...]] = response.json()["data"]["orthologsByTaxon"]
+    homologs_data: list[dict[str, ...]] = sum([v["genes"] for v in homologs_data], [])
+
+    df = json_list_to_df(
+        homologs_data,
+        [
+            ("gene_id", "geneId"),
+            ("gene_name", "name"),
+            ("species_id", "species.id"),
+            ("genus", "species.genus"),
+            ("species", "species.speciesName"),
+        ],
+    )
+
+    if json:
+        return json_.loads(df.to_json(orient="records", force_ascii=False))
+    else:
+        return df
+
+
+def _bgee_expression(
+    gene_id: str, json: bool = False, verbose: bool = True
+) -> Union[pd.DataFrame, List[Dict[str, object]]]:
+    """
+    Get expression data from Bgee
+
+    Args:
+
+    :param gene_id: Ensembl gene ID
+    :param json:    return JSON instead of DataFrame
+    :param verbose: log progress
+
+    Returns requested information as a DataFrame or JSON
+    """
+    # must first obtain species
+    species = _bgee_species(gene_id, verbose=verbose)
+
+    if verbose:
+        logger.info(f"Getting expression data for gene {gene_id} from Bgee")
+
+    # then obtain expression data
+    response = requests.get(
+        "https://bgee.org/api/",
+        params={
+            "display_type": "json",
+            "page": "gene",
+            "action": "expression",
+            "gene_id": gene_id,
+            "species_id": species,
+            "cond_param": ["anat_entity", "cell_type"],
+            "data_type": "all",
+        },
+    )
+
+    if not response.ok:
+        raise RuntimeError(
+            f"Bgee API request returned with error code: {response.status_code}. "
+            "Please double-check the arguments and try again.\n"
+        )
+
+    expression_data: list[dict[str, ...]] = response.json()["data"]["calls"]
+
+    df = json_list_to_df(
+        expression_data,
+        [
+            ("anat_entity_id", "condition.anatEntity.id"),
+            ("anat_entity_name", "condition.anatEntity.name"),
+            ("score", "expressionScore.expressionScore"),
+            ("score_confidence", "expressionScore.expressionScoreConfidence"),
+            ("expression_state", "expressionState"),
+        ],
+    )
+    df["score"] = df["score"].astype(float)
+
+    if json:
+        return json_.loads(df.to_json(orient="records", force_ascii=False))
+    else:
+        return df
+
+
+# noinspection PyShadowingBuiltins
+def bgee(
+    gene_id: str,
+    type: Literal["expression", "orthologs"],
+    json: bool = False,
+    verbose: bool = True,
+) -> Union[pd.DataFrame, List[Dict[str, object]]]:
+    """
+    Get orthologs/expression data for a gene from Bgee
+
+    Args:
+
+    :param type:    type of data to retrieve (expression or orthologs)
+    :param gene_id: Ensembl gene ID
+    :param json:    return JSON instead of DataFrame
+    :param verbose: log progress
+
+    Returns requested information as a DataFrame or JSON
+    """
+    if type == "expression":
+        return _bgee_expression(gene_id, json=json, verbose=verbose)
+    elif type == "orthologs":
+        return _bgee_orthologs(gene_id, json=json, verbose=verbose)
+    else:
+        raise ValueError(f"Unknown type: {type}")
diff --git a/gget/gget_cbio.py b/gget/gget_cbio.py
new file mode 100644
index 00000000..05a40887
--- /dev/null
+++ b/gget/gget_cbio.py
@@ -0,0 +1,1229 @@
+__all__ = ["download_cbioportal_data", "cbio_search", "cbio_plot"]
+
+import json
+import math
+import os
+import subprocess
+from typing import Literal, TypeVar, Callable, Union, Optional
+
+import pandas as pd
+import numpy as np
+
+import requests
+from collections import defaultdict, OrderedDict
+
+from .utils import set_up_logger
+
+import matplotlib.pyplot as plt
+from matplotlib.colors import ListedColormap, BoundaryNorm, TwoSlopeNorm
+
+from .constants import CBIO_CANCER_TYPE_TO_TISSUE_DICTIONARY
+
+_K = TypeVar("_K")
+_V = TypeVar("_V")
+
+logger = set_up_logger()
+
+
+if not hasattr(pd.DataFrame, "map"):
+    logger.info("Old pandas version detected. Patching DataFrame.map to DataFrame.applymap")
+    pd.DataFrame.map = pd.DataFrame.applymap
+
+
+def _ints_between(
+    start: int, end: int, max_count: int, min_count: int, verbose: bool = False
+) -> list[int]:
+    """
+    Generate a list of integers between start and end (inclusive) with a maximum count of max_count and a minimum count min_count.
+    The list is guaranteed to contain start and end, and the spacing between the numbers will be as even as possible.
+    If a perfect spacing is not possible, the spacing will omit a number rather than overcrowding.
+
+    This method is designed to be used for plot labels
+    """
+    assert max_count >= 2, "max_count must be at least 2"
+    assert start < end, "start must be less than end"
+
+    if max_count == 2:
+        return [start, end]
+    else:
+        step = int(math.ceil((end - start) / (max_count - 1)))
+        if verbose:
+            print(f"Original step: {step}, (end - start) % step={(end - start) % step}")
+            print(f"end - start={end - start}")
+        # check if it comes out even
+        while ((end - start) / step) + 1 >= min_count and (end - start) % step != 0:
+            step += 1
+            if verbose:
+                print(f"New step: {step}, (end - start) % step={(end - start) % step}")
+        if (end - start) % step != 0:
+            step = int(math.ceil((end - start) / (max_count - 1)))
+            if verbose:
+                print("Reverted step")
+
+        out = [start]
+        current = start
+        while current + step * 2 <= end:
+            current += step
+            out.append(current)
+        out.append(end)
+        if verbose:
+            print(f"out={out}")
+        return out
+
+
+def _describe_bytes(size: int) -> str:
+    """
+    Describe a size in bytes in human-readable format.
+
+    :param size:    size in bytes
+    """
+
+    steps = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+
+    unit = steps.pop(0)
+
+    while size >= 1000 and steps:
+        size /= 1000
+        unit = steps.pop(0)
+
+    if unit == "bytes":
+        return f"{size} {unit}"
+    return f"{size:.2f} {unit}"
+
+
+def _download_file_from_git_lfs(
+    target_path: str, oid: str, size: int, verbose: bool = False
+) -> bool:
+    """
+    Download a single object from Git LFS.
+
+    :param target_path:  path to save the downloaded object
+    :param oid:          object ID
+    :param size:         size of the object
+
+    :return:             True if the object was downloaded successfully, False otherwise
+    """
+    lfs_metadata = {
+        "operation": "download",
+        "transfer": ["basic"],
+        "objects": [{"oid": oid, "size": size}],
+    }
+    lfs_metadata_json = json.dumps(lfs_metadata)
+
+    try:
+        github_url = f"https://github.com/cBioPortal/datahub.git/info/lfs/objects/batch"
+
+        curl_command = [
+            "curl",
+            "-X", "POST",
+            "-H", "Accept: application/vnd.git-lfs+json",
+            "-H", "Content-Type: application/json",
+            "-d", lfs_metadata_json,
+            github_url,
+        ]
+
+        result = subprocess.run(curl_command, capture_output=True, text=True)
+        response_json = json.loads(result.stdout)
+
+        href = response_json["objects"][0]["actions"]["download"]["href"]
+
+        response = requests.get(href)
+
+        if not response.ok:
+            logger.error(f"Failed to download object {oid} to {target_path}")
+            return False
+
+        with open(target_path, "wb") as f:
+            f.write(response.content)
+
+        if verbose:
+            logger.info(f"Downloaded object {oid} to {target_path}")
+
+    except Exception as e:
+        logger.error(f"Error downloading object {oid} to {target_path}: {e}")
+        return False
+
+    return True
+
+
+class _LFSDownloadPlan:
+    def __init__(self, verbose: bool = False):
+        self.objects: list[tuple[str, tuple[str, int]]] = []
+        """(target_path, (oid, size))"""
+
+        self.verbose = verbose
+
+    @property
+    def total_size(self) -> int:
+        return sum(size for _, (_, size) in self.objects)
+
+    def add(self, target_path: str, oid: str, size: int):
+        self.objects.append((target_path, (oid, size)))
+
+    def download(self) -> bool:
+        """
+        Download all objects in the plan.
+
+        :return:    True if all objects were downloaded successfully, False otherwise
+        """
+        success = True
+
+        for target_path, (oid, size) in self.objects:
+            success &= _download_file_from_git_lfs(target_path, oid, size)
+
+        return success
+
+
+def download_cbioportal_data(
+    study_ids: list[str],
+    verbose: bool = False,
+    out_dir: Optional[str] = None,
+    confirm_download: bool = False,
+) -> bool:
+    """
+    Download data from cBioPortal studies.
+
+    Args:
+
+    :param study_ids:           list of cBioPortal study IDs
+    :param verbose:             whether to print out progress
+    :param out_dir:             output directory to save the data, default `<current_directory>/gget_cbio_cache/`
+    :param confirm_download:    whether to confirm the download before proceeding
+
+    Return:
+
+    :return:            True if successfully downloaded all needed data, False otherwise
+    """
+
+    actual_out_dir = os.path.abspath(out_dir or "gget_cbio_cache")
+
+    os.makedirs(actual_out_dir, exist_ok=True)
+
+    success = True
+
+    if confirm_download:
+        plan = _LFSDownloadPlan(verbose=verbose)
+    else:
+        plan = None
+
+    for study_id in study_ids:
+        file_types = ["mutations", "cna", "sv", "clinical_patient", "clinical_sample"]
+        optional_file_types = ["cna", "sv"]
+
+        for file_type in file_types:
+            try:
+                data_folder = os.path.join(actual_out_dir, study_id)
+                filename = os.path.join(data_folder, f"{file_type}.txt")
+
+                if not os.path.exists(data_folder):
+                    os.makedirs(data_folder, exist_ok=True)
+
+                if os.path.exists(filename):
+                    if verbose:
+                        logger.info(f"File {filename} already exists, skipping.")
+                    continue
+
+                # URL to be downloaded
+                url = f"https://raw.githubusercontent.com/cBioPortal/datahub/master/public/{study_id}/data_{file_type}.txt"
+
+                response = requests.get(url)
+
+                if not response.ok:
+                    logger.error(
+                        f"Failed to download {file_type} data for study {study_id}"
+                    )
+                    if file_type not in optional_file_types:
+                        success = False
+                    continue
+
+                lines = response.content.decode().splitlines(keepends=True)
+
+                fields: dict[str, str] = {}
+
+                for line in lines:
+                    k, v = line.split(" ", 1)
+                    k = k.strip()
+                    v = v.strip()
+                    fields[k] = v
+
+                assert (
+                    fields["version"] == "https://git-lfs.github.com/spec/v1"
+                ), f"Cannot handle git-lfs version {fields['version']}"
+
+                oid: str = fields["oid"].split(":")[1].strip()
+                size: int = int(fields["size"])
+
+                if plan:
+                    plan.add(filename, oid, size)
+                else:
+                    success &= _download_file_from_git_lfs(
+                        filename, oid, size, verbose=verbose
+                    )
+
+            except Exception as e:
+                logger.error(
+                    f"Error downloading {file_type} data for study {study_id}: {e}"
+                )
+                success = False
+
+        if verbose and not confirm_download:
+            logger.info(f"Downloaded all data for study {study_id}")
+
+    # If using a download plan AND there are actually objects to download, ask for confirmation
+    if plan and plan.objects:
+        do_download = (
+            input(
+                f"Do you want to download {_describe_bytes(plan.total_size)} to {actual_out_dir}? (y/n) "
+            )
+            .lower()
+            .strip()
+            == "y"
+        )
+        if do_download:
+            success &= plan.download()
+        else:
+            print("Download aborted")
+            return False
+    elif verbose:
+        logger.info("Downloaded all data for all studies")
+
+    return success
+
+
+def _extract_study_name(name: str) -> str:
+    parenthesis_index = name.find("(")
+    if parenthesis_index != -1:
+        return name[:parenthesis_index].strip()
+    return name
+
+
+def cbio_search(*key_words: str) -> list[str]:
+    """
+    Find cBioPortal study IDs by keyword.
+
+    Args:
+
+    :param key_words:    list of keywords to search for
+
+    Return:
+
+    :return:            list of study IDs that match the keywords
+    """
+
+    try:
+        from bravado.client import SwaggerClient
+    except ImportError:
+        logger.error(
+            """
+            Some third-party dependencies are missing. Please run the following command: 
+            >>> gget.setup('cbio') or $ gget setup cbio
+
+            Alternative: Install the bravado package using pip (https://pypi.org/project/bravado).
+            """
+        )
+        return []
+
+    api = SwaggerClient.from_url(
+        "https://www.cbioportal.org/api/v2/api-docs",
+        config={
+            "validate_requests": False,
+            "validate_responses": False,
+            "validate_swagger_spec": False,
+        },
+    )
+
+    for a in dir(api):
+        setattr(api, a.replace(" ", "_").lower(), getattr(api, a))
+
+    studies = api.Studies.getAllStudiesUsingGET().result()
+
+    cancer_type_acronym_dict = {
+        _extract_study_name(individual_study["name"]): individual_study["cancerTypeId"]
+        for individual_study in studies
+    }
+    cancer_type_acronym_dict = OrderedDict(sorted(cancer_type_acronym_dict.items()))
+
+    cancer_id_list = [
+        cancer_type_acronym
+        for cancer_type, cancer_type_acronym in cancer_type_acronym_dict.items()
+        if any(
+            key_word in cancer_type.lower() or key_word in cancer_type_acronym.lower()
+            for key_word in key_words
+        )
+        and cancer_type_acronym.lower() != "mixed"
+    ]
+
+    matching_study_ids = [
+        individual_study["studyId"]
+        for individual_study in studies
+        if individual_study["cancerTypeId"] in cancer_id_list
+    ]
+
+    return matching_study_ids
+
+
+def _get_ensembl_gene_id(transcript_id: str, verbose: bool = False):
+    try:
+        url = f"https://rest.ensembl.org/lookup/id/{transcript_id}?expand=1"
+        response = requests.get(url, headers={"Content-Type": "application/json"})
+
+        if not response.ok:
+            response.raise_for_status()
+
+        data = response.json()
+
+        return data.get("Parent")
+    except Exception as e:
+        if verbose:
+            print(f"Error for: {transcript_id}")
+        return "Unknown"
+
+
+def _get_ensembl_gene_id_bulk(transcript_ids: list[str]) -> dict[str, str]:
+    if not transcript_ids:
+        return {}
+
+    try:
+        url = f"https://rest.ensembl.org/lookup/id/"
+        response = requests.post(url, json={"ids": transcript_ids}, headers={"Content-Type": "application/json"})
+
+        if not response.ok:
+            response.raise_for_status()
+
+        data = response.json()
+
+        return {transcript_id: data[transcript_id].get("Parent") for transcript_id in transcript_ids if data[transcript_id]}
+    except Exception as e:
+        logger.error(f"Failed to fetch gene IDs from Ensembl: {e}")
+        raise e
+
+
+def _get_ensembl_gene_name_bulk(gene_ids: list[str]) -> dict[str, str]:
+    if not gene_ids:
+        return {}
+
+    try:
+        url = f"https://rest.ensembl.org/lookup/id/"
+        response = requests.post(url, json={"ids": gene_ids}, headers={"Content-Type": "application/json"})
+
+        if not response.ok:
+            response.raise_for_status()
+
+        data = response.json()
+
+        return {gene_id: data[gene_id].get("display_name") for gene_id in gene_ids if data[gene_id]}
+    except Exception as e:
+        logger.error(f"Failed to fetch gene names from Ensembl: {e}")
+        raise e
+
+
+def _get_valid_ensembl_gene_id(
+    row, transcript_column: str = "seq_ID", gene_column: str = "gene_name"
+):
+    ensembl_gene_id = _get_ensembl_gene_id(row[transcript_column])
+    if ensembl_gene_id == "Unknown":
+        return row[gene_column]
+    return ensembl_gene_id
+
+
+def _get_valid_ensembl_gene_id_bulk(df: pd.DataFrame) -> Callable[[pd.Series, str, str], str]:
+    map_: Optional[dict[str, str]] = None
+
+    def f(row: pd.Series, transcript_column: str = "seq_ID", gene_column: str = "gene_name"):
+        # logger.info(f"Row: {row}")
+        nonlocal map_
+        if map_ is None:
+            all_transcript_ids = df[transcript_column].unique()
+            map_ = _get_ensembl_gene_id_bulk(list(all_transcript_ids))
+
+        ensembl_gene_id = map_.get(row[transcript_column], "Unknown")
+        if ensembl_gene_id == "Unknown":
+            return row[gene_column]
+
+        return ensembl_gene_id
+
+    return f
+
+
+def _nested_defaultdict() -> defaultdict[_K, Union[_V, defaultdict[_K]]]:
+    return defaultdict(_nested_defaultdict)
+
+
+_ENSEMBL = "Ensembl"
+_SYMBOL = "Symbol"
+
+
+class _GeneAnalysis:
+    def __init__(
+        self,
+        study_ids: list[str],
+        genes: list[str],
+        merge_type: Literal["Symbol", "Ensembl"] = "Symbol",
+        remove_non_ensembl_genes: bool = False,
+        data_dir: str = "gget_cbio_cache",
+        figure_output_dir: str = "gget_cbio_figures",
+    ):
+        self.study_ids = study_ids
+
+        ensembl_transcripts = [gene for gene in genes if gene.startswith("ENST")]
+        map_ = {k: v for k, v in _get_ensembl_gene_id_bulk(ensembl_transcripts).items() if v != "Unknown" and v is not None}
+        genes = [map_.get(gene, gene) for gene in genes]
+
+        ensembl_gene_ids = [gene for gene in genes if gene.startswith("ENSG")]
+        map_ = {k: v for k, v in _get_ensembl_gene_name_bulk(ensembl_gene_ids).items() if v != "Unknown" and v is not None}
+        self.genes = [map_.get(gene, gene) for gene in genes]
+
+        self.merge_type = merge_type
+        self.remove_non_ensembl_genes = remove_non_ensembl_genes
+        self.data_dir = data_dir
+        self.figure_output_dir = figure_output_dir
+
+        os.makedirs(self.figure_output_dir, exist_ok=True)
+
+        # todo "Ensembl_Gene_ID" is not a valid column name in the dataframes...
+        ##assert self.merge_type == _SYMBOL, "Only 'Symbol' merge type is supported for now."
+        assert self.merge_type in [
+            _SYMBOL,
+            _ENSEMBL,
+        ], "merge_type must be either 'Symbol' or 'Ensembl'"
+
+        self.columns_to_keep = [
+            "Tumor_Sample_Barcode",
+            "Hugo_Symbol",
+            "Entrez_Gene_Id",
+            "Consequence",
+        ]
+        self.column_for_merging: str = (
+            "Hugo_Symbol" if self.merge_type == _SYMBOL else "Ensembl_Gene_ID"
+        )
+
+        self.df_collection: dict[str, dict[str, pd.DataFrame]] = {}
+        self.big_combined_df = self._create_study_dataframes()
+
+    def _create_single_study_dataframe(self, study_id: str) -> pd.DataFrame:
+        data_folder = os.path.join(self.data_dir, study_id)
+
+        mutation_df = pd.read_csv(os.path.join(data_folder, "mutations.txt"), sep="\t")
+        sample_df = pd.read_csv(
+            os.path.join(data_folder, "clinical_sample.txt"), sep="\t"
+        )
+
+        self.df_collection[study_id]["mutations"] = mutation_df
+        self.df_collection[study_id]["samples"] = sample_df
+
+        # Merge on gene name
+        if self.merge_type == _ENSEMBL:
+            if (
+                "Gene" in mutation_df.columns
+                and not mutation_df["Gene"].isna().all()
+                and mutation_df["Gene"].str.startswith("ENSG").any()
+            ):
+                mutation_df.rename(columns={"Gene": "Ensembl_Gene_ID"}, inplace=True)
+                if self.remove_non_ensembl_genes:
+                    mutation_df = mutation_df[
+                        mutation_df["Ensembl_Gene_ID"].str.startswith("ENSG")
+                    ]
+            elif (
+                "Transcript_ID" in mutation_df.columns
+                and mutation_df["Transcript_ID"].str.startswith("ENST").any()
+            ):
+                logger.info("Fetching gene IDs from Ensembl")
+                mutation_df["Ensembl_Gene_ID"] = mutation_df.progress_apply(
+                    _get_valid_ensembl_gene_id_bulk(mutation_df),
+                    axis=1,
+                    transcript_column="Transcript_ID",  # rewriting this column
+                    gene_column="Hugo_Symbol",
+                )
+                if self.remove_non_ensembl_genes:
+                    mutation_df = mutation_df[
+                        mutation_df["Ensembl_Gene_ID"].str.startswith("ENSG")
+                    ]
+            else:
+                self.merge_type = _SYMBOL
+                logger.warn(
+                    "No Ensembl gene IDs found in the mutation data. Merging on gene symbol instead."
+                )
+
+        def join_unique_string_values(series):
+            if series.isnull().all():
+                return np.nan
+            else:
+                return ','.join(series.dropna().unique())
+
+        if self.merge_type == _ENSEMBL:
+            self.column_for_merging = "Ensembl_Gene_ID"
+
+            # logger.info(f"Columns: {mutation_df.columns}")
+            # logger.info(f"Transcript column: {mutation_df.head(20)['Transcript_ID']}")
+            # logger.info(f"Gene ID column: {mutation_df.head(20)['Ensembl_Gene_ID']}")
+            aggregation_dict = {
+                "Hugo_Symbol": lambda x: ",".join(x.unique()),
+                "Entrez_Gene_Id": lambda x: ",".join(map(str, x.unique())),
+                "Consequence": join_unique_string_values,
+            }
+        elif self.merge_type == _SYMBOL:
+            self.column_for_merging = "Hugo_Symbol"
+
+            aggregation_dict = {
+                "Entrez_Gene_Id": lambda x: ",".join(map(str, x.unique())),
+                "Consequence": join_unique_string_values,
+            }
+        else:
+            raise AssertionError(f"Invalid merge type: {self.merge_type}")
+
+        if "Entrez_Gene_Id" not in mutation_df.columns:
+            del aggregation_dict["Entrez_Gene_Id"]
+            if "Entrez_Gene_Id" in self.columns_to_keep:
+                self.columns_to_keep.remove("Entrez_Gene_Id")
+
+        aggregated_df = (
+            mutation_df.groupby(["Tumor_Sample_Barcode", self.column_for_merging])
+            .agg(aggregation_dict)
+            .reset_index()
+        )
+
+        if self.column_for_merging not in self.columns_to_keep:
+            self.columns_to_keep.append(self.column_for_merging)
+
+        aggregated_df["Consequence"] = aggregated_df["Consequence"].apply(
+            lambda x: "Multiple_consequences" if "," in x else x
+        )
+
+        occurrences_df = (
+            aggregated_df.groupby([self.column_for_merging, "Tumor_Sample_Barcode"])
+            .size()
+            .reset_index(name="mutation_occurrences")
+        )
+
+        final_df = pd.merge(
+            aggregated_df,
+            occurrences_df,
+            on=[self.column_for_merging, "Tumor_Sample_Barcode"],
+        )
+
+        # add CNA and SV info
+        if os.path.exists(os.path.join(data_folder, "cna.txt")):
+            cna_df = pd.read_csv(os.path.join(data_folder, "cna.txt"), sep="\t")
+            self.df_collection[study_id]["cna"] = cna_df
+
+            # Exclude 'Hugo_Symbol' column
+            columns_to_transform = self.df_collection[study_id][
+                "cna"
+            ].columns.difference(["Hugo_Symbol"])
+
+            # Apply binary transformation to the selected columns
+            df_binary = self.df_collection[study_id]["cna"][columns_to_transform].map(
+                lambda x: 1 if (pd.notna(x) and x != 0) else x
+            )
+
+            # Add 'Hugo_Symbol' column back to the DataFrame
+            df_binary.insert(
+                0, "Hugo_Symbol", self.df_collection[study_id]["cna"]["Hugo_Symbol"]
+            )
+
+            # Reassign the transformed DataFrame to the collection
+            self.df_collection[study_id]["cna_binary"] = df_binary
+
+            melted_cna = self.df_collection[study_id]["cna_binary"].melt(
+                id_vars=["Hugo_Symbol"],
+                var_name="Tumor_Sample_Barcode",
+                value_name="cna_occurrences",
+            )
+
+            final_df = pd.merge(
+                final_df,
+                melted_cna[["Hugo_Symbol", "Tumor_Sample_Barcode", "cna_occurrences"]],
+                on=["Hugo_Symbol", "Tumor_Sample_Barcode"],
+                how="outer",
+            )
+
+        if os.path.exists(os.path.join(data_folder, "sv.txt")):
+            sv_df = pd.read_csv(os.path.join(data_folder, "sv.txt"), sep="\t")
+            self.df_collection[study_id]["sv"] = sv_df
+
+            # Melt the DataFrame to combine Site1_Hugo_Symbol and Site2_Hugo_Symbol into a single column
+            melted_sv = pd.melt(
+                self.df_collection[study_id]["sv"],
+                id_vars=["Sample_Id"],
+                value_vars=["Site1_Hugo_Symbol", "Site2_Hugo_Symbol"],
+                var_name="site",
+                value_name="Hugo_Symbol",
+            )
+
+            # Drop duplicate rows to ensure each Hugo_Symbol is only counted once per Sample_Id
+            melted_sv = melted_sv.drop_duplicates(subset=["Sample_Id", "Hugo_Symbol"])
+
+            # Count the occurrences of each Hugo_Symbol in each Sample_Id
+            sv_occurrences = (
+                melted_sv.groupby(["Hugo_Symbol", "Sample_Id"])
+                .size()
+                .reset_index(name="sv_occurrences")
+            )
+
+            # Rename columns to match the desired output
+            sv_occurrences = sv_occurrences.rename(
+                columns={"Sample_Id": "Tumor_Sample_Barcode"}
+            )
+
+            final_df = pd.merge(
+                final_df,
+                sv_occurrences[
+                    ["Hugo_Symbol", "Tumor_Sample_Barcode", "sv_occurrences"]
+                ],
+                on=["Hugo_Symbol", "Tumor_Sample_Barcode"],
+                how="outer",
+            )
+
+        final_df["study_id"] = study_id
+
+        if "Sample Identifier" in sample_df.columns:
+            sample_identifier_column = "Sample Identifier"
+        elif "#Sample Identifier" in sample_df.columns:
+            sample_identifier_column = "#Sample Identifier"
+        elif "SAMPLE_ID" in sample_df.columns:
+            sample_identifier_column = "SAMPLE_ID"
+        else:
+            raise AssertionError(
+                "Sample Identifier column not found in the sample dataframe"
+            )
+
+        final_df = pd.merge(
+            final_df,
+            sample_df[
+                [sample_identifier_column, "Cancer Type", "Cancer Type Detailed"]
+            ],
+            left_on="Tumor_Sample_Barcode",
+            right_on=sample_identifier_column,
+            how="left",
+        )
+
+        final_df.rename(
+            columns={
+                "Cancer Type": "cancer_type",
+                "Cancer Type Detailed": "cancer_type_detailed",
+            },
+            inplace=True,
+        )
+
+        final_df["tissue"] = (
+            final_df["cancer_type"]
+            .map(CBIO_CANCER_TYPE_TO_TISSUE_DICTIONARY)
+            .fillna("unclassified")
+        )
+
+        # Drop the redundant SAMPLE_ID column
+        final_df.drop(columns=[sample_identifier_column], inplace=True)
+
+        return final_df
+
+    def _create_study_dataframes(self) -> pd.DataFrame:
+        dataframes = []
+
+        for study_id in self.study_ids:
+            self.df_collection[study_id] = {}
+
+            # clean up data just in case (cut out comments)
+            filename = f"{self.data_dir}/{study_id}/mutations.txt"
+
+            with open(filename, "r") as file:
+                lines = file.readlines()
+
+            changed = False
+            while lines[0].startswith("#"):
+                lines.pop(0)
+                changed = True
+
+            if changed:
+                logger.info(f"Stripped comments from {filename}")
+                # Write the remaining lines back to the file
+                with open(filename, "w") as file:
+                    file.writelines(lines)
+
+            final_df = self._create_single_study_dataframe(study_id=study_id)
+            dataframes.append(final_df)
+
+        return pd.concat(dataframes, ignore_index=True)
+
+    def plot_heatmap(
+        self,
+        stratification: Literal[
+            "tissue", "cancer_type", "cancer_type_detailed", "study_id", "sample"
+        ] = "tissue",
+        filter_category: Optional[str] = None,
+        filter_value: Optional[str] = None,
+        variation_type: Literal[
+            "mutation_occurrences",
+            "cna_nonbinary",
+            "sv_occurrences",
+            "cna_occurrences",
+            "Consequence",
+        ] = "mutation_occurrences",
+        dpi: int = 100,
+        show: bool = False,
+        figure_filename: Optional[str] = None,
+        figure_title: Optional[str] = None
+    ):
+        if variation_type == "cna_nonbinary" or variation_type == "Consequence":
+            assert (
+                stratification == "sample"
+            ), "Stratification must be 'sample' for cna_nonbinary and Consequence variations"
+
+        if variation_type != "cna_nonbinary":
+            simple_merge_by_stratification: dict[str, list[str]] = {
+                "tissue": ["Tumor_Sample_Barcode", "study_id", "cancer_type", "tissue"],
+                "cancer_type": ["Tumor_Sample_Barcode", "study_id", "cancer_type"],
+                "cancer_type_detailed": [
+                    "Tumor_Sample_Barcode",
+                    "study_id",
+                    "cancer_type_detailed",
+                ],
+                "study_id": ["Tumor_Sample_Barcode", "study_id"],
+                "sample": ["Tumor_Sample_Barcode"],
+            }
+            # stratify by merging specific columns
+            if stratification in simple_merge_by_stratification:
+                merge_on: list[str] = simple_merge_by_stratification[stratification]
+
+                if filter_category is None:  # no filtering
+                    final_df = self.big_combined_df
+                else:
+                    final_df = self.big_combined_df[
+                        self.big_combined_df[filter_category] == filter_value
+                    ]
+
+                columns_to_keep_copy = self.columns_to_keep.copy()
+
+                unique_samples_info = final_df[
+                    [
+                        "Tumor_Sample_Barcode",
+                        "cancer_type",
+                        "cancer_type_detailed",
+                        "study_id",
+                        "tissue",
+                    ]
+                ].drop_duplicates()
+
+                hugo_mask = final_df["Hugo_Symbol"].isin(
+                    [gene for gene in (self.genes) if not gene.startswith("ENSG")]
+                )
+
+                if self.merge_type == _ENSEMBL:
+                    ensg_mask = final_df["Ensembl_Gene_ID"].isin(
+                        [gene for gene in (self.genes) if gene.startswith("ENSG")]
+                    )
+                    combined_mask = ensg_mask | hugo_mask
+                elif self.merge_type == _SYMBOL:
+                    combined_mask = hugo_mask
+                else:
+                    raise AssertionError(f"Invalid merge type: {self.merge_type}")
+
+                filtered_genes_df: pd.DataFrame = final_df[combined_mask]
+
+                if self.merge_type == _ENSEMBL:
+                    existing_genes = set(filtered_genes_df["Ensembl_Gene_ID"]).union(
+                        set(filtered_genes_df["Hugo_Symbol"])
+                    )
+                elif self.merge_type == _SYMBOL:
+                    existing_genes = set(filtered_genes_df["Hugo_Symbol"])
+                else:
+                    raise AssertionError(f"Invalid merge type: {self.merge_type}")
+
+                unexpressed_genes = [
+                    gene for gene in (self.genes) if gene not in existing_genes
+                ]
+
+                # Get all unique Tumor_Sample_Barcode from the original DataFrame
+                all_samples = final_df[merge_on].drop_duplicates()
+
+                all_samples = pd.merge(
+                    all_samples, unique_samples_info, on=merge_on, how="left"
+                )
+
+                if variation_type not in columns_to_keep_copy:
+                    columns_to_keep_copy.append(variation_type)
+
+                must_keep = [
+                    "study_id",
+                    "tissue",
+                    "cancer_type",
+                    "cancer_type_detailed",
+                ]
+                for column_name in must_keep:
+                    if (
+                        column_name in merge_on
+                        and column_name not in columns_to_keep_copy
+                    ):
+                        columns_to_keep_copy.append(column_name)
+
+                # Merge the filtered genes DataFrame with all samples to ensure all samples are included
+                merged_df = pd.merge(
+                    all_samples,
+                    filtered_genes_df[columns_to_keep_copy],
+                    on=merge_on,
+                    how="left",
+                    suffixes=("_sample", "_gene"),
+                )
+
+                if (
+                    stratification != "sample"
+                ):  # no filtering
+                    df_for_heatmap_very_final: pd.DataFrame = (
+                        merged_df.groupby([self.column_for_merging, stratification])[
+                            variation_type
+                        ]
+                        .sum()
+                        .reset_index()
+                    )
+                else:
+                    df_for_heatmap_very_final: pd.DataFrame = merged_df
+
+                df = df_for_heatmap_very_final.copy()
+            else:
+                raise ValueError(
+                    "Invalid stratification value. Please choose from 'tissue', 'cancer_type', 'cancer_type_detailed', 'study_id', 'sample'"
+                )
+
+            # plot heatmap code
+            pivot_column = stratification
+
+            # simple stratification (round 2)
+            if stratification in {
+                "tissue",
+                "cancer_type",
+                "cancer_type_detailed",
+                "study_id",
+            }:
+                pass
+            elif stratification == "sample":
+                pivot_column = "Tumor_Sample_Barcode"
+            else:
+                raise ValueError(
+                    "Invalid stratification value. Please choose from 'tissue', 'cancer_type', 'cancer_type_detailed', 'study_id', 'sample'"
+                )
+
+            pivot_df1 = df.pivot(
+                index=self.column_for_merging,
+                columns=pivot_column,
+                values=variation_type,
+            )
+
+            pivot_df1 = pivot_df1.dropna(how="all")
+
+            sorted_columns = pivot_df1.count().sort_values(ascending=False).index
+            pivot_df1 = pivot_df1[sorted_columns]
+
+            if unexpressed_genes:
+                new_rows = pd.DataFrame(
+                    np.nan, index=unexpressed_genes, columns=pivot_df1.columns
+                )
+                pivot_df1 = pd.concat([pivot_df1, new_rows])
+            title = f"Heatmap of Gene mutations per gene across {stratification}"
+
+            if filter_value:
+                title = title + " - " + filter_value
+
+            if self.column_for_merging == "Ensembl_Gene_ID":
+                logger.info("Fetching gene names from Ensembl...")
+                map_ = _get_ensembl_gene_name_bulk(pivot_df1.index.tolist())
+                logger.info("Fetched gene names from Ensembl")
+                pivot_df1.rename(index=map_, inplace=True)
+
+        else:  # variation_type == "cna_nonbinary"
+            assert (
+                stratification == "sample"
+            ), "stratification must be 'sample' for CNA data"
+            assert (
+                filter_category == "study_id"
+            ), "filter_category must be 'study_id' for CNA data"
+            pivot_df1 = self.df_collection[filter_value]["cna"].copy()
+            pivot_df1.set_index("Hugo_Symbol", inplace=True)
+            pivot_df1 = pivot_df1[pivot_df1.index.isin(self.genes)]
+
+            pivot_df1 = pivot_df1.reset_index()
+            if "Hugo_Symbol" not in pivot_df1.columns:
+                pivot_df1["Hugo_Symbol"] = pivot_df1.index
+
+            # Iterate over the top_mutant_gene_list and add missing entries
+            for gene in self.genes:
+                if gene not in pivot_df1["Hugo_Symbol"].values:
+                    new_row = pd.Series(
+                        {col: np.nan for col in pivot_df1.columns}, name=gene
+                    )
+                    new_row["Hugo_Symbol"] = gene
+                    pivot_df1 = pivot_df1.append(new_row)
+
+            # Set 'Hugo_Symbol' back as index if needed
+            pivot_df1 = pivot_df1.set_index("Hugo_Symbol")
+
+            title = "Heatmap of CNA data across samples"
+
+        if pivot_df1.isna().all().all():
+            print(f"No data to plot for {stratification}")
+            return
+
+        nas_present = True
+
+        # ensure pivot_df1 is sorted by columns before plotting
+        pivot_df1: pd.DataFrame
+        pivot_df1.sort_index(axis="columns", inplace=True)
+
+        # limit to first 500 columns
+        render_divider_lines = True
+        render_column_ids = pivot_df1.shape[1] < 100
+        if (
+            pivot_df1.shape[1] > 372
+        ):  # 372 is fine, 373 is not. There's something wrong with pyplot...
+            print("Warning: Too many columns to plot. Limiting to first 372 columns")
+            pivot_df1 = pivot_df1.iloc[:, :372]
+            render_divider_lines = False
+
+        if variation_type == "cna_nonbinary":
+            min_value = -3
+            max_value = 2
+
+            levels = list(range(min_value + 1, max_value + 1))
+            pivot_df1 = pivot_df1.fillna(min_value)
+
+            colors_list = plt.get_cmap("RdBu_r", max_value - min_value + 1)(
+                range(max_value - min_value + 1)
+            )
+            colors_list = np.vstack(
+                ([[0.5, 0.5, 0.5, 0.3]], colors_list[1:])
+            )  # Grey color for -3
+            cmap = ListedColormap(colors_list)
+
+            # Define the norm with the diverging palette centered at 0
+            norm = TwoSlopeNorm(vmin=min_value, vcenter=0, vmax=max_value)
+
+        elif variation_type == "Consequence":
+            consequences: list[Union[str, float]] = list(
+                self.big_combined_df["Consequence"].unique()
+            )
+
+            colors_list = plt.get_cmap("tab20", len(consequences))(
+                range(len(consequences))
+            )
+
+            # if consequences contains nan, ensure the nan value is at the beginning
+            if np.nan in consequences:
+                colors_list = np.vstack(([[1.0, 1.0, 1.0, 0.3]], colors_list[:-1]))
+                consequences = [np.nan] + sorted(
+                    v for v in consequences if not isinstance(v, float)
+                )
+            else:
+                consequences.sort()
+                nas_present = False
+
+            cmap = ListedColormap(colors_list)
+            min_value = 0
+            max_value = len(consequences)
+            norm = BoundaryNorm(
+                boundaries=np.arange(min_value - 0.5, max_value + 0.5, 1),
+                ncolors=cmap.N,
+                clip=False,
+            )
+            levels = list(range(min_value, max_value))
+
+            string_to_int: dict[Union[str, float], int] = {
+                consequence: i for i, consequence in enumerate(consequences)
+            }
+
+            pivot_df1 = pivot_df1.map(lambda x: string_to_int[x])
+
+        else:
+            min_value = int(pivot_df1.min().min())
+
+            if pivot_df1.isna().sum().sum() != 0:
+                min_value -= 1
+            else:
+                nas_present = False
+
+            max_value = max(int(pivot_df1.max().max()), 1)
+
+            levels = list(range(min_value, max_value + 1))
+            pivot_df1 = pivot_df1.fillna(min_value)
+
+            # Create a custom colormap
+            colors_list = plt.get_cmap("Reds", len(levels))(range(len(levels)))
+            if nas_present:
+                colors_list = np.vstack(
+                    ([[0.5, 0.5, 0.5, 0.3]], colors_list)
+                )  # Grey color for -1
+            cmap = ListedColormap(colors_list)
+
+            # Define the norm, with vmin set to -1 and vmax to max_value
+            norm = BoundaryNorm(
+                boundaries=np.arange(min_value - 0.5, max_value + 1.5, 1),
+                ncolors=cmap.N,
+                clip=False,
+            )
+
+        # Create a figure
+        plt.figure(figsize=(18, 4))
+
+        # Display the heatmap with the filled array
+        plt.imshow(pivot_df1, cmap=cmap, norm=norm, aspect="auto")
+
+        colorbar_label = "Number of Mutations"
+        if variation_type == "Consequence":
+            colorbar_label = "Consequence Type"
+        else:
+            # Add colorbar with ticks for all levels, including the placeholder for -1 (NaN)
+            levels = _ints_between(min_value, max_value, 25, 7)
+
+        cbar = plt.colorbar(label=colorbar_label, ticks=levels)
+
+        labels: list[Union[str, int]] = levels.copy()
+        if nas_present:
+            labels[0] = "NaN"
+
+        if variation_type == "Consequence":
+            # Guaranteed to be bound by code flow
+            # noinspection PyUnboundLocalVariable
+            # start at 0 if there are no NaN values, otherwise at 1
+            for i, consequence in enumerate(consequences[int(nas_present) :]):
+                labels[i + int(nas_present)] = consequence
+
+        cbar.ax.set_yticklabels(labels)
+
+        plt.grid(
+            which="both",
+            axis="both" if render_divider_lines else "y",
+            color="black",
+            linestyle="-",
+            linewidth=0.5,
+        )
+        if render_column_ids:
+            x_labels = pivot_df1.columns
+        else:
+            x_labels = [""] * len(pivot_df1.columns)
+        plt.xticks(np.arange(len(pivot_df1.columns)), x_labels, rotation=90)
+        plt.yticks(np.arange(len(pivot_df1.index)), pivot_df1.index)
+
+        plt.gca().set_xticks(np.arange(-0.5, len(pivot_df1.columns), 1), minor=True)
+        plt.gca().set_yticks(np.arange(-0.5, len(pivot_df1.index), 1), minor=True)
+
+        plt.grid(which="major", color="white", linestyle="-", linewidth=0.5, alpha=0)
+        plt.gca().tick_params(which="minor", size=0)
+
+        if figure_title:
+            title = figure_title
+
+        plt.xlabel(stratification)
+        plt.ylabel("Genes")
+        plt.title(title)
+
+        filename = f"Heatmap_{stratification}"
+        if filter_value:
+            filename = f"{filename}_{filter_value}"
+
+        filepath = os.path.join(self.figure_output_dir, f"{filename}.png")
+
+        if figure_filename:
+            filepath = os.path.join(self.figure_output_dir, figure_filename)
+
+        plt.savefig(filepath, bbox_inches="tight", dpi=dpi)
+
+        if show:
+            plt.show()
+
+        # if nas_present:
+        #     pivot_df1 = pivot_df1.replace(min_value, np.nan)
+
+        # if filter_category is None:  # * no filtering
+        #     self.pivot_df_dict[variation_type] = pivot_df1
+        # else:
+        #     self.pivot_df_dict[selected_group_category][selected_group][variation_type] = pivot_df1
+
+
+def cbio_plot(
+    study_ids: list[str],
+    genes: list[str],
+
+    stratification: Literal[
+        "tissue", "cancer_type", "cancer_type_detailed", "study_id", "sample"
+    ] = "tissue",
+    variation_type: Literal[
+        "mutation_occurrences",
+        "cna_nonbinary",
+        "sv_occurrences",
+        "cna_occurrences",
+        "Consequence",
+    ] = "mutation_occurrences",
+    filter: Optional[tuple[str, str]] = None,
+
+    merge_type: Literal["Ensembl", "Symbol"] = "Symbol",
+    remove_non_ensembl_genes: bool = False,
+
+    data_dir: str = "gget_cbio_cache",
+    figure_dir: str = "gget_cbio_figures",
+    figure_filename: Optional[str] = None,
+    verbose: bool = True,
+    confirm_download: bool = False,
+    dpi: int = 100,
+    show: bool = False,
+    figure_title: Optional[str] = None,
+) -> bool:
+    """
+    Plot a heatmap of given genes in the given studies.
+
+    Args:
+    study_ids                   list of cBioPortal study IDs
+    genes                       list of gene names
+
+    stratification              column to group the data by, default 'tissue'
+    variation_type              column to use for the heatmap, default 'mutation_occurrences'
+    filter                      filter to apply to the data in the format (column_name, value), default None
+
+    merge_type                  gene ID type to group by (e.g. 'IL13' or 'ENSG00000169194'), default 'Symbol'
+    remove_non_ensembl_genes    whether to remove genes without Ensembl IDs, default False
+
+    data_dir                    directory to store the downloaded data, default 'gget_cbio_cache'
+    figure_dir                  directory to save the figure in
+    figure_filename             filename to use for the figure, relative to `figure_dir`, default auto-generated
+    verbose                     whether to print out progress, default False
+    confirm_download            whether to confirm the download before proceeding, default False
+    dpi                         resolution of the figure, default 100 dots per inch
+    show                        whether to show the plot, default False
+
+    figure_title                title to use for the figure, default auto-generated
+
+    Returns True if the plot was successful, False otherwise.
+    """
+    if verbose:
+        logger.info("Downloading data")
+
+    if not download_cbioportal_data(
+        study_ids, verbose=verbose, out_dir=data_dir, confirm_download=confirm_download
+    ):
+        logger.error("Failed to download data")
+        return False
+
+    if verbose:
+        logger.info("Loading data")
+    gene_analyzer = _GeneAnalysis(
+        study_ids,
+        genes,
+        data_dir=data_dir,
+        figure_output_dir=figure_dir,
+        merge_type=merge_type,
+        remove_non_ensembl_genes=remove_non_ensembl_genes,
+    )
+    if verbose:
+        logger.info("Data loaded")
+        logger.info("Plotting data")
+
+    gene_analyzer.plot_heatmap(
+        stratification=stratification,
+        filter_category=filter[0] if filter else None,
+        filter_value=filter[1] if filter else None,
+        variation_type=variation_type,
+        dpi=dpi,
+        show=show,
+        figure_filename=figure_filename,
+        figure_title=figure_title
+    )
+
+    del gene_analyzer
+
+    return True
diff --git a/gget/gget_cosmic.py b/gget/gget_cosmic.py
index bd0a602a..ba594e2a 100644
--- a/gget/gget_cosmic.py
+++ b/gget/gget_cosmic.py
@@ -118,7 +118,7 @@ def select_reference(
             f"Cosmic_ResistanceMutations_v{cosmic_version}_GRCh{grch_version}.tsv"
         )
 
-    elif mutation_class == "screen":
+    elif mutation_class == "genome_screen":
         download_link = f"https://cancer.sanger.ac.uk/api/mono/products/v1/downloads/scripted?path=grch{grch_version}/cosmic/v{cosmic_version}/Cosmic_GenomeScreensMutant_Tsv_v{cosmic_version}_GRCh{grch_version}.tar&bucket=downloads"
         tarred_folder = (
             f"Cosmic_GenomeScreensMutant_Tsv_v{cosmic_version}_GRCh{grch_version}"
@@ -127,6 +127,15 @@ def select_reference(
             f"Cosmic_GenomeScreensMutant_v{cosmic_version}_GRCh{grch_version}.tsv"
         )
 
+    elif mutation_class == "targeted_screen":
+        download_link = f"https://cancer.sanger.ac.uk/api/mono/products/v1/downloads/scripted?path=grch{grch_version}/cosmic/v{cosmic_version}/Cosmic_CompleteTargetedScreensMutant_Tsv_v{cosmic_version}_GRCh{grch_version}.tar&bucket=downloads"
+        tarred_folder = (
+            f"Cosmic_CompleteTargetedScreensMutant_Tsv_v{cosmic_version}_GRCh{grch_version}"
+        )
+        contained_file = (
+            f"Cosmic_CompleteTargetedScreensMutant_v{cosmic_version}_GRCh{grch_version}.tsv"
+        )
+
     # Only available for the latest COSMIC version
     elif mutation_class == "cancer_example":
         download_link = f"https://cog.sanger.ac.uk/cosmic-downloads-production/taster/example_grch{grch_version}.tar"
@@ -188,6 +197,14 @@ def select_reference(
     return file_path, overwrite
 
 
+def convert_chromosome_value_to_int_when_possible(val):
+    try:
+        # Try to convert the value to a float, then to an int, and finally to a string
+        return str(int(float(val)))
+    except ValueError:
+        # If conversion fails, keep the value as it is
+        return val
+
 def cosmic(
     searchterm,
     entity="mutations",
@@ -198,6 +215,8 @@ def cosmic(
     cosmic_version=None,
     grch_version=37,
     gget_mutate=True,
+    keep_genome_info=False,
+    remove_duplicates=False,
     out=None,
     verbose=True,
 ):
@@ -234,12 +253,14 @@ def cosmic(
 
     Args for downloading COSMIC databases:
     NOTE: Downloading complete databases from COSMIC requires an account (https://cancer.sanger.ac.uk/cosmic/register; free for academic use, license for commercial use)
-    - download_cosmic (True/False) whether to switch into database download mode. Default: False
-    - mutation_class  (str) Type of COSMIC database to download. One of the following:
-                      'cancer' (default), 'cell_line', 'census', 'resistance', 'screen', 'cancer_example'
-    - cosmic_version  (int) Version of the COSMIC database. Default: None -> Defaults to latest version.
-    - grch_version    (int) Version of the human GRCh reference genome the COSMIC database was based on (37 or 38). Default: 37
-    - gget_mutate     (True/False) whether to create a modified version of the database for use with gget mutate. Default: True
+    - download_cosmic   (True/False) whether to switch into database download mode. Default: False
+    - mutation_class    (str) Type of COSMIC database to download. One of the following:
+                        'cancer' (default), 'cell_line', 'census', 'resistance', 'genome_screen', 'targeted_screen', 'cancer_example'
+    - cosmic_version    (int) Version of the COSMIC database. Default: None -> Defaults to latest version.
+    - grch_version      (int) Version of the human GRCh reference genome the COSMIC database was based on (37 or 38). Default: 37
+    - gget_mutate       (True/False) Whether to create a modified version of the database for use with gget mutate. Default: True
+    - keep_genome_info  (True/False) Whether to keep genome information (e.g. location of mutation in the genome) in the modified database for use with gget mutate. Default: False
+    - remove_duplicates (True/False) Whether to remove duplicate rows from the modified database for use with gget mutate. Default: False
 
     General args:
     - out             (str) Path to the file (or folder when downloading databases with the download_cosmic flag) the results will be saved in, e.g. 'path/to/results.json'.
@@ -261,7 +282,8 @@ def cosmic(
             "cell_line",
             "census",
             "resistance",
-            "screen",
+            "genome_screen"
+            "targeted_screen",
             "cancer_example",
         ]
         if mutation_class not in mut_class_allowed:
@@ -304,18 +326,23 @@ def cosmic(
                 relevant_cols = [
                     "GENE_NAME",
                     "ACCESSION_NUMBER",
-                    "GENOMIC_MUTATION_ID",
                     "MUTATION_URL",
                     "Mutation CDS",
+                    "Mutation AA"
                 ]
+                if keep_genome_info:
+                    relevant_cols.extend(['Mutation genome position GRCh37', 'GENOMIC_WT_ALLELE_SEQ','GENOMIC_MUT_ALLELE_SEQ', 'GENOMIC_MUTATION_ID'])    #* uncomment to include strand information (tested not to be accurate for CMC)
+                    # relevant_cols.extend(['Mutation genome position GRCh37', 'GENOMIC_MUTATION_ID'])    #* erase to include strand information (tested not to be accurate for CMC)
             else:
                 relevant_cols = [
                     "GENE_SYMBOL",
                     "TRANSCRIPT_ACCESSION",
-                    "GENOMIC_MUTATION_ID",
                     "MUTATION_ID",
                     "MUTATION_CDS",
+                    "MUTATION_AA"
                 ]
+                if keep_genome_info:
+                    relevant_cols.extend(['HGVSG', 'STRAND', 'GENOMIC_MUTATION_ID'])
 
             df = pd.read_csv(mutation_tsv_file, usecols=relevant_cols, sep="\t")
 
@@ -327,35 +354,110 @@ def cosmic(
                         "ACCESSION_NUMBER": "seq_ID",
                         "Mutation CDS": "mutation",
                         "MUTATION_URL": "MUTATION_ID",
+                        "Mutation AA": "mutation_aa",
                     }
                 )
+
+                if keep_genome_info:  
+                    #* erase to include strand information (tested not to be accurate for CMC)
+                    # df = df.rename(
+                    #     columns={
+                    #         "Mutation genome position GRCh37": "position_genome",
+                    #     }
+                    # )
+
+                    from gget.gget_mutate import mutation_pattern, convert_chromosome_value_to_int_when_possible
+                    import numpy as np
+
+                    #* uncomment to include strand information (tested not to be accurate for CMC)
+                    df[['chromosome', 'GENOME_POS']] = df['Mutation genome position GRCh37'].str.split(':', expand=True)
+                    df['chromosome'] = df['chromosome'].apply(convert_chromosome_value_to_int_when_possible)
+                    df[['GENOME_START', 'GENOME_STOP']] = df['GENOME_POS'].str.split('-', expand=True)
+
+                    df[["nucleotide_positions", "actual_mutation"]] = df["mutation"].str.extract(mutation_pattern)
+
+                    sub_mask = df['actual_mutation'].str.contains('>')
+                    ins_mask = (df['actual_mutation'].str.contains('ins')) & (~df['actual_mutation'].str.contains('delins')) 
+                    delins_mask = df['actual_mutation'].str.contains('delins')
+                    ins_delins_mask = ins_mask | delins_mask
+                    sub_ins_delins_mask = sub_mask | ins_delins_mask
+
+                    df.loc[sub_mask, 'wt_allele_cds'] = df.loc[sub_mask, 'actual_mutation'].str.split('>').str[0]
+                    df.loc[sub_mask, 'mut_allele_cds'] = df.loc[sub_mask, 'actual_mutation'].str.split('>').str[1]
+
+                    df.loc[ins_delins_mask, 'mut_allele_cds'] = df.loc[ins_delins_mask, 'actual_mutation'].str.extract(r'ins(.+)')[0]
+
+                    df['strand'] = np.nan
+
+                    df.loc[sub_ins_delins_mask, 'strand'] = np.where(
+                        pd.isna(df.loc[sub_ins_delins_mask, 'GENOMIC_MUT_ALLELE_SEQ']),
+                        np.nan,
+                        np.where(
+                            df.loc[sub_ins_delins_mask, 'mut_allele_cds'] != df.loc[sub_ins_delins_mask, 'GENOMIC_MUT_ALLELE_SEQ'],
+                            '-',
+                            '+'
+                        )
+                    )
+
+                    df.loc[sub_mask, 'actual_mutation_updated'] = df.loc[sub_mask, 'GENOMIC_WT_ALLELE_SEQ'] + '>' + df.loc[sub_mask, 'GENOMIC_MUT_ALLELE_SEQ']
+                    df.loc[ins_mask, 'actual_mutation_updated'] = 'ins' + df.loc[ins_mask, 'GENOMIC_MUT_ALLELE_SEQ']
+                    df.loc[delins_mask, 'actual_mutation_updated'] = 'delins' + df.loc[delins_mask, 'GENOMIC_MUT_ALLELE_SEQ']
+
+                    df.loc[~sub_ins_delins_mask, 'actual_mutation_final'] = df.loc[~sub_ins_delins_mask, 'actual_mutation']
+
+                    df.loc[sub_ins_delins_mask, 'actual_mutation_final'] = np.where(
+                        pd.isna(df.loc[sub_ins_delins_mask, 'strand']),
+                        np.nan,
+                        np.where(df.loc[sub_ins_delins_mask, 'strand'] == '+', df.loc[sub_ins_delins_mask, 'actual_mutation'], df.loc[sub_ins_delins_mask, 'actual_mutation_updated'])
+                    )
+
+                    df['mutation_genome'] = np.where(
+                        df['GENOME_START'] != df['GENOME_STOP'],
+                        'g.' + df['GENOME_START'].astype(str) + '_' + df['GENOME_STOP'].astype(str) + df['actual_mutation_final'],
+                        'g.' + df['GENOME_START'].astype(str) + df['actual_mutation_final']
+                    )
+
+                    df.loc[df['Mutation genome position GRCh37'].isna(), 'mutation_genome'] = np.nan
+
+                    df.drop(columns=['GENOME_POS', 'GENOME_START', 'GENOME_STOP', 'nucleotide_positions', 'actual_mutation', 'actual_mutation_updated', 'actual_mutation_final', 'Mutation genome position GRCh37', 'wt_allele_cds', 'mut_allele_cds', 'GENOMIC_WT_ALLELE_SEQ', 'GENOMIC_MUT_ALLELE_SEQ'], inplace=True)
+
             else:
                 df = df.rename(
                     columns={
                         "GENE_SYMBOL": "GENE_NAME",
                         "TRANSCRIPT_ACCESSION": "seq_ID",
                         "MUTATION_CDS": "mutation",
+                        "MUTATION_AA": "mutation_aa",
                     }
                 )
 
+                if keep_genome_info:
+                    df['mutation_genome'] = df['HGVSG'].str.split(':').str[1]
+
+                    df.drop(columns=['HGVSG'], inplace=True)
+
+                    df = df.rename(
+                        columns={
+                            "CHROMOSOME": "chromosome",
+                            "STRAND": "strand",
+                        }
+                    )
+
             # Remove version numbers from Ensembl IDs
             df["seq_ID"] = df["seq_ID"].str.split(".").str[0]
 
-            # Get mut_ID column (by combining GENE_NAME, GENOMIC_MUTATION_ID and MUTATION_URL/MUTATION_ID)
-            df["GENE_NAME"] = df["GENE_NAME"].astype(str)
-            df["GENOMIC_MUTATION_ID"] = df["GENOMIC_MUTATION_ID"].fillna("NA")
-            df["GENOMIC_MUTATION_ID"] = df["GENOMIC_MUTATION_ID"].astype(str)
-            df["MUTATION_ID"] = df["MUTATION_ID"].astype(str)
-
-            df["mut_ID"] = (
-                df["GENE_NAME"]
-                + "_"
-                + df["GENOMIC_MUTATION_ID"]
-                + "_"
-                + df["MUTATION_ID"]
-            )
+            df["gene_name"] = df["GENE_NAME"].astype(str)
+            df["mutation_id"] = df["MUTATION_ID"].astype(str)
+
+            df = df.drop(columns=["GENE_NAME", "MUTATION_ID"])
 
-            df = df.drop(columns=["GENE_NAME", "GENOMIC_MUTATION_ID", "MUTATION_ID"])
+            if remove_duplicates:
+                duplicate_count = df.duplicated(subset=['seq_ID', 'mutation'], keep=False).sum() // 2
+                print(f"Removing {duplicate_count} duplicate entries from the COSMIC csv for gget mutate: {duplicate_count}")
+                df['non_na_count'] = df.notna().sum(axis=1)
+                df = df.sort_values(by='non_na_count', ascending=False)
+                df = df.drop_duplicates(subset=['seq_ID', 'mutation'], keep='first')
+                df = df.drop(columns=['non_na_count'])
 
             mutate_csv_out = mutation_tsv_file.replace(".tsv", "_gget_mutate.csv")
             df.to_csv(mutate_csv_out, index=False)
diff --git a/gget/gget_elm.py b/gget/gget_elm.py
index df5a362a..ab5fb7d2 100644
--- a/gget/gget_elm.py
+++ b/gget/gget_elm.py
@@ -387,7 +387,7 @@ def elm(
     ]
     for col in ortho_cols:
         if col not in ortho_df.columns:
-            ortho_df[col] = np.NaN
+            ortho_df[col] = np.nan
 
     ortho_df = ortho_df[ortho_cols]
     # Remove false positives and true negatives
@@ -486,7 +486,7 @@ def elm(
 
     for col in regex_cols:
         if col not in df_regex_matches.columns:
-            df_regex_matches[col] = np.NaN
+            df_regex_matches[col] = np.nan
 
     df_regex_matches = df_regex_matches[regex_cols]
     # Remove false positives and true negatives
diff --git a/gget/gget_enrichr.py b/gget/gget_enrichr.py
index 69562a56..92eab133 100644
--- a/gget/gget_enrichr.py
+++ b/gget/gget_enrichr.py
@@ -2,6 +2,7 @@
 import pandas as pd
 import json as json_package
 import numpy as np
+from typing import Optional, Literal
 
 # Plotting packages
 import matplotlib.pyplot as plt
@@ -9,8 +10,8 @@
 import textwrap
 
 from .constants import (
-    POST_ENRICHR_URL,
-    GET_ENRICHR_URL,
+    POST_ENRICHR_URLS,
+    GET_ENRICHR_URLS,
     POST_BACKGROUND_ID_ENRICHR_URL,
     GET_BACKGROUND_ENRICHR_URL,
 )
@@ -18,22 +19,24 @@
 from .gget_info import info
 
 from .utils import set_up_logger
+
 logger = set_up_logger()
 
+
 def ensembl_to_gene_names(ensembl_ids):
     """
     Function to fetch gene names from a list of Ensembl IDs using gget info.
     """
     genes_v2 = []
 
-    for gene_id in ensembl_ids:
-        # Remove version number if passed
-        gene_id = gene_id.split(".")[0]
+    # Remove version number if passed
+    ensembl_ids = [gene_id.split(".")[0] for gene_id in ensembl_ids]
 
-        info_df = info(gene_id, pdb=False, ncbi=False, uniprot=False, verbose=False)
+    info_df = info(ensembl_ids, pdb=False, ncbi=False, uniprot=False, verbose=False)
 
+    for gene_id in ensembl_ids:
         # Check if Ensembl ID was found
-        if isinstance(info_df, type(None)):
+        if gene_id not in info_df.index:
             logger.warning(
                 f"ID '{gene_id}' not found. Please double-check spelling/arguments."
             )
@@ -49,30 +52,32 @@ def ensembl_to_gene_names(ensembl_ids):
 
     return genes_v2
 
+
 def clean_genes_list(genes_list):
     # Remove any NaNs/Nones from the gene list
     genes_clean = []
     for gene in genes_list:
-        if not isinstance(gene, float) and not gene is None and not gene == "nan":
+        if not isinstance(gene, float) and gene is not None and gene != "nan":
             genes_clean.append(gene)
     return genes_clean
 
 
 def enrichr(
-    genes,
-    database,
-    background_list=None,
-    background=False,
-    ensembl=False,
-    ensembl_bkg=False,
-    plot=False,
-    figsize=(10, 10),
-    ax=None,
-    kegg_out=None,
-    kegg_rank=1,
-    json=False,
-    save=False,
-    verbose=True,
+    genes: list[str],
+    database: str,
+    species: Literal["human", "mouse", "fly", "yeast", "worm", "fish"] = "human",
+    background_list: Optional[list[str]] = None,
+    background: bool = False,
+    ensembl: bool = False,
+    ensembl_bkg: bool = False,
+    plot: bool = False,
+    figsize: tuple[int, int] = (10, 10),
+    ax: plt.Axes = None,
+    kegg_out: Optional[str] = None,
+    kegg_rank: int = 1,
+    json: bool = False,
+    save: bool = False,
+    verbose: bool = True,
 ):
     """
     Perform an enrichment analysis on a list of genes using Enrichr (https://maayanlab.cloud/Enrichr/).
@@ -81,17 +86,24 @@ def enrichr(
     - genes             List of Entrez gene symbols to perform enrichment analysis on, passed as a list of strings, e.g. ['PHF14', 'RBM3', 'MSL1', 'PHF21A'].
                         Set 'ensembl = True' to input a list of Ensembl gene IDs, e.g. ['ENSG00000106443', 'ENSG00000102317', 'ENSG00000188895'].
     - database          Database to use as reference for the enrichment analysis.
-                        Supported shortcuts (and their default database):
+                        Supported shortcuts (and their default database), ONLY SUPPORTED FOR HUMAN/MOUSE SPECIES (other species must specify the full database name):
                         'pathway' (KEGG_2021_Human)
                         'transcription' (ChEA_2016)
                         'ontology' (GO_Biological_Process_2021)
                         'diseases_drugs' (GWAS_Catalog_2019)
                         'celltypes' (PanglaoDB_Augmented_2021)
                         'kinase_interactions' (KEA_2015)
-                        or any database listed under Gene-set Library at: https://maayanlab.cloud/Enrichr/#libraries
-    - background_list   List of gene names/Ensembl IDs to be used as background genes. (Default: None)
+                        or any database listed under Gene-set Library at: https://maayanlab.cloud/Enrichr/#libraries or the species-specific libraries listed below
+    - species           Enrichr species database to query. Options:
+                        'human' (default) [H. sapiens] - https://maayanlab.cloud/Enrichr/#libraries
+                        'mouse' [M. musculus] - equivalent to 'human'
+                        'fly' [D. melanogaster] - https://maayanlab.cloud/FlyEnrichr/#stats
+                        'yeast' [S. cerevisiae] - https://maayanlab.cloud/YeastEnrichr/#stats
+                        'worm' [C. elegans] - https://maayanlab.cloud/WormEnrichr/#stats
+                        'fish' [D. rerio] - https://maayanlab.cloud/FishEnrichr/#stats
+    - background_list   List of gene names/Ensembl IDs to be used as background genes. ONLY SUPPORTED FOR HUMAN/MOUSE SPECIES (Default: None)
     - background        If True, use set of > 20,000 default background genes listed here: https://github.com/pachterlab/gget/blob/main/gget/constants/enrichr_bkg_genes.txt.
-                        (Default: False)
+                        ONLY SUPPORTED FOR HUMAN/MOUSE SPECIES (Default: False)
     - ensembl           Define as 'True' if 'genes' is a list of Ensembl gene IDs. (Default: False)
     - ensembl_bkg       Define as 'True' if 'background_list' is a list of Ensembl gene IDs. (Default: False)
     - plot              True/False whether to provide a graphical overview of the first 15 results. (Default: False)
@@ -106,13 +118,58 @@ def enrichr(
     Returns a data frame with the Enrichr results.
     """
 
+    if species not in ["human", "mouse", "fly", "yeast", "worm", "fish"]:
+        raise ValueError(
+            f"Argument 'species' must be one of 'human', 'mouse', 'fly', 'yeast', 'worm', or 'fish'."
+        )
+
+    if species == "mouse":
+        species = "human"
+
+    species_enrichr = f"{species.capitalize()}Enrichr"
+    if species == "human":
+        species_enrichr = "Enrichr"
+
+    if species != "human":
+        if database in [
+            "pathway",
+            "transcription",
+            "ontology",
+            "diseases_drugs",
+            "celltypes",
+            "kinase_interactions",
+        ]:
+            raise ValueError(
+                f"Database '{database}' is not supported for species '{species}'."
+                f" Please select a database from the species-specific libraries listed at:"
+                f" https://maayanlab.cloud/{species_enrichr}/#stats."
+            )
+
+        if background:
+            raise ValueError(
+                f"Background genes are only supported for species 'human' and 'mouse', not for species '{species}'."
+                f" Please set 'background=False' or leave it unspecified."
+            )
+
+        if background_list:
+            raise ValueError(
+                f"Background genes are only supported for species 'human' and 'mouse', not for species '{species}'."
+                f" Please do not provide a value for 'background_list'."
+            )
+
     # Define database
     # All available libraries: https://maayanlab.cloud/Enrichr/#libraries
-    db_message = f"""
-    Please note that there might be a more appropriate database for your application. 
-    Go to https://maayanlab.cloud/Enrichr/#libraries for a full list of supported databases.
-    """
-    if not (type(background) == bool):
+    if species == "human":
+        db_message = f"""
+        Please note that there might be a more appropriate database for your application. 
+        Go to https://maayanlab.cloud/{species_enrichr}/#libraries for a full list of supported databases.
+        """
+    else:
+        db_message = f"""
+        Please note that there might be a more appropriate database for your application. 
+        Go to https://maayanlab.cloud/{species_enrichr}/#stats for a full list of supported databases.
+        """
+    if not isinstance(background, bool):
         raise ValueError(
             f"Argument`background` must be a boolean True/False. If you are adding a background list, use the argument `background_list` instead."
         )
@@ -181,7 +238,7 @@ def enrichr(
             return
 
     # If single gene passed as string, convert to list
-    if type(genes) == str:
+    if isinstance(genes, str):
         genes = [genes]
 
     ## Transform Ensembl IDs to gene symbols
@@ -202,7 +259,7 @@ def enrichr(
     if background_list and ensembl_bkg:
         background_list = ensembl_to_gene_names(background_list)
 
-    if not isinstance(background_list, type(None)):
+    if background_list is not None:
         if len(background_list) == 0 and ensembl:
             logger.error("No background gene symbols found for given Ensembl IDs.")
             return
@@ -228,7 +285,7 @@ def enrichr(
         "description": (None, "gget client gene list"),
     }
 
-    r1 = requests.post(POST_ENRICHR_URL, files=args_dict)
+    r1 = requests.post(POST_ENRICHR_URLS[species], files=args_dict)
 
     if not r1.ok:
         raise RuntimeError(
@@ -290,11 +347,19 @@ def enrichr(
 
     # Submit query to Enrich using gene list and background genes list
     if not background_final:
-        query_string = f"?userListId={userListId}&backgroundType={database}"
-        r2 = requests.get(GET_ENRICHR_URL + query_string)
+        r2 = requests.get(
+            GET_ENRICHR_URLS[species],
+            params={"userListId": userListId, "backgroundType": database},
+        )
     else:
-        query_string = f"?userListId={userListId}&backgroundid={background_list_id}&backgroundType={database}"
-        r2 = requests.post(GET_BACKGROUND_ENRICHR_URL + query_string)
+        r2 = requests.post(
+            GET_BACKGROUND_ENRICHR_URL,
+            params={
+                "userListId": userListId,
+                "backgroundid": background_list_id,
+                "backgroundType": database,
+            },
+        )
 
     if not r2.ok:
         if background_final:
@@ -335,12 +400,20 @@ def enrichr(
         df = pd.DataFrame(enrichr_results[database], columns=columns)
 
     except KeyError:
-        logger.error(
-            f"""
-            Database {database} not found. Go to https://maayanlab.cloud/Enrichr/#libraries 
-            for a full list of supported databases.
-            """
-        )
+        if species=="human":
+            logger.error(
+                f"""
+                Database {database} not found. Go to https://maayanlab.cloud/{species_enrichr}/#libraries 
+                for a full list of supported databases.
+                """
+            )
+        else:
+            logger.error(
+                f"""
+                Database {database} not found. Go to https://maayanlab.cloud/{species_enrichr}/#stats 
+                for a full list of supported databases.
+                """
+            )
         return
 
     # Drop last two columns ("Old p-value", "Old adjusted p-value")
@@ -478,7 +551,7 @@ def enrichr(
         plt.tight_layout()
 
         if save:
-            fig.savefig(
+            plt.savefig(
                 "gget_enrichr_results.png",
                 dpi=300,
                 bbox_inches="tight",
diff --git a/gget/gget_info.py b/gget/gget_info.py
index 629bd8a4..d953dbcf 100644
--- a/gget/gget_info.py
+++ b/gget/gget_info.py
@@ -5,7 +5,8 @@
 from bs4 import BeautifulSoup
 
 # Custom functions
-from .utils import rest_query, get_uniprot_info, wrap_cols_func, get_pdb_ids, set_up_logger
+from .utils import rest_query, get_uniprot_info, wrap_cols_func, get_pdb_ids, set_up_logger, post_query
+
 logger = set_up_logger()
 
 # Constants
@@ -105,56 +106,54 @@ def info(
     master_dict = {}
 
     # Query REST APIs from https://rest.ensembl.org/
-    for ensembl_ID in ens_ids_clean:
-        # Create dict to save query results
-        results_dict = {ensembl_ID: {}}
+    endpoint = "lookup/id/"
+    query = {"ids": ens_ids_clean, "expand": True}
 
-        # Define the REST query
-        query = "lookup/id/" + ensembl_ID + "?" + "expand=1"
+    results_dict = post_query(server, endpoint, query)
+    results_dict = {k: v for k, v in results_dict.items() if v is not None}
 
-        # Submit query
+    for ensembl_ID, df_temp in results_dict.items():
         try:
-            df_temp = rest_query(server, query, content_type)
-
+            # Add Ensembl ID with latest version number to df_temp
+            df_temp["ensembl_id"] = str(df_temp["id"]) + "." + str(df_temp["version"])
+        except KeyError:
+            # Just add Ensembl ID if no version found
+            df_temp["ensembl_id"] = str(df_temp["id"])
+
+    # second pass for ids that were not found in the initial query
+    ens_ids_clean_tmp = [ensembl_ID for ensembl_ID in ens_ids_clean if ensembl_ID not in results_dict.keys()]
+
+    if len(ens_ids_clean_tmp) > 0:
+        # print(f"Second pass for ids: {ens_ids_clean_tmp}")
+        # Try submitting query without expand (expand does not work for exons and translation IDs)
+        query = {"ids": ens_ids_clean_tmp}
+        results_dict_new = post_query(server, endpoint, query)
+        results_dict_new = {k: v for k, v in results_dict_new.items() if v is not None}
+
+        for ensembl_ID, df_temp in results_dict_new.items():
             try:
                 # Add Ensembl ID with latest version number to df_temp
-                ensembl_id_dict = {
-                    "ensembl_id": str(df_temp["id"]) + "." + str(df_temp["version"])
-                }
-                df_temp.update(ensembl_id_dict)
-
+                df_temp["ensembl_id"] = str(df_temp["id"]) + "." + str(df_temp["version"])
             except KeyError:
                 # Just add Ensembl ID if no version found
-                ensembl_id_dict = {"ensembl_id": str(df_temp["id"])}
-                df_temp.update(ensembl_id_dict)
+                df_temp["ensembl_id"] = str(df_temp["id"])
 
-        # If query returns in an error:
-        except RuntimeError:
-            # Try submitting query without expand (expand does not work for exons and translation IDs)
-            try:
-                query = "lookup/id/" + ensembl_ID + "?"
-                df_temp = rest_query(server, query, content_type)
-                # Add Ensembl ID with latest version number to df_temp
-                ensembl_id_dict = {
-                    "ensembl_id": str(df_temp["id"]) + "." + str(df_temp["version"])
-                }
-                df_temp.update(ensembl_id_dict)
-
-            # Log error if this also did not work
-            except RuntimeError:
-                if verbose:
-                    logger.warning(
-                        f"ID '{ensembl_ID}' not found. Please double-check spelling/arguments and try again."
-                    )
+        results_dict.update(results_dict_new)
 
-                # Remove IDs that were not found from ID list
-                ens_ids_clean_2.remove(ensembl_ID)
+    ens_ids_clean_2 = []
+    for ensembl_ID in ens_ids_clean:
+        if ensembl_ID in results_dict.keys():
+            ens_ids_clean_2.append(ensembl_ID)
+        else:
+            if verbose:
+                logger.warning(
+                    f"ID '{ensembl_ID}' not found. Please double-check spelling/arguments and try again."
+                )
 
-                continue
+    # rewrite results to be in the input order
+    results_dict = {ensembl_ID: results_dict[ensembl_ID] for ensembl_ID in ens_ids_clean_2}
 
-        # Add results to master dict
-        results_dict[ensembl_ID].update(df_temp)
-        master_dict.update(results_dict)
+    master_dict.update(results_dict)
 
     # Return None if none of the Ensembl IDs were found
     if len(master_dict) == 0:
@@ -187,7 +186,7 @@ def info(
                 except Exception as e:
                     if verbose:
                         logger.warning(
-                            f"UniProt server request for ID '{ens_id}' return following error:\n{e}"
+                            f"UniProt server request for ID '{ens_id}' returned the following error:\n{e}"
                         )
                     continue
 
@@ -321,7 +320,7 @@ def info(
                 if pdb_ids:
                     df_pdb["pdb_id"] = [pdb_ids]
                 else:
-                    df_pdb["pdb_id"] = [np.NaN]
+                    df_pdb["pdb_id"] = [np.nan]
 
                 # Transpose pdb df and add Ensembl ID as column name
                 df_pdb = df_pdb.T
@@ -437,27 +436,27 @@ def info(
                         # Just add ID if no version found
                         all_transcripts.append(trans_dict["id"])
                 except:
-                    all_transcripts.append(np.NaN)
+                    all_transcripts.append(np.nan)
                 try:
                     transcript_names.append(trans_dict["display_name"])
                 except:
-                    transcript_names.append(np.NaN)
+                    transcript_names.append(np.nan)
                 try:
                     transcript_biotypes.append(trans_dict["biotype"])
                 except:
-                    transcript_biotypes.append(np.NaN)
+                    transcript_biotypes.append(np.nan)
                 try:
                     transcript_starts.append(trans_dict["start"])
                 except:
-                    transcript_starts.append(np.NaN)
+                    transcript_starts.append(np.nan)
                 try:
                     transcript_ends.append(trans_dict["end"])
                 except:
-                    transcript_ends.append(np.NaN)
+                    transcript_ends.append(np.nan)
                 try:
                     transcript_strands.append(trans_dict["strand"])
                 except:
-                    transcript_strands.append(np.NaN)
+                    transcript_strands.append(np.nan)
 
             data["all_transcripts"].append(all_transcripts)
             data["transcript_biotypes"].append(transcript_biotypes)
@@ -467,12 +466,12 @@ def info(
             data["transcript_ends"].append(transcript_ends)
 
         except:
-            data["all_transcripts"].append(np.NaN)
-            data["transcript_biotypes"].append(np.NaN)
-            data["transcript_names"].append(np.NaN)
-            data["transcript_strands"].append(np.NaN)
-            data["transcript_starts"].append(np.NaN)
-            data["transcript_ends"].append(np.NaN)
+            data["all_transcripts"].append(np.nan)
+            data["transcript_biotypes"].append(np.nan)
+            data["transcript_names"].append(np.nan)
+            data["transcript_strands"].append(np.nan)
+            data["transcript_starts"].append(np.nan)
+            data["transcript_ends"].append(np.nan)
 
         # Clean up exon info, if available
         all_exons = []
@@ -491,24 +490,24 @@ def info(
                         # Just add ID if no version found
                         all_exons.append(exon_dict["id"])
                 except:
-                    all_exons.append(np.NaN)
+                    all_exons.append(np.nan)
                 try:
                     exon_starts.append(exon_dict["start"])
                 except:
-                    exon_starts.append(np.NaN)
+                    exon_starts.append(np.nan)
                 try:
                     exon_ends.append(exon_dict["end"])
                 except:
-                    exon_ends.append(np.NaN)
+                    exon_ends.append(np.nan)
 
             data["all_exons"].append(all_exons)
             data["exon_starts"].append(exon_starts)
             data["exon_ends"].append(exon_ends)
 
         except:
-            data["all_exons"].append(np.NaN)
-            data["exon_starts"].append(np.NaN)
-            data["exon_ends"].append(np.NaN)
+            data["all_exons"].append(np.nan)
+            data["exon_starts"].append(np.nan)
+            data["exon_ends"].append(np.nan)
 
         # Clean up translation info, if available
         all_translations = []
@@ -527,24 +526,24 @@ def info(
                         # Just add ID if no version found
                         all_translations.append(transl_dict["id"])
                 except:
-                    all_translations.append(np.NaN)
+                    all_translations.append(np.nan)
                 try:
                     translation_starts.append(transl_dict["start"])
                 except:
-                    translation_starts.append(np.NaN)
+                    translation_starts.append(np.nan)
                 try:
                     translation_ends.append(transl_dict["end"])
                 except:
-                    translation_ends.append(np.NaN)
+                    translation_ends.append(np.nan)
 
             data["all_translations"].append(all_translations)
             data["translation_starts"].append(translation_starts)
             data["translation_ends"].append(translation_ends)
 
         except:
-            data["all_translations"].append(np.NaN)
-            data["translation_starts"].append(np.NaN)
-            data["translation_ends"].append(np.NaN)
+            data["all_translations"].append(np.nan)
+            data["translation_starts"].append(np.nan)
+            data["translation_ends"].append(np.nan)
 
     # Append cleaned up info to df_final
     df_final = pd.concat(
diff --git a/gget/gget_mutate.py b/gget/gget_mutate.py
index 99209a69..9292e524 100644
--- a/gget/gget_mutate.py
+++ b/gget/gget_mutate.py
@@ -1,6 +1,9 @@
 import pandas as pd
 import re
 from tqdm import tqdm
+import numpy as np
+import os
+from typing import Union, List, Optional
 
 tqdm.pandas()
 
@@ -17,376 +20,333 @@
 cosmic_incorrect_wt_base = 0
 mut_idx_outside_seq = 0
 
-mutation_pattern = r"c\.([0-9_\-\+\*]+)([a-zA-Z>]+)"  # more complex: r'c\.([0-9_\-\+\*\(\)\?]+)([a-zA-Z>\(\)0-9]+)'
+mutation_pattern = r"(?:c|g)\.([0-9_\-\+\*]+)([a-zA-Z>]+)"  # more complex: r'c\.([0-9_\-\+\*\(\)\?]+)([a-zA-Z>\(\)0-9]+)'
+
+# Get complement
+complement = {
+    "A": "T",
+    "T": "A",
+    "U": "A",
+    "C": "G",
+    "G": "C",
+    "N": "N",
+    "a": "t",
+    "t": "a",
+    "u": "a",
+    "c": "g",
+    "g": "c",
+    "n": "n",
+    ".": ".",  # annotation for gaps
+    "-": "-",  # annotation for gaps
+}
+
+
+codon_to_amino_acid = {
+    "TTT": "F",
+    "TTC": "F",
+    "TTA": "L",
+    "TTG": "L",
+    "CTT": "L",
+    "CTC": "L",
+    "CTA": "L",
+    "CTG": "L",
+    "ATT": "I",
+    "ATC": "I",
+    "ATA": "I",
+    "ATG": "M",
+    "GTT": "V",
+    "GTC": "V",
+    "GTA": "V",
+    "GTG": "V",
+    "TCT": "S",
+    "TCC": "S",
+    "TCA": "S",
+    "TCG": "S",
+    "CCT": "P",
+    "CCC": "P",
+    "CCA": "P",
+    "CCG": "P",
+    "ACT": "T",
+    "ACC": "T",
+    "ACA": "T",
+    "ACG": "T",
+    "GCT": "A",
+    "GCC": "A",
+    "GCA": "A",
+    "GCG": "A",
+    "TAT": "Y",
+    "TAC": "Y",
+    "TAA": "*",
+    "TAG": "*",
+    "CAT": "H",
+    "CAC": "H",
+    "CAA": "Q",
+    "CAG": "Q",
+    "AAT": "N",
+    "AAC": "N",
+    "AAA": "K",
+    "AAG": "K",
+    "GAT": "D",
+    "GAC": "D",
+    "GAA": "E",
+    "GAG": "E",
+    "TGT": "C",
+    "TGC": "C",
+    "TGA": "*",
+    "TGG": "W",
+    "CGT": "R",
+    "CGC": "R",
+    "CGA": "R",
+    "CGG": "R",
+    "AGT": "S",
+    "AGC": "S",
+    "AGA": "R",
+    "AGG": "R",
+    "GGT": "G",
+    "GGC": "G",
+    "GGA": "G",
+    "GGG": "G",
+}
+
+
+def convert_chromosome_value_to_int_when_possible(val):
+    try:
+        # Try to convert the value to a float, then to an int, and finally to a string
+        return str(int(float(val)))
+    except ValueError:
+        # If conversion fails, keep the value as it is
+        return val
+
+
+def merge_gtf_transcript_locations_into_cosmic_csv(mutations, gtf_path, gtf_transcript_id_column):
+    gtf_df = pd.read_csv(gtf_path, sep='\t', comment='#', header=None, names=[
+    'seqname', 'source', 'feature', 'start', 'end', 'score', 'strand', 'frame', 'attribute'])
+
+    if 'strand' in mutations.columns:
+        mutations.rename(columns={'strand': 'strand_original'}, inplace=True)
+
+    gtf_df = gtf_df[gtf_df['feature'] == 'transcript']
+
+    gtf_df['transcript_id'] = gtf_df['attribute'].str.extract('transcript_id "([^"]+)"')
+
+    assert len(gtf_df['transcript_id']) == len(set(gtf_df['transcript_id'])), "Duplicate transcript_id values found!"
+
+    # Filter out rows where transcript_id is NaN
+    gtf_df = gtf_df.dropna(subset=['transcript_id'])
+
+    gtf_df = gtf_df[['transcript_id', 'start', 'end', 'strand']].rename(
+        columns={'transcript_id': gtf_transcript_id_column, 'start': 'start_transcript_position', 'end': 'end_transcript_position'})
+    
+    merged_df = pd.merge(mutations, gtf_df, on=gtf_transcript_id_column, how='left')
 
+    # Fill NaN values
+    merged_df['start_transcript_position'] = merged_df['start_transcript_position'].fillna(0)
+    merged_df['end_transcript_position'] = merged_df['end_transcript_position'].fillna(9999999)
+    merged_df['strand'] = merged_df['strand'].fillna('.')
 
-def extract_mutation_type(mutation):
-    match = re.match(mutation_pattern, mutation)
+    return merged_df
 
-    if match:
-        letters = match.group(2)  # The letter sequence
-        mutation_type = re.findall(r"[a-z>-]+", letters)
-        if mutation_type[0] == ">":  # substitution - eg c.1252C>T
-            return "substitution"
+def get_sequence_length(seq_id, seq_dict):
+    return len(seq_dict.get(seq_id, ""))
 
-        elif mutation_type[0] == "del":  # deletion - eg c.2126_2128del OR c.1627del
-            return "deletion"
+def get_nucleotide_at_position(seq_id, pos, seq_dict):
+    full_seq = seq_dict.get(seq_id, "")
+    if pos < len(full_seq):
+        return full_seq[pos]
+    return None
 
-        elif (
-            mutation_type[0] == "delins"
-        ):  # insertion-deletion - eg c.2239_2253delinsAAT OR c.646delins
-            return "delins"
+def translate_sequence(sequence, start, end):
+    amino_acid_sequence = ""
+    for i in range(start, end, 3):
+        codon = sequence[i : i + 3].upper()
+        amino_acid = codon_to_amino_acid.get(
+            codon, "X"
+        )  # Use 'X' for unknown or incomplete codons
+        amino_acid_sequence += amino_acid
 
-        elif (
-            mutation_type[0] == "ins"
-        ):  # insertion - eg c.2239_2240insAAT   (interval is always 1)
-            return "insertion"
+    return amino_acid_sequence
 
-        elif mutation_type[0] == "dup":  # duplication - eg c.2239_2253dup OR c.646dup
-            return "duplication"
 
-        elif (
-            mutation_type[0] == "inv"
-        ):  # inversion - eg c.2239_2253inv    (always has an interval)
-            return "inversion"
+# def remove_all_but_first_gt(line):
+#     return line[:1] + line[1:].replace(">", "")
 
+def remove_gt_after_semicolon(line):
+    parts = line.split(';')
+    # Remove '>' from the beginning of each part except the first part
+    parts = [parts[0]] + [part.lstrip('>') for part in parts[1:]]
+    return ';'.join(parts)
+
+
+def wt_fragment_and_mutant_fragment_share_kmer(mutated_fragment: str, wildtype_fragment: str, k: int) -> bool:
+    if len(mutated_fragment) <= k:
+        if mutated_fragment in wildtype_fragment:
+            return True
         else:
-            logger.debug(
-                f"mutation {mutation} matches re but is not a known mutation type"
-            )
-            # raise_pytest_error()
-            return "unknown"
+            return False
     else:
-        logger.debug(f"mutation {mutation} does not match re")
-        # raise_pytest_error()
-        return "unknown"
+        for mutant_position in range(len(mutated_fragment) - k):
+            mutant_kmer = mutated_fragment[mutant_position:mutant_position + k]
+            if mutant_kmer in wildtype_fragment:
+                # wt_position = wildtype_fragment.find(mutant_kmer)
+                return True
+        return False
 
 
-def create_mutant_sequence(
-    row,
-    mutation_function,
-    kmer_flanking_length=30,
-    mut_column="mutation",
-    seq_id_column="seq_ID",
-):
-    global intronic_mutations, posttranslational_region_mutations, unknown_mutations, uncertain_mutations, ambiguous_position_mutations
+def add_mutation_type(mutations, mut_column):
+    mutations["mutation_type_id"] = mutations[mut_column].str.extract(mutation_pattern)[
+        1
+    ]
 
-    if "?" in row[mut_column]:
-        logger.debug(
-            f"Uncertain mutation found in mutation {row[mut_column]} - mutation is ignored"
-        )
-        uncertain_mutations += 1
-        return ""
+    # Define conditions and choices for the mutation types
+    conditions = [
+        mutations["mutation_type_id"].str.contains(">", na=False),
+        mutations["mutation_type_id"].str.contains("delins", na=False),
+        mutations["mutation_type_id"].str.contains("del", na=False)
+        & ~mutations["mutation_type_id"].str.contains("delins", na=False),
+        mutations["mutation_type_id"].str.contains("ins", na=False)
+        & ~mutations["mutation_type_id"].str.contains("delins", na=False),
+        mutations["mutation_type_id"].str.contains("dup", na=False),
+        mutations["mutation_type_id"].str.contains("inv", na=False),
+    ]
 
-    if "(" in row[mut_column]:
-        logger.debug(
-            f"Ambiguous mutational position found in mutation {row[mut_column]} - mutation is ignored"
-        )
-        ambiguous_position_mutations += 1
-        return ""
-
-    match = re.match(mutation_pattern, row[mut_column])
-    if match:
-        nucleotide_position = match.group(1)  # The number sequence
-        letters = match.group(2)  # The letter sequence
-
-        try:
-            if "-" in nucleotide_position or "+" in nucleotide_position:
-                logger.debug(
-                    f"Intronic nucleotide position found in mutation {row[mut_column]} - {nucleotide_position}{letters} - mutation is ignored"
-                )
-                intronic_mutations += 1
-                return ""
+    choices = [
+        "substitution",
+        "delins",
+        "deletion",
+        "insertion",
+        "duplication",
+        "inversion",
+    ]
 
-            elif "*" in nucleotide_position:
-                logger.debug(
-                    f"Posttranslational region nucleotide position found in mutation {row[mut_column]} - {nucleotide_position}{letters} - mutation is ignored"
-                )
-                posttranslational_region_mutations += 1
-                return ""
+    # Assign the mutation types
+    mutations["mutation_type"] = np.select(conditions, choices, default="unknown")
 
-            else:
-                if "_" in nucleotide_position:
-                    starting_nucleotide_position_index_0 = (
-                        int(nucleotide_position.split("_")[0]) - 1
-                    )
-                    ending_nucleotide_position_index_0 = (
-                        int(nucleotide_position.split("_")[1]) - 1
-                    )
-                else:
-                    starting_nucleotide_position_index_0 = int(nucleotide_position) - 1
-                    ending_nucleotide_position_index_0 = (
-                        starting_nucleotide_position_index_0
-                    )
-        except:
-            logger.debug(f"Error with mutation {row[mut_column]}]")
-            unknown_mutations += 1
-            # raise_pytest_error()
-            return ""
-
-        mutant_sequence, adjusted_end_position = mutation_function(
-            row,
-            letters,
-            starting_nucleotide_position_index_0,
-            ending_nucleotide_position_index_0,
-            mut_column,
-            seq_id_column,
-        )
+    # Drop the temporary mutation_type_id column
+    mutations.drop(columns=["mutation_type_id"], inplace=True)
 
-        if not mutant_sequence:
-            return ""
+    return mutations
 
-        kmer_start = max(0, starting_nucleotide_position_index_0 - kmer_flanking_length)
-        kmer_end = min(
-            len(mutant_sequence), adjusted_end_position + kmer_flanking_length + 1
-        )
-        return str(mutant_sequence[kmer_start:kmer_end])
-    else:
-        logger.debug(f"Error with mutation {row[mut_column]}")
-        unknown_mutations += 1
-        # raise_pytest_error()
-        return ""
-
-
-def substitution_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    # assert letters[0] == row['full_sequence'][starting_nucleotide_position_index_0], f"Transcript has {row['full_sequence'][starting_nucleotide_position_index_0]} at position {starting_nucleotide_position_index_0} but mutation is {letters[-1]} at position {starting_nucleotide_position_index_0} in {row[mut_column]}"
-    global cosmic_incorrect_wt_base
-    global mut_idx_outside_seq
-    try:
-        if letters[0] != row["full_sequence"][starting_nucleotide_position_index_0]:
-            logger.debug(
-                f"Sequence {row[seq_id_column]} has nucleotide '{row['full_sequence'][starting_nucleotide_position_index_0]}' at position {starting_nucleotide_position_index_0}, but mutation {row[mut_column]} expected '{letters[0]}'."
-            )
-            cosmic_incorrect_wt_base += 1
-        mutant_sequence = (
-            row["full_sequence"][:starting_nucleotide_position_index_0]
-            + letters[-1]
-            + row["full_sequence"][ending_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = ending_nucleotide_position_index_0
 
-        return mutant_sequence, adjusted_end_position
+def extract_sequence(row, seq_dict, seq_id_column = "seq_ID"):
+    if pd.isna(row["start_mutation_position"]) or pd.isna(row["end_mutation_position"]):
+        return None    
+    seq = seq_dict[row[seq_id_column]][
+        int(row["start_mutation_position"]) : int(row["end_mutation_position"]) + 1
+    ]
+    return seq
 
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
 
+def common_prefix_length(s1, s2):
+    min_len = min(len(s1), len(s2))
+    for i in range(min_len):
+        if s1[i] != s2[i]:
+            return i
+    return min_len
 
-def deletion_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    global mut_idx_outside_seq
-    try:
-        # Accessing the sequence to trigger IndexError if out of bounds
-        _ = row["full_sequence"][starting_nucleotide_position_index_0]
 
-        mutant_sequence = (
-            row["full_sequence"][:starting_nucleotide_position_index_0]
-            + row["full_sequence"][ending_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = starting_nucleotide_position_index_0 - 1
-        return mutant_sequence, adjusted_end_position
-
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
-
-
-def delins_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    global mut_idx_outside_seq
-    try:
-        # Accessing the sequence to trigger IndexError if out of bounds
-        _ = row["full_sequence"][starting_nucleotide_position_index_0]
-
-        # yields 60+(length of insertion)-mer
-        insertion_string = "".join(re.findall(r"[A-Z]+", letters))
-        mutant_sequence = (
-            row["full_sequence"][:starting_nucleotide_position_index_0]
-            + insertion_string
-            + row["full_sequence"][ending_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = (
-            starting_nucleotide_position_index_0 + len(insertion_string) - 1
-        )
-        return mutant_sequence, adjusted_end_position
+# Function to find the length of the common suffix with the prefix
+def common_suffix_length(s1, s2):
+    min_len = min(len(s1), len(s2))
+    for i in range(min_len):
+        if s1[-(i + 1)] != s2[-(i + 1)]:
+            return i
+    return min_len
 
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
 
+def count_repeat_right_flank(mut_nucleotides, right_flank_region):
+    total_overlap_len = 0
+    while right_flank_region.startswith(mut_nucleotides):
+        total_overlap_len += len(mut_nucleotides)
+        right_flank_region = right_flank_region[len(mut_nucleotides) :]
+    total_overlap_len += common_prefix_length(mut_nucleotides, right_flank_region)
+    return total_overlap_len
 
-def insertion_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    global mut_idx_outside_seq
-    try:
-        # Accessing the sequence to trigger IndexError if out of bounds
-        _ = row["full_sequence"][starting_nucleotide_position_index_0]
-
-        # yields 61+(length of insertion)-mer  - k before, length of insertion, k-1 after (k before and k-1 after is a little unconventional, but if I want to make it k-1 before then I have to return an adjusted start as well as an adjusted end)
-        insertion_string = "".join(re.findall(r"[A-Z]+", letters))
-        mutant_sequence = (
-            row["full_sequence"][: starting_nucleotide_position_index_0 + 1]
-            + insertion_string
-            + row["full_sequence"][starting_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = (
-            ending_nucleotide_position_index_0 + len(insertion_string) - 1
-        )
-        return mutant_sequence, adjusted_end_position
 
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
+def count_repeat_left_flank(mut_nucleotides, left_flank_region):
+    total_overlap_len = 0
+    while left_flank_region.endswith(mut_nucleotides):
+        total_overlap_len += len(mut_nucleotides)
+        left_flank_region = left_flank_region[: -len(mut_nucleotides)]
+    total_overlap_len += common_suffix_length(mut_nucleotides, left_flank_region)
+    return total_overlap_len
 
 
-def duplication_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    global mut_idx_outside_seq
-    try:
-        # Accessing the sequence to trigger IndexError if out of bounds
-        _ = row["full_sequence"][starting_nucleotide_position_index_0]
-        insertion_string = row["full_sequence"][
-            starting_nucleotide_position_index_0 : ending_nucleotide_position_index_0
-            + 1
-        ]
-        mutant_sequence = (
-            row["full_sequence"][:starting_nucleotide_position_index_0]
-            + insertion_string * 2
-            + row["full_sequence"][ending_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = ending_nucleotide_position_index_0 + len(
-            insertion_string
-        )
-        return mutant_sequence, adjusted_end_position
+def beginning_mut_nucleotides_with_right_flank(mut_nucleotides, right_flank_region):
+    if mut_nucleotides == right_flank_region[: len(mut_nucleotides)]:
+        return count_repeat_right_flank(mut_nucleotides, right_flank_region)
+    else:
+        return common_prefix_length(mut_nucleotides, right_flank_region)
 
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
 
+# Comparing end of mut_nucleotides to the end of left_flank_region
+def end_mut_nucleotides_with_left_flank(mut_nucleotides, left_flank_region):
+    if mut_nucleotides == left_flank_region[-len(mut_nucleotides) :]:
+        return count_repeat_left_flank(mut_nucleotides, left_flank_region)
+    else:
+        return common_suffix_length(mut_nucleotides, left_flank_region)
 
-def inversion_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    global mut_idx_outside_seq
-    try:
-        # Accessing the sequence to trigger IndexError if out of bounds
-        _ = row["full_sequence"][starting_nucleotide_position_index_0]
-        insertion_string = row["full_sequence"][
-            starting_nucleotide_position_index_0 : ending_nucleotide_position_index_0
-            + 1
-        ]
 
-        # Reverse
-        reverse_insertion_string = insertion_string[::-1]
-
-        # Get complement
-        complement = {
-            "A": "T",
-            "T": "A",
-            "U": "A",
-            "C": "G",
-            "G": "C",
-            "N": "N",
-            "a": "t",
-            "t": "a",
-            "u": "a",
-            "c": "g",
-            "g": "c",
-            "n": "n",
-            ".": ".",  # annotation for gaps
-            "-": "-",  # annotation for gaps
-        }
-        mutated_string = "".join(
-            complement.get(nucleotide, "N") for nucleotide in reverse_insertion_string
-        )
+def calculate_beginning_mutation_overlap_with_right_flank(row):
+    if row["mutation_type"] == "deletion":
+        sequence_to_check = row["wt_nucleotides_ensembl"]
+    else:
+        sequence_to_check = row["mut_nucleotides"]
 
-        # Create final sequence
-        mutant_sequence = (
-            row["full_sequence"][:starting_nucleotide_position_index_0]
-            + mutated_string
-            + row["full_sequence"][ending_nucleotide_position_index_0 + 1 :]
-        )
-        adjusted_end_position = ending_nucleotide_position_index_0
-        return mutant_sequence, adjusted_end_position
-
-    # Mutation index is outside sequence length
-    except IndexError:
-        mut_idx_outside_seq += 1
-        return "", ""
-
-
-def unknown_mutation(
-    row,
-    letters,
-    starting_nucleotide_position_index_0,
-    ending_nucleotide_position_index_0,
-    mut_column,
-    seq_id_column,
-):
-    return "", ""
+    if row["mutation_type"] == "delins" or row["mutation_type"] == "inversion":
+        original_sequence = row["wt_nucleotides_ensembl"] + row["right_flank_region"]
+    else:
+        original_sequence = row["right_flank_region"]
+
+    return beginning_mut_nucleotides_with_right_flank(
+        sequence_to_check, original_sequence
+    )
+
+
+def calculate_end_mutation_overlap_with_left_flank(row):
+    if row["mutation_type"] == "deletion":
+        sequence_to_check = row["wt_nucleotides_ensembl"]
+    else:
+        sequence_to_check = row["mut_nucleotides"]
+
+    if row["mutation_type"] == "delins" or row["mutation_type"] == "inversion":
+        original_sequence = row["left_flank_region"] + row["wt_nucleotides_ensembl"]
+    else:
+        original_sequence = row["left_flank_region"]
+
+    return end_mut_nucleotides_with_left_flank(sequence_to_check, original_sequence)
 
 
 def mutate(
-    sequences,
-    mutations,
-    k=30,
-    mut_column="mutation",
-    mut_id_column="mut_ID",
-    seq_id_column="seq_ID",
-    out=None,
-    verbose=True,
+    sequences: Union[str, List[str]],
+    mutations: Union[str, List[str]],
+    mut_column: str = "mutation",
+    seq_id_column: str = "seq_ID",
+    mut_id_column: Optional[str] = None,
+    gtf: Optional[str] = None,
+    gtf_transcript_id_column: Optional[str] = None,
+    k: int = 30,
+    min_seq_len: Optional[int] = None,
+    optimize_flanking_regions: bool = False,
+    remove_seqs_with_wt_kmers: bool = False,
+    max_ambiguous: Optional[int] = None,
+    merge_identical: bool = True,
+    update_df: bool = False,
+    update_df_out: Optional[str] = None,
+    store_full_sequences: bool = False,
+    translate: bool = False,
+    translate_start: Union[int, str, None] = None,
+    translate_end: Union[int, str, None] = None,
+    out: Optional[str] = None,
+    verbose: bool = True,
 ):
     """
     Takes in nucleotide sequences and mutations (in standard mutation annotation - see below)
     and returns mutated versions of the input sequences according to the provided mutations.
 
-    Args:
+    Reuiqred input argument:
     - sequences     (str) Path to the fasta file containing the sequences to be mutated, e.g., 'seqs.fa'.
                     Sequence identifiers following the '>' character must correspond to the identifiers
                     in the seq_ID column of 'mutations'.
-                    NOTE: Only string until first space or dot will be used as sequence identifier
-                    - Version numbers of Ensembl IDs will be ignored.
 
                     Example:
                     >seq1 (or ENSG00000106443)
@@ -395,6 +355,11 @@ def mutate(
                     AGATCGCTAG
 
                     Alternatively: Input sequence(s) as a string or list, e.g. 'AGCTAGCT' or ['ACTGCTAGCT', 'AGCTAGCT'].
+
+                    NOTE: Only the letters until the first space or dot will be used as sequence identifiers
+                    - Version numbers of Ensembl IDs will be ignored.
+                    NOTE: When 'sequences' input is a genome, also see 'gtf' argument below.
+                    
     - mutations     Path to csv or tsv file (str) (e.g., 'mutations.csv') or data frame (DataFrame object)
                     containing information about the mutations in the following format:
 
@@ -412,23 +377,61 @@ def mutate(
 
                     Alternatively: Input mutation(s) as a string or list, e.g., 'c.2C>T' or ['c.2C>T', 'c.1A>C'].
                     If a list is provided, the number of mutations must equal the number of input sequences.
-    - k             (int) Length of sequences flanking the mutation. Default: 30.
-                    If k > total length of the sequence, the entire sequence will be kept.
-    - mut_column    (str) Name of the column containing the mutations to be performed in 'mutations'. Default: 'mutation'.
-    - mut_id_column (str) Name of the column containing the IDs of each mutation in 'mutations'. Default: 'mut_ID'.
-    - seq_id_column (str) Name of the column containing the IDs of the sequences to be mutated in 'mutations'. Default: 'seq_ID'.
-    - out           (str) Path to output fasta file containing the mutated sequences, e.g., 'path/to/output_fasta.fa'.
-                    Default: None -> returns a list of the mutated sequences to standard out.
-                    The identifiers (following the '>') of the mutated sequences in the output fasta will be '>[seq_ID]_[mut_ID]'.
-    - verbose       (True/False) whether to print progress information. Default: True
-
-    For more information on the standard mutation annotation, see https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1867422/.
+                    
+                    For more information on the standard mutation annotation, see https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1867422/.
+   
+    Additional input arguments:
+    - mut_column                   (str) Name of the column containing the mutations to be performed in 'mutations'. Default: 'mutation'.
+    - seq_id_column                (str) Name of the column containing the IDs of the sequences to be mutated in 'mutations'. Default: 'seq_ID'.
+    - mut_id_column                (str) Name of the column containing the IDs of each mutation in 'mutations'. Default: Will use mut_column.
+    - gtf                          (str) Path to .gtf file. When providing a genome fasta file as input for 'sequences', you can provide a .gtf file here 
+                                   and the input sequences will be defined according to the transcript boundaries. Default: None
+    - gtf_transcript_id_column     (str) Column name in the input 'mutations' file containing the transcript ID. In this case, column seq_id_column should contain the chromosome number.
+                                   Required when 'gtf' is provided. Default: None
+
+    Mutant sequence generation/filtering options:
+    - k                            (int) Length of sequences flanking the mutation. Default: 30.
+                                   If k > total length of the sequence, the entire sequence will be kept.
+    - min_seq_len                  (int) Minimum length of the mutant output sequence. Mutant sequences smaller than this will be dropped. 
+                                   Default: None
+    - optimize_flanking_regions    (True/False) Whether to remove nucleotides from either end of the mutant sequence to ensure (when possible) 
+                                   that the mutant sequence does not contain any k-mers also found in the wildtype/input sequence. Default: False
+    - remove_seqs_with_wt_kmers    (True/False) Removes output sequences where at least one k-mer is also present in the wildtype/input sequence in the same region. 
+                                   If optimize_flanking_regions=True, only sequences for which a wildtpye kmer is still present after optimization will be removed.
+                                   Default: False
+    - max_ambiguous                (int) Maximum number of 'N' characters allowed in the output sequence. Default: None (no 'N' filter will be applied)
+    - merge_identical              (True/False) Whether to merge identical mutant sequences in the output (identical sequences will be merged by concatenating the sequence 
+                                   headers for all identical sequences). Default: True
+
+    # Optional arguments to generate additional output stored in a copy of the 'mutations' DataFrame
+    - update_df                    (True/False) Whether to update the input 'mutations' DataFrame to include additional columns with the mutation type, 
+                                   wildtype nucleotide sequence, and mutant nucleotide sequence (only valid if 'mutations' is a csv or tsv file). Default: False
+    - update_df_out                (str) Path to output csv file containing the updated DataFrame. Only valid if update_df=True.
+                                   Default: None -> the new DataFrame will be saved in the same directory as the 'mutations' DataFrame with appendix '_updated'
+    - store_full_sequences         (True/False) Whether to also include the complete wildtype and mutant sequences in the updated 'mutations' DataFrame (not just the sub-sequence with 
+                                   k-length flanks). Only valid if update_df=True. Default: False
+    - translate                    (True/False) Add additional columns to the 'mutations' DataFrame containing the wildtype and mutant amino acid sequences. 
+                                   Only valid if store_full_sequences=True. Default: False
+    - translate_start              (int | str | None) The position in the input nucleotide sequence to start translating. If a string is provided, it should correspond 
+                                   to a column name in 'mutations' containing the open reading frame start positions for each sequence/mutation.
+                                   Only valid if translate=True. Default: None (translate from the beginning of the sequence)
+    - translate_end                (int | str | None) The position in the input nucleotide sequence to end translating. If a string is provided, it should correspond 
+                                   to a column name in 'mutations' containing the open reading frame end positions for each sequence/mutation.
+                                   Only valid if translate=True. Default: None (translate from to the end of the sequence)
+    
+    # General arguments:
+    - out                          (str) Path to output fasta file containing the mutated sequences, e.g., 'path/to/output_fasta.fa'.
+                                   Default: None -> returns a list of the mutated sequences to standard out.
+                                   The identifiers (following the '>') of the mutated sequences in the output fasta will be '>[seq_ID]_[mut_ID]'.
+    - verbose                      (True/False) whether to print progress information. Default: True
 
     Saves mutated sequences in fasta format (or returns a list containing the mutated sequences if out=None).
     """
-
+ 
     global intronic_mutations, posttranslational_region_mutations, unknown_mutations, uncertain_mutations, ambiguous_position_mutations, cosmic_incorrect_wt_base, mut_idx_outside_seq
 
+    columns_to_keep = ["header", seq_id_column, mut_column, "mutation_type", "wt_sequence", "mutant_sequence"]
+
     # Load input sequences and their identifiers from fasta file
     if "." in sequences:
         titles, seqs = read_fasta(sequences)
@@ -454,12 +457,22 @@ def mutate(
             """
         )
 
+    mutations_path = None
+
     # Read in 'mutations' if passed as filepath to comma-separated csv
-    if isinstance(mutations, str) and ".csv" in mutations:
+    if isinstance(mutations, str) and mutations.endswith(".csv"):
+        mutations_path = mutations
         mutations = pd.read_csv(mutations)
+        for col in mutations.columns:
+            if col not in columns_to_keep:
+                columns_to_keep.append(col)  # append "mutation_aa", "gene_name", "mutation_id"
 
-    elif isinstance(mutations, str) and ".tsv" in mutations:
+    elif isinstance(mutations, str) and mutations.endswith(".tsv"):
+        mutations_path = mutations
         mutations = pd.read_csv(mutations, sep="\t")
+        for col in mutations.columns:
+            if col not in columns_to_keep:
+                columns_to_keep.append(col)  # append "mutation_aa", "gene_name", "mutation_id"
 
     # Handle mutations passed as a list
     elif isinstance(mutations, list):
@@ -527,24 +540,33 @@ def mutate(
             """
         )
 
-    # Get all mutation types
-    if verbose:
-        tqdm.pandas(desc="Extracting mutation types")
-        mutations["mutation_type"] = mutations[mut_column].progress_apply(
-            extract_mutation_type
+    number_of_missing_seq_ids = mutations[seq_id_column].isna().sum()
+
+    if number_of_missing_seq_ids > 0:
+        logger.warning(
+            f"""
+            {number_of_missing_seq_ids} rows in 'mutations' are missing sequence IDs. These rows will be dropped from the analysis.
+            """
         )
-    else:
-        mutations["mutation_type"] = mutations[mut_column].apply(extract_mutation_type)
+
+        # Drop rows with missing sequence IDs
+        mutations = mutations.dropna(subset=[seq_id_column])
+
+    # ensure seq_ID column is string type, and chromosome numbers don't have decimals
+    mutations[seq_id_column] = mutations[seq_id_column].apply(convert_chromosome_value_to_int_when_possible)
+
+    mutations = add_mutation_type(mutations, mut_column)
 
     # Link sequences to their mutations using the sequence identifiers
-    mutations["full_sequence"] = mutations[seq_id_column].map(seq_dict)
+    if store_full_sequences:
+        mutations["wt_sequence_full"] = mutations[seq_id_column].map(seq_dict)
 
     # Handle sequences that were not found based on their sequence IDs
-    seqs_not_found = mutations[mutations["full_sequence"].isnull()]
+    seqs_not_found = mutations[~mutations[seq_id_column].isin(seq_dict.keys())]
     if 0 < len(seqs_not_found) < 20:
         logger.warning(
             f"""
-            The sequences with the following {len(seqs_not_found)} sequence ID(s) were not found: {", ".join(seqs_not_found["seq_ID"].values)}  
+            The sequences with the following {len(seqs_not_found)} sequence ID(s) were not found: {", ".join(seqs_not_found[seq_id_column].values)}  
             These sequences and their corresponding mutations will not be included in the output.  
             Ensure that the sequence IDs correspond to the string following the > character in the 'sequences' fasta file (do NOT include spaces or dots).
             """
@@ -559,7 +581,7 @@ def mutate(
         )
 
     # Drop inputs for sequences that were not found
-    mutations = mutations.dropna()
+    mutations = mutations.dropna(subset=[seq_id_column, mut_column])
     if len(mutations) < 1:
         raise ValueError(
             """
@@ -568,221 +590,624 @@ def mutate(
             """
         )
 
-    # Split data frame by mutation type
-    mutation_types = [
-        "substitution",
-        "deletion",
-        "delins",
-        "insertion",
-        "duplication",
-        "inversion",
-        "unknown",
-    ]
-    mutation_dict = {mutation_type: [] for mutation_type in mutation_types}
-    for mutation_type in mutation_types:
-        df_mutation_type = mutations[mutations["mutation_type"] == mutation_type].copy()
-        df_mutation_type["mutant_sequence_kmer"] = ""
-        # Define header for mutated sequences in output fasta
-        df_mutation_type["header"] = (
-            ">"
-            + df_mutation_type[seq_id_column]
-            + "_"
-            + df_mutation_type[mut_id_column]
+    total_mutations = mutations.shape[0]
+
+    if mut_id_column is None:
+        mut_id_column = mut_column
+
+    mutations["mutant_sequence"] = ""
+    mutations["header"] = (
+        ">" + mutations[seq_id_column] + ":" + mutations[mut_id_column]
+    )
+
+    # Calculate number of bad mutations
+    uncertain_mutations = mutations[mut_column].str.contains(r"\?").sum()
+
+    ambiguous_position_mutations = mutations[mut_column].str.contains(r"\(|\)").sum()
+
+    intronic_mutations = mutations[mut_column].str.contains(r"\+|\-").sum()
+
+    posttranslational_region_mutations = mutations[mut_column].str.contains(r"\*").sum()
+
+    # Filter out bad mutations
+    combined_pattern = re.compile(r"(\?|\(|\)|\+|\-|\*)")
+    mask = mutations[mut_column].str.contains(combined_pattern)
+    mutations = mutations[~mask]
+
+    # Extract nucleotide positions and mutation info from Mutation CDS
+    mutations[["nucleotide_positions", "actual_mutation"]] = mutations[
+        mut_column
+    ].str.extract(mutation_pattern)
+
+    # Filter out mutations that did not match the re
+    unknown_mutations = mutations["nucleotide_positions"].isna().sum()
+    mutations = mutations.dropna(subset=["nucleotide_positions", "actual_mutation"])
+
+    if mutations.empty:
+        logger.warning("No valid mutations found in the input.")
+        return []
+
+    # Split nucleotide positions into start and end positions
+    split_positions = mutations["nucleotide_positions"].str.split("_", expand=True)
+
+    mutations["start_mutation_position"] = split_positions[0]
+    if split_positions.shape[1] > 1:
+        mutations["end_mutation_position"] = split_positions[1].fillna(
+            split_positions[0]
         )
-        mutation_dict[mutation_type] = df_mutation_type
+    else:
+        mutations["end_mutation_position"] = mutations["start_mutation_position"]
 
-    # Create mutated sequences
-    if verbose:
-        logger.info("Mutating sequences...")
-    if not mutation_dict["substitution"].empty:
-        if verbose:
-            tqdm.pandas(desc="Performing substitutions")
-            mutation_dict["substitution"]["mutant_sequence_kmer"] = mutation_dict[
-                "substitution"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(substitution_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
+    mutations.loc[
+        mutations["end_mutation_position"].isna(), "end_mutation_position"
+    ] = mutations["start_mutation_position"]
+
+    mutations[["start_mutation_position", "end_mutation_position"]] = mutations[
+        ["start_mutation_position", "end_mutation_position"]
+    ].astype(int)
+
+    # Adjust positions to 0-based indexing
+    mutations["start_mutation_position"] -= 1
+    mutations["end_mutation_position"] -= 1  # don't forget to increment by 1 later
+
+    # Calculate sequence length
+    mutations["sequence_length"] = mutations[seq_id_column].apply(lambda x: get_sequence_length(x, seq_dict))
+
+    # Filter out mutations with positions outside the sequence
+    index_error_mask = (
+        mutations["start_mutation_position"] > mutations["sequence_length"]
+    ) | (mutations["end_mutation_position"] > mutations["sequence_length"])
+
+    mut_idx_outside_seq = index_error_mask.sum()
+
+    mutations = mutations[~index_error_mask]
+
+    if mutations.empty:
+        logger.warning("No valid mutations found in the input.")
+        return []
+
+    # Create masks for each type of mutation
+    mutations["wt_nucleotides_ensembl"] = None
+    substitution_mask = mutations["mutation_type"] == "substitution"
+    deletion_mask = mutations["mutation_type"] == "deletion"
+    delins_mask = mutations["mutation_type"] == "delins"
+    insertion_mask = mutations["mutation_type"] == "insertion"
+    duplication_mask = mutations["mutation_type"] == "duplication"
+    inversion_mask = mutations["mutation_type"] == "inversion"
+
+    if remove_seqs_with_wt_kmers:
+        long_duplications = ((duplication_mask) & ((mutations["end_mutation_position"] - mutations["start_mutation_position"]) >= k)).sum()
+        logger.info(f"Removing {long_duplications} duplications > k")
+        mutations = mutations[~((duplication_mask) & ((mutations['end_mutation_position'] - mutations['start_mutation_position']) >= k))]
+
+    # Create a mask for all non-substitution mutations
+    non_substitution_mask = (
+        deletion_mask | delins_mask | insertion_mask | duplication_mask | inversion_mask
+    )
+
+    # Extract the WT nucleotides for the substitution rows from reference fasta (i.e., Ensembl)
+    start_positions = mutations.loc[substitution_mask, "start_mutation_position"].values
+    
+    # Get the nucleotides at the start positions
+    wt_nucleotides_substitution = np.array([
+        get_nucleotide_at_position(seq_id, pos, seq_dict)
+        for seq_id, pos in zip(mutations.loc[substitution_mask, seq_id_column], start_positions)
+    ])
+
+    mutations.loc[substitution_mask, "wt_nucleotides_ensembl"] = (
+        wt_nucleotides_substitution
+    )
+
+    # Extract the WT nucleotides for the substitution rows from the Mutation CDS (i.e., COSMIC)
+    mutations["wt_nucleotides_cosmic"] = None
+    mutations.loc[substitution_mask, "wt_nucleotides_cosmic"] = mutations[
+        "actual_mutation"
+    ].str[0]
+
+    congruent_wt_bases_mask = (
+        (mutations["wt_nucleotides_cosmic"] == mutations["wt_nucleotides_ensembl"]) |
+        mutations[["wt_nucleotides_cosmic", "wt_nucleotides_ensembl"]].isna().any(axis=1)
+    )
+
+    cosmic_incorrect_wt_base = (~congruent_wt_bases_mask).sum()
+
+    mutations = mutations[congruent_wt_bases_mask]
+
+    if mutations.empty:
+        logger.warning("No valid mutations found in the input.")
+        return []
+
+    # Adjust the start and end positions for insertions
+    mutations.loc[
+        insertion_mask, "start_mutation_position"
+    ] += 1  # in other cases, we want left flank to exclude the start of mutation site; but with insertion, the start of mutation site as it is denoted still belongs in the flank region
+    mutations.loc[
+        insertion_mask, "end_mutation_position"
+    ] -= 1  # in this notation, the end position is one before the start position
+
+    # Extract the WT nucleotides for the non-substitution rows from the Mutation CDS (i.e., COSMIC)
+    mutations.loc[non_substitution_mask, "wt_nucleotides_ensembl"] = mutations.loc[
+        non_substitution_mask
+    ].apply(lambda row: extract_sequence(row, seq_dict, seq_id_column), axis=1)
+
+    # Apply mutations to the sequences
+    mutations["mut_nucleotides"] = None
+    mutations.loc[substitution_mask, "mut_nucleotides"] = mutations.loc[
+        substitution_mask, "actual_mutation"
+    ].str[-1]
+    mutations.loc[deletion_mask, "mut_nucleotides"] = ""
+    mutations.loc[delins_mask, "mut_nucleotides"] = mutations.loc[
+        delins_mask, "actual_mutation"
+    ].str.extract(r"delins([A-Z]+)")[0]
+    mutations.loc[insertion_mask, "mut_nucleotides"] = mutations.loc[
+        insertion_mask, "actual_mutation"
+    ].str.extract(r"ins([A-Z]+)")[0]
+    mutations.loc[duplication_mask, "mut_nucleotides"] = mutations.loc[
+        duplication_mask
+    ].apply(lambda row: row["wt_nucleotides_ensembl"], axis=1)
+    mutations.loc[inversion_mask, "mut_nucleotides"] = mutations.loc[
+        inversion_mask
+    ].apply(
+        lambda row: "".join(
+            complement.get(nucleotide, "N")
+            for nucleotide in row["wt_nucleotides_ensembl"][::-1]
+        ),
+        axis=1,
+    )
+
+    # Adjust the nucleotide positions of duplication mutations to mimic that of insertions (since duplications are essentially just insertions)
+    mutations.loc[duplication_mask, "start_mutation_position"] = (
+        mutations.loc[duplication_mask, "end_mutation_position"] + 1
+    )  # in the case of duplication, the "mutant" site is still in the left flank as well
+
+    mutations.loc[duplication_mask, "wt_nucleotides_ensembl"] = ""
+
+    # Calculate the kmer bounds
+    mutations["start_kmer_position_min"] = mutations["start_mutation_position"] - k
+    mutations["start_kmer_position"] = mutations["start_kmer_position_min"].combine(
+        0, max
+    )
+
+    mutations["end_kmer_position_max"] = mutations["end_mutation_position"] + k
+    mutations["end_kmer_position"] = mutations[
+        ["end_kmer_position_max", "sequence_length"]
+    ].min(
+        axis=1
+    )  # don't forget to increment by 1 later on
+
+    if gtf is not None:        
+        assert (mutations_path.endswith(".csv") or mutations_path.endswith(".tsv")), "Mutations must be a CSV or TSV file"
+        if "start_transcript_position" not in mutations.columns and "end_transcript_position" not in mutations.columns:  #* currently hard-coded column names, but optionally can be changed to arguments later
+            mutations = merge_gtf_transcript_locations_into_cosmic_csv(mutations, gtf, gtf_transcript_id_column = gtf_transcript_id_column)
+            columns_to_keep.extend(["start_transcript_position", "end_transcript_position", "strand"])
         else:
-            mutation_dict["substitution"]["mutant_sequence_kmer"] = mutation_dict[
-                "substitution"
-            ].apply(
-                create_mutant_sequence,
-                args=(substitution_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-    if not mutation_dict["deletion"].empty:
+            logger.warning("Transcript positions already present in the input mutations file. Skipping GTF file merging.")
+       
+        # adjust start_transcript_position to be 0-index
+        mutations["start_transcript_position"] -= 1
+        
+        mutations["start_kmer_position"] = mutations[["start_kmer_position", "start_transcript_position"]].max(axis=1)
+        mutations["end_kmer_position"] = mutations[["end_kmer_position", "end_transcript_position"]].min(axis=1)
+
+    mut_apply = (lambda *args, **kwargs: mutations.progress_apply(*args, **kwargs)) if verbose else mutations.apply
+
+    if update_df and store_full_sequences:
+        # Extract flank sequences
         if verbose:
-            tqdm.pandas(desc="Performing deletions")
-            mutation_dict["deletion"]["mutant_sequence_kmer"] = mutation_dict[
-                "deletion"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(deletion_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-        else:
-            mutation_dict["deletion"]["mutant_sequence_kmer"] = mutation_dict[
-                "deletion"
-            ].apply(
-                create_mutant_sequence,
-                args=(deletion_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-    if not mutation_dict["delins"].empty:
+            tqdm.pandas(desc="Extracting full left flank sequences")
+        
+        mutations["left_flank_region_full"] = mut_apply(
+            lambda row: seq_dict[row[seq_id_column]][0 : row["start_mutation_position"]], axis=1
+        )  # ? vectorize
+
         if verbose:
-            tqdm.pandas(desc="Performing delins")
-            mutation_dict["delins"]["mutant_sequence_kmer"] = mutation_dict[
-                "delins"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(delins_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-        else:
-            mutation_dict["delins"]["mutant_sequence_kmer"] = mutation_dict[
-                "delins"
-            ].apply(
-                create_mutant_sequence,
-                args=(delins_mutation, k, mut_column, seq_id_column),
-                axis=1,
+            tqdm.pandas(desc="Extracting full right flank sequences")
+
+        mutations["right_flank_region_full"] = mut_apply(
+            lambda row: seq_dict[row[seq_id_column]][
+                row["end_mutation_position"] + 1 : row["sequence_length"]
+            ],
+            axis=1,
+        )  # ? vectorize
+
+    if verbose:
+        tqdm.pandas(desc="Extracting k-mer left flank sequences")
+
+    mutations["left_flank_region"] = mut_apply(
+        lambda row: seq_dict[row[seq_id_column]][
+            row["start_kmer_position"] : row["start_mutation_position"]
+        ],
+        axis=1,
+    )  # ? vectorize
+
+    if verbose:
+        tqdm.pandas(desc="Extracting k-mer right flank sequences")
+
+    mutations["right_flank_region"] = mut_apply(
+        lambda row: seq_dict[row[seq_id_column]][
+            row["end_mutation_position"] + 1 : row["end_kmer_position"] + 1
+        ],
+        axis=1,
+    )  # ? vectorize
+
+    mutations["beginning_mutation_overlap_with_right_flank"] = 0
+    mutations["end_mutation_overlap_with_left_flank"] = 0
+
+    # Rules for shaving off kmer ends - r1 = left flank, r2 = right flank, d = deleted portion, i = inserted portion
+    # Substitution: N/A
+    # Deletion:
+    # To what extend the beginning of d overlaps with the beginning of r2 --> shave up to that many nucleotides off the beginning of r1 until k - len(r1) ≥ extent of overlap
+    # To what extend the end of d overlaps with the beginning of r1 --> shave up to that many nucleotides off the end of r2 until k - len(r2) ≥ extent of overlap
+    # Insertion, Duplication:
+    # To what extend the beginning of i overlaps with the beginning of r2 --> shave up to that many nucleotides off the beginning of r1 until k - len(r1) ≥ extent of overlap
+    # To what extend the end of i overlaps with the beginning of r1 --> shave up to that many nucleotides off the end of r2 until k - len(r2) ≥ extent of overlap
+    # Delins, inversion:
+    # To what extend the beginning of i overlaps with the beginning of d --> shave up to that many nucleotides off the beginning of r1 until k - len(r1) ≥ extent of overlap
+    # To what extend the end of i overlaps with the beginning of d --> shave up to that many nucleotides off the end of r2 until k - len(r2) ≥ extent of overlap
+
+    if optimize_flanking_regions:
+        # Apply the function for beginning of mut_nucleotides with right_flank_region
+        mutations.loc[
+            non_substitution_mask, "beginning_mutation_overlap_with_right_flank"
+        ] = mutations.loc[non_substitution_mask].apply(
+            calculate_beginning_mutation_overlap_with_right_flank, axis=1
+        )
+
+        # Apply the function for end of mut_nucleotides with left_flank_region
+        mutations.loc[non_substitution_mask, "end_mutation_overlap_with_left_flank"] = (
+            mutations.loc[non_substitution_mask].apply(
+                calculate_end_mutation_overlap_with_left_flank, axis=1
             )
-    if not mutation_dict["insertion"].empty:
+        )
+
+        # Calculate k-len(flank) (see above instructions)
+        mutations.loc[non_substitution_mask, "k_minus_left_flank_length"] = (
+            k - mutations.loc[non_substitution_mask, "left_flank_region"].apply(len)
+        )
+        mutations.loc[non_substitution_mask, "k_minus_right_flank_length"] = (
+            k - mutations.loc[non_substitution_mask, "right_flank_region"].apply(len)
+        )
+
+        mutations.loc[non_substitution_mask, "updated_left_flank_start"] = np.maximum(
+            mutations.loc[
+                non_substitution_mask, "beginning_mutation_overlap_with_right_flank"
+            ]
+            - mutations.loc[non_substitution_mask, "k_minus_left_flank_length"],
+            0,
+        )
+        mutations.loc[non_substitution_mask, "updated_right_flank_end"] = np.maximum(
+            mutations.loc[non_substitution_mask, "end_mutation_overlap_with_left_flank"]
+            - mutations.loc[non_substitution_mask, "k_minus_right_flank_length"],
+            0,
+        )
+
+        mutations["updated_left_flank_start"] = (
+            mutations["updated_left_flank_start"].fillna(0).astype(int)
+        )
+        mutations["updated_right_flank_end"] = (
+            mutations["updated_right_flank_end"].fillna(0).astype(int)
+        )
+
+    else:
+        mutations["updated_left_flank_start"] = 0
+        mutations["updated_right_flank_end"] = 0
+
+    # Create WT substitution k-mer sequences
+    mutations.loc[substitution_mask, "wt_sequence"] = (
+        mutations.loc[substitution_mask, "left_flank_region"]
+        + mutations.loc[substitution_mask, "wt_nucleotides_ensembl"]
+        + mutations.loc[substitution_mask, "right_flank_region"]
+    )
+
+    # Create WT non-substitution k-mer sequences
+    mutations.loc[non_substitution_mask, "wt_sequence"] = mutations.loc[
+        non_substitution_mask
+    ].apply(
+        lambda row: row["left_flank_region"][row["updated_left_flank_start"] :]
+        + row["wt_nucleotides_ensembl"]
+        + row["right_flank_region"][: len(row['right_flank_region']) - row["updated_right_flank_end"]],
+        axis=1,
+    )
+
+    # Create mutant substitution k-mer sequences
+    mutations.loc[substitution_mask, "mutant_sequence"] = (
+        mutations.loc[substitution_mask, "left_flank_region"]
+        + mutations.loc[substitution_mask, "mut_nucleotides"]
+        + mutations.loc[substitution_mask, "right_flank_region"]
+    )
+
+    # Create mutant non-substitution k-mer sequences
+    mutations.loc[non_substitution_mask, "mutant_sequence"] = mutations.loc[
+        non_substitution_mask
+    ].apply(
+        lambda row: row["left_flank_region"][row["updated_left_flank_start"] :]
+        + row["mut_nucleotides"]
+        + row["right_flank_region"][: len(row['right_flank_region']) - row["updated_right_flank_end"]],
+        axis=1,
+    )
+
+    if remove_seqs_with_wt_kmers:
         if verbose:
-            tqdm.pandas(desc="Performing insertions")
-            mutation_dict["insertion"]["mutant_sequence_kmer"] = mutation_dict[
-                "insertion"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(insertion_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-        else:
-            mutation_dict["insertion"]["mutant_sequence_kmer"] = mutation_dict[
-                "insertion"
-            ].apply(
-                create_mutant_sequence,
-                args=(insertion_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-    if not mutation_dict["duplication"].empty:
+            tqdm.pandas(desc="Removing mutant fragments that share a kmer with wt fragments")
+
+        mutations['wt_fragment_and_mutant_fragment_share_kmer'] = mut_apply(lambda row: wt_fragment_and_mutant_fragment_share_kmer(mutated_fragment=row['mutant_sequence'], wildtype_fragment=row['wt_sequence'], k=k+1), axis=1)
+
+        mutations_overlapping_with_wt = mutations['wt_fragment_and_mutant_fragment_share_kmer'].sum()
+
+        mutations = mutations[~mutations['wt_fragment_and_mutant_fragment_share_kmer']]
+
+
+    if update_df and store_full_sequences:
+        columns_to_keep.extend(["wt_sequence_full", "mutant_sequence_full"])
+
+        # Create full sequences (substitution and non-substitution)
+        mutations["mutant_sequence_full"] = (
+            mutations["left_flank_region_full"]
+            + mutations["mut_nucleotides"]
+            + mutations["right_flank_region_full"]
+        )
+
+    # Calculate k-mer lengths and report the distribution
+    mutations["mutant_sequence_kmer_length"] = mutations["mutant_sequence"].apply(
+        lambda x: len(x) if pd.notna(x) else 0
+    )
+
+    max_length = mutations["mutant_sequence_kmer_length"].max()
+
+    # Create bins of width 5 from 0 to max_length
+    bins = range(0, max_length + 6, 5)
+
+    # Bin the lengths and count the number of elements in each bin
+    binned_lengths = pd.cut(
+        mutations["mutant_sequence_kmer_length"], bins=bins, right=False
+    )
+    bin_counts = binned_lengths.value_counts().sort_index()
+
+    # Display the report
+    if verbose:
+        logger.debug("Report of the number of elements in each bin of width 5:")
+        logger.debug(bin_counts)
+
+    if min_seq_len:
+        rows_less_than_minimum = (mutations["mutant_sequence_kmer_length"] < min_seq_len).sum()
+
+        mutations = mutations[
+            mutations["mutant_sequence_kmer_length"] >= min_seq_len
+        ]
+
         if verbose:
-            tqdm.pandas(desc="Performing duplications")
-            mutation_dict["duplication"]["mutant_sequence_kmer"] = mutation_dict[
-                "duplication"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(duplication_mutation, k, mut_column, seq_id_column),
-                axis=1,
-            )
-        else:
-            mutation_dict["duplication"]["mutant_sequence_kmer"] = mutation_dict[
-                "duplication"
-            ].apply(
-                create_mutant_sequence,
-                args=(duplication_mutation, k, mut_column, seq_id_column),
-                axis=1,
+            logger.info(
+                f"Removed {rows_less_than_minimum} mutant kmers with length less than {min_seq_len}..."
             )
-    if not mutation_dict["inversion"].empty:
+
+    if max_ambiguous is not None:
+        # Get number of 'N' or 'n' occuring in the sequence
+        mutations['num_N'] = mutations['mutant_sequence'].str.lower().str.count('n')
+        num_rows_with_N = (mutations['num_N'] > max_ambiguous).sum()
+        mutations = mutations[mutations['num_N'] <= max_ambiguous]
+    
         if verbose:
-            tqdm.pandas(desc="Performing inversions")
-            mutation_dict["inversion"]["mutant_sequence_kmer"] = mutation_dict[
-                "inversion"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(inversion_mutation, k, mut_column, seq_id_column),
-                axis=1,
+            logger.info(
+                f"Removed {num_rows_with_N} mutant kmers containing more than {max_ambiguous} 'N's..."
+            )
+        
+        # Drop the 'num_N' column after filtering
+        mutations = mutations.drop(columns=['num_N'])
+
+    # split_cols = mutations[mut_id_column].str.split("_", n=1, expand=True)
+
+    # if split_cols.shape[1] == 1:
+    #     split_cols[1] = None
+
+    # # Extract gene name and mutation ID from mut_ID column (based on formatting of gget cosmic)
+    # mutations["gene_name"] = split_cols[0]
+    # mutations["mutation_id"] = split_cols[1].fillna(mutations[mut_id_column])
+
+    # Report status of mutations back to user
+    good_mutations = mutations.shape[0]
+
+    # good_mutations = total_mutations - intronic_mutations - posttranslational_region_mutations - unknown_mutations - uncertain_mutations - ambiguous_position_mutations - cosmic_incorrect_wt_base - mut_idx_outside_seq
+
+    # if remove_seqs_with_wt_kmers:
+    #     good_mutations = good_mutations - long_duplications - mutations_overlapping_with_wt
+        
+    # if min_seq_len:
+    #     good_mutations = good_mutations - rows_less_than_minimum
+
+    # if max_ambigious:
+    #     good_mutations = good_mutations - num_rows_with_N
+
+    report = f"""
+        {good_mutations} mutations correctly recorded ({good_mutations/total_mutations*100:.2f}%)
+        {intronic_mutations} intronic mutations found ({intronic_mutations/total_mutations*100:.2f}%)
+        {posttranslational_region_mutations} posttranslational region mutations found ({posttranslational_region_mutations/total_mutations*100:.2f}%)
+        {unknown_mutations} unknown mutations found ({unknown_mutations/total_mutations*100:.2f}%)
+        {uncertain_mutations} mutations with uncertain mutation found ({uncertain_mutations/total_mutations*100:.2f}%)
+        {ambiguous_position_mutations} mutations with ambiguous position found ({ambiguous_position_mutations/total_mutations*100:.2f}%)
+        {cosmic_incorrect_wt_base} mutations with incorrect wildtype base found ({cosmic_incorrect_wt_base/total_mutations*100:.2f}%)
+        {mut_idx_outside_seq} mutations with indices outside of the sequence length found ({mut_idx_outside_seq/total_mutations*100:.2f}%)
+        """
+
+    if remove_seqs_with_wt_kmers:        
+        report += f"""{long_duplications} duplications longer than k found ({long_duplications/total_mutations*100:.2f}%)
+        {mutations_overlapping_with_wt} mutations with overlapping kmers found ({mutations_overlapping_with_wt/total_mutations*100:.2f}%)
+        """
+
+    if min_seq_len:
+        report += f"""{rows_less_than_minimum} mutations with fragment length < k found ({rows_less_than_minimum/total_mutations*100:.2f}%)
+        """
+
+    if max_ambiguous is not None:
+        report += f"""{num_rows_with_N} mutations with Ns found ({num_rows_with_N/total_mutations*100:.2f}%)
+        """
+
+    if good_mutations != total_mutations:
+        logger.warning(report)
+    else:
+        logger.info("All mutations correctly recorded")
+
+    if translate and update_df and store_full_sequences:
+        columns_to_keep.extend(["wt_sequence_aa_full", "mutant_sequence_aa_full"])
+
+        if not mutations_path:
+            assert (
+                type(translate_start) != str and type(translate_end) != str
+            ), "translate_start and translate_end must be integers when translating sequences (or default None)."
+            if translate_start is None:
+                translate_start = 0
+            if translate_end is None:
+                translate_end = mutations["sequence_length"][0]
+
+            # combined_df['ORF'] = combined_df[translate_start] % 3
+
+            if verbose:
+                tqdm.pandas(desc="Translating WT amino acid sequences")
+                mutations["wt_sequence_aa_full"] = mutations["wt_sequence_full"].progress_apply(
+                    lambda x: translate_sequence(
+                        x, start=translate_start, end=translate_end
+                    )
+                )
+            else:
+                mutations["wt_sequence_aa_full"] = mutations["wt_sequence_full"].apply(
+                    lambda x: translate_sequence(
+                        x, start=translate_start, end=translate_end
+                    )
+                )
+
+            if verbose:
+                tqdm.pandas(desc="Translating mutant amino acid sequences")
+
+                mutations["mutant_sequence_aa_full"] = mutations[
+                    "mutant_sequence_full"
+                ].progress_apply(
+                    lambda x: translate_sequence(
+                        x, start=translate_start, end=translate_end
+                    )
+                )
+            
+            else:
+                mutations["mutant_sequence_aa_full"] = mutations[
+                    "mutant_sequence_full"
+                ].apply(
+                    lambda x: translate_sequence(
+                        x, start=translate_start, end=translate_end
+                    )
+                )
+
+            print(
+                f"Translated mutated sequences: {mutations['wt_sequence_aa_full']}"
             )
         else:
-            mutation_dict["inversion"]["mutant_sequence_kmer"] = mutation_dict[
-                "inversion"
-            ].apply(
-                create_mutant_sequence,
-                args=(inversion_mutation, k, mut_column, seq_id_column),
+            if not translate_start:
+                translate_start = "translate_start"
+
+            if not translate_end:
+                translate_end = "translate_end"
+
+            if translate_start not in mutations.columns:
+                mutations["translate_start"] = 0
+
+            if translate_end not in mutations.columns:
+                mutations["translate_end"] = mutations["sequence_length"]
+
+            if verbose:
+                tqdm.pandas(desc="Translating WT amino acid sequences")
+
+            mutations["wt_sequence_aa_full"] = mut_apply(
+                lambda row: translate_sequence(
+                    row["wt_sequence_full"], row[translate_start], row[translate_end]
+                ),
                 axis=1,
             )
-    if not mutation_dict["unknown"].empty:
-        if verbose:
-            tqdm.pandas(desc="Unknown mutations")
-            mutation_dict["unknown"]["mutant_sequence_kmer"] = mutation_dict[
-                "unknown"
-            ].progress_apply(
-                create_mutant_sequence,
-                args=(unknown_mutation, k, mut_column, seq_id_column),
+
+            if verbose:
+                tqdm.pandas(desc="Translating mutant amino acid sequences")
+            
+            mutations["mutant_sequence_aa_full"] = mut_apply(
+                lambda row: translate_sequence(
+                    row["mutant_sequence_full"],
+                    row[translate_start],
+                    row[translate_end],
+                ),
                 axis=1,
             )
-        else:
-            mutation_dict["unknown"]["mutant_sequence_kmer"] = mutation_dict[
-                "unknown"
-            ].apply(
-                create_mutant_sequence,
-                args=(unknown_mutation, k, mut_column, seq_id_column),
-                axis=1,
+
+    mutations = mutations[columns_to_keep]
+
+    if merge_identical:
+        logger.info("Merging identical mutated sequences")
+        if update_df:
+            logger.warning("Merging identical mutated sequences can take a while if update_df=True since it will concatenate all MCRSs too)")
+            mutations = (
+                mutations.groupby("mutant_sequence", sort=False)
+                .agg(lambda x: ";".join(x.astype(str)))  # Concatenate values with semicolons
+                .reset_index()
             )
 
-    # Report status of mutations back to user
-    total_mutations = mutations.shape[0]
-    good_mutations = (
-        total_mutations
-        - intronic_mutations
-        - posttranslational_region_mutations
-        - unknown_mutations
-        - uncertain_mutations
-        - ambiguous_position_mutations
-        - cosmic_incorrect_wt_base
-        - mut_idx_outside_seq
-    )
+        else:
+            mutations = (
+                mutations.groupby("mutant_sequence", sort=False, group_keys=False)["header"]
+                .apply(";".join)
+                .reset_index()
+            )
 
-    if good_mutations != total_mutations:
-        logger.warning(
-            f"""
-            {good_mutations} mutations correctly recorded ({good_mutations/total_mutations*100:.2f}%)
-            {intronic_mutations} intronic mutations found ({intronic_mutations/total_mutations*100:.2f}%)
-            {posttranslational_region_mutations} posttranslational region mutations found ({posttranslational_region_mutations/total_mutations*100:.2f}%)
-            {unknown_mutations} unknown mutations found ({unknown_mutations/total_mutations*100:.2f}%)
-            {uncertain_mutations} mutations with uncertain mutation found ({uncertain_mutations/total_mutations*100:.2f}%)
-            {ambiguous_position_mutations} mutations with ambiguous position found ({ambiguous_position_mutations/total_mutations*100:.2f}%)
-            {cosmic_incorrect_wt_base} mutations with incorrect wildtype base found ({cosmic_incorrect_wt_base/total_mutations*100:.2f}%)
-            {mut_idx_outside_seq} mutations with indeces outside of the sequence length found ({mut_idx_outside_seq/total_mutations*100:.2f}%)
-            """
+        # apply remove_gt_after_semicolon to mutant_sequence
+        mutations["header"] = mutations["header"].apply(
+            remove_gt_after_semicolon
         )
-    else:
+
+        # Calculate the number of semicolons in each entry
+        mutations['semicolon_count'] = mutations['header'].str.count(';')
+
+        mutations['semicolon_count'] += 1
+
+        # Convert all 1 values to NaN
+        mutations['semicolon_count'] = mutations['semicolon_count'].replace(1, np.nan)
+
+        # Take the sum across all rows of the new column
+        total_semicolons = int(mutations['semicolon_count'].sum())
+
+        mutations = mutations.drop(columns=['semicolon_count'])
+
         if verbose:
             logger.info(
-                f"""
-                {good_mutations} mutations correctly recorded ({good_mutations/total_mutations*100:.2f}%)
-                {intronic_mutations} intronic mutations found ({intronic_mutations/total_mutations*100:.2f}%)
-                {posttranslational_region_mutations} posttranslational region mutations found ({posttranslational_region_mutations/total_mutations*100:.2f}%)
-                {unknown_mutations} unknown mutations found ({unknown_mutations/total_mutations*100:.2f}%)
-                {uncertain_mutations} mutations with uncertain mutation found ({uncertain_mutations/total_mutations*100:.2f}%)
-                {ambiguous_position_mutations} mutations with ambiguous position found ({ambiguous_position_mutations/total_mutations*100:.2f}%)
-                {cosmic_incorrect_wt_base} mutations with incorrect wildtype base found ({cosmic_incorrect_wt_base/total_mutations*100:.2f}%)
-                {mut_idx_outside_seq} mutations with indeces outside of the sequence length found ({mut_idx_outside_seq/total_mutations*100:.2f}%)
-                """
+                f"{total_semicolons} identical mutated sequences were merged (headers were combined and separated using a semicolon (;). Occurences of identical mutated sequences may be reduced by increasing k."
             )
 
-    if out:
-        # Save mutated sequences in new fasta file
-        with open(out, "w") as fasta_file:
-            for mutation in mutation_dict:
-                if not mutation_dict[mutation].empty:
-                    df = mutation_dict[mutation]
-                    df["fasta_format"] = (
-                        df["header"] + "\n" + df["mutant_sequence_kmer"] + "\n"
-                    )
+    empty_kmer_count = (mutations["mutant_sequence"] == "").sum()
+    
+    if empty_kmer_count > 0 and verbose:
+        logger.warning(
+            f"{empty_kmer_count} mutated sequences were empty and were not included in the output."
+        )
 
-                    # Do not include an empty string as a mutated sequence
-                    # (these are introduced when unknown mutations are encountered)
-                    df = df[df["mutant_sequence_kmer"] != ""]
+    mutations = mutations[mutations["mutant_sequence"] != ""]
 
-                    fasta_file.write("".join(df["fasta_format"].values))
+    mutations['header'] = mutations['header'].str[1:]  # remove the > character
 
-        # with open(out, "r") as fasta_file:
-        #     lines = [line for line in fasta_file if line.strip()]
+    if update_df:
+        logger.info("Saving dataframe with updated mutation info...")
+        saved_updated_df = True
+        logger.warning("File size can be very large if the number of mutations is large.")
+        if not update_df_out:
+            if not mutations_path:
+                logger.warning("mutations_path must be provided if update_df is True and update_df_out is not provided.")
+                saved_updated_df = False
+            else:
+                base_name, ext = os.path.splitext(mutations_path)
+                update_df_out = f"{base_name}_updated{ext}"
+        if saved_updated_df:
+            mutations.to_csv(update_df_out, index=False)
+            print(f"Updated mutation info has been saved to {update_df_out}")
+
+    mutations["fasta_format"] = (
+        ">" + mutations["header"] + "\n" + mutations["mutant_sequence"] + "\n"
+    )
 
-        # with open(out, "w") as fasta_file:
-        #     fasta_file.writelines(lines)
+    if out:
+        # Save mutated sequences in new fasta file
+        with open(out, "w") as fasta_file:
+            fasta_file.write("".join(mutations["fasta_format"].values))
 
         if verbose:
             logger.info(f"FASTA file containing mutated sequences created at {out}.")
@@ -790,9 +1215,7 @@ def mutate(
     # When out=None, return list of mutated seqs
     else:
         all_mut_seqs = []
-        for mutation in mutation_dict:
-            df = mutation_dict[mutation]
-            all_mut_seqs.extend(df["mutant_sequence_kmer"].values)
+        all_mut_seqs.extend(mutations["mutant_sequence"].values)
 
         # Remove empty strings from final list of mutated sequences
         # (these are introduced when unknown mutations are encountered)
@@ -801,3 +1224,4 @@ def mutate(
 
         if len(all_mut_seqs) > 0:
             return all_mut_seqs
+
diff --git a/gget/gget_opentargets.py b/gget/gget_opentargets.py
new file mode 100644
index 00000000..4c91a967
--- /dev/null
+++ b/gget/gget_opentargets.py
@@ -0,0 +1,667 @@
+import json as json_
+try:
+    from typing import Literal, Callable, Any, TypeAlias, Union, Optional
+except ImportError:
+    from typing_extensions import Literal, TypeAlias
+    from typing import Callable, Any, Union, Optional
+
+import pandas as pd
+
+from .constants import OPENTARGETS_GRAPHQL_API
+from .utils import set_up_logger, wrap_cols_func, graphql_query, json_list_to_df
+
+logger = set_up_logger()
+
+
+def opentargets(
+    ensembl_id: str,
+    resource: Literal[
+        "diseases",
+        "drugs",
+        "tractability",
+        "pharmacogenetics",
+        "expression",
+        "depmap",
+        "interactions",
+    ] = "diseases",
+    limit: Optional[int] = None,
+    verbose: bool = True,
+    wrap_text: bool = False,
+    filters: Optional[dict[str, Union[str, list[str]]]] = None,
+    filter_mode: Literal["and", "or"] = "and",
+    json: bool = False
+) -> Union[pd.DataFrame, list[dict[str, ...]]]:
+    """
+    Query OpenTargets for data associated with a given Ensembl gene ID.
+
+    Args:
+
+    - ensembl_id    Ensembl gene ID to be queried (str), e.g. "ENSG00000169194".
+    - resource      Defines type of information to be returned.
+                    "diseases":         Returns diseases associated with the gene (default).
+                    "drugs":            Returns drugs associated with the gene.
+                    "tractability":     Returns tractability data for the gene.
+                    "pharmacogenetics": Returns pharmacogenetics data for the gene.
+                    "expression":       Returns gene expression data (by tissues, organs, and anatomical systems).
+                    "depmap":           Returns DepMap gene-disease effect data for the gene.
+                    "interactions":     Returns protein-protein interactions for the gene.
+    - limit         Limit the number of results returned (default: No limit).
+                    Note: Not compatible with the 'tractability' and 'depmap' resources.
+    - verbose       Print progress messages (default: True).
+    - wrap_text     If True, displays data frame with wrapped text for easy reading. Default: False.
+    - filters       Filters to apply to the data. Supported filters by resource:
+                    "diseases": None
+                    "drugs": disease_id (e.g. "EFO_0000274")
+                    "tractability": None
+                    "pharmacogenetics": drug_id (e.g. "CHEMBL535")
+                    "expression": tissue_id (e.g. "UBERON_0002245"), anatomical_system (e.g. "nervous system"), organ (e.g. "brain")
+                    "depmap": tissue_id (e.g. "UBERON_0002245")
+                    "interactions": protein_a_id (e.g. "ENSP00000304915"), protein_b_id (e.g. "ENSP00000379111"), gene_b_id (e.g. "ENSG00000077238")
+    - filter_mode   For resources that support multiple types of filters, this argument specifies how to combine them.
+    - json          If True, returns results in JSON format instead of as a Data Frame. Default: False.
+
+
+    Returns requested information in DataFrame format.
+    """
+
+    # check if resource argument is valid
+    if resource not in _RESOURCES:
+        raise ValueError(
+            f"'resource' argument specified as {resource}. Expected one of: {', '.join(_RESOURCES)}"
+        )
+
+    # Wrap everything into a list
+    if filters is not None:
+        filters = {k: v if isinstance(v, list) else [v] for k, v in filters.items()}
+
+    df: pd.DataFrame = _RESOURCES[resource](ensembl_id, limit=limit, verbose=verbose, wrap_text=wrap_text, filters=filters, filter_mode=filter_mode, )
+
+    if json:
+        return json_.loads(df.to_json(orient="records", force_ascii=False))
+    else:
+        return df
+
+
+def _limit_pagination() -> tuple[str, str, callable]:
+    """
+    Limit is expressed as (page: {"index": 0, "size": limit}).
+    """
+
+    def f(limit: Optional[int], is_rows_based_query: bool):
+        if limit is None:
+            # special case because `None` is used to probe the total count
+            if is_rows_based_query:
+                limit = 1
+            else:
+                return None
+        return {"index": 0, "size": limit}
+
+    return "page", "Pagination", f
+
+
+def _limit_size() -> tuple[str, str, callable]:
+    """
+    Limit is expressed as (size: limit).
+    """
+
+    def f(limit: Optional[int], is_rows_based_query: bool):
+        # special case because `None` is used to probe the total count
+        if limit is None and is_rows_based_query:
+            limit = 1
+        return limit
+
+    return "size", "Int", f
+
+
+def _limit_not_supported() -> tuple[None, None, callable]:
+    """
+    Limit is not supported for this resource (it has no GraphQL-support, and it is meaningless).
+    """
+
+    def f(limit: Optional[int], _is_rows_based_query: bool):
+        if limit is not None:
+            raise ValueError("Limit is not supported for this resource.")
+        return None
+
+    return None, None, f
+
+
+def _limit_deferred() -> tuple[None, None, callable]:
+    """
+    Limit is handled after fetching the data (it is not supported by the GraphQL query, but does have meaning).
+    """
+
+    def f(_limit: Optional[int], _is_rows_based_query: bool):
+        return None
+
+    return None, None, f
+
+
+def _tractability_converter(row: dict[str, ...]):
+    _modality_map = {
+        "SM": "Small molecule",
+        "AB": "Antibody",
+        "PR": "PROTAC",
+        "OC": "Other",
+    }
+    row["modality"] = _modality_map.get(row["modality"], row["modality"])
+
+
+def _pharmacogenetics_converter(row: dict[str, ...]):
+    # need to modify the drugs field to parse it into a dataframe
+    drugs = row["drugs"]
+    drugs_df = json_list_to_df(drugs, [("id", "drugId"), ("name", "drugFromSource")])
+    row["drugs"] = drugs_df
+
+
+def _mk_list_converter(
+    f: Callable[[dict[str, ...]], None]
+) -> Callable[[list[dict[str, ...]]], None]:
+    def fun(rows: list[dict[str, ...]]):
+        for row in rows:
+            f(row)
+
+    return fun
+
+
+def _flatten_depmap(json_entries: list[dict[str, ...]]):
+    old_entries = json_entries.copy()
+    json_entries.clear()
+
+    for entry in old_entries:
+        for screen in entry["screens"]:
+            new_entry = entry.copy()
+            del new_entry["screens"]
+            new_entry["screen"] = screen
+            json_entries.append(new_entry)
+
+
+_FilterApplicator: TypeAlias = Callable[
+    [dict[str, ...], Literal["and", "or"], dict[str, list[str]]], bool
+]
+
+
+def _mk_filter_applicator(id_key: dict[str, str]) -> tuple[set[str], _FilterApplicator]:
+    """
+    Make a filter applicator function based on the provided mapping.
+    :param id_key: Mapping of filter key to the key in the row data. (e.g. {"disease_id": "disease.id"})
+    """
+
+    def f(
+        row: dict[str, ...], mode: Literal["and", "or"], filters: dict[str, list[str]]
+    ) -> bool:
+        for filter_id, filter_values in filters.items():
+            split_key = id_key[filter_id].split(".")
+            actual_value: Union[dict[str, ...], list[dict[str, ...]]] = row
+            for k in split_key:
+                if actual_value is None:
+                    break
+                if type(actual_value) is list:
+                    actual_value = [v[k] for v in actual_value]
+                else:
+                    actual_value = actual_value[k]
+
+            if mode == "and":
+                if type(actual_value) is list:
+                    if not any(
+                        v in filter_values for v in actual_value
+                    ):  # if none match, return False
+                        return False
+                elif actual_value not in filter_values:
+                    return False
+            else:
+                if type(actual_value) is list:
+                    if any(
+                        v in filter_values for v in actual_value
+                    ):  # if any match, return True
+                        return True
+                elif actual_value in filter_values:
+                    return True
+
+        return mode == "and"  # fallthrough return for "and" is True, for "or" is False
+
+    return set(id_key.keys()), f
+
+
+def _make_query_fun(
+    top_level_key: str,
+    inner_query: str,
+    human_readable_tlk: str,
+    df_schema: list[tuple[str, str]],
+    wrap_columns: list[str],
+    limit_func: callable,
+    is_rows_based_query: bool = True,
+    sorter: Optional[Callable[[dict[str, ...]], Any]] = None,
+    sort_reverse: bool = False,
+    filter_: Callable[[dict[str, ...]], bool] = lambda x: True,
+    converter: Callable[[list[dict[str, ...]]], None] = lambda x: None,
+    user_filter: Optional[tuple[set[str], _FilterApplicator]] = None,
+) -> callable:
+    """
+    Make a query function for OpenTargets API.
+
+    Args:
+
+    - top_level_key         Top level key in the GraphQL query response, e.g. "associatedDiseases".
+    - inner_query           Query string for the row data, e.g. "score disease{id name description}".
+    - human_readable_tlk    Human readable version of the top level key, e.g. "associated diseases".
+    - df_schema             Schema for the DataFrame, e.g. [("id", "disease.id"), ("name", "disease.name")].
+    - wrap_columns          Columns to wrap text for easy reading, e.g. ["description"].
+    - limit_func            Function to convert a limit into a pagination variable.
+    - is_rows_based_query   If True, the query is wrapped inside `count rows{query}`.
+    - sorter                Function to sort the raw data before it is limited, filtered, or converted.
+                            Note: you may want to use `_limit_deferred`, otherwise the limit will be
+                            applied through GraphQL before sorting.
+    - sort_reverse          If True, sort in reverse order.
+    - filter_               Function to filter the raw data (return False to skip value).
+                            Note: applied after limit but before converter.
+    - converter             Function to optionally manipulate the raw data IN PLACE before it is converted to a DataFrame.
+    - user_filter           Tuple of a set of valid filter keys and a function to apply filters to the raw data.
+                            Run before limit, `filter_`, and `converter`. Only called if filters are provided.
+
+    Returns a function that queries the OpenTargets API and returns the data in DataFrame format.
+    """
+
+    limit_key, limit_type, limit_func = limit_func()
+
+    def fun(
+        ensembl_id: str,
+        limit: Optional[int] = None,
+        verbose: bool = True,
+        wrap_text: bool = False,
+        filters: Optional[dict[str, list[str]]] = None,
+        filter_mode: Literal["and", "or"] = "and",
+    ) -> pd.DataFrame:
+        if limit_key is None:
+            query_string = """
+            query target($ensemblId: String!) {
+                target(ensemblId: $ensemblId) {
+                    <TOP_LEVEL_KEY>{
+                        <ROW_QUERY>
+                    }
+                }
+            }
+            """.replace(
+                "<TOP_LEVEL_KEY>", top_level_key
+            ).replace(
+                "<ROW_QUERY>", inner_query
+            )
+        else:
+            if is_rows_based_query:
+                query_tmp = """
+                count
+                rows{
+                    <INNER_QUERY>
+                }""".replace(
+                    "<INNER_QUERY>", inner_query
+                )
+            else:
+                query_tmp = inner_query
+            query_string = (
+                """
+            query target($ensemblId: String!, $pagination: <LIMIT_TYPE>) {
+                target(ensemblId: $ensemblId) {
+                    <TOP_LEVEL_KEY>(<LIMIT_KEY>: $pagination){
+                        <QUERY>
+                    }
+                }
+            }
+            """.replace(
+                    "<TOP_LEVEL_KEY>", top_level_key
+                )
+                .replace("<LIMIT_TYPE>", limit_type)
+                .replace("<LIMIT_KEY>", limit_key)
+                .replace("<QUERY>", query_tmp)
+            )
+
+        actual_limit = limit
+
+        if filters is not None and len(filters) > 0:
+            if user_filter is None:
+                raise ValueError("Filters are not supported for this resource.")
+            valid_keys, filter_func = user_filter
+            invalid_keys = set(filters.keys()) - valid_keys
+            if len(invalid_keys) > 0:
+                invalid_keys = [f"'{k}'" for k in invalid_keys]
+                valid_keys = [f"'{k}'" for k in valid_keys]
+                raise ValueError(
+                    f"The following filter keys are invalid for this resource: {', '.join(invalid_keys)}. Valid keys are: {', '.join(valid_keys)}"
+                )
+            # we have to fetch all data to be able to apply limit in a sane way when filters are specified
+            limit = None
+
+        pagination = limit_func(limit, is_rows_based_query)
+        variables = {"ensemblId": ensembl_id, "pagination": pagination}
+
+        if limit_key is None:
+            del variables["pagination"]
+
+        results = graphql_query(OPENTARGETS_GRAPHQL_API, query_string, variables)
+        target: dict[str, ...] = results["data"]["target"]
+        if target is None:
+            raise ValueError(
+                f"No data found for Ensembl ID: {ensembl_id}. Please double-check the ID and try again."
+            )
+        data: dict[str, ...] = target[top_level_key]
+
+        if is_rows_based_query:
+            total_count: int = data["count"]
+            rows: list[dict[str, ...]] = data["rows"]
+        else:
+            # noinspection PyTypeChecker
+            rows: list[dict[str, ...]] = data
+            total_count = len(data)
+
+        if verbose:
+            explanation = ""
+            if limit is None and limit_key is not None and is_rows_based_query:
+                explanation = " (Querying count, will fetch all results next.)"
+            logger.info(
+                f"Retrieved {len(rows)}/{total_count} {human_readable_tlk}.{explanation}"
+            )
+
+        if limit is None and limit_key is not None and is_rows_based_query:
+            variables["pagination"] = limit_func(total_count, is_rows_based_query)
+
+            new_results = graphql_query(
+                OPENTARGETS_GRAPHQL_API, query_string, variables
+            )
+            new_data: dict[str, ...] = new_results["data"]["target"][top_level_key]
+            new_rows: list[dict[str, ...]] = new_data["rows"]
+            # we re-fetched the original 1, so we need to replace them
+            rows = new_rows
+            if verbose:
+                logger.info(
+                    f"Retrieved {len(rows)}/{total_count} {human_readable_tlk}."
+                )
+
+        if sorter is not None:
+            rows.sort(key=sorter, reverse=sort_reverse)
+
+        if filters is not None and len(filters) > 0:
+            rows = [row for row in rows if user_filter[1](row, filter_mode, filters)]
+
+        if actual_limit is not None:
+            rows = rows[:actual_limit]
+
+        rows = [row for row in rows if filter_(row)]
+        converter(rows)
+
+        if actual_limit is not None: # just in case the converter changed the length
+            rows = rows[:actual_limit]
+
+        df = json_list_to_df(
+            rows,
+            df_schema,
+        )
+
+        if wrap_text:
+            df_wrapped = df.copy()
+            wrap_cols_func(df_wrapped, wrap_columns)
+
+        return df
+
+    return fun
+
+
+_RESOURCES = {
+    "diseases": _make_query_fun(
+        "associatedDiseases",
+        """
+        score
+        disease{
+            id
+            name
+            description
+        }""",
+        "associated diseases",
+        [
+            ("id", "disease.id"),
+            ("name", "disease.name"),
+            ("description", "disease.description"),
+            ("score", "score"),
+        ],
+        ["description"],
+        _limit_pagination,
+    ),
+    "drugs": _make_query_fun(
+        "knownDrugs",
+        """
+        # Basic drug data
+        drugId
+        prefName
+        drugType
+        mechanismOfAction
+        # Disease data
+        disease{
+            id
+            name
+        }
+        # Clinical trial data
+        phase
+        status
+        ctIds
+        # More drug data
+        drug{
+            synonyms
+            tradeNames
+            description
+            isApproved
+        }
+        """,
+        "known drugs",
+        [
+            # Drug data
+            ("id", "drugId"),
+            ("name", "prefName"),
+            ("type", "drugType"),
+            ("action_mechanism", "mechanismOfAction"),
+            ("description", "drug.description"),
+            ("synonyms", "drug.synonyms"),
+            ("trade_names", "drug.tradeNames"),
+            # Disease data
+            ("disease_id", "disease.id"),
+            ("disease_name", "disease.name"),
+            # Trial data
+            ("trial_phase", "phase"),
+            ("trial_status", "status"),
+            ("trial_ids", "ctIds"),
+            ("approved", "drug.isApproved"),
+        ],
+        ["description", "synonyms", "trade_names", "trial_ids"],
+        _limit_size,
+        user_filter=_mk_filter_applicator({"disease_id": "disease.id"}),
+    ),
+    "tractability": _make_query_fun(
+        "tractability",
+        """
+        label
+        modality
+        value
+        """,
+        "tractability states",
+        [
+            ("label", "label"),
+            ("modality", "modality"),
+        ],
+        [],
+        _limit_not_supported,
+        filter_=lambda x: x["value"],
+        converter=_mk_list_converter(_tractability_converter),
+        is_rows_based_query=False,
+    ),
+    "pharmacogenetics": _make_query_fun(
+        "pharmacogenomics",
+        """
+        variantRsId
+        
+        genotypeId
+        genotype
+        
+        variantFunctionalConsequence{
+            id
+            label
+        }
+        
+        drugs{
+            drugId
+            drugFromSource
+        }
+        phenotypeText
+        genotypeAnnotationText
+        
+        pgxCategory
+        isDirectTarget
+        
+        evidenceLevel
+        
+        datasourceId
+        literature
+        """,
+        "pharmacogenetic responses",
+        [
+            ("rs_id", "variantRsId"),
+            ("genotype_id", "genotypeId"),
+            ("genotype", "genotype"),
+            ("variant_consequence_id", "variantFunctionalConsequence.id"),
+            ("variant_consequence_label", "variantFunctionalConsequence.label"),
+            ("drugs", "drugs"),  # this is processed into a DataFrame by the converter
+            ("phenotype", "phenotypeText"),
+            ("genotype_annotation", "genotypeAnnotationText"),
+            ("response_category", "pgxCategory"),
+            ("direct_target", "isDirectTarget"),
+            ("evidence_level", "evidenceLevel"),
+            ("source", "datasourceId"),
+            ("literature", "literature"),
+        ],
+        ["phenotype", "genotype_annotation"],
+        _limit_pagination,
+        is_rows_based_query=False,
+        converter=_mk_list_converter(_pharmacogenetics_converter),
+        user_filter=_mk_filter_applicator({"drug_id": "drugs.drugId"}),
+    ),
+    "expression": _make_query_fun(
+        "expressions",
+        """
+        tissue{
+            id
+            label
+            anatomicalSystems
+            organs
+        }
+        rna{
+            zscore
+            value
+            unit
+            level
+        }
+        """,
+        "baseline expressions",
+        [
+            ("tissue_id", "tissue.id"),
+            ("tissue_name", "tissue.label"),
+            ("rna_zscore", "rna.zscore"),
+            ("rna_value", "rna.value"),
+            ("rna_unit", "rna.unit"),
+            ("rna_level", "rna.level"),
+            ("anatomical_systems", "tissue.anatomicalSystems"),
+            ("organs", "tissue.organs"),
+        ],
+        ["tissue_name", "anatomical_systems", "organs"],
+        _limit_deferred,
+        is_rows_based_query=False,
+        sorter=lambda x: (x["rna"]["value"], x["rna"]["zscore"]),
+        sort_reverse=True,
+        user_filter=_mk_filter_applicator(
+            {
+                "tissue_id": "tissue.id",
+                "anatomical_system": "tissue.anatomicalSystems",
+                "organ": "tissue.organs",
+            }
+        ),
+    ),
+    "depmap": _make_query_fun(
+        "depMapEssentiality",
+        """
+        tissueId
+        tissueName
+        screens{
+            depmapId
+            expression
+            geneEffect
+            cellLineName
+            diseaseCellLineId
+            diseaseFromSource
+            mutation
+        }
+        """,
+        "DepMap entries",
+        [
+            ("depmap_id", "screen.depmapId"),
+            ("expression", "screen.expression"),
+            ("effect", "screen.geneEffect"),
+            ("tissue_id", "tissueId"),
+            ("tissue_name", "tissueName"),
+            ("cell_line_name", "screen.cellLineName"),
+            ("disease_cell_line_id", "screen.diseaseCellLineId"),
+            ("disease_name", "screen.diseaseFromSource"),
+            ("mutation", "screen.mutation"),
+        ],
+        [],
+        _limit_not_supported,
+        is_rows_based_query=False,
+        converter=_flatten_depmap,
+        user_filter=_mk_filter_applicator({"tissue_id": "tissueId"}),
+    ),
+    "interactions": _make_query_fun(
+        "interactions",
+        """
+        score
+        count
+        sourceDatabase
+        
+        intA
+        targetA{
+            id
+            approvedSymbol
+        }
+        intABiologicalRole
+        speciesA{
+            taxonId
+        }
+        
+        intB
+        targetB{
+            id
+            approvedSymbol
+        }
+        intBBiologicalRole
+        speciesB{
+            taxonId
+        }
+        """,
+        "interactions",
+        [
+            ("evidence_score", "score"),
+            ("evidence_count", "count"),
+            ("source_db", "sourceDatabase"),
+            ("protein_a_id", "intA"),
+            ("gene_a_id", "targetA.id"),
+            ("gene_a_symbol", "targetA.approvedSymbol"),
+            ("role_a", "intABiologicalRole"),
+            ("taxon_a", "speciesA.taxonId"),
+            ("protein_b_id", "intB"),
+            ("gene_b_id", "targetB.id"),
+            ("gene_b_symbol", "targetB.approvedSymbol"),
+            ("role_b", "intBBiologicalRole"),
+            ("taxon_b", "speciesB.taxonId"),
+        ],
+        [],
+        _limit_pagination,
+        user_filter=_mk_filter_applicator(
+            {"protein_a_id": "intA", "protein_b_id": "intB", "gene_b_id": "targetB.id"}
+        ),
+    ),
+}
+
+OPENTARGETS_RESOURCES = list(_RESOURCES.keys())
diff --git a/gget/gget_seq.py b/gget/gget_seq.py
index a1145956..5656e0c2 100644
--- a/gget/gget_seq.py
+++ b/gget/gget_seq.py
@@ -1,7 +1,8 @@
 import numpy as np
 
 # Custom functions
-from .utils import rest_query, get_uniprot_seqs, set_up_logger
+from .utils import rest_query, get_uniprot_seqs, set_up_logger, post_query
+
 logger = set_up_logger()
 from .gget_info import info
 
@@ -85,41 +86,52 @@ def seq(
         master_dict = {}
 
         # Query REST APIs from https://rest.ensembl.org/
-        for ensembl_ID in ens_ids_clean:
-            # Create dict to save query results
-            results_dict = {ensembl_ID: {}}
 
-            # If isoforms False, just fetch sequences of passed Ensembl ID
-            if isoforms == False:
-                # sequence/id/ query: Request sequence by stable identifier
-                query = "sequence/id/" + ensembl_ID + "?"
+        # If isoforms False, just fetch sequences of passed Ensembl ID
+        if not isoforms:
+            actual_results_dict = {}
 
-                # Try if query valid
-                try:
-                    # Submit query; this will throw RuntimeError if ID not found
-                    df_temp = rest_query(server, query, content_type)
+            endpoint = "sequence/id/"
+            query = {"ids": ens_ids_clean}
 
-                    # Delete superfluous entries
-                    keys_to_delete = ["query", "id", "version", "molecule"]
-                    for key in keys_to_delete:
-                        # Pop keys, None -> do not raise an error if key to delete not found
-                        df_temp.pop(key, None)
+            # noinspection PyTypeChecker
+            results_list: list[dict[str, ...]] = post_query(server, endpoint, query)
+            results_dict: dict[str, dict[str, ...]] = {v['query']: v for v in results_list if v is not None}
 
-                    # Add results to main dict
-                    results_dict[ensembl_ID].update({"seq": df_temp})
+            for ensembl_ID, df_temp in results_dict.items():
+                df_temp: dict[str, ...]
 
-                    if verbose:
-                        logger.info(
-                            f"Requesting nucleotide sequence of {ensembl_ID} from Ensembl."
-                        )
+                # Delete superfluous entries
+                keys_to_delete = ["query", "id", "version", "molecule"]
+                for key in keys_to_delete:
+                    # Pop keys, None -> do not raise an error if key to delete not found
+                    df_temp.pop(key, None)
 
-                except RuntimeError:
-                    logger.error(
-                        f"ID {ensembl_ID} not found. Please double-check spelling/arguments and try again."
+                # Add results to main dict
+                actual_results_dict[ensembl_ID] = {"seq": df_temp}
+
+                if verbose:
+                    logger.info(
+                        f"Requesting nucleotide sequence of {ensembl_ID} from Ensembl."
                     )
 
-            # If isoforms true, fetch sequences of isoforms instead
-            if isoforms == True:
+            missing_ids = set(ens_ids_clean) - set(actual_results_dict.keys())
+
+            for missing in missing_ids:
+                logger.error(
+                    f"ID {missing} not found. Please double-check spelling/arguments and try again."
+                )
+
+            # Add results to master dict
+            master_dict.update(actual_results_dict)
+
+        # If isoforms true, fetch sequences of isoforms instead
+        # todo this could possibly be refactored to use bulk queries
+        else:
+            for ensembl_ID in ens_ids_clean:
+                # Create dict to save query results
+                results_dict = {ensembl_ID: {}}
+
                 # Get ID type (gene, transcript, ...) using gget info
                 info_df = info(ensembl_ID, verbose=False, pdb=False, ncbi=False, uniprot=False)
 
@@ -198,8 +210,8 @@ def seq(
                             "Please double-check spelling/arguments and try again."
                         )
 
-            # Add results to master dict
-            master_dict.update(results_dict)
+                # Add results to master dict
+                master_dict.update(results_dict)
 
         # Build FASTA file
         for ens_ID in master_dict:
diff --git a/gget/gget_setup.py b/gget/gget_setup.py
index e01f5447..ad826a3d 100644
--- a/gget/gget_setup.py
+++ b/gget/gget_setup.py
@@ -41,6 +41,35 @@
 PARAMS_PATH = os.path.join(PARAMS_DIR, "params_temp.tar")
 
 
+def _install(package: str, import_name: str, verbose: bool = True):
+    if verbose:
+        logger.info(f"Installing {package} package (requires pip).")
+    command = f"pip install -q -U {package}"
+    with subprocess.Popen(command, shell=True, stderr=subprocess.PIPE) as process:
+        stderr = process.stderr.read().decode("utf-8")
+    # Exit system if the subprocess returned with an error
+    if process.wait() != 0:
+        if stderr:
+            # Log the standard error if it is not empty
+            sys.stderr.write(stderr)
+        logger.error(
+            f"{package} installation with pip (https://pypi.org/project/{package}) failed."
+        )
+        return
+
+    # Test installation
+    try:
+        exec(f"import {import_name}")
+
+        if verbose:
+            logger.info(f"{import_name} installed succesfully.")
+    except ImportError as e:
+        logger.error(
+            f"{package} installation with pip (https://pypi.org/project/{package}) failed. Import error:\n{e}"
+        )
+        return
+
+
 def setup(module, verbose=True, out=None):
     """
     Function to install third-party dependencies for a specified gget module.
@@ -48,13 +77,13 @@ def setup(module, verbose=True, out=None):
     Some modules require curl to be installed (https://everything.curl.dev/get).
 
     Args:
-    - module    (str) gget module for which dependencies should be installed, e.g. "alphafold", "cellxgene", "elm", or "gpt".
+    - module    (str) gget module for which dependencies should be installed, e.g. "alphafold", "cellxgene", "elm", "gpt", or "cbio".
     - verbose   True/False whether to print progress information. Default True.
     - out       (str) Path to directory to save downloaded files in (currently only applies when module='elm').
                 NOTE: Do not use this argument when downloading the files for use with 'gget.elm'.
                 Default None (files are saved in the gget installation directory).
     """
-    supported_modules = ["alphafold", "cellxgene", "elm", "gpt"]
+    supported_modules = ["alphafold", "cellxgene", "elm", "gpt", "cbio"]
     if module not in supported_modules:
         raise ValueError(
             f"'module' argument specified as {module}. Expected one of: {', '.join(supported_modules)}"
@@ -88,35 +117,10 @@ def setup(module, verbose=True, out=None):
             )
             return
 
-    if module == "cellxgene":
-        if verbose:
-            logger.info("Installing cellxgene-census package (requires pip).")
-        command = "pip install -q -U cellxgene-census"
-        with subprocess.Popen(command, shell=True, stderr=subprocess.PIPE) as process:
-            stderr = process.stderr.read().decode("utf-8")
-        # Exit system if the subprocess returned with an error
-        if process.wait() != 0:
-            if stderr:
-                # Log the standard error if it is not empty
-                sys.stderr.write(stderr)
-            logger.error(
-                "cellxgene-census installation with pip (https://pypi.org/project/cellxgene-census) failed."
-            )
-            return
-
-        # Test installation
-        try:
-            import cellxgene_census
+    elif module == "cellxgene":
+        _install("cellxgene-census", "cellxgene_census", verbose=verbose)
 
-            if verbose:
-                logger.info(f"cellxgene_census installed succesfully.")
-        except ImportError as e:
-            logger.error(
-                f"cellxgene-census installation with pip (https://pypi.org/project/cellxgene-census) failed. Import error:\n{e}"
-            )
-            return
-
-    if module == "elm":
+    elif module == "elm":
         if verbose:
             logger.info(
                 "ELM data can be downloaded & distributed for non-commercial use according to the following license: http://elm.eu.org/media/Elm_academic_license.pdf"
@@ -199,7 +203,7 @@ def setup(module, verbose=True, out=None):
         else:
             logger.error("ELM interactions domains file missing.")
 
-    if module == "alphafold":
+    elif module == "alphafold":
         if platform.system() == "Windows":
             logger.error(
                 "gget setup alphafold and gget alphafold are not supported on Windows OS."
@@ -428,3 +432,6 @@ def setup(module, verbose=True, out=None):
         else:
             if verbose:
                 logger.info("AlphaFold model parameters already downloaded.")
+
+    elif module == "cbio":
+        _install("bravado", "bravado", verbose=verbose)
diff --git a/gget/main.py b/gget/main.py
index db97a3c3..0d82f41c 100644
--- a/gget/main.py
+++ b/gget/main.py
@@ -1,6 +1,9 @@
 import argparse
 import sys
 from datetime import datetime
+from typing import Optional
+
+import pandas as pd
 
 # Get current date and time for alphafold default foldername
 dt_string = datetime.now().strftime("%Y_%m_%d-%H_%M")
@@ -33,6 +36,9 @@
 from .gget_diamond import diamond
 from .gget_cosmic import cosmic
 from .gget_mutate import mutate
+from .gget_opentargets import opentargets, OPENTARGETS_RESOURCES
+from .gget_cbio import cbio_plot, cbio_search
+from .gget_bgee import bgee
 
 
 # Custom formatter for help messages that preserved the text formatting and adds the default value to the end of the help message
@@ -43,11 +49,23 @@ def _get_help_string(self, action):
             "%(default)" not in help_str
             and action.default is not argparse.SUPPRESS
             and action.default is not None
+            # default information can be deceptive or confusing for boolean flags.
+            # For example, `--quiet` says "Does not print progress information. (default: True)" even though
+            # the default action is to NOT be quiet (to the user, the default is False).
+            and not isinstance(action, argparse._StoreTrueAction)
+            and not isinstance(action, argparse._StoreFalseAction)
         ):
             help_str += " (default: %(default)s)"
         return help_str
 
 
+def int_or_str(value):
+    try:
+        return int(value)
+    except ValueError:
+        return value
+
+
 def main():
     """
     Function containing argparse parsers and arguments to allow the use of gget from the terminal.
@@ -154,7 +172,17 @@ def main():
         default=False,
         action="store_true",
         required=False,
-        help="Download FTPs to the current directory using curl.",
+        help="Download FTPs to the directory specified by --out_dir using curl.",
+    )
+    parser_ref.add_argument(
+        "-od",
+        "--out_dir",
+        type=str,
+        required=False,
+        help=(
+            "Path to the directory the FTPs will be saved in, e.g. path/to/directory.\n"
+            "Default: Current working directory."
+        ),
     )
     parser_ref.add_argument(
         "-o",
@@ -966,8 +994,18 @@ def main():
         help=(
             "'pathway', 'transcription', 'ontology', 'diseases_drugs', 'celltypes', 'kinase_interactions'"
             "or any database listed at: https://maayanlab.cloud/Enrichr/#libraries"
+            " or the species-specific libraries listed in the documentation"
         ),
     )
+    parser_enrichr.add_argument(
+        "-s",
+        "--species",
+        type=str,
+        choices=["human", "mouse", "fly", "yeast", "worm", "fish"],
+        default="human",
+        required=False,
+        help="Enrichr variant to query. Default: 'human'.",
+    )
     parser_enrichr.add_argument(
         "-bkg_l",
         "--background_list",
@@ -975,7 +1013,7 @@ def main():
         nargs="*",
         default=None,
         required=False,
-        help="List of gene names/Ensembl IDs to be used as background genes.",
+        help="List of gene names/Ensembl IDs to be used as background genes. ONLY SUPPORTED FOR HUMAN/MOUSE SPECIES",
     )
     parser_enrichr.add_argument(
         "-bkg",
@@ -983,7 +1021,7 @@ def main():
         default=False,
         action="store_true",
         required=False,
-        help="If True, use set of >20,000 default background genes listed here: https://github.com/pachterlab/gget/blob/main/gget/constants/enrichr_bkg_genes.txt.",
+        help="If True, use set of >20,000 default background genes listed here: https://github.com/pachterlab/gget/blob/main/gget/constants/enrichr_bkg_genes.txt. ONLY SUPPORTED FOR HUMAN/MOUSE SPECIES",
     )
     parser_enrichr.add_argument(
         "-e",
@@ -1182,7 +1220,7 @@ def main():
     parser_setup.add_argument(
         "module",
         type=str,
-        choices=["alphafold", "gpt", "cellxgene", "elm"],
+        choices=["alphafold", "gpt", "cellxgene", "elm", "cbio"],
         help="gget module for which dependencies should be installed, e.g. 'alphafold'",
     )
 
@@ -1781,7 +1819,8 @@ def main():
             "cell_line",
             "census",
             "resistance",
-            "screen",
+            "genome_screen",
+            "targeted_screen",
             "cancer_example",
         ],
         default="cancer",
@@ -1814,6 +1853,20 @@ def main():
         required=False,
         help="Do NOT create a modified version of the database for use with gget mutate (only for use with --download_cosmic).",
     )
+    parser_cosmic.add_argument(
+        "--keep_genome_info",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Whether to keep genome information (e.g. location of mutation in the genome) in the modified database for use with gget mutate (only for use with --download_cosmic).",
+    )
+    parser_cosmic.add_argument(
+        "--remove_duplicates",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Whether to remove duplicated rows from the modified database for use with gget mutate (only for use with --download_cosmic).",
+    )
     parser_cosmic.add_argument(
         "-o",
         "--out",
@@ -1837,7 +1890,7 @@ def main():
     )
 
     # mutate parser arguments
-    mutate_desc = "Mutate nucleotide sequences according to defined mutations."
+    mutate_desc = "Mutate nucleotide sequences based on provided mutations."
     parser_mutate = parent_subparsers.add_parser(
         "mutate",
         parents=[parent],
@@ -1879,7 +1932,7 @@ def main():
             "| c.2239_2253delinsAAT | mut3   | seq3   | -> Apply mutation 3 to sequence 3\n"
             "| ...                  | ...    | ...    |\n\n"
             "'mutation' = Column containing the mutations to be performed written in standard mutation annotation (see below)\n"
-            "'mut_ID' = Column containing an identifier for each mutation\n"
+            "'mut_ID' = Column containing an identifier for each mutation (optional)\n"
             "'seq_ID' = Column containing the identifiers of the sequences to be mutated\n"
             "(sequence IDs must correspond to the string following the > character in the input fasta; do NOT include spaces or dots)\n\n"
             "Alternatively: Input mutation(s) as a string or list, e.g. 'c.2C>T' or 'c.2C>T' 'c.1A>C'\n\n"
@@ -1887,14 +1940,6 @@ def main():
             "If a list is passed, the number of mutations must equal the number of input sequences."
         ),
     )
-    parser_mutate.add_argument(
-        "-k",
-        "--k",
-        default=30,
-        type=int,
-        required=False,
-        help="Length of sequences flanking the mutation. If k > total length of the sequence, the entire sequence will be kept.",
-    )
     parser_mutate.add_argument(
         "-mc",
         "--mut_column",
@@ -1903,21 +1948,131 @@ def main():
         required=False,
         help="Name of the column containing the mutations to be performed in 'mutations'.",
     )
+    parser_mutate.add_argument(
+        "-sic",
+        "--seq_id_column",
+        default="seq_ID",
+        type=str,
+        required=False,
+        help="Name of the column containing the IDs of the sequences to be mutated in 'mutations'.",
+    )
     parser_mutate.add_argument(
         "-mic",
         "--mut_id_column",
-        default="mut_ID",
+        default=None,
         type=str,
         required=False,
-        help="Name of the column containing the IDs of each mutation in 'mutations'.",
+        help="Name of the column containing the IDs of each mutation in 'mutations'. Default: Same as 'mut_column'.",
     )
     parser_mutate.add_argument(
-        "-sic",
-        "--seq_id_column",
-        default="seq_ID",
+        "-gtf",
+        "--gtf",
+        default=None,
         type=str,
         required=False,
-        help="Name of the column containing the IDs of the sequences to be mutated in 'mutations'.",
+        help="Path to a .gtf file. When providing a genome fasta file as input for 'sequences', you can provide a .gtf file here and the input sequences will be defined according to the transcript boundaries, e.g. 'path/to/genome_annotation.gtf'.",
+    )
+    parser_mutate.add_argument(
+        "-gtic",
+        "--gtf_transcript_id_column",
+        default=None,
+        type=str,
+        required=False,
+        help="Column name in the input 'mutations' file containing the transcript ID. In this case, column 'seq_id_column' should contain the chromosome number. Required when 'gtf' is provided.",
+    )
+    parser_mutate.add_argument(
+        "-k",
+        "--k",
+        default=30,
+        type=int,
+        required=False,
+        help="Length of sequences flanking the mutation. If k > total length of the sequence, the entire sequence will be kept.",
+    )
+    parser_mutate.add_argument(
+        "-msl",
+        "--min_seq_len",
+        default=None,
+        type=int,
+        required=False,
+        help="Minimum length of the mutant output sequence, e.g. 100. Mutant sequences smaller than this will be dropped.",
+    )
+    parser_mutate.add_argument(
+        "-ma",
+        "--max_ambiguous",
+        default=None,
+        type=int,
+        required=False,
+        help="Maximum number of 'N' (or 'n') characters allowed in the output sequence, e.g. 10. Default: None (no ambiguous character filter will be applied).",
+    )
+    parser_mutate.add_argument(
+        "-ofr",
+        "--optimize_flanking_regions",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Removes nucleotides from either end of the mutant sequence to ensure (when possible) that the mutant sequence does not contain any k-mers also found in the wildtype/input sequence.",
+    )
+    parser_mutate.add_argument(
+        "-rswk",
+        "--remove_seqs_with_wt_kmers",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Removes output sequences where at least one k-mer is also present in the wildtype/input sequence in the same region. When used with `--optimize_flanking_regions`, only sequences for which a wildtpye kmer is still present after optimization will be removed.",
+    )
+    parser_mutate.add_argument(
+        "-mio",
+        "--merge_identical_off",
+        default=True,
+        action="store_false",
+        required=False,
+        help="Do not merge identical mutant sequences in the output (by default, identical sequences will be merged by concatenating the sequence headers for all identical sequences).",
+    )
+    parser_mutate.add_argument(
+        "-udf",
+        "--update_df",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Updates the input `mutations` DataFrame to include additional columns with the mutation type, wildtype nucleotide sequence, and mutant nucleotide sequence (only valid if `mutations` is a .csv or .tsv file).",
+    )
+    parser_mutate.add_argument(
+        "-udf_o",
+        "--update_df_out",
+        default=None,
+        type=str,
+        required=False,
+        help="Path to output csv file containing the updated DataFrame, e.g. 'path/to/mutations_updated.csv'. Only valid when used with `--update_df`. Default: None -> the new csv file will be saved in the same directory as the `mutations` DataFrame with appendix '_updated'.",
+    )
+    parser_mutate.add_argument(
+        "--translate",
+        default=None,
+        action="store_true",
+        required=False,
+        help="Adds additional columns to the updated `mutations` DataFrame containing the wildtype and mutant amino acid sequences. Only valid when used with `--store_full_sequences`.",
+    )
+    parser_mutate.add_argument(
+        "-ts",
+        "--translate_start",
+        default=None,
+        type=int_or_str,
+        required=False,
+        help="(int or str) The position in the input nucleotide sequence to start translating, e.g. 5. If a string is provided, it should correspond to a column name in `mutations` containing the open reading frame start positions for each sequence/mutation. Only valid when used with `--translate`. Default: translates from the beginning of each sequence.",
+    )
+    parser_mutate.add_argument(
+        "--translate_end",
+        default=None,
+        type=int_or_str,
+        required=False,
+        help="(int or str) The position in the input nucleotide sequence to end translating, e.g. 35. If a string is provided, it should correspond to a column name in `mutations` containing the open reading frame end positions for each sequence/mutation. Only valid when used with `--translate`. Default: translates until the end of each sequence.",
+    )
+    parser_mutate.add_argument(
+        "-sfs",
+        "--store_full_sequences",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Includes the complete wildtype and mutant sequences in the updated `mutations` DataFrame (not just the sub-sequence with k-length flanks). Only valid when used with `--update_df`.",
     )
     parser_mutate.add_argument(
         "-o",
@@ -1940,6 +2095,301 @@ def main():
         help="Do not print progress information.",
     )
 
+    ## opentargets parser arguments
+    opentargets_desc = (
+        "Query the Open Targets Platform with a gene for associated drugs, diseases, tractability stats,"
+        " pharmacogenetic responses, expression data, DepMap effects, and protein-protein interaction data."
+    )
+    parser_opentargets = parent_subparsers.add_parser(
+        "opentargets",
+        parents=[parent],
+        description=opentargets_desc,
+        help=opentargets_desc,
+        add_help=True,
+        formatter_class=CustomHelpFormatter,
+    )
+    parser_opentargets.add_argument(
+        "ens_id",
+        type=str,
+        help="Ensembl gene ID, e.g. ENSG00000169194.",
+    )
+    parser_opentargets.add_argument(
+        "-r",
+        "--resource",
+        choices=OPENTARGETS_RESOURCES,
+        default="diseases",
+        type=str,
+        required=False,
+        help="Type of information to be returned.",
+    )
+    parser_opentargets.add_argument(
+        "-l",
+        "--limit",
+        default=None,
+        type=int,
+        required=False,
+        help="Limits the number of results, e.g. 10 (default: None). Note: Not compatible with the 'tractability' and 'depmap' resources",
+    )
+    parser_opentargets.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.json.\n"
+            "Default: Standard out."
+        ),
+    )
+    # Filters
+    _filters = [
+        # flag, long flag,   filter name,            example,            valid resources
+        ("d", "disease", "disease ID", "EFO_0000274", ["drugs"]),
+        ("c", "drug", "drug ID", "CHEMBL1743081", ["pharmacogenetics"]),
+        ("t", "tissue", "tissue ID", "UBERON_0000473", ["expression", "depmap"]),
+        ("a", "anat_sys", "anatomical system", "nervous system", ["expression"]),
+        ("o", "organ", "organ", "brain", ["expression"]),
+        ("pa", "protein_a", "protein A ID", "ENSP00000304915", ["interactions"]),
+        ("pb", "protein_b", "protein B ID", "ENSP00000379111", ["interactions"]),
+        ("gb", "gene_b", "gene B ID", "ENSG00000077238", ["interactions"]),
+    ]
+    for flag, long_flag, filter_name, example, valid_resources in _filters:
+        help_text = f"Filter results by {filter_name}, e.g. '{example}'.\n"
+        if len(valid_resources) > 1:
+            quot = "'"
+            help_text += f"Only valid for the following resources: {', '.join([quot+vr+quot for vr in valid_resources])}."
+        else:
+            help_text += f"Only valid for the '{valid_resources[0]}' resource."
+        parser_opentargets.add_argument(
+            "-f" + flag,
+            "--filter_" + long_flag,
+            type=str,
+            required=False,
+            nargs="+",
+            default=None,
+            help=help_text,
+        )
+    # End Filters
+    parser_opentargets.add_argument(
+        "-csv",
+        "--csv",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Returns results in csv format instead of json.",
+    )
+    parser_opentargets.add_argument(
+        "-q",
+        "--quiet",
+        default=True,
+        action="store_false",
+        required=False,
+        help="Does not print progress information.",
+    )
+    parser_opentargets.add_argument(
+        "-or",
+        "--or",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Use OR instead of AND logic for multiple filter IDs.",
+    )
+
+    ## cbio parser arguments
+    cbio_desc = "Plot cancer genomics heatmaps using data from cBioPortal using Ensembl IDs or gene names"
+    parser_cbio = parent_subparsers.add_parser(
+        "cbio",
+        parents=[parent],
+        description=cbio_desc,
+        help=cbio_desc,
+        add_help=True,
+        formatter_class=CustomHelpFormatter,
+    )
+    parser_cbio_subparsers = parser_cbio.add_subparsers(
+        dest="subcommand", help="Subcommand to execute."
+    )
+    parser_cbio_search = parser_cbio_subparsers.add_parser(
+        "search",
+        description="Search for genes in cBioPortal.",
+        help="Find cBioPortal study IDs by keyword.",
+        add_help=True,
+        formatter_class=CustomHelpFormatter,
+    )
+    parser_cbio_search.add_argument(
+        "keywords",
+        type=str,
+        nargs="+",
+        help="Keywords to search for in cBioPortal.",
+    )
+    parser_cbio_plot = parser_cbio_subparsers.add_parser(
+        "plot",
+        description="Plot a heatmap of cancer genomics data.",
+        help="Plot a heatmap of cancer genomics data.",
+        add_help=True,
+        formatter_class=CustomHelpFormatter,
+    )
+    parser_cbio_plot.add_argument(
+        "-s",
+        "--study_ids",
+        type=str,
+        nargs="+",
+        help="Space-separated list of cBioPortal study IDs, e.g. `msk_impact_2017 egc_msk_2023`",
+        required=True,
+    )
+    parser_cbio_plot.add_argument(
+        "-g",
+        "--genes",
+        type=str,
+        nargs="+",
+        help="Space-separated list of gene names or Ensembl IDs, e.g. `NOTCH3 ENSG00000108375`",
+        required=True,
+    )
+    parser_cbio_plot.add_argument(
+        "-st",
+        "--stratification",
+        type=str,
+        choices=["tissue", "cancer_type", "cancer_type_detailed", "study_id", "sample"],
+        help="Column to stratify the heatmap by.",
+        default="tissue",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-vt",
+        "--variation_type",
+        type=str,
+        choices=[
+            "mutation_occurrences",
+            "cna_nonbinary",
+            "sv_occurrences",
+            "cna_occurrences",
+            "Consequence",
+        ],
+        help="Type of variation to plot",
+        default="mutation_occurrences",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-f",
+        "--filter",
+        type=str,
+        default=None,
+        help="Filter the heatmap by a specific value in a specific column, e.g. `tissue:intestine`",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-dd",
+        "--data_dir",
+        type=str,
+        default="gget_cbio_cache",
+        help="Directory to store downloaded data (default: ./gget_cbio_cache)",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-fd",
+        "--figure_dir",
+        type=str,
+        default="gget_cbio_figures",
+        help="Directory to store generated figures (default: ./gget_cbio_figures)",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-fn",
+        "--filename",
+        type=str,
+        default=None,
+        help="Filename for the generated figure, relative to `figure_dir` (default: auto-generated)",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-t",
+        "--title",
+        type=str,
+        default=None,
+        help="Title for the generated figure (default: auto-generated)",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-dpi",
+        "--dpi",
+        type=int,
+        default=100,
+        help="DPI of the generated figures (default: 100)",
+        required=False,
+    )
+    parser_cbio_plot.add_argument(
+        "-q",
+        "--quiet",
+        default=True,
+        action="store_false",
+        required=False,
+        help="Does not print progress information.",
+    )
+    parser_cbio_plot.add_argument(
+        "-nc",
+        "--no_confirm",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Skip confirmation before downloading data.",
+    )
+    parser_cbio_plot.add_argument(
+        "-sh",
+        "--show",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Show the plot in a window",
+    )
+
+    ## bgee parser arguments
+    bgee_desc = "Query the Bgee database for orthology and gene expression data using Ensembl IDs."
+    parser_bgee = parent_subparsers.add_parser(
+        "bgee",
+        parents=[parent],
+        description=bgee_desc,
+        help=bgee_desc,
+        add_help=True,
+        formatter_class=CustomHelpFormatter,
+    )
+    parser_bgee.add_argument(
+        "ens_id",
+        type=str,
+        help="Ensembl gene ID, e.g. ENSG00000169194 or ENSSSCG00000014725.",
+    )
+    parser_bgee.add_argument(
+        "-t",
+        "--type",
+        type=str,
+        choices=["orthologs", "expression"],
+        help="Type of information to be returned.",
+        required=True,
+    )
+    parser_bgee.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.json.\n"
+            "Default: Standard out."
+        ),
+    )
+    parser_bgee.add_argument(
+        "-csv",
+        "--csv",
+        default=False,
+        action="store_true",
+        required=False,
+        help="Returns results in csv format instead of json.",
+    )
+    parser_bgee.add_argument(
+        "-q",
+        "--quiet",
+        default=True,
+        action="store_false",
+        required=False,
+        help="Does not print progress information.",
+    )
+
     ### Define return values
     args = parent_parser.parse_args()
 
@@ -1988,6 +2438,9 @@ def main():
         "diamond": parser_diamond,
         "cosmic": parser_cosmic,
         "mutate": parser_mutate,
+        "opentargets": parser_opentargets,
+        "cbio": parser_cbio,
+        "bgee": parser_bgee,
     }
 
     if len(sys.argv) == 2:
@@ -2170,10 +2623,23 @@ def main():
         mutate_results = mutate(
             sequences=seqs,
             mutations=muts,
+            gtf=args.gtf,
+            gtf_transcript_id_column=args.gtf_transcript_id_column,
             k=args.k,
             mut_column=args.mut_column,
             mut_id_column=args.mut_id_column,
             seq_id_column=args.seq_id_column,
+            min_seq_len=args.min_seq_len,
+            max_ambiguous=args.max_ambiguous,
+            optimize_flanking_regions=args.optimize_flanking_regions,
+            remove_seqs_with_wt_kmers=args.remove_seqs_with_wt_kmers,
+            merge_identical=args.merge_identical_off,
+            update_df=args.update_df,
+            update_df_out=args.update_df_out,
+            store_full_sequences=args.store_full_sequences,
+            translate=args.translate,
+            translate_start=args.translate_start,
+            translate_end=args.translate_end,
             out=args.out,
             verbose=args.quiet,
         )
@@ -2196,6 +2662,8 @@ def main():
             cosmic_version=args.cosmic_version,
             grch_version=args.grch_version,
             gget_mutate=args.gget_mutate,
+            keep_genome_info=args.keep_genome_info,
+            remove_duplicates=args.remove_duplicates,
             out=args.out,
             verbose=args.quiet,
         )
@@ -2418,33 +2886,23 @@ def main():
                     with open(args.out, "w") as tfile:
                         tfile.write("\n".join(ref_results))
 
-                    if args.download == True:
-                        # Download list of URLs
-                        for link in ref_results:
-                            # command = "wget " + link
-                            command = "curl -O " + link
-                            os.system(command)
-                #                     else:
-                #                         logger.info(
-                #                             "To download the FTPs to the current directory, add flag [-d]."
-                #                         )
-
                 # Print results if no directory specified
                 else:
                     # Print results
                     for ref_res in ref_results:
                         print(ref_res)
 
-                    if args.download == True:
-                        # Download list of URLs
-                        for link in ref_results:
-                            # command = "wget " + link
-                            command = "curl -O " + link
-                            os.system(command)
-            #                     else:
-            #                         logger.info(
-            #                             "To download the FTPs to the current directory, add flag [-d]."
-            #                         )
+                # Either way, download the files if download flag is set
+                if args.download:
+                    output_dir_part = ""
+                    if args.out_dir is not None and args.out_dir != "":
+                        output_dir_part = f'--output-dir "{args.out_dir}" '
+                        os.makedirs(args.out_dir, exist_ok=True)
+
+                    # Download list of URLs
+                    for link in ref_results:
+                        command = f"curl {output_dir_part}-O " + link
+                        os.system(command)
 
             # Print or save json file (ftp=False)
             else:
@@ -2456,37 +2914,23 @@ def main():
                     with open(args.out, "w", encoding="utf-8") as f:
                         json.dump(ref_results, f, ensure_ascii=False, indent=4)
 
-                    if args.download == True:
-                        # Download the URLs from the dictionary
-                        for link in ref_results:
-                            for sp in ref_results:
-                                for ftp_type in ref_results[sp]:
-                                    link = ref_results[sp][ftp_type]["ftp"]
-                                    #                                     command = "wget " + link
-                                    command = "curl -O " + link
-                                    os.system(command)
-                #                     else:
-                #                         logger.info(
-                #                             "To download the FTPs to the current directory, add flag [-d]."
-                #                         )
-
                 # Print results if no directory specified
                 else:
                     print(json.dumps(ref_results, ensure_ascii=False, indent=4))
 
-                    if args.download == True:
-                        # Download the URLs from the dictionary
-                        for link in ref_results:
-                            for sp in ref_results:
-                                for ftp_type in ref_results[sp]:
-                                    link = ref_results[sp][ftp_type]["ftp"]
-                                    #                                     command = "wget " + link
-                                    command = "curl -O " + link
-                                    os.system(command)
-    #                     else:
-    #                         logger.info(
-    #                             "To download the FTPs to the current directory, add flag [-d]."
-    #                         )
+                # Either way, download the files if download flag is set
+                if args.download:
+                    output_dir_part = ""
+                    if args.out_dir is not None and args.out_dir != "":
+                        output_dir_part = f'--output-dir "{args.out_dir}" '
+                        os.makedirs(args.out_dir, exist_ok=True)
+
+                    # Download the URLs from the dictionary
+                    for sp in ref_results:
+                        for ftp_type in ref_results[sp]:
+                            link = ref_results[sp][ftp_type]["ftp"]
+                            command = f"curl {output_dir_part}-O " + link
+                            os.system(command)
 
     ## search return
     if args.command == "search":
@@ -2595,6 +3039,7 @@ def main():
         # Submit Enrichr query
         enrichr_results = enrichr(
             genes=genes_clean_final,
+            species=args.species,
             background=args.background,
             background_list=bkg_genes_clean_final,
             database=args.database,
@@ -2607,7 +3052,7 @@ def main():
         )
 
         # Check if the function returned something
-        if not isinstance(enrichr_results, type(None)):
+        if enrichr_results is not None:
             # Save enrichr results if args.out specified
             if args.out and not args.csv:
                 # Create saving directory
@@ -2811,3 +3256,109 @@ def main():
                         json.dump(pdb_results, f, ensure_ascii=False, indent=4)
                 else:
                     print(json.dumps(pdb_results, ensure_ascii=False, indent=4))
+
+    ## opentargets return
+    if args.command == "opentargets":
+        flag_to_filter_id = {
+            "filter_disease": "disease_id",
+            "filter_drug": "drug_id",
+            "filter_tissue": "tissue_id",
+            "filter_anat_sys": "anatomical_system",
+            "filter_organ": "organ",
+            "filter_protein_a": "protein_a_id",
+            "filter_protein_b": "protein_b_id",
+            "filter_gene_b": "gene_b_id",
+        }
+        filters: Optional[dict[str, list[str]]] = {}
+
+        for flag, filter_id in flag_to_filter_id.items():
+            if getattr(args, flag) is not None:
+                filters[filter_id] = getattr(args, flag)
+
+        if len(filters) == 0:
+            filters = None
+
+        opentargets_results = opentargets(
+            ensembl_id=args.ens_id,
+            resource=args.resource,
+            limit=args.limit,
+            verbose=args.quiet,
+            filters=filters,
+            filter_mode="or" if getattr(args, "or") else "and",
+        )
+
+        if args.out is not None and args.out != "":
+            # Make saving directory
+            directory = os.path.dirname(args.out)
+            if directory != "":
+                os.makedirs(directory, exist_ok=True)
+
+            # Save json
+            with open(args.out, "w", encoding="utf-8") as f:
+                if args.csv:
+                    opentargets_results.to_csv(f, index=False)
+                else:
+                    opentargets_results.to_json(
+                        f, orient="records", force_ascii=False, indent=4
+                    )
+        else:
+            if args.csv:
+                opentargets_results.to_csv(sys.stdout, index=False)
+            else:
+                print(
+                    opentargets_results.to_json(
+                        orient="records", force_ascii=False, indent=4
+                    )
+                )
+
+    ## cbio return
+    if args.command == "cbio":
+        if args.subcommand == "search":
+            cbio_results = cbio_search(*args.keywords)
+            print(json.dumps(cbio_results, ensure_ascii=False, indent=4))
+        elif args.subcommand == "plot":
+            cbio_plot(
+                args.study_ids,
+                args.genes,
+                stratification=args.stratification,
+                variation_type=args.variation_type,
+                filter=args.filter.split(":", 1) if args.filter else None,
+                data_dir=args.data_dir,
+                figure_dir=args.figure_dir,
+                dpi=args.dpi,
+                verbose=args.quiet,
+                confirm_download=not args.no_confirm,
+                show=args.show,
+                figure_filename=args.filename,
+                figure_title=args.title,
+            )
+
+    ## bgee return
+    if args.command == "bgee":
+        bgee_results: pd.DataFrame = bgee(
+            args.ens_id,
+            type=args.type,
+            verbose=args.quiet,
+        )
+
+        if args.out is not None and args.out != "":
+            # Make saving directory
+            directory = os.path.dirname(args.out)
+            if directory != "":
+                os.makedirs(directory, exist_ok=True)
+
+            # Save json
+            with open(args.out, "w", encoding="utf-8") as f:
+                if args.csv:
+                    bgee_results.to_csv(f, index=False)
+                else:
+                    bgee_results.to_json(
+                        f, orient="records", force_ascii=False, indent=4
+                    )
+        else:
+            if args.csv:
+                bgee_results.to_csv(sys.stdout, index=False)
+            else:
+                print(
+                    bgee_results.to_json(orient="records", force_ascii=False, indent=4)
+                )
diff --git a/gget/utils.py b/gget/utils.py
index 7fc66a77..82ac2b5a 100644
--- a/gget/utils.py
+++ b/gget/utils.py
@@ -25,7 +25,10 @@
 
 
 def set_up_logger():
-    logging_level = logging.INFO
+    logging_level_name = os.getenv("GGET_LOGLEVEL", "INFO")
+    logging_level = logging.getLevelName(logging_level_name)
+    if type(logging_level) != int:  # unknown log level
+        logging_level = logging.INFO
     logger = logging.getLogger(__name__)
     logger.setLevel(logging_level)
 
@@ -307,7 +310,7 @@ def get_uniprot_seqs(server, ensembl_ids):
                         json["results"][i]["genes"][0]["geneName"]["value"]
                     )
                 except:
-                    gene_names.append(np.NaN)
+                    gene_names.append(np.nan)
             df["gene_name"] = gene_names
             df["query"] = id_
 
@@ -381,7 +384,7 @@ def get_uniprot_info(server, ensembl_id, verbose=True):
             try:
                 gene_names.append(json["results"][i]["genes"][0]["geneName"]["value"])
             except:
-                gene_names.append(np.NaN)
+                gene_names.append(np.nan)
         df["primary_gene_name"] = gene_names
 
         # Get synonyms for each result
@@ -392,7 +395,7 @@ def get_uniprot_info(server, ensembl_id, verbose=True):
                 for syn in json["results"][i]["genes"][0]["synonyms"]:
                     uni_syn_temp.append(syn["value"])
             except:
-                uni_syn_temp.append(np.NaN)
+                uni_syn_temp.append(np.nan)
             uni_synonyms.append(uni_syn_temp)
         df["uni_synonyms"] = uni_synonyms
 
@@ -406,7 +409,7 @@ def get_uniprot_info(server, ensembl_id, verbose=True):
                     ]["value"]
                 )
             except:
-                protein_names.append(np.NaN)
+                protein_names.append(np.nan)
         df["protein_names"] = protein_names
 
         # Get descriptions for each result
@@ -422,7 +425,7 @@ def get_uniprot_info(server, ensembl_id, verbose=True):
                 # Append all descriptions to a single string object
                 des_temp = " ".join(des_temp)
             except:
-                des_temp.append(np.NaN)
+                des_temp.append(np.nan)
 
             descriptions.append(des_temp)
         df["uniprot_description"] = descriptions
@@ -446,7 +449,7 @@ def get_uniprot_info(server, ensembl_id, verbose=True):
         else:
             # No subcellular localisation data will return as nan
             nan_list = np.empty(len(subcel_locs_final))
-            nan_list[:] = np.NaN
+            nan_list[:] = np.nan
             df["subcellular_localisation"] = nan_list
 
         # Add query colunm
@@ -648,6 +651,53 @@ def rest_query(server, query, content_type):
         return r.text
 
 
+def post_query(server: str, endpoint: str, query: dict[str, ...]) -> dict[str, ...]:
+    """
+    Function to perform a POST API query.
+
+    :param server:  Server to query .
+    :param query:   Query that is passed to server.
+
+    :return: server output
+    """
+
+    r = requests.post(server + endpoint, json=query, headers={"Content-Type": "application/json"})
+
+    if not r.ok:
+        raise RuntimeError(
+            f"{server} returned error status code {r.status_code}. "
+            "Please double-check arguments and try again.\n"
+        )
+
+    return r.json()
+
+
+def graphql_query(server: str, query: str, variables: dict[str, ...]) -> dict[str, ...]:
+    """
+    Function to perform a GraphQL API query.
+
+    Args:
+    - server        Server to query.
+    - query         Query that is passed to server.
+    - variables     Variables that are passed to server.
+
+    Returns server output.
+    """
+
+    r = requests.post(server, json={"query": query, "variables": variables})
+
+    if not r.ok:
+        logger.debug(
+            f"Server: {server}, Query: {query}, Variables: {variables}, Response: {r.text}"
+        )
+        raise RuntimeError(
+            f"{server} returned error status code {r.status_code}. "
+            "Please double-check arguments and try again.\n"
+        )
+
+    return r.json()
+
+
 def find_latest_ens_rel(database=ENSEMBL_FTP_URL):
     """
     Returns the latest Ensembl release number.
@@ -979,3 +1029,34 @@ def remove_temp_files(files_to_delete):
     for file in files_to_delete:
         if os.path.exists(file):
             os.remove(file)
+
+
+def json_list_to_df(
+    json_list: list[dict[str, ...]], columns: list[tuple[str, str]]
+) -> pd.DataFrame:
+    """
+    Convert list of JSON objects to data frame.
+
+    Args:
+
+    - json_list     List of JSON objects.
+    - columns       List of (column, key) pairs in the data frame.
+                    Format of each key is "key.subkey.etc", which corresponds to json_list[i]["key"]["subkey"]["etc"].
+
+    Returns data frame with columns as specified in keys.
+    """
+
+    tmp_columns = [[] for _ in range(len(columns))]
+
+    for json_obj in json_list:
+        for i, column_key in enumerate(columns):
+            _, key = column_key
+            keys_split = key.split(".")
+            value = json_obj
+            for k in keys_split:
+                if value is None:
+                    break
+                value = value[k]
+            tmp_columns[i].append(value)
+
+    return pd.DataFrame({key[0]: value for key, value in zip(columns, tmp_columns)})
diff --git a/setup.py b/setup.py
index cd9cc321..711c9200 100644
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,7 @@ def read(path):
     zip_safe=False,
     packages=find_packages(include=["gget", "gget.*"]),
     include_package_data=True,
-    python_requires=">=3.6",
+    python_requires=">=3.7",
     install_requires=open("requirements.txt").read().strip().split("\n"),
     setup_requires=open("requirements.txt").read().strip().split("\n"),
     url="https://github.com/pachterlab/gget",


Test patch:
diff --git a/tests/fixtures/test_archs4.json b/tests/fixtures/test_archs4.json
index e2775944..ea0d9b24 100644
--- a/tests/fixtures/test_archs4.json
+++ b/tests/fixtures/test_archs4.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_archs4_defaults": {
         "type": "assert_equal",
         "args": {
             "gene": "FUNDC1"
@@ -407,7 +407,7 @@
             ]
         ]
     },
-    "test2": {
+    "test_archs4_mouse": {
         "type": "assert_equal",
         "args": {
             "gene": "FUNDC1",
@@ -437,7 +437,7 @@
             ]
         ]
     },
-    "test3": {
+    "test_archs4_mouse_json": {
         "type": "assert_equal",
         "args": {
             "gene": "FUNDC1",
@@ -468,7 +468,7 @@
             }
         ]
     },
-    "test4": {
+    "test_archs4_mouse_json_ensembl": {
         "type": "assert_equal",
         "args": {
             "gene": "ENSG00000106443.4",
@@ -500,7 +500,7 @@
             }
         ]
     },
-    "test5": {
+    "test_archs4_tissue": {
         "type": "assert_equal",
         "args": {
             "gene": "fuNdC1",
@@ -1085,7 +1085,7 @@
             ]
         ]
     },
-    "test6": {
+    "test_archs4_tissue_json": {
         "type": "assert_equal",
         "args": {
             "gene": "fuNdC1",
@@ -1671,7 +1671,7 @@
             }
         ]
     },
-    "test7": {
+    "test_archs4_tissue_mouse": {
         "type": "assert_equal",
         "args": {
             "gene": "fuNdC1",
@@ -2153,7 +2153,7 @@
             ]
         ]
     },
-    "test8": {
+    "test_archs4_tissue_ensembl": {
         "type": "assert_equal",
         "args": {
             "gene": "ENSG00000106443",
@@ -2636,22 +2636,22 @@
             ]
         ]
     },
-    "none_test1": {
-        "type": "none",
+    "test_archs4_bad_ensembl": {
+        "type": "assert_none",
         "args": {
             "gene": "ENSG00000106443"
         },
         "expected_result": null
     },
-    "none_test2": {
-        "type": "none",
+    "test_archs4_bad_gene": {
+        "type": "assert_none",
         "args": {
             "gene": "BANANA"
         },
         "expected_result": null
     },
-    "none_test3": {
-        "type": "none",
+    "test_archs4_bad_gene_tissue": {
+        "type": "assert_none",
         "args": {
             "gene": "BANANA",
             "which": "tissue",
@@ -2659,7 +2659,7 @@
         },
         "expected_result": null
     },
-    "error_test1": {
+    "test_archs4_bad_which": {
         "type": "error",
         "args": {
             "gene": "OAS1",
@@ -2667,7 +2667,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test2": {
+    "test_archs4_bad_species": {
         "type": "error",
         "args": {
             "gene": "OAS1",
diff --git a/tests/fixtures/test_bgee.json b/tests/fixtures/test_bgee.json
new file mode 100644
index 00000000..2eeb58f2
--- /dev/null
+++ b/tests/fixtures/test_bgee.json
@@ -0,0 +1,710 @@
+{
+    "test_bgee_expression": {
+        "type": "assert_equal",
+        "args": {
+            "type": "expression",
+            "gene_id": "ENSBTAG00000047356"
+        },
+        "expected_result": [
+            [
+                "UBERON:0001968",
+                "semen",
+                97.46,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001954",
+                "Ammon's horn",
+                53.61,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001801",
+                "anterior segment of eyeball",
+                51.28,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002367",
+                "prostate gland",
+                49.46,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0007358",
+                "abomasum",
+                45.43,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0000947",
+                "aorta",
+                45.09,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001225",
+                "cortex of kidney",
+                43.64,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0010533",
+                "metanephros cortex",
+                43.51,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002000",
+                "gluteal muscle",
+                41.91,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001891",
+                "midbrain",
+                35.41,
+                "high",
+                "expressed"
+            ],
+            [
+                "CL:0000711",
+                "cumulus cell",
+                32.33,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001158",
+                "descending colon",
+                28.93,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001870",
+                "frontal cortex",
+                28.83,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002107",
+                "liver",
+                27.93,
+                "high",
+                "expressed"
+            ],
+            [
+                "CL:0000576",
+                "monocyte",
+                27.18,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001111",
+                "intercostal muscle",
+                26.73,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0000569",
+                "ileocecal valve",
+                24.96,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001134",
+                "skeletal muscle tissue",
+                22.99,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0007365",
+                "rumen",
+                22.88,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002114",
+                "duodenum",
+                22.34,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0010239",
+                "spiral colon",
+                22.25,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001911",
+                "mammary gland",
+                22.19,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002048",
+                "lung",
+                21.36,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0000459",
+                "uterine wall",
+                21.27,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0001295",
+                "endometrium",
+                21.26,
+                "high",
+                "expressed"
+            ],
+            [
+                "UBERON:0002115",
+                "jejunum",
+                20.7,
+                "high",
+                "expressed"
+            ]
+        ]
+    },
+
+
+    "test_bgee_orthologs": {
+        "type": "assert_equal",
+        "args": {
+            "type": "orthologs",
+            "gene_id": "ENSOARG00000019163"
+        },
+        "expected_result": [
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "ENSBTAG00000037644",
+                "HBB",
+                9913,
+                "Bos",
+                "taurus"
+            ],
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "ENSSSCG00000014725",
+                "HBB",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSSSCG00000014727",
+                "LOC100515788",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSBTAG00000037644",
+                "HBB",
+                9913,
+                "Bos",
+                "taurus"
+            ],
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "ENSCAFG00000030286",
+                "LOC609402",
+                9615,
+                "Canis",
+                "lupus familiaris"
+            ],
+            [
+                "ENSFCAG00000038029",
+                "LOC101098159",
+                9685,
+                "Felis",
+                "catus"
+            ],
+            [
+                "ENSECAG00000010020",
+                "LOC100068926",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSECAG00000030768",
+                "GLNB2",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSSSCG00000014725",
+                "HBB",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSSSCG00000014727",
+                "LOC100515788",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSBTAG00000037644",
+                "HBB",
+                9913,
+                "Bos",
+                "taurus"
+            ],
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "108407212",
+                "LOC108407212",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407213",
+                "LOC108407213",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407214",
+                "LOC108407214",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "ENSCAFG00000030286",
+                "LOC609402",
+                9615,
+                "Canis",
+                "lupus familiaris"
+            ],
+            [
+                "ENSFCAG00000038029",
+                "LOC101098159",
+                9685,
+                "Felis",
+                "catus"
+            ],
+            [
+                "ENSECAG00000010020",
+                "LOC100068926",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSECAG00000030768",
+                "GLNB2",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSSSCG00000014725",
+                "HBB",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSSSCG00000014727",
+                "LOC100515788",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSBTAG00000037644",
+                "HBB",
+                9913,
+                "Bos",
+                "taurus"
+            ],
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "108407212",
+                "LOC108407212",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407213",
+                "LOC108407213",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407214",
+                "LOC108407214",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "100037217",
+                "XB5745823.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "100381042",
+                "hbg2.S",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108701506",
+                "LOC108701506",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702026",
+                "XB5745823.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702417",
+                "LOC108702417",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702418",
+                "LOC108702418",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108703093",
+                "LOC108703093",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108703094",
+                "LOC108703094",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "379431",
+                "hbd.S",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "397871",
+                "hbb2",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "447111",
+                "hbe1.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "734881",
+                "hbb1",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "ENSXETG00000025667",
+                "hbg1",
+                8364,
+                "Xenopus",
+                "tropicalis"
+            ],
+            [
+                "ENSCAFG00000030286",
+                "LOC609402",
+                9615,
+                "Canis",
+                "lupus familiaris"
+            ],
+            [
+                "ENSFCAG00000038029",
+                "LOC101098159",
+                9685,
+                "Felis",
+                "catus"
+            ],
+            [
+                "ENSECAG00000010020",
+                "LOC100068926",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSECAG00000030768",
+                "GLNB2",
+                9796,
+                "Equus",
+                "caballus"
+            ],
+            [
+                "ENSSSCG00000014725",
+                "HBB",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSSSCG00000014727",
+                "LOC100515788",
+                9823,
+                "Sus",
+                "scrofa"
+            ],
+            [
+                "ENSBTAG00000037644",
+                "HBB",
+                9913,
+                "Bos",
+                "taurus"
+            ],
+            [
+                "ENSCHIG00000017796",
+                "LOC102175876",
+                9925,
+                "Capra",
+                "hircus"
+            ],
+            [
+                "108407212",
+                "LOC108407212",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407213",
+                "LOC108407213",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "108407214",
+                "LOC108407214",
+                9974,
+                "Manis",
+                "javanica"
+            ],
+            [
+                "100037217",
+                "XB5745823.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "100381042",
+                "hbg2.S",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108701506",
+                "LOC108701506",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702026",
+                "XB5745823.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702417",
+                "LOC108702417",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108702418",
+                "LOC108702418",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108703093",
+                "LOC108703093",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "108703094",
+                "LOC108703094",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "379431",
+                "hbd.S",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "397871",
+                "hbb2",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "447111",
+                "hbe1.L",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "734881",
+                "hbb1",
+                8355,
+                "Xenopus",
+                "laevis"
+            ],
+            [
+                "ENSXETG00000025667",
+                "hbg1",
+                8364,
+                "Xenopus",
+                "tropicalis"
+            ],
+            [
+                "ENSAMXG00000016420",
+                "",
+                7994,
+                "Astyanax",
+                "mexicanus"
+            ],
+            [
+                "ENSAMXG00000016437",
+                "HBE1",
+                7994,
+                "Astyanax",
+                "mexicanus"
+            ]
+        ]
+    },
+
+
+    "test_error_bgee_expression_no_exist": {
+        "type": "error",
+        "args": {
+            "type": "expression",
+            "gene_id": "this gene does not exist"
+        },
+        "expected_result": "RuntimeError"
+    },
+
+
+    "test_error_bgee_orthologs_no_exist": {
+        "type": "error",
+        "args": {
+            "type": "orthologs",
+            "gene_id": "this gene does not exist"
+        },
+        "expected_result": "RuntimeError"
+    },
+
+
+    "test_error_bgee_unknown_type": {
+        "type": "error",
+        "args": {
+            "type": "this type does not exist",
+            "gene_id": "ENSBTAG00000047356"
+        },
+        "expected_result": "ValueError"
+    }
+}
\ No newline at end of file
diff --git a/tests/fixtures/test_blast.json b/tests/fixtures/test_blast.json
index 20578e18..4e9b4240 100644
--- a/tests/fixtures/test_blast.json
+++ b/tests/fixtures/test_blast.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_blast_nt": {
         "type": "assert_equal",
         "args": {
             "sequence": "ATACTCAGTCACACAAGCCATAGCAGGAAACAGCGAGCTTGCAGCCTCACCGACGAGTCTCAACTAAAAGGGACTCCCGGAGCTAGGGGTGGGGACTCGGCCTCACACAGTGAGTGCCGG",
@@ -21,7 +21,7 @@
             ]
         ]
     },
-    "test2": {
+    "test_blast_nt_json": {
         "type": "assert_equal",
         "args": {
             "sequence": "ATACTCAGTCACACAAGCCATAGCAGGAAACAGCGAGCTTGCAGCCTCACCGACGAGTCTCAACTAAAAGGGACTCCCGGAGCTAGGGGTGGGGACTCGGCCTCACACAGTGAGTGCCGG",
@@ -44,21 +44,21 @@
             }
         ]
     },
-    "error_test1": {
+    "test_blast_bad_seq": {
         "type": "error",
         "args": {
             "sequence": "BANANA123"
         },
         "expected_result": "ValueError"
     },
-    "error_test2": {
+    "test_blast_bad_fasta": {
         "type": "error",
         "args": {
             "sequence": "banana.fa"
         },
         "expected_result": "FileNotFoundError"
     },
-    "error_test3": {
+    "test_blast_bad_program": {
         "type": "error",
         "args": {
             "sequence": "tests/fixtures/muscle_aa_test.txt",
@@ -67,7 +67,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test4": {
+    "test_blast_db_missing": {
         "type": "error",
         "args": {
             "sequence": "tests/fixtures/muscle_aa_test.txt",
@@ -76,7 +76,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test5": {
+    "test_blast_bad_db1": {
         "type": "error",
         "args": {
             "sequence": "tests/fixtures/muscle_aa_test.txt",
@@ -86,7 +86,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test6": {
+    "test_blast_bad_db2": {
         "type": "error",
         "args": {
             "sequence": "tests/fixtures/muscle_aa_test.txt",
diff --git a/tests/fixtures/test_blat.json b/tests/fixtures/test_blat.json
index 3635c528..255e5dd8 100644
--- a/tests/fixtures/test_blat.json
+++ b/tests/fixtures/test_blat.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_blat_nt": {
         "type": "assert_equal",
         "args": {
             "sequence": "ATGCTGAATTTATGCTGAATTTATGCTGAATTTATGCTGAATTT"
@@ -21,7 +21,7 @@
             ]
         ]
     },
-    "test2": {
+    "test_blat_nt_json": {
         "type": "assert_equal",
         "args": {
             "sequence": "CACACATCCGGTTCTTCCGGGAGCTAGGGG",
@@ -45,7 +45,7 @@
             }
         ]
     },
-    "test3": {
+    "test_blat_nt_DNA": {
         "type": "assert_equal",
         "args": {
             "sequence": "ATGCTGAATTTATGCTGAATTTATGCTGAATTTATGCTGAATTT",
@@ -68,7 +68,7 @@
             ]
         ]
     },
-    "test4": {
+    "test_blat_aa": {
         "type": "assert_equal",
         "args": {
             "sequence": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVAT",
@@ -91,7 +91,7 @@
             ]
         ]
     },
-    "test5": {
+    "test_blat_aa_protein": {
         "type": "assert_equal",
         "args": {
             "sequence": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVAT",
@@ -115,7 +115,7 @@
             ]
         ]
     },
-    "test6": {
+    "test_blat_nt_fasta": {
         "type": "assert_equal",
         "args": {
             "sequence": "tests/fixtures/muscle_nt_test.fa",
@@ -152,7 +152,7 @@
             ]
         ]
     },
-    "test7": {
+    "test_blat_nt_txt": {
         "type": "assert_equal",
         "args": {
             "sequence": "tests/fixtures/muscle_nt_test.txt",
@@ -189,56 +189,62 @@
             ]
         ]
     },
-    "none_test1": {
-        "type": "none",
+    "test_blat_nt_protein": {
+        "type": "assert_none",
         "args": {
             "sequence": "ATGCTGAATTTATGCTGAATTTATGCTGAATTTATGCTGAATTT",
             "seqtype": "protein",
             "assembly": "zebrafinch"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "DNA search in protein database is not None."
     },
-    "none_test2": {
-        "type": "none",
+    "test_blat_nt_RNA": {
+        "type": "assert_none",
         "args": {
             "sequence": "ATGCTGAATTTATGCTGAATTTATGCTGAATTTATGCTGAATTT",
             "seqtype": "translated%20RNA"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "DNA search in RNA database is not None."
     },
-    "none_test3": {
-        "type": "none",
+    "test_blat_nt_transDNA": {
+        "type": "assert_none",
         "args": {
             "sequence": "ATGCTGAATTTATGCTGAATTTATGCTGAATTTATGCTGAATTT",
             "seqtype": "translated%20DNA"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "DNA search in translated DNA database is not None."
     },
-    "none_test4": {
-        "type": "none",
+    "test_blat_aa_RNA": {
+        "type": "assert_none",
         "args": {
             "sequence": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVAT",
             "seqtype": "translated%20RNA"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "DNA search in RNA database is not None."
     },
-    "none_test5": {
-        "type": "none",
+    "test_blat_bad_assembly": {
+        "type": "assert_none",
         "args": {
             "sequence": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVAT",
             "seqtype": "DNA",
             "assembly": "banana"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "Invalid assembly result is not None."
     },
-    "none_test6": {
-        "type": "none",
+    "test_blat_shortseq": {
+        "type": "assert_none",
         "args": {
             "sequence": "MLMPGPLRRALGQ"
         },
-        "expected_result": null
+        "expected_result": null,
+        "msg": "Sequence too short result is not None."
     },
-    "error_test1": {
+    "test_blat_bad_seqtype": {
         "type": "error",
         "args": {
             "sequence": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVAT",
@@ -246,7 +252,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test2": {
+    "test_blat_bad_fileformat": {
         "type": "error",
         "args": {
             "sequence": "tests/fixtures/muscle_nt_test.banana",
@@ -255,14 +261,14 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test3": {
+    "test_blat_bad_fasta": {
         "type": "error",
         "args": {
             "sequence": "banana.fa"
         },
         "expected_result": "FileNotFoundError"
     },
-    "error_test4": {
+    "test_blat_bad_txt": {
         "type": "error",
         "args": {
             "sequence": "banana.txt"
diff --git a/tests/fixtures/test_cbio.json b/tests/fixtures/test_cbio.json
new file mode 100644
index 00000000..e179eaef
--- /dev/null
+++ b/tests/fixtures/test_cbio.json
@@ -0,0 +1,45 @@
+{
+    "test_cbio_download": {
+        "type": "cbio_download",
+        "args": {
+            "study_ids": ["msk_impact_2017"],
+            "out_dir": "/tmp/test_cbio_download"
+        },
+        "expected_result": {
+            "/tmp/test_cbio_download/msk_impact_2017/clinical_patient.txt": "08caa5754ffcae79b39eecad37edf201",
+            "/tmp/test_cbio_download/msk_impact_2017/clinical_sample.txt": "7550a0468bb410a2bdef42bd27c5ad32",
+            "/tmp/test_cbio_download/msk_impact_2017/cna.txt": "e79f73232793853e808f69bd9d034125",
+            "/tmp/test_cbio_download/msk_impact_2017/mutations.txt": "35264fd2f43492f09a15f15516fbe1b4",
+            "/tmp/test_cbio_download/msk_impact_2017/sv.txt": "24ce260e03b417d47929f8fc34fc49a4"
+        }
+    },
+    "test_cbio_download_partially_existing_files": {
+        "type": "cbio_download",
+        "args": {
+            "study_ids": ["msk_impact_2017", "egc_msk_2023"],
+            "out_dir": "/tmp/test_cbio_download"
+        },
+        "expected_result": {
+            "/tmp/test_cbio_download/msk_impact_2017/clinical_patient.txt": "08caa5754ffcae79b39eecad37edf201",
+            "/tmp/test_cbio_download/msk_impact_2017/clinical_sample.txt": "7550a0468bb410a2bdef42bd27c5ad32",
+            "/tmp/test_cbio_download/msk_impact_2017/cna.txt": "e79f73232793853e808f69bd9d034125",
+            "/tmp/test_cbio_download/msk_impact_2017/mutations.txt": "35264fd2f43492f09a15f15516fbe1b4",
+            "/tmp/test_cbio_download/msk_impact_2017/sv.txt": "24ce260e03b417d47929f8fc34fc49a4",
+
+            "/tmp/test_cbio_download/egc_msk_2023/clinical_patient.txt": "6fbd67c0eca7a24fa6918f37c989033f",
+            "/tmp/test_cbio_download/egc_msk_2023/clinical_sample.txt": "8469bcc199fc653afb867242b3ab6e00",
+            "/tmp/test_cbio_download/egc_msk_2023/cna.txt": "1acb67c06284754ee472402753e54263",
+            "/tmp/test_cbio_download/egc_msk_2023/mutations.txt": "be28497d2f95d933ad8e14a04adc2beb",
+            "/tmp/test_cbio_download/egc_msk_2023/sv.txt": "c1a0011480b533d2b659373ffd178c32"
+        }
+    },
+
+    "test_error_cbio_download": {
+        "type": "cbio_download",
+        "args": {
+            "study_ids": ["non_existent_study_id"],
+            "out_dir": "/tmp/test_cbio_download"
+        },
+        "expected_result": false
+    }
+}
\ No newline at end of file
diff --git a/tests/fixtures/test_cbio_search.json b/tests/fixtures/test_cbio_search.json
new file mode 100644
index 00000000..119f56ba
--- /dev/null
+++ b/tests/fixtures/test_cbio_search.json
@@ -0,0 +1,32 @@
+{
+    "test_cbio_search": {
+        "type": "assert_equal",
+        "args": [
+            "esophag",
+            "ovary",
+            "ovarian"
+        ],
+        "expected_result": [
+            "egc_msk_2017",
+            "egc_msk_2023",
+            "egc_msk_tp53_ccr_2022",
+            "egc_mskcc_2020",
+            "egc_tmucih_2015",
+            "egc_trap_ccr_msk_2023",
+            "egc_trap_msk_2020",
+            "esca_broad",
+            "esca_tcga",
+            "esca_tcga_pan_can_atlas_2018",
+            "escc_icgc",
+            "escc_ucla_2014",
+            "hgsoc_msk_2021",
+            "lgsoc_mapk_msk_2022",
+            "msk_spectrum_tme_2022",
+            "ov_tcga",
+            "ov_tcga_pan_can_atlas_2018",
+            "ov_tcga_pub",
+            "scco_mskcc",
+            "stes_tcga_pub"
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/fixtures/test_cosmic.json b/tests/fixtures/test_cosmic.json
index c65f1e44..9d9e90b4 100644
--- a/tests/fixtures/test_cosmic.json
+++ b/tests/fixtures/test_cosmic.json
@@ -1,6 +1,6 @@
 {
-    "test1": {
-        "type": "assert_equal_mutations",
+    "test_cosmic_defaults": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "v600e"
         },
@@ -79,8 +79,8 @@
             ]
         ]
     },
-    "test2": {
-        "type": "assert_equal_pubmed",
+    "test_cosmic_limit_and_pubmet": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "v600e",
             "entity": "pubmed",
@@ -99,8 +99,8 @@
             ]
         ]
     },
-    "test3": {
-        "type": "assert_equal_genes_and_json",
+    "test_cosmic_json_and_genes": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "EGFR",
             "entity": "genes",
@@ -173,8 +173,8 @@
             }
         ]
     },
-    "test4": {
-        "type": "assert_equal_samples",
+    "test_cosmic_samples": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "EGFR",
             "entity": "samples"
@@ -190,8 +190,8 @@
             ]
         ]
     },
-    "test5": {
-        "type": "assert_equal_studies",
+    "test_cosmic_studies": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "THCA-SA",
             "entity": "studies"
@@ -204,8 +204,8 @@
             ]
         ]
     },
-    "test6": {
-        "type": "assert_equal_cancer",
+    "test_cosmic_cancer": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "prostate",
             "entity": "cancer",
@@ -226,8 +226,8 @@
             ]
         ]
     },
-    "test7": {
-        "type": "assert_equal_tumour",
+    "test_cosmic_tumour": {
+        "type": "assert_equal",
         "args": {
             "searchterm": "prostate",
             "entity": "tumour_site",
diff --git a/tests/fixtures/test_diamond.json b/tests/fixtures/test_diamond.json
index 9144fe40..12125411 100644
--- a/tests/fixtures/test_diamond.json
+++ b/tests/fixtures/test_diamond.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_diamond_seqs_multiple": {
         "type": "assert_equal",
         "args": {
             "query": [
@@ -64,7 +64,7 @@
             ]
         ]
     },
-    "test2": {
+    "test_diamond_seqs_single": {
         "type": "assert_equal",
         "args": {
             "query": "GGETISAWESQME",
@@ -89,7 +89,7 @@
             ]
         ]
     },
-    "test3": {
+    "test_diamond_ref_file": {
         "type": "assert_equal",
         "args": {
             "query": "MLMPGPLRRALGQKFSIFPSVDHDSDDDSYEVLDLTEYARRHHWWNRLFGRNSGPVVEKYSVATQIVMGGVTGWCAGFLFQKVGKLAA",
@@ -130,7 +130,7 @@
             ]
         ]
     },
-    "test4": {
+    "test_diamond_query_file": {
         "type": "assert_equal",
         "args": {
             "query": "tests/fixtures/muscle_aa_test.fa",
@@ -171,7 +171,7 @@
             ]
         ]
     },
-    "test5": {
+    "test_diamond_both_files": {
         "type": "assert_equal",
         "args": {
             "query": "tests/fixtures/muscle_aa_test.fa",
@@ -244,7 +244,7 @@
             ]
         ]
     },
-    "test6": {
+    "test_diamond_JSON_out": {
         "type": "assert_equal",
         "args": {
             "query": "tests/fixtures/muscle_aa_test.fa",
diff --git a/tests/fixtures/test_enrichr.json b/tests/fixtures/test_enrichr.json
index 3a147a51..df2c03d7 100644
--- a/tests/fixtures/test_enrichr.json
+++ b/tests/fixtures/test_enrichr.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_enrichr_pathway": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -42,7 +42,7 @@
             ]
         ]
     },
-    "test2": {
+    "test_enrichr_json": {
         "type": "assert_equal",
         "args": {
             "genes": "ACE2",
@@ -88,7 +88,54 @@
             }
         ]
     },
-    "test3": {
+    "test_enrichr_mouse_is_human": {
+        "type": "assert_equal",
+        "args": {
+            "genes": "ACE2",
+            "species": "mouse",
+            "database": "KEGG_2021_Human",
+            "json": true
+        },
+        "expected_result": [
+            {
+                "rank": 1,
+                "path_name": "Renin-angiotensin system",
+                "p_val": 0.0011499749,
+                "z_score": 19977.0,
+                "combined_score": 135204.6392226724,
+                "overlapping_genes": [
+                    "ACE2"
+                ],
+                "adj_p_val": 0.0034499246,
+                "database": "KEGG_2021_Human"
+            },
+            {
+                "rank": 2,
+                "path_name": "Protein digestion and absorption",
+                "p_val": 0.0051499318,
+                "z_score": 19897.0,
+                "combined_score": 104832.7525292024,
+                "overlapping_genes": [
+                    "ACE2"
+                ],
+                "adj_p_val": 0.0077248977,
+                "database": "KEGG_2021_Human"
+            },
+            {
+                "rank": 3,
+                "path_name": "Coronavirus disease",
+                "p_val": 0.0115998901,
+                "z_score": 19768.0,
+                "combined_score": 88101.2248015025,
+                "overlapping_genes": [
+                    "ACE2"
+                ],
+                "adj_p_val": 0.0115998901,
+                "database": "KEGG_2021_Human"
+            }
+        ]
+    },
+    "test_enrichr_none": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -133,7 +180,7 @@
             ]
         ]
     },
-    "test4": {
+    "test_enrichr_transcription": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -3366,7 +3413,7 @@
             ]
         ]
     },
-    "test5": {
+    "test_enrichr_ensembl_ids": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -3694,7 +3741,7 @@
             ]
         ]
     },
-    "test6": {
+    "test_enrichr_ontology": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -4943,7 +4990,7 @@
             ]
         ]
     },
-    "test7": {
+    "test_enrichr_diseases_drugs": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -5226,7 +5273,7 @@
             ]
         ]
     },
-    "test8": {
+    "test_enrichr_celltypes": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -5301,7 +5348,7 @@
             ]
         ]
     },
-    "test9": {
+    "test_enrichr_kinase_interactions": {
         "type": "assert_equal",
         "args": {
             "genes": [
@@ -5387,8 +5434,431 @@
             ]
         ]
     },
-    "test10": {
+
+
+    "test_enrichr_fly": {
+        "type": "assert_equal",
+        "args": {
+            "species": "fly",
+            "genes": [
+                "Atu",
+                "Arp10",
+                "14-3-3epsilon",
+                "CG14715",
+                "AGO1",
+                "Akt1",
+                "Aats-thr",
+                "Amph",
+                "Buffy"
+            ],
+            "database": "KEGG_2019"
+        },
+        "expected_result": [
+            [
+                1,
+                "AGE-RAGE signaling pathway in diabetic complications",
+                0.013866578448356225,
+                -71.70990840432587,
+                306.79461962321056,
+                [
+                    "Akt1"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                2,
+                "Longevity regulating pathway",
+                0.024484333254890283,
+                -74.67869191692976,
+                277.03717326344616,
+                [
+                    "Akt1"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                3,
+                "FoxO signaling pathway",
+                0.028878320773539124,
+                -34.65497149188862,
+                122.84023373611505,
+                [
+                    "Akt1"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                4,
+                "Hippo signaling pathway",
+                0.028000933364347887,
+                -7.653509902619876,
+                27.36525809516185,
+                [
+                    "14-3-3epsilon"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                5,
+                "Apoptosis",
+                0.03063098391468958,
+                -1.4461493848642943,
+                5.040905436639352,
+                [
+                    "Buffy"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                6,
+                "Endocytosis",
+                0.05316112044067067,
+                -0.721972272636934,
+                2.118575629377468,
+                [
+                    "Amph"
+                ],
+                0.05316112044067067,
+                "KEGG_2019"
+            ],
+            [
+                7,
+                "mTOR signaling pathway",
+                0.04238809923318461,
+                -0.25864570811488513,
+                0.8175500205238455,
+                [
+                    "Akt1"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ],
+            [
+                8,
+                "Autophagy",
+                0.04195492135229644,
+                -0.2430235804590055,
+                0.770666545188524,
+                [
+                    "Akt1"
+                ],
+                0.04844354198078241,
+                "KEGG_2019"
+            ]
+        ]
+    },
+    "test_enrichr_yeast": {
+        "type": "assert_equal",
+        "args": {
+            "species": "yeast",
+            "genes": [
+                "OST6",
+                "UGO1",
+                "ICE2",
+                "RLI1",
+                "VPS75",
+                "YBL010C",
+                "FLO5",
+                "TIM18"
+            ],
+            "database": "KEGG_2019"
+        },
+        "expected_result": [
+            [
+                1,
+                "Various types of N-glycan biosynthesis",
+                0.011939267213322538,
+                -1.7200637726637844,
+                7.616309158365445,
+                [
+                    "OST6"
+                ],
+                0.01790890081998381,
+                "KEGG_2019"
+            ],
+            [
+                2,
+                "N-Glycan biosynthesis",
+                0.011939267213322538,
+                -1.6633575843321016,
+                7.365218548592448,
+                [
+                    "OST6"
+                ],
+                0.01790890081998381,
+                "KEGG_2019"
+            ],
+            [
+                3,
+                "Protein processing in endoplasmic reticulum",
+                0.034668638399731576,
+                -1.5542294494115034,
+                5.2251947491679225,
+                [
+                    "OST6"
+                ],
+                0.034668638399731576,
+                "KEGG_2019"
+            ]
+        ]
+    },
+    "test_enrichr_worm": {
         "type": "assert_equal",
+        "args": {
+            "species": "worm",
+            "genes": [
+                "hop-1",
+                "C49C8",
+                "mom-4",
+                "ggtb-1",
+                "atg-5",
+                "lsm-8",
+                "mrps-6",
+                "acr-7"
+            ],
+            "database": "KEGG_2019"
+        },
+        "expected_result": [
+            [
+                1,
+                "Wnt signaling pathway",
+                0.00039315413604505114,
+                -1.7002729688381644,
+                13.332365425784747,
+                [
+                    "hop-1",
+                    "mom-4"
+                ],
+                0.0022121576616973802,
+                "KEGG_2019"
+            ],
+            [
+                2,
+                "Autophagy",
+                0.0004915905914883068,
+                -1.488674839512249,
+                11.340522942523213,
+                [
+                    "atg-5",
+                    "mom-4"
+                ],
+                0.0022121576616973802,
+                "KEGG_2019"
+            ],
+            [
+                3,
+                "Notch signaling pathway",
+                0.0075760994669518055,
+                -2.1016367079310982,
+                10.261780914101102,
+                [
+                    "hop-1"
+                ],
+                0.022728298400855416,
+                "KEGG_2019"
+            ],
+            [
+                4,
+                "Mitophagy",
+                0.011543310400553262,
+                -1.890764643162447,
+                8.435928549814511,
+                [
+                    "atg-5"
+                ],
+                0.025972448401244838,
+                "KEGG_2019"
+            ],
+            [
+                5,
+                "RNA degradation",
+                0.019436123470595184,
+                -1.956731941412363,
+                7.710740759902681,
+                [
+                    "lsm-8"
+                ],
+                0.034985022247071326,
+                "KEGG_2019"
+            ],
+            [
+                6,
+                "MAPK signaling pathway",
+                0.030393398104287924,
+                -1.0786817155918655,
+                3.768406784772349,
+                [
+                    "mom-4"
+                ],
+                0.045590097156431884,
+                "KEGG_2019"
+            ],
+            [
+                7,
+                "Spliceosome",
+                0.04355451225469591,
+                -1.153689460317217,
+                3.615365082316738,
+                [
+                    "lsm-8"
+                ],
+                0.0489988262865329,
+                "KEGG_2019"
+            ],
+            [
+                8,
+                "Longevity regulating pathway",
+                0.03660647048462186,
+                -1.082994681603428,
+                3.582037686240481,
+                [
+                    "atg-5"
+                ],
+                0.047065462051656674,
+                "KEGG_2019"
+            ],
+            [
+                9,
+                "Ribosome",
+                0.058078771800230945,
+                -0.9614498070377168,
+                2.736242938832567,
+                [
+                    "mrps-6"
+                ],
+                0.058078771800230945,
+                "KEGG_2019"
+            ]
+        ]
+    },
+    "test_enrichr_fish": {
+        "type": "assert_equal",
+        "args": {
+            "species": "fish",
+            "genes": [
+                "si:dkeyp-110e4",
+                "myog",
+                "rpl6",
+                "zfp36l1b",
+                "ptp4a2a",
+                "cby1",
+                "snx16",
+                "chata"
+            ],
+            "database": "KEGG_2019"
+        },
+        "expected_result": [
+            [
+                1,
+                "Ribosome",
+                0.052368733453294354,
+                -8.37978531263129,
+                24.715720546954245,
+                [
+                    "rpl6"
+                ],
+                0.07128526475645196,
+                "KEGG_2019"
+            ],
+            [
+                2,
+                "Glycerophospholipid metabolism",
+                0.03854089792131061,
+                -3.6734364035533082,
+                11.960838667816189,
+                [
+                    "chata"
+                ],
+                0.07128526475645196,
+                "KEGG_2019"
+            ],
+            [
+                3,
+                "Cellular senescence",
+                0.07128526475645196,
+                -1.7042650657646372,
+                4.501075903792993,
+                [
+                    "zfp36l1b"
+                ],
+                0.07128526475645196,
+                "KEGG_2019"
+            ]
+        ]
+    },
+
+
+    "test_enrichr_bad_species": {
+        "type": "error",
+        "args": {
+            "genes": [
+                "HAND1",
+                "HAND2",
+                "HEG1",
+                "HRAT17"
+            ],
+            "database": "KEGG_2019",
+            "species": "chocolate"
+        },
+        "expected_result": "ValueError",
+        "expected_msg": "Argument 'species' must be one of 'human', 'mouse', 'fly', 'yeast', 'worm', or 'fish'."
+    },
+    "test_enrichr_bad_database_shortcut": {
+        "type": "error",
+        "args": {
+            "genes": [
+                "HAND1",
+                "HAND2",
+                "HEG1",
+                "HRAT17"
+            ],
+            "database": "pathway",
+            "species": "fish"
+        },
+        "expected_result": "ValueError",
+        "expected_msg": "Database 'pathway' is not supported for species 'fish'. Please select a database from the species-specific libraries listed at: https://maayanlab.cloud/FishEnrichr/#libraries."
+    },
+    "test_enrichr_bad_background": {
+        "type": "error",
+        "args": {
+            "genes": [
+                "HAND1",
+                "HAND2",
+                "HEG1",
+                "HRAT17"
+            ],
+            "database": "KEGG_2019",
+            "species": "fly",
+            "background": true
+        },
+        "expected_result": "ValueError",
+        "expected_msg": "Background genes are only supported for species 'human' and 'mouse', not for species 'fly'. Please set 'background=False' or leave it unspecified."
+    },
+    "test_enrichr_bad_background_list": {
+        "type": "error",
+        "args": {
+            "genes": [
+                "HAND1",
+                "HAND2",
+                "HEG1",
+                "HRAT17"
+            ],
+            "database": "KEGG_2019",
+            "species": "fly",
+            "background_list": ["banana"]
+        },
+        "expected_result": "ValueError",
+        "expected_msg": "Background genes are only supported for species 'human' and 'mouse', not for species 'fly'. Please do not provide a value for 'background_list'."
+    },
+
+
+    "test_enrichr_bad_gene": {
+        "type": "code_defined",
         "args": {
             "genes": "banana",
             "database": "ontology",
@@ -5396,8 +5866,8 @@
         },
         "expected_result": "df.empty"
     },
-    "test11": {
-        "type": "assert_equal",
+    "test_enrichr_background": {
+        "type": "code_defined",
         "args": {
             "genes": [
                 "ALB",
@@ -5617,8 +6087,8 @@
             ]
         ]
     },
-    "test12": {
-        "type": "assert_equal",
+    "test_enrichr_background_ensembl": {
+        "type": "code_defined",
         "args": {
             "genes": [
                 "ENSG00000120053",
diff --git a/tests/fixtures/test_mutate.json b/tests/fixtures/test_mutate.json
new file mode 100644
index 00000000..d077218c
--- /dev/null
+++ b/tests/fixtures/test_mutate.json
@@ -0,0 +1,248 @@
+{
+    "test_single_substitution": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35G>A"
+        },
+        "expected_result": "GCCCCACCCCGCCCCTCCCCGCCCCACCCCACCCCTCCCCGCCCCACCCCGCCCCTCCCCG",
+        "global_variables": {}
+    },
+    "test_single_substitution_near_right_end": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.65G>A"
+        },
+        "expected_result": "GCCCCTCCCCGCCCCACCCCGCCCCTCCCCACCCCACCCCG",
+        "global_variables": {}
+    },
+    "test_single_substitution_near_left_end": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.5G>A"
+        },
+        "expected_result": "CCCCACCCCACCCCGCCCCTCCCCGCCCCACCCCG",
+        "global_variables": {}
+    },
+    "test_single_deletion": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35del"
+        },
+        "expected_result": "GCCCCACCCCGCCCCTCCCCGCCCCACCCCCCCCTCCCCGCCCCACCCCGCCCCTCCCCG",
+        "global_variables": {}
+    },
+    "test_multi_deletion": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35_40del"
+        },
+        "expected_result": "GCCCCACCCCGCCCCTCCCCGCCCCACCCCCCCCGCCCCACCCCGCCCCTCCCCGCCCCA",
+        "global_variables": {}
+    },
+    "test_single_deletion_with_right_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.31del"
+        },
+        "expected_result": "CGCCCCACCCCGCCCCTCCCCGCCCCACCCGCCCCTCCCCGCCCCACCCCGCCCCTC",
+        "global_variables": {}
+    },
+    "test_single_deletion_with_left_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.34del"
+        },
+        "expected_result": "CGCCCCACCCCGCCCCTCCCCGCCCCACCCGCCCCTCCCCGCCCCACCCCGCCCCTC",
+        "global_variables": {}
+    },
+    "test_multi_deletion_with_right_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.31_32del"
+        },
+        "expected_result": "CCGCCCCACCCCGCCCCTCCCCGCCCCACCGCCCCTCCCCGCCCCACCCCGCCCCTCC",
+        "global_variables": {}
+    },
+    "test_single_insertion": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.4_5insT"
+        },
+        "expected_result": "CCCCTGCCCCACCCCGCCCCTCCCCGCCCCACCCC",
+        "global_variables": {}
+    },
+    "test_multi_insertion": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.65_66insTTTTT"
+        },
+        "expected_result": "CCCCTCCCCGCCCCACCCCGCCCCTCCCCGTTTTTCCCCACCCCG",
+        "global_variables": {}
+    },
+    "test_multi_insertion_with_left_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.20_21insCCAAA"
+        },
+        "expected_result": "CCCCGCCCCACCCCGCCCCTCCAAACCCCGCCCCACCCCGCCCCTCCCCGCCCCA",
+        "global_variables": {}
+    },
+    "test_single_delins": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.38delinsAAA"
+        },
+        "expected_result": "CCACCCCGCCCCTCCCCGCCCCACCCCGCCAAACTCCCCGCCCCACCCCGCCCCTCCCCGCCC",
+        "global_variables": {}
+    },
+    "test_multi_delins": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.38_40delinsAAA"
+        },
+        "expected_result": "CCACCCCGCCCCTCCCCGCCCCACCCCGCCAAACCCCGCCCCACCCCGCCCCTCCCCGCCCCA",
+        "global_variables": {}
+    },
+    "test_multi_delins_with_psuedo_left_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.36_37delinsAG"
+        },
+        "expected_result": "CCCCACCCCGCCCCTCCCCGCCCCACCCCGAGCCTCCCCGCCCCACCCCGCCCCTCCCCGCC",
+        "global_variables": {}
+    },
+    "test_multi_delins_with_true_left_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.36_37delinsAC"
+        },
+        "expected_result": "CCCCACCCCGCCCCTCCCCGCCCCACCCCGACCCTCCCCGCCCCACCCCGCCCCTCCCCGC",
+        "global_variables": {}
+    },
+    "test_multi_delins_with_true_right_repeats": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.36_37delinsCA"
+        },
+        "expected_result": "CCCACCCCGCCCCTCCCCGCCCCACCCCGCACCTCCCCGCCCCACCCCGCCCCTCCCCGCC",
+        "global_variables": {}
+    },
+    "test_single_dup": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35dup"
+        },
+        "expected_result": "CCCCACCCCGCCCCTCCCCGCCCCACCCCGGCCCCTCCCCGCCCCACCCCGCCCCTCCCC",
+        "global_variables": {}
+    },
+    "test_multi_dup": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35_37dup"
+        },
+        "expected_result": "CCACCCCGCCCCTCCCCGCCCCACCCCGCCGCCCCTCCCCGCCCCACCCCGCCCCTCC",
+        "global_variables": {}
+    },
+    "test_inversion_with_overlaps": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.35_38inv"
+        },
+        "expected_result": "CCCCACCCCGCCCCTCCCCGCCCCACCCCGGGCCTCCCCGCCCCACCCCGCCCCTCCCCGCC",
+        "global_variables": {}
+    },
+    "test_list_of_mutations": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": ["<long_sequence>", "<long_sequence>", "<long_sequence>", "<long_sequence>"],
+            "mutations": ["c.35G>A", "c.65G>A", "c.35del", "c.4_5insT"]
+        },
+        "expected_result": "",
+        "global_variables": {}
+    },
+    "test_intron_mutation_plus": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.20+3T>A"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_intronic_position_mutations": 1
+        }
+    },
+    "test_intron_mutation_minus": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.20-3T>A"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_intronic_position_mutations": 1
+        }
+    },
+    "test_posttranslational_mutation": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.20*5T>A"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_posttranslational_region_mutations": 1
+        }
+    },
+    "test_uncertain_mutation": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.?"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_uncertain_mutations": 1
+        }
+    },
+    "test_ambiguous_mutation": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.(20_28)del"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_ambiguous_position_mutations": 1
+        }
+    },
+    "test_index_error": {
+        "type": "assert_mutate",
+        "args": {
+            "sequences": "<long_sequence>",
+            "mutations": "c.99999999C>A"
+        },
+        "expected_result": null,
+        "global_variables": {
+            "number_index_errors": 1
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/fixtures/test_opentargets.json b/tests/fixtures/test_opentargets.json
new file mode 100644
index 00000000..3b87ccae
--- /dev/null
+++ b/tests/fixtures/test_opentargets.json
@@ -0,0 +1,848 @@
+{
+    "test_opentargets": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "diseases",
+            "limit": 5
+        },
+        "expected_result": [
+            [
+                "EFO_0000274",
+                "atopic eczema",
+                "A chronic inflammatory genetically determined disease of the skin marked by increased ability to form reagin (IgE), with increased susceptibility to allergic rhinitis and asthma, and hereditary disposition to a lowered threshold for pruritus. It is manifested by lichenification, excoriation, and crusting, mainly on the flexural surfaces of the elbow and knee. In infants it is known as infantile eczema.",
+                0.6636434724183102
+            ],
+            [
+                "MONDO_0004979",
+                "asthma",
+                "A bronchial disease that is characterized by chronic inflammation and narrowing of the airways, which is caused by a combination of environmental and genetic factors resulting in recurring periods of wheezing (a whistling sound while breathing), chest tightness, shortness of breath, mucus production and coughing. The symptoms appear due to a variety of triggers such as allergens, irritants, respiratory infections, weather changes, exercise, stress, reflux disease, medications, foods and emotional anxiety.",
+                0.5436820890787305
+            ],
+            [
+                "HP_0000964",
+                "Eczematoid dermatitis",
+                "Eczema is a form of dermatitis that is characterized by scaly, pruritic, erythematous lesions located on flexural surfaces.",
+                0.5116116699305998
+            ],
+            [
+                "EFO_0005854",
+                "allergic rhinitis",
+                "Inflammation of the nasal mucous membranes caused by an IgE-mediated response to external allergens. The inflammation may also involve the mucous membranes of the sinuses, eyes, middle ear, and pharynx. Symptoms include sneezing, nasal congestion, rhinorrhea, and itching. It may lead to fatigue, drowsiness, and malaise thus causing impairment of the quality of life.",
+                0.48632862582122033
+            ],
+            [
+                "EFO_0000676",
+                "psoriasis",
+                "A common genetically determined, chronic, inflammatory skin disease characterized by rounded erythematous, dry, scaling patches. The lesions have a predilection for nails, scalp, genitalia, extensor surfaces, and the lumbosacral region. Accelerated epidermopoiesis is considered to be the fundamental pathologic feature in psoriasis.",
+                0.4338730716075263
+            ]
+        ]
+    },
+    "test_opentargets_no_limit": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "diseases"
+        },
+        "expected_result": "e6030c0f3da91f8176deaf29dcc08285"
+    },
+    "test_opentargets_no_limit_accurate_coverage": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "diseases",
+            "verbose": false,
+            "wrap_text": true
+        },
+        "expected_result": "e6030c0f3da91f8176deaf29dcc08285"
+    },
+    "test_opentargets_no_specified_resource": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "limit": 5
+        },
+        "expected_result": [
+            [
+                "EFO_0000274",
+                "atopic eczema",
+                "A chronic inflammatory genetically determined disease of the skin marked by increased ability to form reagin (IgE), with increased susceptibility to allergic rhinitis and asthma, and hereditary disposition to a lowered threshold for pruritus. It is manifested by lichenification, excoriation, and crusting, mainly on the flexural surfaces of the elbow and knee. In infants it is known as infantile eczema.",
+                0.6636434724183102
+            ],
+            [
+                "MONDO_0004979",
+                "asthma",
+                "A bronchial disease that is characterized by chronic inflammation and narrowing of the airways, which is caused by a combination of environmental and genetic factors resulting in recurring periods of wheezing (a whistling sound while breathing), chest tightness, shortness of breath, mucus production and coughing. The symptoms appear due to a variety of triggers such as allergens, irritants, respiratory infections, weather changes, exercise, stress, reflux disease, medications, foods and emotional anxiety.",
+                0.5436820890787305
+            ],
+            [
+                "HP_0000964",
+                "Eczematoid dermatitis",
+                "Eczema is a form of dermatitis that is characterized by scaly, pruritic, erythematous lesions located on flexural surfaces.",
+                0.5116116699305998
+            ],
+            [
+                "EFO_0005854",
+                "allergic rhinitis",
+                "Inflammation of the nasal mucous membranes caused by an IgE-mediated response to external allergens. The inflammation may also involve the mucous membranes of the sinuses, eyes, middle ear, and pharynx. Symptoms include sneezing, nasal congestion, rhinorrhea, and itching. It may lead to fatigue, drowsiness, and malaise thus causing impairment of the quality of life.",
+                0.48632862582122033
+            ],
+            [
+                "EFO_0000676",
+                "psoriasis",
+                "A common genetically determined, chronic, inflammatory skin disease characterized by rounded erythematous, dry, scaling patches. The lesions have a predilection for nails, scalp, genitalia, extensor surfaces, and the lumbosacral region. Accelerated epidermopoiesis is considered to be the fundamental pathologic feature in psoriasis.",
+                0.4338730716075263
+            ]
+        ]
+    },
+
+
+    "test_opentargets_drugs": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "drugs",
+            "limit": 2
+        },
+        "expected_result": [
+            [
+                "CHEMBL1743081",
+                "TRALOKINUMAB",
+                "Antibody",
+                "Interleukin-13 inhibitor",
+                "Antibody drug with a maximum clinical trial phase of IV (across all indications) that was first approved in 2021 and is indicated for atopic eczema and eczematoid dermatitis and has 5 investigational indications.",
+                [
+                    "CAT-354",
+                    "Tralokinumab"
+                ],
+                [
+                    "Adbry",
+                    "Adtralza"
+                ],
+                "EFO_0000274",
+                "atopic eczema",
+                4,
+                [],
+                true
+            ],
+            [
+                "CHEMBL1743081",
+                "TRALOKINUMAB",
+                "Antibody",
+                "Interleukin-13 inhibitor",
+                "Antibody drug with a maximum clinical trial phase of IV (across all indications) that was first approved in 2021 and is indicated for atopic eczema and eczematoid dermatitis and has 5 investigational indications.",
+                [
+                    "CAT-354",
+                    "Tralokinumab"
+                ],
+                [
+                    "Adbry",
+                    "Adtralza"
+                ],
+                "HP_0000964",
+                "Eczematoid dermatitis",
+                4,
+                [],
+                true
+            ]
+        ]
+    },
+    "test_opentargets_drugs_no_limit": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "drugs"
+        },
+        "expected_result": "dcdd8421204bb3a273c8ca0b2de8d088"
+    },
+    "test_opentargets_drugs_filter": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "drugs",
+            "filters": {
+                "disease_id": "EFO_0000274"
+            },
+            "limit": 2
+        },
+        "expected_result": [
+            [
+                "CHEMBL1743081",
+                "TRALOKINUMAB",
+                "Antibody",
+                "Interleukin-13 inhibitor",
+                "Antibody drug with a maximum clinical trial phase of IV (across all indications) that was first approved in 2021 and is indicated for atopic eczema and eczematoid dermatitis and has 5 investigational indications.",
+                [
+                    "CAT-354",
+                    "Tralokinumab"
+                ],
+                [
+                    "Adbry",
+                    "Adtralza"
+                ],
+                "EFO_0000274",
+                "atopic eczema",
+                4,
+                [],
+                true
+            ],
+            [
+                "CHEMBL1743081",
+                "TRALOKINUMAB",
+                "Antibody",
+                "Interleukin-13 inhibitor",
+                "Antibody drug with a maximum clinical trial phase of IV (across all indications) that was first approved in 2021 and is indicated for atopic eczema and eczematoid dermatitis and has 5 investigational indications.",
+                [
+                    "CAT-354",
+                    "Tralokinumab"
+                ],
+                [
+                    "Adbry",
+                    "Adtralza"
+                ],
+                "EFO_0000274",
+                "atopic eczema",
+                3,
+                [
+                    "NCT03587805"
+                ],
+                true
+            ]
+        ]
+    },
+
+
+    "test_opentargets_tractability": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "tractability"
+        },
+        "expected_result": [
+            [
+                "High-Quality Pocket",
+                "Small molecule"
+            ],
+            [
+                "Approved Drug",
+                "Antibody"
+            ],
+            [
+                "GO CC high conf",
+                "Antibody"
+            ],
+            [
+                "UniProt loc med conf",
+                "Antibody"
+            ],
+            [
+                "UniProt SigP or TMHMM",
+                "Antibody"
+            ]
+        ]
+    },
+
+
+    "test_opentargets_pharmacogenetics": {
+        "type": "assert_equal_nested",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "pharmacogenetics",
+            "limit": 2
+        },
+        "expected_result": [
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": null,
+                        "name": "hepatitis vaccines"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "TT",
+                "genotype_annotation": "Patients with the TT genotype may be at increased risk for non-immune response to the hepatitis B vaccine, as compared to patients with the CC genotype. Other genetic and clinical factors may also influence risk of non-immune response in patients receiving the hepatitis B vaccine.",
+                "genotype_id": "5_132660151_T_T,T",
+                "literature": [
+                    "21111021"
+                ],
+                "phenotype": "increased risk for non-immune response to the hepatitis B vaccine",
+                "response_category": "efficacy",
+                "rs_id": "rs1295686",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0002073",
+                "variant_consequence_label": "no_sequence_alteration"
+            },
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": "CHEMBL535",
+                        "name": "sunitinib"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "TT",
+                "genotype_annotation": "Patients with renal cell carcinoma and the TT genotype may have a decreased severity of drug-induced toxicity when administered sunitinib as compared to patients with the CC or CT genotypes. Other clinical and genetic factors may also influence severity of drug-induced toxicity in patients with renal cell carcinoma who are administered sunitinib.",
+                "genotype_id": "5_132657117_C_T,T",
+                "literature": [
+                    "26387812",
+                    "26387812"
+                ],
+                "phenotype": "decreased severity of drug-induced toxicity",
+                "response_category": "toxicity",
+                "rs_id": "rs1800925",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0001631",
+                "variant_consequence_label": "upstream_gene_variant"
+            }
+        ]
+    },
+    "test_opentargets_pharmacogenetics_json": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "pharmacogenetics",
+            "limit": 2,
+            "json": true
+        },
+        "expected_result": [
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": null,
+                        "name": "hepatitis vaccines"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "TT",
+                "genotype_annotation": "Patients with the TT genotype may be at increased risk for non-immune response to the hepatitis B vaccine, as compared to patients with the CC genotype. Other genetic and clinical factors may also influence risk of non-immune response in patients receiving the hepatitis B vaccine.",
+                "genotype_id": "5_132660151_T_T,T",
+                "literature": [
+                    "21111021"
+                ],
+                "phenotype": "increased risk for non-immune response to the hepatitis B vaccine",
+                "response_category": "efficacy",
+                "rs_id": "rs1295686",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0002073",
+                "variant_consequence_label": "no_sequence_alteration"
+            },
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": "CHEMBL535",
+                        "name": "sunitinib"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "TT",
+                "genotype_annotation": "Patients with renal cell carcinoma and the TT genotype may have a decreased severity of drug-induced toxicity when administered sunitinib as compared to patients with the CC or CT genotypes. Other clinical and genetic factors may also influence severity of drug-induced toxicity in patients with renal cell carcinoma who are administered sunitinib.",
+                "genotype_id": "5_132657117_C_T,T",
+                "literature": [
+                    "26387812",
+                    "26387812"
+                ],
+                "phenotype": "decreased severity of drug-induced toxicity",
+                "response_category": "toxicity",
+                "rs_id": "rs1800925",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0001631",
+                "variant_consequence_label": "upstream_gene_variant"
+            }
+        ],
+        "__comment": "Same result as test_opentargets_pharmacogenetics"
+    },
+    "test_opentargets_pharmacogenetics_no_limit": {
+        "type": "assert_equal_json_hash_nested",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "pharmacogenetics"
+        },
+        "expected_result": "34e64088e72da5710cdacd522c5fbd67"
+    },
+    "test_opentargets_pharmacogenetics_filter": {
+        "type": "assert_equal_nested",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "pharmacogenetics",
+            "filters": {
+                "drug_id": "CHEMBL535"
+            }
+        },
+        "expected_result": [
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": "CHEMBL535",
+                        "name": "sunitinib"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "TT",
+                "genotype_annotation": "Patients with renal cell carcinoma and the TT genotype may have a decreased severity of drug-induced toxicity when administered sunitinib as compared to patients with the CC or CT genotypes. Other clinical and genetic factors may also influence severity of drug-induced toxicity in patients with renal cell carcinoma who are administered sunitinib.",
+                "genotype_id": "5_132657117_C_T,T",
+                "literature": [
+                    "26387812",
+                    "26387812"
+                ],
+                "phenotype": "decreased severity of drug-induced toxicity",
+                "response_category": "toxicity",
+                "rs_id": "rs1800925",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0001631",
+                "variant_consequence_label": "upstream_gene_variant"
+            },
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": "CHEMBL535",
+                        "name": "sunitinib"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "CC",
+                "genotype_annotation": "Patients with renal cell carcinoma and the CC genotype may have a decreased severity of drug-induced toxicity when administered sunitinib as compared to patients with the TT genotype. Other clinical and genetic factors may also influence severity of drug-induced toxicity in patients with renal cell carcinoma who are administered sunitinib.",
+                "genotype_id": "5_132657117_C_C,C",
+                "literature": [
+                    "26387812",
+                    "26387812"
+                ],
+                "phenotype": "decreased severity of drug-induced toxicity",
+                "response_category": "toxicity",
+                "rs_id": "rs1800925",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0002073",
+                "variant_consequence_label": "no_sequence_alteration"
+            },
+            {
+                "direct_target": false,
+                "drugs": [
+                    {
+                        "id": "CHEMBL535",
+                        "name": "sunitinib"
+                    }
+                ],
+                "evidence_level": "3",
+                "genotype": "CT",
+                "genotype_annotation": "Patients with renal cell carcinoma and the CT genotype may have a decreased severity of drug-induced toxicity when administered sunitinib as compared to patients with the TT genotype. Other clinical and genetic factors may also influence severity of drug-induced toxicity in patients with renal cell carcinoma who are administered sunitinib.",
+                "genotype_id": "5_132657117_C_C,T",
+                "literature": [
+                    "26387812",
+                    "26387812"
+                ],
+                "phenotype": "decreased severity of drug-induced toxicity",
+                "response_category": "toxicity",
+                "rs_id": "rs1800925",
+                "source": "pharmgkb",
+                "variant_consequence_id": "SO:0001631",
+                "variant_consequence_label": "upstream_gene_variant"
+            }
+        ]
+    },
+    "test_opentargets_pharmacogenetics_filter_or": {
+        "type": "assert_equal_json_hash_nested",
+        "args": {
+            "ensembl_id": "ENSG00000130164",
+            "resource": "pharmacogenetics",
+            "filters": {
+                "drug_id": [
+                    "CHEMBL1643",
+                    "CHEMBL24"
+                ]
+            },
+            "filter_mode": "or"
+        },
+        "expected_result": "870d20f5746e90144ff986e800767b87"
+    },
+
+
+    "test_opentargets_expression": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "expression",
+            "limit": 3
+        },
+        "expected_result": [
+            [
+                "UBERON_0000473",
+                "testis",
+                5,
+                1026.0,
+                "",
+                3,
+                [
+                    "reproductive system"
+                ],
+                [
+                    "reproductive organ",
+                    "reproductive structure"
+                ]
+            ],
+            [
+                "CL_0000542",
+                "EBV-transformed lymphocyte",
+                1,
+                54.0,
+                "",
+                2,
+                [
+                    "hemolymphoid system",
+                    "immune system",
+                    "lymphoid system"
+                ],
+                [
+                    "immune organ"
+                ]
+            ],
+            [
+                "UBERON_0002371",
+                "bone marrow",
+                1,
+                45.5,
+                "TPM",
+                2,
+                [
+                    "hemolymphoid system",
+                    "musculoskeletal system",
+                    "hematopoietic system",
+                    "immune system"
+                ],
+                [
+                    "skeletal element"
+                ]
+            ]
+        ]
+    },
+    "test_opentargets_expression_no_limit": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "expression"
+        },
+        "expected_result": "192b5fceebfbb1cc7386cf319c264688"
+    },
+    "test_opentargets_expression_filter": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "expression",
+            "filters": {
+                "tissue_id": "UBERON_0000473",
+                "organ": "reproductive organ"
+            }
+        },
+        "expected_result": [
+            [
+                "UBERON_0000473",
+                "testis",
+                5,
+                1026,
+                "",
+                3,
+                [
+                    "reproductive system"
+                ],
+                [
+                    "reproductive organ",
+                    "reproductive structure"
+                ]
+            ]
+        ]
+    },
+
+
+    "test_opentargets_depmap": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "depmap"
+        },
+        "expected_result": "83de5c6bfee6e796bd5d68af1af702fc"
+    },
+    "test_opentargets_depmap_filter": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "depmap",
+            "filters": {
+                "tissue_id": "UBERON_0002367"
+            },
+            "limit": 2
+        },
+        "expected_result": [
+            [
+                "ACH-000977",
+                -0.08063846,
+                "UBERON_0002367",
+                "prostate gland",
+                "LNCaP clone FGC",
+                "SIDM00683",
+                "Prostate Adenocarcinoma"
+            ],
+            [
+                "ACH-000979",
+                -0.09671201,
+                "UBERON_0002367",
+                "prostate gland",
+                "DU 145",
+                "SIDM00120",
+                "Prostate Adenocarcinoma"
+            ]
+        ]
+    },
+
+
+    "test_opentargets_interactions": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "interactions",
+            "limit": 2
+        },
+        "expected_result": [
+            [
+                0.999,
+                3,
+                "string",
+                "ENSP00000304915",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "ENSP00000379111",
+                "ENSG00000077238",
+                "IL4R",
+                "unspecified role",
+                9606
+            ],
+            [
+                0.999,
+                3,
+                "string",
+                "ENSP00000304915",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "ENSP00000360730",
+                "ENSG00000131724",
+                "IL13RA1",
+                "unspecified role",
+                9606
+            ]
+        ]
+    },
+    "test_opentargets_interactions_no_limit": {
+        "type": "assert_equal_json_hash",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "interactions"
+        },
+        "expected_result": "483362ee0e9454f16cde738a6dc4887c"
+    },
+    "test_opentargets_interactions_simple_filter": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "interactions",
+            "filters": {
+                "protein_a_id": "P35225",
+                "gene_b_id": "ENSG00000077238"
+            }
+        },
+        "expected_result": [
+            [
+                3,
+                "reactome",
+                "P35225",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "P24394",
+                "ENSG00000077238",
+                "IL4R",
+                "unspecified role",
+                9606
+            ],
+            [
+                2,
+                "signor",
+                "P35225",
+                "ENSG00000169194",
+                "IL13",
+                "regulator",
+                9606,
+                "P24394",
+                "ENSG00000077238",
+                "IL4R",
+                "regulator target",
+                9606
+            ]
+        ]
+    },
+    "test_opentargets_interactions_complex_filter": {
+        "type": "assert_equal",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "interactions",
+            "filters": {
+                "protein_a_id": "P35225",
+                "gene_b_id": [
+                    "ENSG00000077238",
+                    "ENSG00000111537"
+                ]
+            },
+            "filter_mode": "or",
+            "limit": 5
+        },
+        "expected_result": [
+            [
+                0.999,
+                3,
+                "string",
+                "ENSP00000304915",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "ENSP00000379111",
+                "ENSG00000077238",
+                "IL4R",
+                "unspecified role",
+                9606
+            ],
+            [
+                0.961,
+                2,
+                "string",
+                "ENSP00000304915",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "ENSP00000229135",
+                "ENSG00000111537",
+                "IFNG",
+                "unspecified role",
+                9606
+            ],
+            [
+                0.8,
+                9,
+                "intact",
+                "P35225",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "Q14627",
+                "ENSG00000123496",
+                "IL13RA2",
+                "unspecified role",
+                9606
+            ],
+            [
+                0.74,
+                6,
+                "intact",
+                "P35225",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "P78552",
+                "ENSG00000131724",
+                "IL13RA1",
+                "unspecified role",
+                9606
+            ],
+            [
+                0.4,
+                1,
+                "intact",
+                "P35225",
+                "ENSG00000169194",
+                "IL13",
+                "unspecified role",
+                9606,
+                "Q86XT9",
+                "ENSG00000149932",
+                "TMEM219",
+                "stimulator",
+                9606
+            ]
+        ]
+    },
+
+
+    "test_error_opentargets_bad_resource": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "this resource does not exist",
+            "limit": 5
+        },
+        "expected_result": "ValueError"
+    },
+    "test_error_opentargets_bad_limit": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "diseases",
+            "limit": "this is not a number"
+        },
+        "expected_result": "RuntimeError"
+    },
+    "test_error_opentargets_nonexistent_id": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "NON EXISTENT ID",
+            "resource": "diseases",
+            "limit": 5
+        },
+        "expected_result": "ValueError"
+    },
+    "test_error_opentargets_diseases_filter": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "diseases",
+            "filters": {
+                "disease_id": "EFO_0000274"
+            }
+        },
+        "expected_result": "ValueError"
+    },
+
+
+    "test_error_opentargets_drugs_invalid_filter": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "drugs",
+            "filters": {
+                "organ": "brain"
+            }
+        },
+        "expected_result": "ValueError"
+    },
+
+
+    "test_error_opentargets_tractability_limit": {
+        "type": "error",
+        "args": {
+            "ensembl_id": "ENSG00000169194",
+            "resource": "tractability",
+            "limit": 2
+        },
+        "expected_result": "ValueError"
+    }
+}
\ No newline at end of file
diff --git a/tests/fixtures/test_ref.json b/tests/fixtures/test_ref.json
index 4a21614c..c88a364d 100644
--- a/tests/fixtures/test_ref.json
+++ b/tests/fixtures/test_ref.json
@@ -1,5 +1,5 @@
 {
-    "test1": {
+    "test_ref": {
         "type": "assert_equal",
         "args": {
             "species": "taeniopygia_guttata",
@@ -54,7 +54,7 @@
             }
         }
     },
-    "test2": {
+    "test_ref_which": {
         "type": "assert_equal",
         "args": {
             "species": "taeniopygia_guttata",
@@ -92,7 +92,7 @@
             }
         }
     },
-    "test3": {
+    "test_ref_rel": {
         "type": "assert_equal",
         "args": {
             "species": "taeniopygia_guttata",
@@ -130,7 +130,7 @@
             }
         }
     },
-    "test4": {
+    "test_ref_rel_ftp": {
         "type": "assert_equal",
         "args": {
             "species": "taeniopygia_guttata",
@@ -148,7 +148,7 @@
             "http://ftp.ensembl.org/pub/release-76/fasta/taeniopygia_guttata/pep/Taeniopygia_guttata.taeGut3.2.4.pep.all.fa.gz"
         ]
     },
-    "test5": {
+    "test_ref_ftp": {
         "type": "assert_equal",
         "args": {
             "species": "taeniopygia_guttata",
@@ -166,7 +166,7 @@
             "http://ftp.ensembl.org/pub/release-110/gtf/taeniopygia_guttata/Taeniopygia_guttata.bTaeGut1_v1.p.110.gtf.gz"
         ]
     },
-    "test6": {
+    "test_ref_plant": {
         "type": "assert_equal",
         "args": {
             "species": "actinidia_chinensis",
@@ -221,7 +221,7 @@
             }
         }
     },
-    "test7": {
+    "test_ref_which_plant": {
         "type": "assert_equal",
         "args": {
             "species": "aegilops_tauschii",
@@ -259,7 +259,7 @@
             }
         }
     },
-    "test8": {
+    "test_ref_rel_protist": {
         "type": "assert_equal",
         "args": {
             "species": "entamoeba_histolytica",
@@ -297,7 +297,7 @@
             }
         }
     },
-    "test9": {
+    "test_ref_rel_ftp_octopus": {
         "type": "assert_equal",
         "args": {
             "species": "octopus_bimaculoides",
@@ -315,7 +315,7 @@
             "http://ftp.ensemblgenomes.org/pub/release-51/metazoa/fasta/octopus_bimaculoides/pep/Octopus_bimaculoides.PRJNA270931.pep.all.fa.gz"
         ]
     },
-    "test10": {
+    "test_ref_list": {
         "type": "assert_equal",
         "args": {
             "species": null,
@@ -611,7 +611,7 @@
             "zosterops_lateralis_melanops"
         ]
     },
-    "error_test1": {
+    "test_ref_bad_species": {
         "type": "error",
         "args": {
             "species": "banana",
@@ -625,7 +625,7 @@
         },
         "expected_result": "RuntimeError"
     },
-    "error_test2": {
+    "test_ref_bad_which": {
         "type": "error",
         "args": {
             "species": "taeniopygia_guttata",
@@ -637,7 +637,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test3": {
+    "test_ref_bad_rel": {
         "type": "error",
         "args": {
             "species": "taeniopygia_guttata",
diff --git a/tests/fixtures/test_search.json b/tests/fixtures/test_search.json
index dea5b461..b49b8f23 100644
--- a/tests/fixtures/test_search.json
+++ b/tests/fixtures/test_search.json
@@ -1,6 +1,6 @@
 {
-    "test1": {
-        "type": "assert_equal",
+    "test_search_gene_one_sw": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": "swiss",
             "species": "drosophila_melanogaster_core_110_10",
@@ -21,8 +21,8 @@
             ]
         ]
     },
-    "test2": {
-        "type": "assert_equal",
+    "test_search_gene_one_sw_json": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": "swiss",
             "species": "drosophila_melanogaster_core_110_10",
@@ -43,8 +43,8 @@
             }
         ]
     },
-    "test3": {
-        "type": "assert_equal",
+    "test_search_gene_two_sw_or": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "swiss",
@@ -80,8 +80,8 @@
             ]
         ]
     },
-    "test4": {
-        "type": "assert_equal",
+    "test_search_gene_one_sw_limit": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": "fun",
             "species": "human",
@@ -127,8 +127,8 @@
             ]
         ]
     },
-    "test5": {
-        "type": "assert_equal",
+    "test_search_transcript_one_sw": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": "nep3",
             "species": "drosophila_melanogaster_core_110_10",
@@ -171,8 +171,8 @@
             ]
         ]
     },
-    "test6": {
-        "type": "assert_equal",
+    "test_search_transcript_two_sw_or": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "SNORA71",
@@ -242,8 +242,8 @@
             ]
         ]
     },
-    "test7": {
-        "type": "assert_equal",
+    "test_search_transcript_two_sw_and": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "SNORA71",
@@ -279,8 +279,8 @@
             ]
         ]
     },
-    "test8": {
-        "type": "assert_equal",
+    "test_search_gene_two_sw_limit": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "SNORA71",
@@ -327,8 +327,8 @@
             ]
         ]
     },
-    "test9": {
-        "type": "assert_equal",
+    "test_search_gene_two_sw_and": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "swiss",
@@ -353,8 +353,8 @@
             ]
         ]
     },
-    "test10": {
-        "type": "assert_equal",
+    "test_search_release": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "ACE2"
@@ -414,8 +414,8 @@
             ]
         ]
     },
-    "test11": {
-        "type": "assert_equal",
+    "test_search_db": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "hi"
@@ -459,8 +459,8 @@
             ]
         ]
     },
-    "test12": {
-        "type": "assert_equal",
+    "test_search_plant": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "hi"
@@ -509,8 +509,8 @@
             ]
         ]
     },
-    "test13": {
-        "type": "assert_equal",
+    "test_search_plant_db": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": [
                 "CLF"
@@ -539,8 +539,8 @@
             ]
         ]
     },
-    "test14": {
-        "type": "assert_equal",
+    "test_search_octopus": {
+        "type": "assert_equal_na",
         "args": {
             "searchwords": "methi",
             "species": "octopus_bimaculoides",
@@ -560,7 +560,7 @@
             ]
         ]
     },
-    "error_test1": {
+    "test_search_gene_bad_species": {
         "type": "error",
         "args": {
             "searchwords": "fun",
@@ -571,7 +571,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test2": {
+    "test_search_transcript_bad_species": {
         "type": "error",
         "args": {
             "searchwords": "fun",
@@ -582,7 +582,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test3": {
+    "test_search_gene_bad_andor": {
         "type": "error",
         "args": {
             "searchwords": "fun",
@@ -593,7 +593,7 @@
         },
         "expected_result": "ValueError"
     },
-    "error_test4": {
+    "test_search_transcript_bad_andor": {
         "type": "error",
         "args": {
             "searchwords": "fun",
diff --git a/tests/from_json.py b/tests/from_json.py
new file mode 100644
index 00000000..5ea99755
--- /dev/null
+++ b/tests/from_json.py
@@ -0,0 +1,268 @@
+from __future__ import annotations
+
+import unittest
+from typing import Callable, Any, Optional, Union
+import pandas as pd
+import sys
+import json
+import hashlib
+
+# Here's a question: how many errors does Copilot know? Answer: see below.
+_KNOWN_ERRORS = {
+    "ValueError": ValueError,
+    "RuntimeError": RuntimeError,
+    "TypeError": TypeError,
+    "KeyError": KeyError,
+    "AssertionError": AssertionError,
+    "AttributeError": AttributeError,
+    "NotImplementedError": NotImplementedError,
+    "FileNotFoundError": FileNotFoundError,
+    "IndexError": IndexError,
+    "OSError": OSError,
+    "PermissionError": PermissionError,
+    "ConnectionError": ConnectionError,
+    "TimeoutError": TimeoutError,
+    "RecursionError": RecursionError,
+    "OverflowError": OverflowError,
+    "ZeroDivisionError": ZeroDivisionError,
+    "ArithmeticError": ArithmeticError,
+    "ImportError": ImportError,
+    "ModuleNotFoundError": ModuleNotFoundError,
+    "NameError": NameError,
+    "SyntaxError": SyntaxError,
+    "IndentationError": IndentationError,
+    "TabError": TabError,
+    "SystemError": SystemError,
+    "SystemExit": SystemExit,
+    "KeyboardInterrupt": KeyboardInterrupt,
+    "GeneratorExit": GeneratorExit,
+    "StopIteration": StopIteration,
+    "MemoryError": MemoryError,
+    "BufferError": BufferError,
+    "LookupError": LookupError,
+    "EnvironmentError": EnvironmentError,
+    "IOError": IOError,
+    "BlockingIOError": BlockingIOError,
+    "ChildProcessError": ChildProcessError,
+    "BrokenPipeError": BrokenPipeError,
+    "ConnectionAbortedError": ConnectionAbortedError,
+    "ConnectionRefusedError": ConnectionRefusedError,
+}
+
+
+def do_call(func: Callable, args: Union[dict[str, ...], list[...]]) -> Any:
+    if isinstance(args, dict):
+        return func(**args)
+    return func(*args)
+
+
+def _assert_equal(name: str, td: dict[str, dict[str, ...]], func: Callable) -> Callable:
+    def assert_equal(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+        result_to_test = do_call(func, td[test]["args"])
+        # If result is a DataFrame, convert to list
+        if isinstance(result_to_test, pd.DataFrame):
+            result_to_test = result_to_test.dropna(axis=1).values.tolist()
+
+        self.assertEqual(result_to_test, expected_result)
+
+    return assert_equal
+
+
+def _assert_equal_na(
+    name: str, td: dict[str, dict[str, ...]], func: Callable
+) -> Callable:
+    def assert_equal_na(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+        result_to_test = do_call(func, td[test]["args"])
+        # If result is a DataFrame, convert to list
+        if isinstance(result_to_test, pd.DataFrame):
+            result_to_test = result_to_test.values.tolist()
+
+        self.assertEqual(result_to_test, expected_result)
+
+    return assert_equal_na
+
+
+def _assert_none(name: str, td: dict[str, dict[str, ...]], func: Callable) -> Callable:
+    def assert_none(self: unittest.TestCase):
+        test = name
+        expected_result = td[test].get("expected_result", None)
+        msg = td[test].get("msg", None)
+        result_to_test = do_call(func, td[test]["args"])
+
+        self.assertIsNone(
+            expected_result,
+            "assert_none test must not have a non-null expected_result key.",
+        )
+        self.assertIsNone(result_to_test, msg=msg)
+
+    return assert_none
+
+
+def _assert_equal_json_hash(
+    name: str, td: dict[str, dict[str, ...]], func: Callable
+) -> Callable:
+    def assert_equal_json_hash(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+        result_to_test = do_call(func, td[test]["args"])
+        # If result is a DataFrame, convert to list
+        if isinstance(result_to_test, pd.DataFrame):
+            result_to_test = result_to_test.dropna(axis=1).values.tolist()
+
+        result_to_test = json.dumps(result_to_test)
+        result_to_test = hashlib.md5(result_to_test.encode()).hexdigest()
+
+        self.assertEqual(result_to_test, expected_result)
+
+    return assert_equal_json_hash
+
+
+def _assert_equal_nested(
+    name: str, td: dict[str, dict[str, ...]], func: Callable
+) -> Callable:
+    def assert_equal_nested(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+        result_to_test = do_call(func, td[test]["args"])
+        # If result is a DataFrame, convert to json (nested dataframes prevent easy listification)
+        if isinstance(result_to_test, pd.DataFrame):
+            result_to_test = json.loads(
+                result_to_test.to_json(orient="records", force_ascii=False)
+            )
+
+        self.assertEqual(result_to_test, expected_result)
+
+    return assert_equal_nested
+
+
+def _assert_equal_json_hash_nested(
+    name: str, td: dict[str, dict[str, ...]], func: Callable
+) -> Callable:
+    def assert_equal_json_hash_nested(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+        result_to_test = do_call(func, td[test]["args"])
+        # If result is a DataFrame, convert to json (nested dataframes prevent easy listification)
+        if isinstance(result_to_test, pd.DataFrame):
+            result_to_test = json.loads(
+                result_to_test.to_json(orient="records", force_ascii=False)
+            )
+
+        result_to_test = json.dumps(result_to_test)
+        result_to_test = hashlib.md5(result_to_test.encode()).hexdigest()
+
+        self.assertEqual(result_to_test, expected_result)
+
+    return assert_equal_json_hash_nested
+
+
+def _error(name: str, td: dict[str, dict[str, ...]], func: Callable) -> Callable:
+    try:
+        # noinspection PyPep8Naming
+        Error = td[name]["expected_result"]
+    except KeyError:
+        raise ValueError("Error test must have an 'expected_result' key.")
+
+    if Error not in _KNOWN_ERRORS:
+        raise ValueError(f"Unknown error type: {Error}")
+
+    # noinspection PyPep8Naming
+    Error = _KNOWN_ERRORS[Error]
+
+    if "expected_msg" not in td[name]:
+        print(
+            f"^ Warning: 'error' test should have an 'expected_msg' key, but test '{name}' lacks one."
+        )
+
+    def error(self: unittest.TestCase):
+        test = name
+        with self.assertRaises(Error) as cm:
+            do_call(func, td[test]["args"])
+        the_exception = cm.exception
+
+        if "expected_msg" in td[test]:
+            self.assertEqual(
+                td[test]["expected_msg"], str(the_exception), f"Error message mismatch"
+            )
+
+    return error
+
+
+_test_constructor = Callable[[str, dict[str, dict[str, ...]], Callable], Callable]
+_TYPES: dict[str, _test_constructor] = {
+    "assert_equal": _assert_equal,
+    "assert_equal_na": _assert_equal_na,
+    "assert_none": _assert_none,
+    "assert_equal_json_hash": _assert_equal_json_hash,
+    "assert_equal_nested": _assert_equal_nested,
+    "assert_equal_json_hash_nested": _assert_equal_json_hash_nested,
+    "error": _error,
+}
+
+
+def from_json(
+    test_dict: dict[str, dict[str, ...]],
+    func: Callable,
+    custom_types: Optional[dict[str, _test_constructor]] = None,
+    pre_test: Optional[Callable[[], None]] = None,
+) -> type:
+    """
+    Create a metaclass that will generate test methods from a (json-loaded) dictionary.
+    """
+
+    local_types = _TYPES.copy()
+    if custom_types:
+        local_types.update(custom_types)
+
+    class C(type):
+        def __new__(cls, name: str, bases: tuple[type, ...], dct: dict[str, ...]):
+            assert (
+                unittest.TestCase in bases
+            ), "from_json should only be applied to unittest.TestCase subclasses."
+            for k, v in test_dict.items():
+                type_ = v["type"]
+                if type_ == "code_defined":
+                    if k not in dct:
+                        raise ValueError(
+                            f"Test {k} is not defined in code, despite being of type 'code_defined'."
+                        )
+                    continue
+                if type_ in local_types:
+                    if not k.startswith("test_"):
+                        print(f"Invalid test name: {k}", file=sys.stderr)
+                        continue
+
+                    if k in dct:
+                        print(f"Duplicate test name: {k}", file=sys.stderr)
+                        continue
+
+                    # Create the test method for the given type and add it to the new class
+                    test_func = local_types[type_](k, test_dict, func)
+                    if pre_test:
+                        # This has to be done with nested functions to actually close on `test_func`.
+                        # Otherwise, it just becomes a recursive mess
+                        def wrap(tf):
+                            def inner(*args, **kwargs):
+                                pre_test()
+                                tf(*args, **kwargs)
+
+                            return inner
+
+                        test_func = wrap(test_func)
+
+                    dct[k] = test_func
+                    print(f"Loaded test {k} of type {type_} from json.")
+                else:
+                    if k not in dct:
+                        raise ValueError(
+                            f"Unknown test type: {type_} and no test method defined."
+                        )
+                    print(f"Unknown test type: {type_}", file=sys.stderr)
+
+            return super().__new__(cls, name, bases, dct)
+
+    return C
diff --git a/tests/test_archs4.py b/tests/test_archs4.py
index 8269d273..c9847cad 100644
--- a/tests/test_archs4.py
+++ b/tests/test_archs4.py
@@ -1,115 +1,12 @@
 import unittest
-import pandas as pd
 import json
 from gget.gget_archs4 import archs4
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_archs4.json") as json_file:
     archs4_dict = json.load(json_file)
 
 
-class TestArchs4(unittest.TestCase):
-    def test_archs4_defaults(self):
-        test = "test1"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_mouse(self):
-        test = "test2"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_mouse_json(self):
-        test = "test3"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_mouse_json_ensembl(self):
-        test = "test4"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_tissue(self):
-        test = "test5"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_tissue_json(self):
-        test = "test6"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_tissue_mouse(self):
-        test = "test7"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_tissue_ensembl(self):
-        test = "test8"
-        expected_result = archs4_dict[test]["expected_result"]
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_archs4_bad_ensembl(self):
-        test = "none_test1"
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        self.assertIsNone(result_to_test, "Invalid argument return is not None.")
-
-    def test_archs4_bad_gene(self):
-        test = "none_test2"
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        self.assertIsNone(result_to_test, "Invalid argument return is not None.")
-
-    def test_archs4_bad_gene_tissue(self):
-        test = "none_test3"
-        result_to_test = archs4(**archs4_dict[test]["args"])
-        self.assertIsNone(result_to_test, "Invalid argument return is not None.")
-
-    def test_archs4_bad_which(self):
-        test = "error_test1"
-        with self.assertRaises(ValueError):
-            archs4(**archs4_dict[test]["args"])
-
-    def test_archs4_bad_species(self):
-        test = "error_test2"
-        with self.assertRaises(ValueError):
-            archs4(**archs4_dict[test]["args"])
+class TestArchs4(unittest.TestCase, metaclass=from_json(archs4_dict, archs4)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_bgee.py b/tests/test_bgee.py
new file mode 100644
index 00000000..58af3cb4
--- /dev/null
+++ b/tests/test_bgee.py
@@ -0,0 +1,12 @@
+import unittest
+import json
+from gget.gget_bgee import bgee
+from .from_json import from_json
+
+# Load dictionary containing arguments and expected results
+with open("./tests/fixtures/test_bgee.json") as json_file:
+    bgee_dict = json.load(json_file)
+
+
+class TestBgee(unittest.TestCase, metaclass=from_json(bgee_dict, bgee)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_blast.py b/tests/test_blast.py
index 054311f2..9a668c34 100644
--- a/tests/test_blast.py
+++ b/tests/test_blast.py
@@ -1,59 +1,12 @@
 import unittest
-import pandas as pd
 import json
 from gget.gget_blast import blast
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_blast.json") as json_file:
     blast_dict = json.load(json_file)
 
-
-class TestBlast(unittest.TestCase):
-    # def test_blast_nt(self):
-    #     test = "test1"
-    #     expected_result = blast_dict[test]["expected_result"]
-    #     result_to_test = blast(**blast_dict[test]["args"])
-    #     # If result is a DataFrame, convert to list
-    #     if isinstance(result_to_test, pd.DataFrame):
-    #         result_to_test = result_to_test.values.tolist()
-
-    #     self.assertListEqual(result_to_test, expected_result)
-
-    def test_blast_nt_json(self):
-        test = "test2"
-        expected_result = blast_dict[test]["expected_result"]
-        result_to_test = blast(**blast_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blast_bad_seq(self):
-        test = "error_test1"
-        with self.assertRaises(ValueError):
-            blast(**blast_dict[test]["args"])
-
-    def test_blast_bad_fasta(self):
-        test = "error_test2"
-        with self.assertRaises(FileNotFoundError):
-            blast(**blast_dict[test]["args"])
-
-    def test_blast_bad_program(self):
-        test = "error_test3"
-        with self.assertRaises(ValueError):
-            blast(**blast_dict[test]["args"])
-
-    def test_blast_db_missing(self):
-        test = "error_test4"
-        with self.assertRaises(ValueError):
-            blast(**blast_dict[test]["args"])
-
-    def test_blast_bad_db1(self):
-        test = "error_test5"
-        with self.assertRaises(ValueError):
-            blast(**blast_dict[test]["args"])
-
-    def test_blast_bad_db2(self):
-        test = "error_test6"
-        with self.assertRaises(ValueError):
-            blast(**blast_dict[test]["args"])
+class TestBlast(unittest.TestCase, metaclass=from_json(blast_dict, blast)):
+    pass  # all tests are loaded from json
+  
\ No newline at end of file
diff --git a/tests/test_blat.py b/tests/test_blat.py
index df2ebb56..d871c27b 100644
--- a/tests/test_blat.py
+++ b/tests/test_blat.py
@@ -1,129 +1,12 @@
 import unittest
-import pandas as pd
 import json
 from gget.gget_blat import blat
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_blat.json") as json_file:
     blat_dict = json.load(json_file)
 
-class TestBlat(unittest.TestCase):
-    def test_blat_nt(self):
-        test = "test1"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
 
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_nt_json(self):
-        test = "test2"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_nt_DNA(self):
-        test = "test3"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_aa(self):
-        test = "test4"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_aa_protein(self):
-        test = "test5"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-    
-    def test_blat_nt_fasta(self):
-        test = "test6"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_nt_txt(self):
-        test = "test7"
-        expected_result = blat_dict[test]["expected_result"]
-        result_to_test = blat(**blat_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_blat_nt_protein(self):
-        test = "none_test1"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "DNA search in protein database is not None.")
-
-    def test_blat_nt_RNA(self):
-        test = "none_test2"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "DNA search in RNA database is not None.")
-
-    def test_blat_nt_transDNA(self):
-        test = "none_test3"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "DNA search in translated DNA database is not None.")
-
-    def test_blat_aa_RNA(self):
-        test = "none_test4"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "DNA search in RNA database is not None.")
-
-    def test_blat_bad_assembly(self):
-        test = "none_test5"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "Invalid assembly result is not None.")
-
-    def test_blat_shortseq(self):
-        test = "none_test6"
-        result_to_test = blat(**blat_dict[test]["args"])
-        self.assertIsNone(result_to_test, "Sequence too short result is not None.")
-
-    def test_blat_bad_seqtype(self):
-        test = "error_test1"
-        with self.assertRaises(ValueError):
-            blat(**blat_dict[test]["args"])
-
-    def test_blat_bad_fileformat(self):
-        test = "error_test2"
-        with self.assertRaises(ValueError):
-            blat(**blat_dict[test]["args"])
-
-    def test_blat_bad_fasta(self):
-        test = "error_test3"
-        with self.assertRaises(FileNotFoundError):
-            blat(**blat_dict[test]["args"])
-
-    def test_blat_bad_txt(self):
-        test = "error_test4"
-        with self.assertRaises(FileNotFoundError):
-            blat(**blat_dict[test]["args"])
+class TestBlat(unittest.TestCase, metaclass=from_json(blat_dict, blat)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_cbio.py b/tests/test_cbio.py
new file mode 100644
index 00000000..4b87bfd7
--- /dev/null
+++ b/tests/test_cbio.py
@@ -0,0 +1,48 @@
+import hashlib
+import unittest
+import json
+from typing import Callable
+from gget.gget_cbio import download_cbioportal_data, cbio_search
+from .from_json import from_json, do_call
+
+# Load dictionary containing arguments and expected results
+with open("./tests/fixtures/test_cbio_search.json") as json_file:
+    cb_search_dict = json.load(json_file)
+
+with open("./tests/fixtures/test_cbio.json") as json_file:
+    cb_dict = json.load(json_file)
+
+
+def _sorted_search(*args):
+    return sorted(cbio_search(*args))
+
+
+class TestCbioSearch(unittest.TestCase, metaclass=from_json(cb_search_dict, _sorted_search)):
+    pass  # all tests are loaded from json
+
+
+def _cbio_download(name: str, td: dict[str, dict[str, ...]], func: Callable) -> Callable:
+    def cbio_download(self: unittest.TestCase):
+        test = name
+        expected_result = td[test]["expected_result"]
+
+        if not isinstance(expected_result, dict) and not isinstance(expected_result, bool):
+            raise ValueError("Expected result must be a dictionary or a boolean")
+
+        result = do_call(func, td[test]["args"])
+
+        if isinstance(expected_result, dict):
+            self.assertTrue(result)
+
+            for file_name, expected_hash in expected_result.items():
+                with open(file_name, "rb") as f:
+                    file_content = f.read()
+                    file_hash = hashlib.md5(file_content).hexdigest()
+                    self.assertEqual(file_hash, expected_hash, f"File {file_name} does not match expected hash")
+        else:
+            self.assertFalse(result)
+    return cbio_download
+
+
+class TestCbio(unittest.TestCase, metaclass=from_json(cb_dict, download_cbioportal_data, {"cbio_download": _cbio_download})):
+    pass  # all tests are loaded from json
diff --git a/tests/test_cosmic.py b/tests/test_cosmic.py
index 729f43cf..ef26e9ee 100644
--- a/tests/test_cosmic.py
+++ b/tests/test_cosmic.py
@@ -7,6 +7,7 @@
 import time
 from gget.gget_cosmic import cosmic
 from gget.utils import get_latest_cosmic
+from .from_json import from_json
 
 # Test download data from the latest COSMIC release
 cosmic_version = get_latest_cosmic()
@@ -19,7 +20,8 @@
     # ("cell_line", 38, "CellLinesProject_GenomeScreensMutant", cosmic_version),
     # ("census", 38, "Cosmic_MutantCensus", cosmic_version),
     # ("resistance", 38, "Cosmic_ResistanceMutations", cosmic_version),
-    # ("screen", 38, "Cosmic_GenomeScreensMutant", cosmic_version),
+    # ("genome_screen", 38, "Cosmic_GenomeScreensMutant", cosmic_version),
+    # ("targeted_screen", 38, "Cosmic_CompleteTargetedScreensMutant", cosmic_version),
 ]
 
 
@@ -27,85 +29,12 @@
 with open("./tests/fixtures/test_cosmic.json") as json_file:
     cosmic_dict = json.load(json_file)
 
-# Sleep time in seconds (wait 2 min between server requests to avoid 429 errors)
-sleep_time = 120
-
-class TestCosmic(unittest.TestCase):
-    def test_cosmic_defaults(self):
-        test = "test1"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    # Delay to avoid server overload errors
-    time.sleep(sleep_time)
-
-    def test_cosmic_limit_and_pubmet(self):
-        test = "test2"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    time.sleep(sleep_time)
-
-    def test_cosmic_json_and_genes(self):
-        test = "test3"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    time.sleep(sleep_time)
-
-    def test_cosmic_samples(self):
-        test = "test4"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    time.sleep(sleep_time)
-
-    def test_cosmic_studies(self):
-        test = "test5"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    time.sleep(sleep_time)
-
-    def test_cosmic_cancer(self):
-        test = "test6"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
-
-    time.sleep(sleep_time)
-
-    def test_cosmic_tumour(self):
-        test = "test7"
-        expected_result = cosmic_dict[test]["expected_result"]
-        result_to_test = cosmic(**cosmic_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-        self.assertListEqual(result_to_test, expected_result)
+# Sleep time in seconds (wait 4 seconds between server requests to avoid 429 errors)
+sleep_time = 4
+
+
+class TestCosmic(unittest.TestCase, metaclass=from_json(cosmic_dict, cosmic, pre_test=lambda: time.sleep(sleep_time))):
+    pass  # all tests are loaded from json
 
 
 class TestCaseBase(unittest.TestCase):
diff --git a/tests/test_diamond.py b/tests/test_diamond.py
index d85f17fd..5bf60274 100644
--- a/tests/test_diamond.py
+++ b/tests/test_diamond.py
@@ -2,62 +2,12 @@
 import json
 
 from gget.gget_diamond import diamond
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_diamond.json") as json_file:
     diamond_dict = json.load(json_file)
 
 
-class TestDiamond(unittest.TestCase):
-    def test_diamond_seqs_multiple(self):
-        test = "test1"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-        result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_diamond_seqs_single(self):
-        test = "test2"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-        result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-    
-    def test_diamond_ref_file(self):
-        test = "test3"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-        result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-    
-    def test_diamond_query_file(self):
-        test = "test4"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-        result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-    
-    def test_diamond_both_files(self):
-        test = "test5"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-        result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-    
-    def test_diamond_JSON_out(self):
-        test = "test6"
-        expected_result = diamond_dict[test]["expected_result"]
-
-        result_to_test = diamond(**diamond_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
\ No newline at end of file
+class TestDiamond(unittest.TestCase, metaclass=from_json(diamond_dict, diamond)):
+    pass  # all tests are loaded from JSON
diff --git a/tests/test_enrichr.py b/tests/test_enrichr.py
index 075c7783..5e0da6c8 100644
--- a/tests/test_enrichr.py
+++ b/tests/test_enrichr.py
@@ -4,6 +4,7 @@
 import matplotlib
 import matplotlib.pyplot as plt
 import math
+from .from_json import from_json
 
 # Prevent matplotlib from opening windows
 matplotlib.use("Agg")
@@ -14,105 +15,17 @@
     enrichr_dict = json.load(json_file)
 
 
-class TestEnrichr(unittest.TestCase):
-    def test_enrichr_pathway(self):
-        test = "test1"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_json(self):
-        test = "test2"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_none(self):
-        test = "test3"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_transcription(self):
-        test = "test4"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_ensembl_ids(self):
-        test = "test5"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_ontology(self):
-        test = "test6"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_diseases_drugs(self):
-        test = "test7"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_celltypes(self):
-        test = "test8"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_enrichr_kinase_interactions(self):
-        test = "test9"
-        expected_result = enrichr_dict[test]["expected_result"]
-        result_to_test = enrichr(**enrichr_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
+class TestEnrichr(unittest.TestCase, metaclass=from_json(enrichr_dict, enrichr)):
+    pass  # Note: some tests are generated from json
 
     def test_enrichr_bad_gene(self):
-        test = "test10"
+        test = "test_enrichr_bad_gene"
         df = enrichr(**enrichr_dict[test]["args"])
 
         self.assertTrue(df.empty, "Invalid gene result is not empty data frame.")
 
     def test_enrichr_background(self):
-        test = "test11"
+        test = "test_enrichr_background"
         expected_result = enrichr_dict[test]["expected_result"]
         result_to_test = enrichr(**enrichr_dict[test]["args"])
         # If result is a DataFrame, convert to list
@@ -126,7 +39,7 @@ def test_enrichr_background(self):
         self.assertListEqual(result_to_test, expected_result)
 
     def test_enrichr_background_ensembl(self):
-        test = "test12"
+        test = "test_enrichr_background_ensembl"
         expected_result = enrichr_dict[test]["expected_result"]
         result_to_test = enrichr(**enrichr_dict[test]["args"])
         # If result is a DataFrame, convert to list
diff --git a/tests/test_info.py b/tests/test_info.py
index 9e622e00..b65224c7 100644
--- a/tests/test_info.py
+++ b/tests/test_info.py
@@ -1,4 +1,5 @@
 import unittest
+import unittest.mock
 import pandas as pd
 import json
 from gget.gget_info import info
@@ -8,6 +9,7 @@
     info_dict = json.load(json_file)
 
 
+# todo convert to json loading once wormbase & flybase IDs are fixed. At that point, the json test framework will need a way to handle the ANY values
 class TestInfo(unittest.TestCase):
     maxDiff = None
 
diff --git a/tests/test_mutate.py b/tests/test_mutate.py
index aee428af..a71db826 100644
--- a/tests/test_mutate.py
+++ b/tests/test_mutate.py
@@ -1,153 +1,135 @@
-import unittest
-
-from gget.gget_mutate import (
-    create_mutant_sequence,
-    substitution_mutation,
-    deletion_mutation,
-    insertion_mutation,
-    delins_mutation,
-    duplication_mutation,
-    inversion_mutation,
-)
+import json
 
-# Length of flanking sequences
-k = 30
+import pytest
+import unittest
+import gget
+import pandas as pd
+import os
+import tempfile
+from typing import Callable, Union
+from .from_json import from_json, do_call
+
+LONG_SEQUENCE = 'CCCCGCCCCACCCCGCCCCTCCCCGCCCCACCCCGCCCCTCCCCGCCCCACCCCGCCCCTCCCCGCCCCACCCCG'
+
+
+@pytest.fixture
+def create_temp_files():
+    # Create a temporary CSV file
+    temp_csv_file = tempfile.NamedTemporaryFile(delete=False, suffix='.csv')
+    
+    # Data to write to CSV
+    mutation_list = ["c.35G>A", "c.65G>A", "c.35del", "c.4_5insT"]
+    protein_mutation_list = ['A12T', 'A22T', 'A12del', 'A4_5insT']
+    mut_ID_list = ['GENE1_MUT1A_MUT1B', 'GENE1_MUT2A_MUT2B', 'GENE2_MUT1A_MUT1B', 'GENE3_MUT1A_MUT1B']
+    seq_ID_list = ['ENST1', 'ENST2', 'ENST3', 'ENST4']
+    
+    data = {
+        'mutation': mutation_list,
+        'mutation_aa': protein_mutation_list,
+        'mut_ID': mut_ID_list,
+        'seq_ID': seq_ID_list
+    }
+
+    df = pd.DataFrame(data)
+    df.to_csv(temp_csv_file.name, index=False)
+
+    # Create a temporary FASTA file
+    sequence_list = [LONG_SEQUENCE for _ in range(len(mutation_list))]
+    temp_fasta_file = tempfile.NamedTemporaryFile(delete=False, suffix='.fasta')
+    
+    with open(temp_fasta_file.name, 'w') as fasta_file:
+        for seq_id, sequence in zip(seq_ID_list, sequence_list):
+            fasta_file.write(f">{seq_id}\n")
+            fasta_file.write(f"{sequence}\n")
+    
+    yield temp_csv_file.name, temp_fasta_file.name
+    
+    # Cleanup
+    os.remove(temp_csv_file.name)
+    os.remove(temp_fasta_file.name)
+
+
+def assert_global_variables_zero(number_intronic_position_mutations = 0, number_posttranslational_region_mutations = 0, number_uncertain_mutations = 0, number_ambiguous_position_mutations = 0, number_index_errors = 0):
+    assert gget.gget_mutate.intronic_mutations == number_intronic_position_mutations
+    assert gget.gget_mutate.posttranslational_region_mutations == number_posttranslational_region_mutations
+    assert gget.gget_mutate.uncertain_mutations == number_uncertain_mutations
+    assert gget.gget_mutate.ambiguous_position_mutations == number_ambiguous_position_mutations
+    assert gget.gget_mutate.mut_idx_outside_seq == number_index_errors
+
+
+def _recursive_replace(v: Union[str, dict, list], old: str, new: str, exact: bool = False) -> Union[str, dict, list]:
+    if isinstance(v, str):
+        if exact:
+            if v == old:
+                return new
+            else:
+                return v
+        else:
+            return v.replace(old, new)
+    elif isinstance(v, dict):
+        return {_recursive_replace(k, old, new, exact=exact): _recursive_replace(v, old, new, exact=exact) for k, v in v.items()}
+    elif isinstance(v, list):
+        return [_recursive_replace(v, old, new, exact=exact) for v in v]
+    else:
+        return v
+
+
+def _assert_mutate(name: str, td: dict[str, dict[str, ...]], func: Callable) -> Callable:
+    ls = LONG_SEQUENCE
+
+    def assert_mutate(self: unittest.TestCase):
+        # reset global variables
+        gget.gget_mutate.intronic_mutations = 0
+        gget.gget_mutate.posttranslational_region_mutations = 0
+        gget.gget_mutate.uncertain_mutations = 0
+        gget.gget_mutate.ambiguous_position_mutations = 0
+        gget.gget_mutate.mut_idx_outside_seq = 0
+
+        test = name
+        expected_result = td[test].get("expected_result", None)
+        global_variables = td[test].get("global_variables", {})
+
+        args = td[test]["args"]
+
+        args = _recursive_replace(args, "<long_sequence>", ls, exact=True)
+
+        result = do_call(func, args)
+
+        if expected_result:
+            self.assertEqual(result[0], expected_result)
+
+        assert_global_variables_zero(**global_variables)
+
+    return assert_mutate
+
+
+with open("./tests/fixtures/test_mutate.json") as json_file:
+    mutate_dict = json.load(json_file)
+
+
+class TestMutate(unittest.TestCase, metaclass=from_json(mutate_dict, gget.mutate, {"assert_mutate": _assert_mutate})):
+    pass  # all tests are loaded from json
+
+
+# special tests that don't fit the json format
+def test_csv_of_mutations(create_temp_files):
+    mutation_temp_csv_file, sequence_temp_fasta_path = create_temp_files
+
+    result = gget.mutate(
+        sequences=sequence_temp_fasta_path,
+        mutations=mutation_temp_csv_file
+    )
 
+    assert result == ["GCCCCACCCCGCCCCTCCCCGCCCCACCCCACCCCTCCCCGCCCCACCCCGCCCCTCCCCG", "GCCCCTCCCCGCCCCACCCCGCCCCTCCCCACCCCACCCCG", "GCCCCACCCCGCCCCTCCCCGCCCCACCCCCCCCTCCCCGCCCCACCCCGCCCCTCCCCG", "CCCCTGCCCCACCCCGCCCCTCCCCGCCCCACCCC"]
 
-# Test sequences
-def alphabet_sequence():
-    return "ABCDEFG"
+    assert_global_variables_zero()
 
 
-def alphabet_sequence_long():
-    return (
-        "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ"
-    )
+def test_mismatch_error():
+    gget.gget_mutate.mutate(
+        sequences=LONG_SEQUENCE,
+        mutations="c.2G>A")
 
+    assert gget.gget_mutate.cosmic_incorrect_wt_base == 1
 
-def create_test_row(mutation, sequence):
-    return {"mutation": mutation, "full_sequence": sequence}
-
-
-class TestMutate(unittest.TestCase):
-    def setUp(self):
-        self.alphabet_sequence = alphabet_sequence()
-        self.alphabet_sequence_long = alphabet_sequence_long()
-
-    def test_substitution(self):
-        test_row = create_test_row("c.3C>X", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, substitution_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABXDEFG", f"Expected ABXDEFG, got {result}"
-
-    def test_substitution_long(self):
-        test_row = create_test_row("c.35I>X", self.alphabet_sequence_long)
-        result = create_mutant_sequence(
-            test_row, substitution_mutation, kmer_flanking_length=k
-        )
-        assert (
-            result == "EFGHIJKLMNOPQRSTUVWXYZABCDEFGHXJKLMNOPQRSTUVWXYZABCDEFGHIJKLM"
-        ), f"Expected EFGHIJKLMNOPQRSTUVWXYZABCDEFGHXJKLMNOPQRSTUVWXYZABCDEFGHIJKLM, got {result}"
-
-    def test_multi_deletion(self):
-        test_row = create_test_row("c.3_6del", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, deletion_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABG", f"Expected ABG, got {result}"
-
-    def test_multi_deletion_long(self):
-        test_row = create_test_row("c.35_37del", self.alphabet_sequence_long)
-        result = create_mutant_sequence(
-            test_row, deletion_mutation, kmer_flanking_length=k
-        )
-        assert (
-            result == "EFGHIJKLMNOPQRSTUVWXYZABCDEFGHLMNOPQRSTUVWXYZABCDEFGHIJKLMNO"
-        ), f"Expected EFGHIJKLMNOPQRSTUVWXYZABCDEFGHLMNOPQRSTUVWXYZABCDEFGHIJKLMNO, got {result}"
-
-    def test_single_deletion(self):
-        test_row = create_test_row("c.3del", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, deletion_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABDEFG", f"Expected ABDEFG, got {result}"
-
-    def test_multi_delins(self):
-        test_row = create_test_row("c.3_6delinsXYZ", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, delins_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABXYZG", f"Expected ABXYZG, got {result}"
-
-    def test_multi_delins_long(self):
-        test_row = create_test_row("c.35_37delinsWXYZ", self.alphabet_sequence_long)
-        result = create_mutant_sequence(
-            test_row, delins_mutation, kmer_flanking_length=k
-        )
-        assert (
-            result == "EFGHIJKLMNOPQRSTUVWXYZABCDEFGHWXYZLMNOPQRSTUVWXYZABCDEFGHIJKLMNO"
-        ), f"Expected EFGHIJKLMNOPQRSTUVWXYZABCDEFGHWXYZLMNOPQRSTUVWXYZABCDEFGHIJKLMNO, got {result}"
-
-    def test_single_delins(self):
-        test_row = create_test_row("c.3delinsXYZ", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, delins_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABXYZDEFG", f"Expected ABXYZDEFG, got {result}"
-
-    def test_ins(self):
-        test_row = create_test_row("c.3_4insXYZ", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, insertion_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABCXYZDEFG", f"Expected ABCXYZDEFG, got {result}"
-
-    def test_ins_long(self):
-        test_row = create_test_row("c.35_36insPPP", self.alphabet_sequence_long)
-        result = create_mutant_sequence(
-            test_row, insertion_mutation, kmer_flanking_length=k
-        )
-        assert (
-            result == "EFGHIJKLMNOPQRSTUVWXYZABCDEFGHIPPPJKLMNOPQRSTUVWXYZABCDEFGHIJKLM"
-        ), f"Expected EFGHIJKLMNOPQRSTUVWXYZABCDEFGHIPPPJKLMNOPQRSTUVWXYZABCDEFGHIJKLM, got {result}"
-
-    def test_multi_dup(self):
-        test_row = create_test_row("c.3_5dup", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, duplication_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABCDECDEFG", f"Expected ABCDECDEFG, got {result}"
-
-    def test_multi_dup_long(self):
-        test_row = create_test_row("c.39_42dup", self.alphabet_sequence_long)
-        result = create_mutant_sequence(
-            test_row, duplication_mutation, kmer_flanking_length=k
-        )
-        assert (
-            result
-            == "IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRST"
-        ), f"Expected IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRST, got {result}"
-
-    def test_single_dup(self):
-        test_row = create_test_row("c.5dup", self.alphabet_sequence)
-        result = create_mutant_sequence(
-            test_row, duplication_mutation, kmer_flanking_length=k
-        )
-        assert result == "ABCDEEFG", f"Expected ABCDEEFG, got {result}"
-
-    # def test_inv(self):
-    #     test_row = create_test_row("c.3_4inv", self.alphabet_sequence)
-    #     result = create_mutant_sequence(
-    #         test_row, inversion_mutation, kmer_flanking_length=k
-    #     )
-    #     assert result == "ABDCEFG", f"Expected ABDCEFG, got {result}"
-
-    # def test_inv_long(self):
-    #     test_row = create_test_row("c.41_42inv", self.alphabet_sequence_long)
-    #     result = create_mutant_sequence(
-    #         test_row, inversion_mutation, kmer_flanking_length=k
-    #     )
-    #     assert (
-    #         result == "KLMNOPQRSTUVWXYZABCDEFGHIJKLMNPOQRSTUVWXYZABCDEFGHIJKLMNOPQRST"
-    #     ), f"Expected KLMNOPQRSTUVWXYZABCDEFGHIJKLMNPOQRSTUVWXYZABCDEFGHIJKLMNOPQRST, got {result}"
+    assert_global_variables_zero()
diff --git a/tests/test_opentargets.py b/tests/test_opentargets.py
new file mode 100644
index 00000000..3879b092
--- /dev/null
+++ b/tests/test_opentargets.py
@@ -0,0 +1,12 @@
+import unittest
+import json
+from gget.gget_opentargets import opentargets
+from .from_json import from_json
+
+# Load dictionary containing arguments and expected results
+with open("./tests/fixtures/test_opentargets.json") as json_file:
+    ot_dict = json.load(json_file)
+
+
+class TestOpenTargets(unittest.TestCase, metaclass=from_json(ot_dict, opentargets)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_ref.py b/tests/test_ref.py
index 7ee9693c..6111e4df 100644
--- a/tests/test_ref.py
+++ b/tests/test_ref.py
@@ -1,95 +1,12 @@
 import unittest
 import json
 from gget.gget_ref import ref
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_ref.json") as json_file:
     ref_dict = json.load(json_file)
 
 
-class TestRef(unittest.TestCase):
-    def test_ref(self):
-        test = "test1"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_which(self):
-        test = "test2"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_rel(self):
-        test = "test3"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_rel_ftp(self):
-        test = "test4"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_ref_ftp(self):
-        test = "test5"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_ref_plant(self):
-        test = "test6"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_which_plant(self):
-        test = "test7"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_rel_protist(self):
-        test = "test8"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_ref_rel_ftp_octopus(self):
-        test = "test9"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_ref_list(self):
-        test = "test10"
-        expected_result = ref_dict[test]["expected_result"]
-        result_to_test = ref(**ref_dict[test]["args"])
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    ## Test bad input errors
-    def test_ref_bad_species(self):
-        test = "error_test1"
-        with self.assertRaises(RuntimeError):
-            ref(**ref_dict[test]["args"])
-
-    def test_ref_bad_which(self):
-        test = "error_test2"
-        with self.assertRaises(ValueError):
-            ref(**ref_dict[test]["args"])
-
-    def test_ref_bad_rel(self):
-        test = "error_test3"
-        with self.assertRaises(RuntimeError):
-            ref(**ref_dict[test]["args"])
+class TestRef(unittest.TestCase, metaclass=from_json(ref_dict, ref)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_search.py b/tests/test_search.py
index 328217c5..b6b911cb 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -2,172 +2,12 @@
 import pandas as pd
 import json
 from gget.gget_search import search
+from .from_json import from_json
 
 # Load dictionary containing arguments and expected results
 with open("./tests/fixtures/test_search.json") as json_file:
     search_dict = json.load(json_file)
 
 
-class TestSearch(unittest.TestCase):
-    def test_search_gene_one_sw(self):
-        test = "test1"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_gene_one_sw_json(self):
-        test = "test2"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertEqual(result_to_test, expected_result)
-
-    def test_search_gene_two_sw_or(self):
-        test = "test3"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_gene_one_sw_limit(self):
-        test = "test4"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_transcript_one_sw(self):
-        test = "test5"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_transcript_two_sw_or(self):
-        test = "test6"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_transcript_two_sw_and(self):
-        test = "test7"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_gene_two_sw_limit(self):
-        test = "test8"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_gene_two_sw_and(self):
-        test = "test9"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_release(self):
-        test = "test10"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_db(self):
-        test = "test11"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_plant(self):
-        test = "test12"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    # Make sure invertebrate databases are no more than 10 releases older than current release
-    def test_search_plant_db(self):
-        test = "test13"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    def test_search_octopus(self):
-        test = "test14"
-        expected_result = search_dict[test]["expected_result"]
-        result_to_test = search(**search_dict[test]["args"])
-        # If result is a DataFrame, convert to list
-        if isinstance(result_to_test, pd.DataFrame):
-            result_to_test = result_to_test.values.tolist()
-
-        self.assertListEqual(result_to_test, expected_result)
-
-    ## Test bad input errors
-    def test_search_gene_bad_species(self):
-        test = "error_test1"
-        with self.assertRaises(ValueError):
-            search(**search_dict[test]["args"])
-
-    def test_search_transcript_bad_species(self):
-        test = "error_test2"
-        with self.assertRaises(ValueError):
-            search(**search_dict[test]["args"])
-
-    def test_search_gene_bad_andor(self):
-        test = "error_test3"
-        with self.assertRaises(ValueError):
-            search(**search_dict[test]["args"])
-
-    def test_search_transcript_bad_andor(self):
-        test = "error_test4"
-        with self.assertRaises(ValueError):
-            search(**search_dict[test]["args"])
+class TestSearch(unittest.TestCase, metaclass=from_json(search_dict, search)):
+    pass  # all tests are loaded from json
diff --git a/tests/test_seq.py b/tests/test_seq.py
index 9e1b8a9c..1144169c 100644
--- a/tests/test_seq.py
+++ b/tests/test_seq.py
@@ -8,6 +8,7 @@
     seq_dict = json.load(json_file)
 
 
+# todo convert to json loading once wormbase & flybase IDs are fixed. At that point, the json test framework will need a way to handle the ANY values
 class TestSeq(unittest.TestCase):
     def test_seq_gene(self):
         test = "test1"