From 06b08a62e8a8d14790ef0b487adbf497caea2e68 Mon Sep 17 00:00:00 2001 From: Erin McAuley Date: Wed, 21 Aug 2024 09:41:21 -0700 Subject: [PATCH] Initial commit Co-authored-by: Yossi Farjoun Co-authored-by: Jeff Gentry Co-authored-by: Tim Fennell Co-authored-by: Nils Homer Co-authored-by: Erin McAuley Co-authored-by: Matt Stone --- .github/CODEOWNERS | 2 + .github/workflows/publish.yml | 130 + .github/workflows/tests.yml | 105 + .github/workflows/wheels.yml | 34 + .gitignore | 162 ++ .readthedocs.yml | 12 + LICENSE | 21 + README.md | 12 + codecov.yml | 2 + docs/index.md | 10 + docs/installation.md | 102 + docs/overview.md | 83 + docs/scripts/gen_ref_pages.py | 49 + mkdocs.yml | 55 + poetry.lock | 2448 +++++++++++++++++ prymer.yml | 14 + prymer/__init__.py | 0 prymer/api/__init__.py | 43 + prymer/api/clustering.py | 227 ++ prymer/api/coordmath.py | 65 + prymer/api/melting.py | 54 + prymer/api/minoptmax.py | 80 + prymer/api/picking.py | 457 +++ prymer/api/primer.py | 205 ++ prymer/api/primer_like.py | 130 + prymer/api/primer_pair.py | 282 ++ prymer/api/span.py | 339 +++ prymer/api/variant_lookup.py | 449 +++ prymer/ntthal/__init__.py | 130 + prymer/offtarget/__init__.py | 13 + prymer/offtarget/bwa.py | 418 +++ prymer/offtarget/offtarget_detector.py | 352 +++ prymer/primer3/__init__.py | 21 + prymer/primer3/primer3.py | 730 +++++ prymer/primer3/primer3_failure_reason.py | 118 + prymer/primer3/primer3_input.py | 121 + prymer/primer3/primer3_input_tag.py | 197 ++ prymer/primer3/primer3_parameters.py | 142 + prymer/primer3/primer3_task.py | 225 ++ prymer/primer3/primer3_weights.py | 82 + prymer/py.typed | 0 prymer/util/__init__.py | 3 + prymer/util/executable_runner.py | 134 + pyproject.toml | 153 ++ tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/conftest.py | 13 + tests/api/data/mini_chr1.vcf.gz | Bin 0 -> 485 bytes tests/api/data/mini_chr1.vcf.gz.tbi | Bin 0 -> 106 bytes tests/api/data/mini_chr3.vcf.gz | Bin 0 -> 486 bytes tests/api/data/mini_chr3.vcf.gz.tbi | Bin 0 -> 106 bytes tests/api/data/miniref.variants.vcf.gz | Bin 0 -> 971 bytes tests/api/data/miniref.variants.vcf.gz.tbi | Bin 0 -> 106 bytes tests/api/data/picking.dict | 3 + tests/api/data/picking.fa | 14 + tests/api/data/picking.fa.amb | 1 + tests/api/data/picking.fa.ann | 5 + tests/api/data/picking.fa.bwt | Bin 0 -> 1260 bytes tests/api/data/picking.fa.fai | 2 + tests/api/data/picking.fa.pac | Bin 0 -> 290 bytes tests/api/data/picking.fa.sa | Bin 0 -> 632 bytes tests/api/test_clustering.py | 101 + tests/api/test_coordmath.py | 21 + tests/api/test_melting.py | 31 + tests/api/test_minoptmax.py | 55 + tests/api/test_picking.py | 916 ++++++ tests/api/test_primer.py | 440 +++ tests/api/test_primer_like.py | 45 + tests/api/test_primer_pair.py | 558 ++++ tests/api/test_span.py | 208 ++ tests/api/test_variant_lookup.py | 618 +++++ tests/conftest.py | 26 + tests/data/miniref.dict | 3 + tests/data/miniref.fa | 107 + tests/data/miniref.fa.fai | 2 + tests/ntthal/__init__.py | 0 tests/ntthal/test_ntthal.py | 115 + tests/offtarget/__init__.py | 0 tests/offtarget/conftest.py | 8 + tests/offtarget/data/miniref.dict | 2 + tests/offtarget/data/miniref.fa | 168 ++ tests/offtarget/data/miniref.fa.amb | 1 + tests/offtarget/data/miniref.fa.ann | 3 + tests/offtarget/data/miniref.fa.bwt | Bin 0 -> 10100 bytes tests/offtarget/data/miniref.fa.fai | 1 + tests/offtarget/data/miniref.fa.pac | Bin 0 -> 2502 bytes tests/offtarget/data/miniref.fa.sa | Bin 0 -> 5056 bytes tests/offtarget/test_bwa.py | 256 ++ tests/offtarget/test_offtarget.py | 230 ++ tests/primer3/__init__.py | 0 tests/primer3/data/miniref.dict | 3 + tests/primer3/data/miniref.fa | 107 + tests/primer3/data/miniref.fa.fai | 2 + tests/primer3/data/miniref.variants.vcf.gz | Bin 0 -> 900 bytes .../primer3/data/miniref.variants.vcf.gz.tbi | Bin 0 -> 105 bytes tests/primer3/test_primer3.py | 580 ++++ tests/primer3/test_primer3_failure_reason.py | 69 + tests/primer3/test_primer3_parameters.py | 88 + tests/primer3/test_primer3_task.py | 164 ++ tests/primer3/test_primer3_weights.py | 30 + tests/util/__init__.py | 0 tests/util/test_executable_runner.py | 64 + 102 files changed, 13471 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/wheels.yml create mode 100644 .gitignore create mode 100644 .readthedocs.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 codecov.yml create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/overview.md create mode 100644 docs/scripts/gen_ref_pages.py create mode 100644 mkdocs.yml create mode 100644 poetry.lock create mode 100644 prymer.yml create mode 100644 prymer/__init__.py create mode 100644 prymer/api/__init__.py create mode 100644 prymer/api/clustering.py create mode 100644 prymer/api/coordmath.py create mode 100644 prymer/api/melting.py create mode 100644 prymer/api/minoptmax.py create mode 100644 prymer/api/picking.py create mode 100644 prymer/api/primer.py create mode 100644 prymer/api/primer_like.py create mode 100644 prymer/api/primer_pair.py create mode 100644 prymer/api/span.py create mode 100644 prymer/api/variant_lookup.py create mode 100644 prymer/ntthal/__init__.py create mode 100644 prymer/offtarget/__init__.py create mode 100644 prymer/offtarget/bwa.py create mode 100644 prymer/offtarget/offtarget_detector.py create mode 100644 prymer/primer3/__init__.py create mode 100644 prymer/primer3/primer3.py create mode 100644 prymer/primer3/primer3_failure_reason.py create mode 100644 prymer/primer3/primer3_input.py create mode 100644 prymer/primer3/primer3_input_tag.py create mode 100644 prymer/primer3/primer3_parameters.py create mode 100644 prymer/primer3/primer3_task.py create mode 100644 prymer/primer3/primer3_weights.py create mode 100644 prymer/py.typed create mode 100644 prymer/util/__init__.py create mode 100644 prymer/util/executable_runner.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/conftest.py create mode 100644 tests/api/data/mini_chr1.vcf.gz create mode 100644 tests/api/data/mini_chr1.vcf.gz.tbi create mode 100644 tests/api/data/mini_chr3.vcf.gz create mode 100644 tests/api/data/mini_chr3.vcf.gz.tbi create mode 100644 tests/api/data/miniref.variants.vcf.gz create mode 100644 tests/api/data/miniref.variants.vcf.gz.tbi create mode 100644 tests/api/data/picking.dict create mode 100644 tests/api/data/picking.fa create mode 100644 tests/api/data/picking.fa.amb create mode 100644 tests/api/data/picking.fa.ann create mode 100644 tests/api/data/picking.fa.bwt create mode 100644 tests/api/data/picking.fa.fai create mode 100644 tests/api/data/picking.fa.pac create mode 100644 tests/api/data/picking.fa.sa create mode 100644 tests/api/test_clustering.py create mode 100644 tests/api/test_coordmath.py create mode 100644 tests/api/test_melting.py create mode 100644 tests/api/test_minoptmax.py create mode 100644 tests/api/test_picking.py create mode 100644 tests/api/test_primer.py create mode 100644 tests/api/test_primer_like.py create mode 100644 tests/api/test_primer_pair.py create mode 100644 tests/api/test_span.py create mode 100644 tests/api/test_variant_lookup.py create mode 100644 tests/conftest.py create mode 100644 tests/data/miniref.dict create mode 100644 tests/data/miniref.fa create mode 100644 tests/data/miniref.fa.fai create mode 100644 tests/ntthal/__init__.py create mode 100644 tests/ntthal/test_ntthal.py create mode 100644 tests/offtarget/__init__.py create mode 100644 tests/offtarget/conftest.py create mode 100644 tests/offtarget/data/miniref.dict create mode 100644 tests/offtarget/data/miniref.fa create mode 100644 tests/offtarget/data/miniref.fa.amb create mode 100644 tests/offtarget/data/miniref.fa.ann create mode 100644 tests/offtarget/data/miniref.fa.bwt create mode 100644 tests/offtarget/data/miniref.fa.fai create mode 100644 tests/offtarget/data/miniref.fa.pac create mode 100644 tests/offtarget/data/miniref.fa.sa create mode 100644 tests/offtarget/test_bwa.py create mode 100644 tests/offtarget/test_offtarget.py create mode 100644 tests/primer3/__init__.py create mode 100644 tests/primer3/data/miniref.dict create mode 100644 tests/primer3/data/miniref.fa create mode 100644 tests/primer3/data/miniref.fa.fai create mode 100644 tests/primer3/data/miniref.variants.vcf.gz create mode 100644 tests/primer3/data/miniref.variants.vcf.gz.tbi create mode 100644 tests/primer3/test_primer3.py create mode 100644 tests/primer3/test_primer3_failure_reason.py create mode 100644 tests/primer3/test_primer3_parameters.py create mode 100644 tests/primer3/test_primer3_task.py create mode 100644 tests/primer3/test_primer3_weights.py create mode 100644 tests/util/__init__.py create mode 100644 tests/util/test_executable_runner.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..8b86b5b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owners: Nils and Tim +* @nh13 @tfenne diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b220bd3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,130 @@ +name: publish + +on: + push: + tags: '\d+.\d+.\d+' + +env: + POETRY_VERSION: 1.8.2 + +jobs: + on-main-branch-check: + runs-on: ubuntu-latest + outputs: + on_main: ${{ steps.contains_tag.outputs.retval }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: rickstaa/action-contains-tag@v1 + id: contains_tag + with: + reference: "main" + tag: "${{ github.ref_name }}" + + tests: + name: tests + needs: on-main-branch-check + if: ${{ needs.on-main-branch-check.outputs.on_main == 'true' }} + uses: "./.github/workflows/tests.yml" + + build-wheels: + name: build wheels + needs: tests + uses: "./.github/workflows/wheels.yml" + + build-sdist: + name: build source distribution + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install poetry + run: | + python -m pip install --upgrade pip + python -m pip install poetry==${{env.POETRY_VERSION}} + + - name: Configure poetry + shell: bash + run: poetry config virtualenvs.in-project true + + - name: Install dependencies + run: poetry install --no-interaction --no-root --without=dev + + - name: Install project + run: poetry install --no-interaction --without=dev + + - name: Build package + run: poetry build --format=sdist + + - uses: actions/upload-artifact@v4 + with: + name: prymer-sdist + path: dist/*.tar.gz + + publish-to-pypi: + runs-on: ubuntu-latest + needs: [build-wheels, build-sdist] + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + path: packages + pattern: 'prymer-*' + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/ + skip-existing: true + verbose: true + + make-changelog: + runs-on: ubuntu-latest + needs: publish-to-pypi + outputs: + release_body: ${{ steps.git-cliff.outputs.content }} + steps: + - name: Checkout the Repository at the Tagged Commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref_name }} + + - name: Generate a Changelog + uses: orhun/git-cliff-action@v3 + id: git-cliff + with: + config: pyproject.toml + args: --latest --verbose + env: + GITHUB_REPO: ${{ github.repository }} + + make-github-release: + runs-on: ubuntu-latest + environment: github + permissions: + contents: write + pull-requests: read + needs: make-changelog + steps: + - name: Create Draft Release + id: create_release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body: | + ${{ needs.draft-changelog.outputs.release_body }} + draft: false + prerelease: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9437b81 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,105 @@ +name: tests + +on: + push: + branches: + - "**" + tags: + - "!**" + workflow_call: + +env: + POETRY_VERSION: 1.8.2 + +jobs: + Tests: + runs-on: ubuntu-latest + strategy: + matrix: + PYTHON_VERSION: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Checkout fulcrumgenomics/bwa + uses: actions/checkout@v4 + with: + repository: fulcrumgenomics/bwa + ref: interactive_aln + path: bwa + fetch-depth: 0 + + - name: Set up Python ${{ matrix.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.PYTHON_VERSION }} + + - name: Set up miniconda + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-variant: Mambaforge + miniforge-version: latest + channels: conda-forge,bioconda + activate-environment: prymer + environment-file: prymer.yml + channel-priority: true + auto-update-conda: true + auto-activate-base: false + python-version: ${{ matrix.PYTHON_VERSION }} + + - name: Install fulcrumgenomics/bwa + shell: bash -l {0} + run: | + conda activate prymer + pushd bwa + make -j $(nproc) + cp bwa ${CONDA_PREFIX}/bin + popd + + - name: Configure poetry and check lock file + shell: bash -l {0} + run: | + conda activate prymer + poetry config virtualenvs.in-project false + poetry check --lock + + - name: Poetry install + shell: bash -l {0} + run: | + conda activate prymer + poetry lock --no-update + poetry install --with dev + + - name: Unit tests (with doctest and coverage) + shell: bash -l {0} + run: | + conda activate prymer + poetry run pytest --cov=prymer --cov-report=xml --cov-branch --doctest-plus --doctest-modules prymer tests + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.5.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Style checking + shell: bash -l {0} + run: | + conda activate prymer + poetry run ruff format --check + + - name: Run lint + shell: bash -l {0} + run: | + conda activate prymer + poetry run ruff check + + - name: Run mypy + shell: bash -l {0} + run: | + conda activate prymer + poetry run mypy + + - name: Run docs + shell: bash -l {0} + run: | + conda activate prymer + set -euo pipefail + poetry run mkdocs build --strict diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..c2815b0 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,34 @@ +name: build wheels + +on: + pull_request: + workflow_call: + workflow_dispatch: + +jobs: + build-wheels: + name: Build wheels for ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + with: + submodules: "true" + + - name: Set up Python ${{ matrix.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Build wheels + run: pip wheel -w wheelhouse . + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: prymer-wheels-${{ matrix.python }} + path: ./wheelhouse/*.whl + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b745cbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +.vscode/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..04913cd --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,12 @@ +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.11" + jobs: + post_install: + - pip install poetry==1.8.3 + - poetry config virtualenvs.create false + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install +mkdocs: + configuration: mkdocs.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcc149a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Fulcrum Genomics LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..296bae8 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Python Primer Design Library + +[![CI](https://github.com/fulcrumgenomics/prymer/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/fulcrumgenomics/prymer/actions/workflows/tests.yml?query=branch%3Amain) +[![Python Versions](https://img.shields.io/badge/python-3.11_|_3.12-blue)](https://github.com/fulcrumgenomics/prymer) +[![MyPy Checked](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://docs.astral.sh/ruff/) +[![codecov](https://codecov.io/github/fulcrumgenomics/prymer/graph/badge.svg?token=wuTCpNGVaP)](https://codecov.io/github/fulcrumgenomics/prymer) + +## Quick setup + +See [Installation](docs/installation.md). + diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e6b99e6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "tests" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4b9fde1 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,10 @@ +# prymer + +Python Primer Design Library + +## Documentation Contents + +* [Installation](installation.md) +* [Overview](overview.md) +* [API](reference/prymer/index.md) + diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e173dac --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,102 @@ +# Installation + + +## Installing `prymer` + +The installation requires three steps: + +1. Install python and other dependencies with `conda` +2. Install the custom version of bwa +3. Install `prymer` with `poetry. + +Install the required Python version, [`poetry`](https://github.com/python-poetry/poetry), and [`primer3](https://github.com/primer3-org/primer3) into your environment manager of choice, e.g. + +```sh +$ mamba env create -y -f prymer.yml +$ conda activate prymer +``` + +Install the custom version of bwa: +```sh +$ git clone -b interactive_aln git@github.com:fulcrumgenomics/bwa.git +$ cd bwa +$ make -j 12 +$ cp bwa ${CONDA_PREFIX}/bin +``` + +Note: the `virtualenvs.create false` setting in `poetry.toml` stops poetry from creating new virtual environments and forces it to use the active conda environment instead. +This can be set once per machine/user and stored in the user's poetry configuration with: + +```sh +$ poetry config settings.virtualenvs.create false +``` + +Install the prymer with `poetry`. + +```console +$ poetry install +``` + +## Getting Setup for Development Work + +Follow the [instructions above](#installing-prymer) + +```console +$ poetry install --with dev +``` + +## Checking the Build + +Make sure that [instructions for development work](#getting-setup-for-development-work) have been followed. + +Use `poetry` to format, lint, type-check, and test your code. +Note that `poetry run pytest` will run `mypy` and `ruff` code checks in addition to `pytest` unit tests, and will provide a unit test coverage report. + +```console +$ poetry run pytest +``` + +However, `pytest` will neither run the ruff formatter nor apply `ruff`'s automatic lint fixes, which can be done by calling `ruff` directly. + +```console +$ poetry run ruff format && poetry run ruff check --fix +``` + +Static type checking is performed using `mpyp`. + +```console +poetry run mypy +``` + +## Building the Documentation + +Make sure that [instructions for development work](#getting-setup-for-development-work) have been followed. + +Use `mkdocs` to build and serve the documentation. + +```console +$ poetry install --with dev +$ poetry run mkdocs build +$ poetry run mkdocs serve +``` + +## Creating a Release on PyPi + +1. Clone the repository recursively and ensure you are on the `main` (un-dirty) branch +2. Checkout a new branch to prepare the library for release +3. Bump the version of the library to the desired SemVer with `poetry version #.#.#` +4. Commit the version bump changes with a Git commit message like `chore(release): bump to #.#.#` +5. Push the commit to the upstream remote, open a PR, ensure tests pass, and seek reviews +6. Squash merge the PR +7. Tag the new commit on the main branch of the repository with the new SemVer + +GitHub Actions will take care of the remainder of the deployment and release process with: + +1. Unit tests will be run for safety-sake +2. A source distribution will be built +3. Many multi-arch multi-Python binary distributions will be built +4. Assets will be deployed to PyPi with the new SemVer +5. A [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/)-aware changelog will be drafted +6. A GitHub release will be created with the new SemVer and the drafted changelog + +Consider editing the changelog if there are any errors or necessary enhancements. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..1e92398 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,83 @@ +# Overview + +The `prymer` Python library is intended to be used for three main purposes: + +1. [Clustering targets](#clustering-targets) into larger amplicons prior to designing primers. +2. [Designing primers](#designing-primers) (left or right) or primer pairs using Primer3 for each target from (1). +3. [Build and Picking a set of primer pairs](#build-and-picking-primer-pairs) from the designed primer pairs produced in (2). + +## Clustering Targets + +Optionally, input targets may be clustered into larger amplicons prior to designing primers. These amplicons wholly +contain the input targets, and are used as input for primer design. The [`cluster_intervals()`][prymer.api.clustering.cluster_intervals] +method in the [`prymer.api.clustering`][prymer.api.clustering] module is used to cluster targets into larger +amplicons prior to primer design. + +## Designing Primers + +Designing primers (left or right) or primer pairs using Primer3 is primarily performed using the +[`Primer3`][prymer.primer3.primer3.Primer3] class, which wraps the +[`primer3` command line tool](https://github.com/primer3-org/primer3). The +[`design_primers()`][prymer.primer3.primer3.Primer3.design_primers] facilitates the design of single and paired primers +for a single target. The `Primer3` instance is intended to be re-used to design primers across multiple targets, or +re-design (after changing parameters) for the same target, or both! + +Common input parameters are specified in [`Primer3Parameters()`][prymer.primer3.primer3_parameters.Primer3Parameters] and +[`Primer3Weights()`][prymer.primer3.primer3_weights.Primer3Weights], while the task type (left primer, +right primer, or primer pair design) is specified with the corresponding +[`Primer3Task`][prymer.primer3.primer3_task.Primer3Task]. + +The result of a primer design is encapsulated in the [`Primer3Result`][prymer.primer3.primer3.Primer3Result] class. It +provides the primers (or primer pairs) that were designed, as well as a list of reasons some primers were not returned, +for example exceeding the melting temperature threshold, too high GC content, and so on. These failures are +encapsulated in the [`Primer3Failures`][prymer.primer3.primer3.Primer3Failure] class. + +The [`Primer3Result`][prymer.primer3.primer3.Primer3Result] returned by the primer design contains either a list of +[`Primer`][prymer.api.primer.Primer]s or [`PrimerPair`][prymer.api.primer_pair.PrimerPair]s, depending on the +[`Primer3Task`][prymer.primer3.primer3_task.Primer3Task] specified in the input parameters. +These can be subsequently filtered or examined. + +## Build and Picking Primer Pairs + +It is recommended to design left and right primers individually, then to screen each primer for off-target mappings +using the [`OffTargetDetector`][prymer.offtarget.offtarget_detector.OffTargetDetector] class, which wraps +`bwa aln` to identify off-target primer mappings. + +The remaining primers may then be used with the +[`build_primer_pairs()`][prymer.api.picking.build_primer_pairs] method to build primer pairs +from all combinations of the left and right primers. +The produced primer pairs are scored in a manner similar to Primer3 using the [`score()`][prymer.api.picking.score] method. +The [`FilterParams`][prymer.api.picking.FilteringParams] class is used to provide parameters for scoring. + +Next, the [`pick_top_primer_pairs()`][prymer.api.picking.pick_top_primer_pairs] method is used to select up to +a maximum number of primer pairs. The primer pairs are selected in the order of lowest penalty (highest score). As +part of this process, each primer pair must: + +1. Have an amplicon in the desired size range (see [`is_acceptable_primer_pair`][prymer.api.picking.is_acceptable_primer_pair]). +2. Have an amplicon melting temperature in the desired range (see [`is_acceptable_primer_pair`][prymer.api.picking.is_acceptable_primer_pair]). +3. Not have too many off-targets (see [`OffTargetDetector.check_one()`][prymer.offtarget.offtarget_detector.OffTargetDetector.check_one]). +4. Not have primer pairs that overlap too much (see [`check_primer_overlap()`][prymer.api.picking.check_primer_overlap]). +5. Not form a dimer with a melting temperature above a specified threshold (see the [`max_dimer_tm` attribute in `FilterParams`][prymer.api.picking.FilteringParams]). + +Checking for dimers may be performed using the [`NtThermoAlign`][prymer.ntthal.NtThermoAlign] command line executable, +and can be passed to [`pick_top_primer_pairs()`][prymer.api.picking.pick_top_primer_pairs] as follows: + +```python +from prymer.ntthal import NtThermoAlign +from prymer.api.picking import FilteringParams, pick_top_primer_pairs +params = FilteringParams(...) +dimer_checker = NtThermoAlign() +pick_top_primer_pairs( + is_dimer_tm_ok=lambda s1, s2: ( + dimer_checker.duplex_tm(s1=s1, s2=s2) <= params.max_dimer_tm + ), + ... +) + +``` + +For convenience, the [`build_and_pick_primer_pairs()`][prymer.api.picking.build_and_pick_primer_pairs] method combines +both the [`build_primer_pairs()`][prymer.api.picking.build_primer_pairs] and +[`pick_top_primer_pairs()`][prymer.api.picking.pick_top_primer_pairs] methods in single invocation. + +The resulting primer pairs may be further examined. \ No newline at end of file diff --git a/docs/scripts/gen_ref_pages.py b/docs/scripts/gen_ref_pages.py new file mode 100644 index 0000000..d2b866e --- /dev/null +++ b/docs/scripts/gen_ref_pages.py @@ -0,0 +1,49 @@ +"""Generate the code reference pages and navigation.""" + +# Adapted from https://mkdocstrings.github.io/recipes/#bind-pages-to-sections-themselves + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() + +library_name = "prymer" +root = Path(__file__).parent.parent.parent +src = root +module_root = root / library_name + +assert module_root.exists(), ( + f"module root does not exist: {module_root}\n" + f"Are you sure the library name '{library_name}' is correct?" +) + +for path in sorted(module_root.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + # ignore tests + if "tests" in parts: + continue + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1] == "__main__": + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + #mkdocs_gen_files.set_edit_path(full_doc_path, Path("../") / path.relative_to(root)) + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c22726e --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,55 @@ +site_name: Python Primer Design Library +site_url: https://github.com/fulcrumgenomics/prymer +use_directory_urls: false +theme: + name: material + highlightjs: true + hljs_languages: + - python + palette: + primary: teal + navstyle: dark + include_sidebar: true + collapse_navigation: false +repo_url: https://github.com/fulcrumgenomics/prymer +plugins: + - autorefs + - include-markdown + - search + - gen-files: + scripts: + - docs/scripts/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + - section-index + - mkdocstrings: + handlers: + python: + options: + docstring_section_style: table + docstring_style: google + group_by_category: true + separate_signature: true + show_category_heading: true + show_if_no_docstring: false + show_root_toc_entry: true + show_signature_annotations: true + signature_crossrefs: true + show_submodules: true + - table-reader +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - toc: + permalink: true +exclude_docs: | + test_*.py +watch: + - docs + - prymer + - mkdocs.yml diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..a45a48a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2448 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "babel" +version = "2.16.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "black" +version = "24.8.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, + {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, + {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, + {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, + {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, + {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, + {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, + {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, + {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, + {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, + {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, + {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, + {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, + {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, + {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, + {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, + {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, + {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, + {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, + {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, + {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, + {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "bracex" +version = "2.5" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bracex-2.5-py3-none-any.whl", hash = "sha256:d2fcf4b606a82ac325471affe1706dd9bbaa3536c91ef86a31f6b766f3dad1d0"}, + {file = "bracex-2.5.tar.gz", hash = "sha256:0725da5045e8d37ea9592ab3614d8b561e22c3c5fde3964699be672e072ab611"}, +] + +[[package]] +name = "build" +version = "1.2.1" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.8" +files = [ + {file = "build-1.2.1-py3-none-any.whl", hash = "sha256:75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4"}, + {file = "build-1.2.1.tar.gz", hash = "sha256:526263f4870c26f26c433545579475377b2b7588b6f1eac76a001e873ae3e19d"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +packaging = ">=19.1" +pyproject_hooks = "*" + +[package.extras] +docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] +test = ["build[uv,virtualenv]", "filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "setuptools (>=56.0.0)", "setuptools (>=67.8.0)", "wheel (>=0.36.0)"] +typing = ["build[uv]", "importlib-metadata (>=5.1)", "mypy (>=1.9.0,<1.10.0)", "tomli", "typing-extensions (>=3.7.4.3)"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.0.35)"] + +[[package]] +name = "cachecontrol" +version = "0.14.0" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, + {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "mypy", "pytest", "pytest-cov", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "cffi" +version = "1.17.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, + {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, + {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, + {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, + {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, + {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, + {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, + {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, + {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, + {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, + {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, + {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, + {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, + {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, + {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "cleo" +version = "2.1.0" +description = "Cleo allows you to create beautiful and testable command-line interfaces." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, + {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, +] + +[package.dependencies] +crashtest = ">=0.4.1,<0.5.0" +rapidfuzz = ">=3.0.0,<4.0.0" + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "crashtest" +version = "0.4.1" +description = "Manage Python errors with ease" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, + {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, +] + +[[package]] +name = "cryptography" +version = "43.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "dulwich" +version = "0.21.7" +description = "Python Git Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "dulwich-0.21.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d4c0110798099bb7d36a110090f2688050703065448895c4f53ade808d889dd3"}, + {file = "dulwich-0.21.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2bc12697f0918bee324c18836053644035362bb3983dc1b210318f2fed1d7132"}, + {file = "dulwich-0.21.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:471305af74790827fcbafe330fc2e8bdcee4fb56ca1177c8c481b1c8f806c4a4"}, + {file = "dulwich-0.21.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d54c9d0e845be26f65f954dff13a1cd3f2b9739820c19064257b8fd7435ab263"}, + {file = "dulwich-0.21.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12d61334a575474e707614f2e93d6ed4cdae9eb47214f9277076d9e5615171d3"}, + {file = "dulwich-0.21.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e274cebaf345f0b1e3b70197f2651de92b652386b68020cfd3bf61bc30f6eaaa"}, + {file = "dulwich-0.21.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:817822f970e196e757ae01281ecbf21369383285b9f4a83496312204cf889b8c"}, + {file = "dulwich-0.21.7-cp310-cp310-win32.whl", hash = "sha256:7836da3f4110ce684dcd53489015fb7fa94ed33c5276e3318b8b1cbcb5b71e08"}, + {file = "dulwich-0.21.7-cp310-cp310-win_amd64.whl", hash = "sha256:4a043b90958cec866b4edc6aef5fe3c2c96a664d0b357e1682a46f6c477273c4"}, + {file = "dulwich-0.21.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce8db196e79c1f381469410d26fb1d8b89c6b87a4e7f00ff418c22a35121405c"}, + {file = "dulwich-0.21.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:62bfb26bdce869cd40be443dfd93143caea7089b165d2dcc33de40f6ac9d812a"}, + {file = "dulwich-0.21.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c01a735b9a171dcb634a97a3cec1b174cfbfa8e840156870384b633da0460f18"}, + {file = "dulwich-0.21.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa4d14767cf7a49c9231c2e52cb2a3e90d0c83f843eb6a2ca2b5d81d254cf6b9"}, + {file = "dulwich-0.21.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bca4b86e96d6ef18c5bc39828ea349efb5be2f9b1f6ac9863f90589bac1084d"}, + {file = "dulwich-0.21.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7b5624b02ef808cdc62dabd47eb10cd4ac15e8ac6df9e2e88b6ac6b40133673"}, + {file = "dulwich-0.21.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c3a539b4696a42fbdb7412cb7b66a4d4d332761299d3613d90a642923c7560e1"}, + {file = "dulwich-0.21.7-cp311-cp311-win32.whl", hash = "sha256:675a612ce913081beb0f37b286891e795d905691dfccfb9bf73721dca6757cde"}, + {file = "dulwich-0.21.7-cp311-cp311-win_amd64.whl", hash = "sha256:460ba74bdb19f8d498786ae7776745875059b1178066208c0fd509792d7f7bfc"}, + {file = "dulwich-0.21.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4c51058ec4c0b45dc5189225b9e0c671b96ca9713c1daf71d622c13b0ab07681"}, + {file = "dulwich-0.21.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4bc4c5366eaf26dda3fdffe160a3b515666ed27c2419f1d483da285ac1411de0"}, + {file = "dulwich-0.21.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a0650ec77d89cb947e3e4bbd4841c96f74e52b4650830112c3057a8ca891dc2f"}, + {file = "dulwich-0.21.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f18f0a311fb7734b033a3101292b932158cade54b74d1c44db519e42825e5a2"}, + {file = "dulwich-0.21.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c589468e5c0cd84e97eb7ec209ab005a2cb69399e8c5861c3edfe38989ac3a8"}, + {file = "dulwich-0.21.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d62446797163317a397a10080c6397ffaaca51a7804c0120b334f8165736c56a"}, + {file = "dulwich-0.21.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e84cc606b1f581733df4350ca4070e6a8b30be3662bbb81a590b177d0c996c91"}, + {file = "dulwich-0.21.7-cp312-cp312-win32.whl", hash = "sha256:c3d1685f320907a52c40fd5890627945c51f3a5fa4bcfe10edb24fec79caadec"}, + {file = "dulwich-0.21.7-cp312-cp312-win_amd64.whl", hash = "sha256:6bd69921fdd813b7469a3c77bc75c1783cc1d8d72ab15a406598e5a3ba1a1503"}, + {file = "dulwich-0.21.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7d8ab29c660125db52106775caa1f8f7f77a69ed1fe8bc4b42bdf115731a25bf"}, + {file = "dulwich-0.21.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0d2e4485b98695bf95350ce9d38b1bb0aaac2c34ad00a0df789aa33c934469b"}, + {file = "dulwich-0.21.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e138d516baa6b5bafbe8f030eccc544d0d486d6819b82387fc0e285e62ef5261"}, + {file = "dulwich-0.21.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f34bf9b9fa9308376263fd9ac43143c7c09da9bc75037bb75c6c2423a151b92c"}, + {file = "dulwich-0.21.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2e2c66888207b71cd1daa2acb06d3984a6bc13787b837397a64117aa9fc5936a"}, + {file = "dulwich-0.21.7-cp37-cp37m-win32.whl", hash = "sha256:10893105c6566fc95bc2a67b61df7cc1e8f9126d02a1df6a8b2b82eb59db8ab9"}, + {file = "dulwich-0.21.7-cp37-cp37m-win_amd64.whl", hash = "sha256:460b3849d5c3d3818a80743b4f7a0094c893c559f678e56a02fff570b49a644a"}, + {file = "dulwich-0.21.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:74700e4c7d532877355743336c36f51b414d01e92ba7d304c4f8d9a5946dbc81"}, + {file = "dulwich-0.21.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c92e72c43c9e9e936b01a57167e0ea77d3fd2d82416edf9489faa87278a1cdf7"}, + {file = "dulwich-0.21.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d097e963eb6b9fa53266146471531ad9c6765bf390849230311514546ed64db2"}, + {file = "dulwich-0.21.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:808e8b9cc0aa9ac74870b49db4f9f39a52fb61694573f84b9c0613c928d4caf8"}, + {file = "dulwich-0.21.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1957b65f96e36c301e419d7adaadcff47647c30eb072468901bb683b1000bc5"}, + {file = "dulwich-0.21.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4b09bc3a64fb70132ec14326ecbe6e0555381108caff3496898962c4136a48c6"}, + {file = "dulwich-0.21.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5882e70b74ac3c736a42d3fdd4f5f2e6570637f59ad5d3e684760290b58f041"}, + {file = "dulwich-0.21.7-cp38-cp38-win32.whl", hash = "sha256:29bb5c1d70eba155ded41ed8a62be2f72edbb3c77b08f65b89c03976292f6d1b"}, + {file = "dulwich-0.21.7-cp38-cp38-win_amd64.whl", hash = "sha256:25c3ab8fb2e201ad2031ddd32e4c68b7c03cb34b24a5ff477b7a7dcef86372f5"}, + {file = "dulwich-0.21.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8929c37986c83deb4eb500c766ee28b6670285b512402647ee02a857320e377c"}, + {file = "dulwich-0.21.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc1e11be527ac06316539b57a7688bcb1b6a3e53933bc2f844397bc50734e9ae"}, + {file = "dulwich-0.21.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0fc3078a1ba04c588fabb0969d3530efd5cd1ce2cf248eefb6baf7cbc15fc285"}, + {file = "dulwich-0.21.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dcbd29ba30ba2c5bfbab07a61a5f20095541d5ac66d813056c122244df4ac0"}, + {file = "dulwich-0.21.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8869fc8ec3dda743e03d06d698ad489b3705775fe62825e00fa95aa158097fc0"}, + {file = "dulwich-0.21.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d96ca5e0dde49376fbcb44f10eddb6c30284a87bd03bb577c59bb0a1f63903fa"}, + {file = "dulwich-0.21.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0064363bd5e814359657ae32517fa8001e8573d9d040bd997908d488ab886ed"}, + {file = "dulwich-0.21.7-cp39-cp39-win32.whl", hash = "sha256:869eb7be48243e695673b07905d18b73d1054a85e1f6e298fe63ba2843bb2ca1"}, + {file = "dulwich-0.21.7-cp39-cp39-win_amd64.whl", hash = "sha256:404b8edeb3c3a86c47c0a498699fc064c93fa1f8bab2ffe919e8ab03eafaaad3"}, + {file = "dulwich-0.21.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e598d743c6c0548ebcd2baf94aa9c8bfacb787ea671eeeb5828cfbd7d56b552f"}, + {file = "dulwich-0.21.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a2d76c96426e791556836ef43542b639def81be4f1d6d4322cd886c115eae1"}, + {file = "dulwich-0.21.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6c88acb60a1f4d31bd6d13bfba465853b3df940ee4a0f2a3d6c7a0778c705b7"}, + {file = "dulwich-0.21.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ecd315847dea406a4decfa39d388a2521e4e31acde3bd9c2609c989e817c6d62"}, + {file = "dulwich-0.21.7-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d05d3c781bc74e2c2a2a8f4e4e2ed693540fbe88e6ac36df81deac574a6dad99"}, + {file = "dulwich-0.21.7-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6de6f8de4a453fdbae8062a6faa652255d22a3d8bce0cd6d2d6701305c75f2b3"}, + {file = "dulwich-0.21.7-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e25953c7acbbe4e19650d0225af1c0c0e6882f8bddd2056f75c1cc2b109b88ad"}, + {file = "dulwich-0.21.7-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:4637cbd8ed1012f67e1068aaed19fcc8b649bcf3e9e26649826a303298c89b9d"}, + {file = "dulwich-0.21.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:858842b30ad6486aacaa607d60bab9c9a29e7c59dc2d9cb77ae5a94053878c08"}, + {file = "dulwich-0.21.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:739b191f61e1c4ce18ac7d520e7a7cbda00e182c3489552408237200ce8411ad"}, + {file = "dulwich-0.21.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:274c18ec3599a92a9b67abaf110e4f181a4f779ee1aaab9e23a72e89d71b2bd9"}, + {file = "dulwich-0.21.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2590e9b431efa94fc356ae33b38f5e64f1834ec3a94a6ac3a64283b206d07aa3"}, + {file = "dulwich-0.21.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed60d1f610ef6437586f7768254c2a93820ccbd4cfdac7d182cf2d6e615969bb"}, + {file = "dulwich-0.21.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8278835e168dd097089f9e53088c7a69c6ca0841aef580d9603eafe9aea8c358"}, + {file = "dulwich-0.21.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffc27fb063f740712e02b4d2f826aee8bbed737ed799962fef625e2ce56e2d29"}, + {file = "dulwich-0.21.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:61e3451bd3d3844f2dca53f131982553be4d1b1e1ebd9db701843dd76c4dba31"}, + {file = "dulwich-0.21.7.tar.gz", hash = "sha256:a9e9c66833cea580c3ac12927e4b9711985d76afca98da971405d414de60e968"}, +] + +[package.dependencies] +urllib3 = ">=1.25" + +[package.extras] +fastimport = ["fastimport"] +https = ["urllib3 (>=1.24.1)"] +paramiko = ["paramiko"] +pgp = ["gpg"] + +[[package]] +name = "fastjsonschema" +version = "2.20.0" +description = "Fastest Python implementation of JSON schema" +optional = false +python-versions = "*" +files = [ + {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"}, + {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"}, +] + +[package.extras] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] + +[[package]] +name = "fgpyo" +version = "0.7.1" +description = "Python bioinformatics and genomics library" +optional = false +python-versions = "<4.0,>=3.8.0" +files = [ + {file = "fgpyo-0.7.1-py3-none-any.whl", hash = "sha256:8daae2a157cc5a21c789f81bb9bace5c12fb1749c55325c15aec03f9b0b5d908"}, + {file = "fgpyo-0.7.1.tar.gz", hash = "sha256:0e73745e88fda51b5c11ae1bdc457c999746f79ae9336360d6ba11c374de0db0"}, +] + +[package.dependencies] +attrs = ">=19.3.0" +numpy = [ + {version = ">=1.26.4,<2.0.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.25.2,<2.0.0", markers = "python_version >= \"3.9\" and python_version < \"3.12\""}, +] +pysam = ">=0.22.1" +strenum = ">=0.4.15,<0.5.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.12\""} + +[[package]] +name = "filelock" +version = "3.15.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "griffe" +version = "1.1.1" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "griffe-1.1.1-py3-none-any.whl", hash = "sha256:0c469411e8d671a545725f5c0851a746da8bd99d354a79fdc4abd45219252efb"}, + {file = "griffe-1.1.1.tar.gz", hash = "sha256:faeb78764c0b2bd010719d6e015d07709b0f260258b5d4dd6c88343d9702aa30"}, +] + +[package.dependencies] +colorama = ">=0.4" + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "importlib-metadata" +version = "8.4.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "installer" +version = "0.7.0" +description = "A library for installing Python wheels." +optional = false +python-versions = ">=3.7" +files = [ + {file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"}, + {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jeepney" +version = "0.8.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, + {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, +] + +[package.extras] +test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["async_generator", "trio"] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "keyring" +version = "24.3.1" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "keyring-24.3.1-py3-none-any.whl", hash = "sha256:df38a4d7419a6a60fea5cef1e45a948a3e8430dd12ad88b0f423c5c143906218"}, + {file = "keyring-24.3.1.tar.gz", hash = "sha256:c3327b6ffafc0e8befbdb597cacdb4928ffe5c1212f7645f186e6d9957a898db"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +completion = ["shtab (>=1.1.0)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.0" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7"}, + {file = "mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "1.0.1" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_autorefs-1.0.1-py3-none-any.whl", hash = "sha256:aacdfae1ab197780fb7a2dac92ad8a3d8f7ca8049a9cbe56a4218cd52e8da570"}, + {file = "mkdocs_autorefs-1.0.1.tar.gz", hash = "sha256:f684edf847eced40b570b57846b15f0bf57fb93ac2c510450775dcf16accb971"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-gen-files" +version = "0.5.0" +description = "MkDocs plugin to programmatically generate documentation pages during the build" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs_gen_files-0.5.0-py3-none-any.whl", hash = "sha256:7ac060096f3f40bd19039e7277dd3050be9a453c8ac578645844d4d91d7978ea"}, + {file = "mkdocs_gen_files-0.5.0.tar.gz", hash = "sha256:4c7cf256b5d67062a788f6b1d035e157fc1a9498c2399be9af5257d4ff4d19bc"}, +] + +[package.dependencies] +mkdocs = ">=1.0.3" + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "6.2.2" +description = "Mkdocs Markdown includer plugin." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_include_markdown_plugin-6.2.2-py3-none-any.whl", hash = "sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7"}, + {file = "mkdocs_include_markdown_plugin-6.2.2.tar.gz", hash = "sha256:f2bd5026650492a581d2fd44be6c22f90391910d76582b96a34c264f2d17875d"}, +] + +[package.dependencies] +mkdocs = ">=1.4" +wcmatch = "*" + +[package.extras] +cache = ["platformdirs"] + +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.1" +description = "MkDocs plugin to specify the navigation in Markdown instead of YAML" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs_literate_nav-0.6.1-py3-none-any.whl", hash = "sha256:e70bdc4a07050d32da79c0b697bd88e9a104cf3294282e9cb20eec94c6b0f401"}, + {file = "mkdocs_literate_nav-0.6.1.tar.gz", hash = "sha256:78a7ab6d878371728acb0cdc6235c9b0ffc6e83c997b037f4a5c6ff7cef7d759"}, +] + +[package.dependencies] +mkdocs = ">=1.0.3" + +[[package]] +name = "mkdocs-material" +version = "9.5.32" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material-9.5.32-py3-none-any.whl", hash = "sha256:f3704f46b63d31b3cd35c0055a72280bed825786eccaf19c655b44e0cd2c6b3f"}, + {file = "mkdocs_material-9.5.32.tar.gz", hash = "sha256:38ed66e6d6768dde4edde022554553e48b2db0d26d1320b19e2e2b9da0be1120"}, +] + +[package.dependencies] +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.6,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mkdocs-section-index" +version = "0.3.9" +description = "MkDocs plugin to allow clickable sections that lead to an index page" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_section_index-0.3.9-py3-none-any.whl", hash = "sha256:5e5eb288e8d7984d36c11ead5533f376fdf23498f44e903929d72845b24dfe34"}, + {file = "mkdocs_section_index-0.3.9.tar.gz", hash = "sha256:b66128d19108beceb08b226ee1ba0981840d14baf8a652b6c59e650f3f92e4f8"}, +] + +[package.dependencies] +mkdocs = ">=1.2" + +[[package]] +name = "mkdocs-table-reader-plugin" +version = "3.0.1" +description = "MkDocs plugin to directly insert tables from files into markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_table_reader_plugin-3.0.1-py3-none-any.whl", hash = "sha256:9944be79f212d6b45a41aadfbe1946d4939b3e9b0245a847a5acfbf4767e1c97"}, + {file = "mkdocs_table_reader_plugin-3.0.1.tar.gz", hash = "sha256:1580327ba39ee2b4d3763704215666eb909b86116596a8dbffd5715afdfa1e20"}, +] + +[package.dependencies] +mkdocs = ">=1.0" +pandas = ">=1.1" +PyYAML = ">=5.4.1" +tabulate = ">=0.8.7" + +[[package]] +name = "mkdocstrings" +version = "0.25.2" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"}, + {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"}, +] + +[package.dependencies] +click = ">=7.0" +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.4" +mkdocs-autorefs = ">=0.3.1" +platformdirs = ">=2.2.0" +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "1.10.8" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings_python-1.10.8-py3-none-any.whl", hash = "sha256:bb12e76c8b071686617f824029cb1dfe0e9afe89f27fb3ad9a27f95f054dcd89"}, + {file = "mkdocstrings_python-1.10.8.tar.gz", hash = "sha256:5856a59cbebbb8deb133224a540de1ff60bded25e54d8beacc375bb133d39016"}, +] + +[package.dependencies] +griffe = ">=0.49" +mkdocstrings = ">=0.25" + +[[package]] +name = "more-itertools" +version = "10.4.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.4.0.tar.gz", hash = "sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923"}, + {file = "more_itertools-10.4.0-py3-none-any.whl", hash = "sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27"}, +] + +[[package]] +name = "msgpack" +version = "1.0.8" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, + {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, + {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, + {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, + {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, + {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, + {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, + {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, + {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, + {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, + {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, +] + +[[package]] +name = "mypy" +version = "1.11.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, + {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, + {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, + {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, + {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, + {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, + {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, + {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, + {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, + {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, + {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, + {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, + {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, + {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, + {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, + {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, + {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, + {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "ordered-set" +version = "4.1.0" +description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, + {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, +] + +[package.extras] +dev = ["black", "mypy", "pytest"] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "paginate" +version = "0.5.6" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +files = [ + {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, +] + +[[package]] +name = "pandas" +version = "2.2.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce"}, + {file = "pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99"}, + {file = "pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1"}, + {file = "pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32"}, + {file = "pandas-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57"}, + {file = "pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4"}, + {file = "pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pkginfo" +version = "1.11.1" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pkginfo-1.11.1-py3-none-any.whl", hash = "sha256:bfa76a714fdfc18a045fcd684dbfc3816b603d9d075febef17cb6582bea29573"}, + {file = "pkginfo-1.11.1.tar.gz", hash = "sha256:2e0dca1cf4c8e39644eed32408ea9966ee15e0d324c62ba899a393b3c6b467aa"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "poetry" +version = "1.8.3" +description = "Python dependency management and packaging made easy." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "poetry-1.8.3-py3-none-any.whl", hash = "sha256:88191c69b08d06f9db671b793d68f40048e8904c0718404b63dcc2b5aec62d13"}, + {file = "poetry-1.8.3.tar.gz", hash = "sha256:67f4eb68288eab41e841cc71a00d26cf6bdda9533022d0189a145a34d0a35f48"}, +] + +[package.dependencies] +build = ">=1.0.3,<2.0.0" +cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]} +cleo = ">=2.1.0,<3.0.0" +crashtest = ">=0.4.1,<0.5.0" +dulwich = ">=0.21.2,<0.22.0" +fastjsonschema = ">=2.18.0,<3.0.0" +installer = ">=0.7.0,<0.8.0" +keyring = ">=24.0.0,<25.0.0" +packaging = ">=23.1" +pexpect = ">=4.7.0,<5.0.0" +pkginfo = ">=1.10,<2.0" +platformdirs = ">=3.0.0,<5" +poetry-core = "1.9.0" +poetry-plugin-export = ">=1.6.0,<2.0.0" +pyproject-hooks = ">=1.0.0,<2.0.0" +requests = ">=2.26,<3.0" +requests-toolbelt = ">=1.0.0,<2.0.0" +shellingham = ">=1.5,<2.0" +tomlkit = ">=0.11.4,<1.0.0" +trove-classifiers = ">=2022.5.19" +virtualenv = ">=20.23.0,<21.0.0" +xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""} + +[[package]] +name = "poetry-core" +version = "1.9.0" +description = "Poetry PEP 517 Build Backend" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "poetry_core-1.9.0-py3-none-any.whl", hash = "sha256:4e0c9c6ad8cf89956f03b308736d84ea6ddb44089d16f2adc94050108ec1f5a1"}, + {file = "poetry_core-1.9.0.tar.gz", hash = "sha256:fa7a4001eae8aa572ee84f35feb510b321bd652e5cf9293249d62853e1f935a2"}, +] + +[[package]] +name = "poetry-plugin-export" +version = "1.8.0" +description = "Poetry plugin to export the dependencies to various formats" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "poetry_plugin_export-1.8.0-py3-none-any.whl", hash = "sha256:adbe232cfa0cc04991ea3680c865cf748bff27593b9abcb1f35fb50ed7ba2c22"}, + {file = "poetry_plugin_export-1.8.0.tar.gz", hash = "sha256:1fa6168a85d59395d835ca564bc19862a7c76061e60c3e7dfaec70d50937fc61"}, +] + +[package.dependencies] +poetry = ">=1.8.0,<3.0.0" +poetry-core = ">=1.7.0,<3.0.0" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pybedlite" +version = "1.0.0" +description = "Python classes for interfacing with bed intervals" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "pybedlite-1.0.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f75a313c1a30435e18cb4f1c905ff742b90e41dd99013bcebcc0e6923ccd6177"}, + {file = "pybedlite-1.0.0.tar.gz", hash = "sha256:c87a246a673f69b929103176b744b6ad6978b63fae45f1ca6becc315a72d671c"}, +] + +[package.dependencies] +attrs = ">=23.0.0,<24.0.0" + +[package.extras] +docs = ["sphinx (>=7.0.0,<8.0.0)"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymdown-extensions" +version = "10.9" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626"}, + {file = "pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pyproject-hooks" +version = "1.0.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyproject_hooks-1.0.0-py3-none-any.whl", hash = "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8"}, + {file = "pyproject_hooks-1.0.0.tar.gz", hash = "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5"}, +] + +[[package]] +name = "pysam" +version = "0.22.1" +description = "Package for reading, manipulating, and writing genomic data" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pysam-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f18e72013ef2db9a9bb7e8ac421934d054427f6c03e66ce8abc39b09c846ba72"}, + {file = "pysam-0.22.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79cd94eeb96541385fa99e759a8f83d21428e092c8b577d50b4eee5823e757cd"}, + {file = "pysam-0.22.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c71ea45461ee596949061f321a799a97c418164485fdd7e8db89aea2ff979092"}, + {file = "pysam-0.22.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ab3343f221994d163e1ba2691430ce0f6e7da13762473e0d7f9a2d5db3bec235"}, + {file = "pysam-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:503c833e6cf348d87aec9113b1386d5c85c031d64deb914c29f5ad1792d103e6"}, + {file = "pysam-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4447fdc2630519a00b6bf598995f1440e6f398eb0c084a7c141db026990ae07a"}, + {file = "pysam-0.22.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1be663a73cf56ddd1d309b91d314a0c94c9bf352eaa3c6eda30cef12699843f0"}, + {file = "pysam-0.22.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:aeb31472365014fd8b37da4a88af758094b5872a8a16a25635a52cf8ceff5a9f"}, + {file = "pysam-0.22.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e72e129d245574801125029a5892c9e18d2956b13c4203ea585cbd64ccde9351"}, + {file = "pysam-0.22.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8f00bb1fb977fc33c87cf5fe9023eefc2ba3d43d30ab4875a1765827018c949"}, + {file = "pysam-0.22.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c0e051fda433c1c7ff94532f60477bb83b97f4bb183567a0ae23f340e1c200b4"}, + {file = "pysam-0.22.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:860c7c78ddb1539b83d5476502ba14c8b4e8435810dc7a5b715196da3dfb86b6"}, + {file = "pysam-0.22.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:18d886d50d75d8f853057fbbb284f0f0e98afad1f76b1a6f55660ea167d31c17"}, + {file = "pysam-0.22.1-cp36-cp36m-manylinux_2_28_aarch64.whl", hash = "sha256:44420290a619c02da48ca0956548eb82a1665ae97b6ee69c094f9da5a6206431"}, + {file = "pysam-0.22.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:acff506c921af36f364c5a87f3a30b3c105ebeb270d0e821c2ca571eaf60ca20"}, + {file = "pysam-0.22.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:098e0bf12d8b0399613065843310c91ba31a02d014b1f6b4e9d7f2d0d1254ff8"}, + {file = "pysam-0.22.1-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:cd9d457063272df16136640515183ea501bf3371f140a134b2f0a42f425a37d9"}, + {file = "pysam-0.22.1-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:af9fb53157ba2431b7b20a550c0223f4a039304c9f180d8da98ea9d2d3ef3fbf"}, + {file = "pysam-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d3fd6fe5aca79933632f38e5b568ce8d4e67e5c4f3bd39bff55fd9646af814d2"}, + {file = "pysam-0.22.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b6cf1871c99cfc9c01261ec5f628519c2c889f0ff070e7a26aa5adbf9f69af1"}, + {file = "pysam-0.22.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:b1addca11c5cfceefaebdfcf3d83bc42f4b89fb1e8ae645a4bdab971cbcd2bc0"}, + {file = "pysam-0.22.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:17fac22fc89c86241a71084ca097878c61c97f6ff5fd4535d718681a849852a7"}, + {file = "pysam-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4aff9b41856d5dba6585ffd60884b8f3778c5d2688f33989662aabe7f4cd0fe0"}, + {file = "pysam-0.22.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:faa5298291b54f185c7b8f84510224918bddc64bbdcb2e8426ff43e83452310f"}, + {file = "pysam-0.22.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:4dfae1de006d1c6491a59b00052a3f67c53a136165cf4edd7789b5dcb1e6806f"}, + {file = "pysam-0.22.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:78ed746a39c9cebe489b8f0f86cf23c09c942e76c901260fb2794906e4cd0e26"}, + {file = "pysam-0.22.1.tar.gz", hash = "sha256:18a0b97be95bd71e584de698441c46651cdff378db1c9a4fb3f541e560253b22"}, +] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-doctestplus" +version = "1.2.1" +description = "Pytest plugin with advanced doctest features." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-doctestplus-1.2.1.tar.gz", hash = "sha256:2472a8a2c8cea34d2f65f6499543faeb748eecb59c597852fd98839b47307679"}, + {file = "pytest_doctestplus-1.2.1-py3-none-any.whl", hash = "sha256:103705daee8d4468eb59d444c29b0d71eb85b8f6d582295c8bc3d68ee1d88911"}, +] + +[package.dependencies] +packaging = ">=17.0" +pytest = ">=4.6" +setuptools = ">=30.3.0" + +[package.extras] +test = ["numpy", "pytest-remotedata (>=0.3.2)", "sphinx"] + +[[package]] +name = "pytest-mypy" +version = "0.10.3" +description = "Mypy static type checker plugin for Pytest" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-mypy-0.10.3.tar.gz", hash = "sha256:f8458f642323f13a2ca3e2e61509f7767966b527b4d8adccd5032c3e7b4fd3db"}, + {file = "pytest_mypy-0.10.3-py3-none-any.whl", hash = "sha256:7638d0d3906848fc1810cb2f5cc7fceb4cc5c98524aafcac58f28620e3102053"}, +] + +[package.dependencies] +attrs = ">=19.0" +filelock = ">=3.0" +mypy = {version = ">=0.900", markers = "python_version >= \"3.11\""} +pytest = {version = ">=6.2", markers = "python_version >= \"3.10\""} + +[[package]] +name = "pytest-ruff" +version = "0.3.2" +description = "pytest plugin to check ruff requirements." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "pytest_ruff-0.3.2-py3-none-any.whl", hash = "sha256:5096578df2240b2a99f7376747bc433ce25e590c7d570d5c2b47f725497f2c10"}, + {file = "pytest_ruff-0.3.2.tar.gz", hash = "sha256:8d82882969e52b664a7cef4465cba63e45173f38d907dffeca41d9672f59b6c6"}, +] + +[package.dependencies] +pytest = ">=5" +ruff = ">=0.0.242" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "rapidfuzz" +version = "3.9.6" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rapidfuzz-3.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a7ed0d0b9c85720f0ae33ac5efc8dc3f60c1489dad5c29d735fbdf2f66f0431f"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f3deff6ab7017ed21b9aec5874a07ad13e6b2a688af055837f88b743c7bfd947"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3f9fc060160507b2704f7d1491bd58453d69689b580cbc85289335b14fe8ca"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e86c2b3827fa6169ad6e7d4b790ce02a20acefb8b78d92fa4249589bbc7a2c"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f982e1aafb4bd8207a5e073b1efef9e68a984e91330e1bbf364f9ed157ed83f0"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9196a51d0ec5eaaaf5bca54a85b7b1e666fc944c332f68e6427503af9fb8c49e"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb5a514064e02585b1cc09da2fe406a6dc1a7e5f3e92dd4f27c53e5f1465ec81"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e3a4244f65dbc3580b1275480118c3763f9dc29fc3dd96610560cb5e140a4d4a"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6ebb910a702e41641e1e1dada3843bc11ba9107a33c98daef6945a885a40a07"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:624fbe96115fb39addafa288d583b5493bc76dab1d34d0ebba9987d6871afdf9"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1c59f1c1507b7a557cf3c410c76e91f097460da7d97e51c985343798e9df7a3c"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f0256cb27b6a0fb2e1918477d1b56473cd04acfa245376a342e7c15806a396"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-win32.whl", hash = "sha256:24d473d00d23a30a85802b502b417a7f5126019c3beec91a6739fe7b95388b24"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:248f6d2612e661e2b5f9a22bbd5862a1600e720da7bb6ad8a55bb1548cdfa423"}, + {file = "rapidfuzz-3.9.6-cp310-cp310-win_arm64.whl", hash = "sha256:e03fdf0e74f346ed7e798135df5f2a0fb8d6b96582b00ebef202dcf2171e1d1d"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52e4675f642fbc85632f691b67115a243cd4d2a47bdcc4a3d9a79e784518ff97"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1f93a2f13038700bd245b927c46a2017db3dcd4d4ff94687d74b5123689b873b"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b70500bca460264b8141d8040caee22e9cf0418c5388104ff0c73fb69ee28f"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1e037fb89f714a220f68f902fc6300ab7a33349f3ce8ffae668c3b3a40b0b06"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6792f66d59b86ccfad5e247f2912e255c85c575789acdbad8e7f561412ffed8a"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68d9cffe710b67f1969cf996983608cee4490521d96ea91d16bd7ea5dc80ea98"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63daaeeea76da17fa0bbe7fb05cba8ed8064bb1a0edf8360636557f8b6511961"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d214e063bffa13e3b771520b74f674b22d309b5720d4df9918ff3e0c0f037720"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ed443a2062460f44c0346cb9d269b586496b808c2419bbd6057f54061c9b9c75"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5b0c9b227ee0076fb2d58301c505bb837a290ae99ee628beacdb719f0626d749"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82c9722b7dfaa71e8b61f8c89fed0482567fb69178e139fe4151fc71ed7df782"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c18897c95c0a288347e29537b63608a8f63a5c3cb6da258ac46fcf89155e723e"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-win32.whl", hash = "sha256:3e910cf08944da381159587709daaad9e59d8ff7bca1f788d15928f3c3d49c2a"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:59c4a61fab676d37329fc3a671618a461bfeef53a4d0b8b12e3bc24a14e166f8"}, + {file = "rapidfuzz-3.9.6-cp311-cp311-win_arm64.whl", hash = "sha256:8b4afea244102332973377fddbe54ce844d0916e1c67a5123432291717f32ffa"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:70591b28b218fff351b88cdd7f2359a01a71f9f7f5a2e465ce3715ed4b3c422b"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee2d8355c7343c631a03e57540ea06e8717c19ecf5ff64ea07e0498f7f161457"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:708fb675de0f47b9635d1cc6fbbf80d52cb710d0a1abbfae5c84c46e3abbddc3"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d66c247c2d3bb7a9b60567c395a15a929d0ebcc5f4ceedb55bfa202c38c6e0c"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15146301b32e6e3d2b7e8146db1a26747919d8b13690c7f83a4cb5dc111b3a08"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7a03da59b6c7c97e657dd5cd4bcaab5fe4a2affd8193958d6f4d938bee36679"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d2c2fe19e392dbc22695b6c3b2510527e2b774647e79936bbde49db7742d6f1"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91aaee4c94cb45930684f583ffc4e7c01a52b46610971cede33586cf8a04a12e"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3f5702828c10768f9281180a7ff8597da1e5002803e1304e9519dd0f06d79a85"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ccd1763b608fb4629a0b08f00b3c099d6395e67c14e619f6341b2c8429c2f310"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc7a0d4b2cb166bc46d02c8c9f7551cde8e2f3c9789df3827309433ee9771163"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7496f53d40560a58964207b52586783633f371683834a8f719d6d965d223a2eb"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-win32.whl", hash = "sha256:5eb1a9272ca71bc72be5415c2fa8448a6302ea4578e181bb7da9db855b367df0"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-win_amd64.whl", hash = "sha256:0d21fc3c0ca507a1180152a6dbd129ebaef48facde3f943db5c1055b6e6be56a"}, + {file = "rapidfuzz-3.9.6-cp312-cp312-win_arm64.whl", hash = "sha256:43bb27a57c29dc5fa754496ba6a1a508480d21ae99ac0d19597646c16407e9f3"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:83a5ac6547a9d6eedaa212975cb8f2ce2aa07e6e30833b40e54a52b9f9999aa4"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10f06139142ecde67078ebc9a745965446132b998f9feebffd71acdf218acfcc"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74720c3f24597f76c7c3e2c4abdff55f1664f4766ff5b28aeaa689f8ffba5fab"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2bce52b5c150878e558a0418c2b637fb3dbb6eb38e4eb27d24aa839920483e"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1611199f178793ca9a060c99b284e11f6d7d124998191f1cace9a0245334d219"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0308b2ad161daf502908a6e21a57c78ded0258eba9a8f5e2545e2dafca312507"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eda91832201b86e3b70835f91522587725bec329ec68f2f7faf5124091e5ca7"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ece873c093aedd87fc07c2a7e333d52e458dc177016afa1edaf157e82b6914d8"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d97d3c9d209d5c30172baea5966f2129e8a198fec4a1aeb2f92abb6e82a2edb1"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6c4550d0db4931f5ebe9f0678916d1b06f06f5a99ba0b8a48b9457fd8959a7d4"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b6b8dd4af6324fc325d9483bec75ecf9be33e590928c9202d408e4eafff6a0a6"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16122ae448bc89e2bea9d81ce6cb0f751e4e07da39bd1e70b95cae2493857853"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-win32.whl", hash = "sha256:71cc168c305a4445109cd0d4925406f6e66bcb48fde99a1835387c58af4ecfe9"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-win_amd64.whl", hash = "sha256:59ee78f2ecd53fef8454909cda7400fe2cfcd820f62b8a5d4dfe930102268054"}, + {file = "rapidfuzz-3.9.6-cp313-cp313-win_arm64.whl", hash = "sha256:58b4ce83f223605c358ae37e7a2d19a41b96aa65b1fede99cc664c9053af89ac"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9f469dbc9c4aeaac7dd005992af74b7dff94aa56a3ea063ce64e4b3e6736dd2f"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a9ed7ad9adb68d0fe63a156fe752bbf5f1403ed66961551e749641af2874da92"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39ffe48ffbeedf78d120ddfb9d583f2ca906712159a4e9c3c743c9f33e7b1775"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8502ccdea9084d54b6f737d96a3b60a84e3afed9d016686dc979b49cdac71613"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a4bec4956e06b170ca896ba055d08d4c457dac745548172443982956a80e118"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c0488b1c273be39e109ff885ccac0448b2fa74dea4c4dc676bcf756c15f16d6"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0542c036cb6acf24edd2c9e0411a67d7ba71e29e4d3001a082466b86fc34ff30"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0a96b52c9f26857bf009e270dcd829381e7a634f7ddd585fa29b87d4c82146d9"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:6edd3cd7c4aa8c68c716d349f531bd5011f2ca49ddade216bb4429460151559f"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:50b2fb55d7ed58c66d49c9f954acd8fc4a3f0e9fd0ff708299bd8abb68238d0e"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:32848dfe54391636b84cda1823fd23e5a6b1dbb8be0e9a1d80e4ee9903820994"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:29146cb7a1bf69c87e928b31bffa54f066cb65639d073b36e1425f98cccdebc6"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-win32.whl", hash = "sha256:aed13e5edacb0ecadcc304cc66e93e7e77ff24f059c9792ee602c0381808e10c"}, + {file = "rapidfuzz-3.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:af440e36b828922256d0b4d79443bf2cbe5515fc4b0e9e96017ec789b36bb9fc"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:efa674b407424553024522159296690d99d6e6b1192cafe99ca84592faff16b4"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0b40ff76ee19b03ebf10a0a87938f86814996a822786c41c3312d251b7927849"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16a6c7997cb5927ced6f617122eb116ba514ec6b6f60f4803e7925ef55158891"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3f42504bdc8d770987fc3d99964766d42b2a03e4d5b0f891decdd256236bae0"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9462aa2be9f60b540c19a083471fdf28e7cf6434f068b631525b5e6251b35e"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1629698e68f47609a73bf9e73a6da3a4cac20bc710529215cbdf111ab603665b"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68bc7621843d8e9a7fd1b1a32729465bf94b47b6fb307d906da168413331f8d6"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c6254c50f15bc2fcc33cb93a95a81b702d9e6590f432a7f7822b8c7aba9ae288"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7e535a114fa575bc143e175e4ca386a467ec8c42909eff500f5f0f13dc84e3e0"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d50acc0e9d67e4ba7a004a14c42d1b1e8b6ca1c515692746f4f8e7948c673167"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fa742ec60bec53c5a211632cf1d31b9eb5a3c80f1371a46a23ac25a1fa2ab209"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c256fa95d29cbe5aa717db790b231a9a5b49e5983d50dc9df29d364a1db5e35b"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-win32.whl", hash = "sha256:89acbf728b764421036c173a10ada436ecca22999851cdc01d0aa904c70d362d"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:c608fcba8b14d86c04cb56b203fed31a96e8a1ebb4ce99e7b70313c5bf8cf497"}, + {file = "rapidfuzz-3.9.6-cp39-cp39-win_arm64.whl", hash = "sha256:d41c00ded0e22e9dba88ff23ebe0dc9d2a5f21ba2f88e185ea7374461e61daa9"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a65c2f63218ea2dedd56fc56361035e189ca123bd9c9ce63a9bef6f99540d681"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:680dc78a5f889d3b89f74824b89fe357f49f88ad10d2c121e9c3ad37bac1e4eb"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8ca862927a0b05bd825e46ddf82d0724ea44b07d898ef639386530bf9b40f15"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2116fa1fbff21fa52cd46f3cfcb1e193ba1d65d81f8b6e123193451cd3d6c15e"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dcb7d9afd740370a897c15da61d3d57a8d54738d7c764a99cedb5f746d6a003"}, + {file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1a5bd6401bb489e14cbb5981c378d53ede850b7cc84b2464cad606149cc4e17d"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:29fda70b9d03e29df6fc45cc27cbcc235534b1b0b2900e0a3ae0b43022aaeef5"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:88144f5f52ae977df9352029488326afadd7a7f42c6779d486d1f82d43b2b1f2"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:715aeaabafba2709b9dd91acb2a44bad59d60b4616ef90c08f4d4402a3bbca60"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af26ebd3714224fbf9bebbc27bdbac14f334c15f5d7043699cd694635050d6ca"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101bd2df438861a005ed47c032631b7857dfcdb17b82beeeb410307983aac61d"}, + {file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2185e8e29809b97ad22a7f99281d1669a89bdf5fa1ef4ef1feca36924e675367"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9e53c72d08f0e9c6e4a369e52df5971f311305b4487690c62e8dd0846770260c"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a0cb157162f0cdd62e538c7bd298ff669847fc43a96422811d5ab933f4c16c3a"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bb5ff2bd48132ed5e7fbb8f619885facb2e023759f2519a448b2c18afe07e5d"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc37f601865e8407e3a8037ffbc3afe0b0f837b2146f7632bd29d087385babe"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a657eee4b94668faf1fa2703bdd803654303f7e468eb9ba10a664d867ed9e779"}, + {file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:51be6ab5b1d5bb32abd39718f2a5e3835502e026a8272d139ead295c224a6f5e"}, + {file = "rapidfuzz-3.9.6.tar.gz", hash = "sha256:5cf2a7d621e4515fee84722e93563bf77ff2cbe832a77a48b81f88f9e23b9e8d"}, +] + +[package.extras] +full = ["numpy"] + +[[package]] +name = "regex" +version = "2024.7.24" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +files = [ + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"}, + {file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"}, + {file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"}, + {file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"}, + {file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"}, + {file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"}, + {file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"}, + {file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"}, + {file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"}, + {file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"}, + {file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"}, + {file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "ruff" +version = "0.3.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"}, + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"}, + {file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"}, + {file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"}, + {file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"}, + {file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"}, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, + {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "setuptools" +version = "73.0.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-73.0.1-py3-none-any.whl", hash = "sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e"}, + {file = "setuptools-73.0.1.tar.gz", hash = "sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193"}, +] + +[package.extras] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "strenum" +version = "0.4.15" +description = "An Enum that inherits from str." +optional = false +python-versions = "*" +files = [ + {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"}, + {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"}, +] + +[package.extras] +docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"] +release = ["twine"] +test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + +[[package]] +name = "trove-classifiers" +version = "2024.7.2" +description = "Canonical source for classifiers on PyPI (pypi.org)." +optional = false +python-versions = "*" +files = [ + {file = "trove_classifiers-2024.7.2-py3-none-any.whl", hash = "sha256:ccc57a33717644df4daca018e7ec3ef57a835c48e96a1e71fc07eb7edac67af6"}, + {file = "trove_classifiers-2024.7.2.tar.gz", hash = "sha256:8328f2ac2ce3fd773cbb37c765a0ed7a83f89dc564c7d452f039b69249d0ac35"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.26.3" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchdog" +version = "4.0.2" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c50f148b31b03fbadd6d0b5980e38b558046b127dc483e5e4505fcef250f9503"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c7d4bf585ad501c5f6c980e7be9c4f15604c7cc150e942d82083b31a7548930"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:914285126ad0b6eb2258bbbcb7b288d9dfd655ae88fa28945be05a7b475a800b"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:984306dc4720da5498b16fc037b36ac443816125a3705dfde4fd90652d8028ef"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:aa160781cafff2719b663c8a506156e9289d111d80f3387cf3af49cedee1f040"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6ee8dedd255087bc7fe82adf046f0b75479b989185fb0bdf9a98b612170eac7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b4359067d30d5b864e09c8597b112fe0a0a59321a0f331498b013fb097406b4"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:770eef5372f146997638d737c9a3c597a3b41037cfbc5c41538fc27c09c3a3f9"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeea812f38536a0aa859972d50c76e37f4456474b02bd93674d1947cf1e39578"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2c45f6e1e57ebb4687690c05bc3a2c1fb6ab260550c4290b8abb1335e0fd08b"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10b6683df70d340ac3279eff0b2766813f00f35a1d37515d2c99959ada8f05fa"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7c739888c20f99824f7aa9d31ac8a97353e22d0c0e54703a547a218f6637eb3"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c100d09ac72a8a08ddbf0629ddfa0b8ee41740f9051429baa8e31bb903ad7508"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f5315a8c8dd6dd9425b974515081fc0aadca1d1d61e078d2246509fd756141ee"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2d468028a77b42cc685ed694a7a550a8d1771bb05193ba7b24006b8241a571a1"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f15edcae3830ff20e55d1f4e743e92970c847bcddc8b7509bcd172aa04de506e"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8"}, + {file = "watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19"}, + {file = "watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b"}, + {file = "watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c"}, + {file = "watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcmatch" +version = "9.0" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wcmatch-9.0-py3-none-any.whl", hash = "sha256:af25922e2b6dbd1550fa37a4c8de7dd558d6c1bb330c641de9b907b9776cb3c4"}, + {file = "wcmatch-9.0.tar.gz", hash = "sha256:567d66b11ad74384954c8af86f607857c3bdf93682349ad32066231abd556c92"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + +[[package]] +name = "xattr" +version = "1.1.0" +description = "Python wrapper for extended filesystem attributes" +optional = false +python-versions = ">=3.8" +files = [ + {file = "xattr-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef2fa0f85458736178fd3dcfeb09c3cf423f0843313e25391db2cfd1acec8888"}, + {file = "xattr-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ccab735d0632fe71f7d72e72adf886f45c18b7787430467ce0070207882cfe25"}, + {file = "xattr-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9013f290387f1ac90bccbb1926555ca9aef75651271098d99217284d9e010f7c"}, + {file = "xattr-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcd5dfbcee73c7be057676ecb900cabb46c691aff4397bf48c579ffb30bb963"}, + {file = "xattr-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6480589c1dac7785d1f851347a32c4a97305937bf7b488b857fe8b28a25de9e9"}, + {file = "xattr-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08f61cbed52dc6f7c181455826a9ff1e375ad86f67dd9d5eb7663574abb32451"}, + {file = "xattr-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:918e1f83f2e8a072da2671eac710871ee5af337e9bf8554b5ce7f20cdb113186"}, + {file = "xattr-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0f06e0c1e4d06b4e0e49aaa1184b6f0e81c3758c2e8365597918054890763b53"}, + {file = "xattr-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46a641ac038a9f53d2f696716147ca4dbd6a01998dc9cd4bc628801bc0df7f4d"}, + {file = "xattr-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e4ca0956fd11679bb2e0c0d6b9cdc0f25470cc00d8da173bb7656cc9a9cf104"}, + {file = "xattr-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6881b120f9a4b36ccd8a28d933bc0f6e1de67218b6ce6e66874e0280fc006844"}, + {file = "xattr-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dab29d9288aa28e68a6f355ddfc3f0a7342b40c9012798829f3e7bd765e85c2c"}, + {file = "xattr-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0c80bbf55339c93770fc294b4b6586b5bf8e85ec00a4c2d585c33dbd84b5006"}, + {file = "xattr-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1418705f253b6b6a7224b69773842cac83fcbcd12870354b6e11dd1cd54630f"}, + {file = "xattr-1.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:687e7d18611ef8d84a6ecd8f4d1ab6757500c1302f4c2046ce0aa3585e13da3f"}, + {file = "xattr-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6ceb9efe0657a982ccb8b8a2efe96b690891779584c901d2f920784e5d20ae3"}, + {file = "xattr-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b489b7916f239100956ea0b39c504f3c3a00258ba65677e4c8ba1bd0b5513446"}, + {file = "xattr-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0a9c431b0e66516a078125e9a273251d4b8e5ba84fe644b619f2725050d688a0"}, + {file = "xattr-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1a5921ea3313cc1c57f2f53b63ea8ca9a91e48f4cc7ebec057d2447ec82c7efe"}, + {file = "xattr-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f6ad2a7bd5e6cf71d4a862413234a067cf158ca0ae94a40d4b87b98b62808498"}, + {file = "xattr-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0683dae7609f7280b0c89774d00b5957e6ffcb181c6019c46632b389706b77e6"}, + {file = "xattr-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54cb15cd94e5ef8a0ef02309f1bf973ba0e13c11e87686e983f371948cfee6af"}, + {file = "xattr-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff6223a854229055e803c2ad0c0ea9a6da50c6be30d92c198cf5f9f28819a921"}, + {file = "xattr-1.1.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d44e8f955218638c9ab222eed21e9bd9ab430d296caf2176fb37abe69a714e5c"}, + {file = "xattr-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:caab2c2986c30f92301f12e9c50415d324412e8e6a739a52a603c3e6a54b3610"}, + {file = "xattr-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d6eb7d5f281014cd44e2d847a9107491af1bf3087f5afeded75ed3e37ec87239"}, + {file = "xattr-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:47a3bdfe034b4fdb70e5941d97037405e3904accc28e10dbef6d1c9061fb6fd7"}, + {file = "xattr-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:00d2b415cf9d6a24112d019e721aa2a85652f7bbc9f3b9574b2d1cd8668eb491"}, + {file = "xattr-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:78b377832dd0ee408f9f121a354082c6346960f7b6b1480483ed0618b1912120"}, + {file = "xattr-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6461a43b585e5f2e049b39bcbfcb6391bfef3c5118231f1b15d10bdb89ef17fe"}, + {file = "xattr-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24d97f0d28f63695e3344ffdabca9fcc30c33e5c8ccc198c7524361a98d526f2"}, + {file = "xattr-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ad47d89968c9097900607457a0c89160b4771601d813e769f68263755516065"}, + {file = "xattr-1.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc53cab265f6e8449bd683d5ee3bc5a191e6dd940736f3de1a188e6da66b0653"}, + {file = "xattr-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cd11e917f5b89f2a0ad639d9875943806c6c9309a3dd02da5a3e8ef92db7bed9"}, + {file = "xattr-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9c5a78c7558989492c4cb7242e490ffb03482437bf782967dfff114e44242343"}, + {file = "xattr-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cebcf8a303a44fbc439b68321408af7267507c0d8643229dbb107f6c132d389c"}, + {file = "xattr-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b0d73150f2f9655b4da01c2369eb33a294b7f9d56eccb089819eafdbeb99f896"}, + {file = "xattr-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:793c01deaadac50926c0e1481702133260c7cb5e62116762f6fe1543d07b826f"}, + {file = "xattr-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e189e440bcd04ccaad0474720abee6ee64890823ec0db361fb0a4fb5e843a1bf"}, + {file = "xattr-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afacebbc1fa519f41728f8746a92da891c7755e6745164bd0d5739face318e86"}, + {file = "xattr-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b1664edf003153ac8d1911e83a0fc60db1b1b374ee8ac943f215f93754a1102"}, + {file = "xattr-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dda2684228798e937a7c29b0e1c7ef3d70e2b85390a69b42a1c61b2039ba81de"}, + {file = "xattr-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b735ac2625a4fc2c9343b19f806793db6494336338537d2911c8ee4c390dda46"}, + {file = "xattr-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fa6a7af7a4ada43f15ccc58b6f9adcdbff4c36ba040013d2681e589e07ae280a"}, + {file = "xattr-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1059b2f726e2702c8bbf9bbf369acfc042202a4cc576c2dec6791234ad5e948"}, + {file = "xattr-1.1.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e2255f36ebf2cb2dbf772a7437ad870836b7396e60517211834cf66ce678b595"}, + {file = "xattr-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba4f80b9855cc98513ddf22b7ad8551bc448c70d3147799ea4f6c0b758fb466"}, + {file = "xattr-1.1.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb70c16e7c3ae6ba0ab6c6835c8448c61d8caf43ea63b813af1f4dbe83dd156"}, + {file = "xattr-1.1.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83652910ef6a368b77b00825ad67815e5c92bfab551a848ca66e9981d14a7519"}, + {file = "xattr-1.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7a92aff66c43fa3e44cbeab7cbeee66266c91178a0f595e044bf3ce51485743b"}, + {file = "xattr-1.1.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d4f71b673339aeaae1f6ea9ef8ea6c9643c8cd0df5003b9a0eaa75403e2e06c"}, + {file = "xattr-1.1.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a20de1c47b5cd7b47da61799a3b34e11e5815d716299351f82a88627a43f9a96"}, + {file = "xattr-1.1.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23705c7079b05761ff2fa778ad17396e7599c8759401abc05b312dfb3bc99f69"}, + {file = "xattr-1.1.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:27272afeba8422f2a9d27e1080a9a7b807394e88cce73db9ed8d2dde3afcfb87"}, + {file = "xattr-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd43978966de3baf4aea367c99ffa102b289d6c2ea5f3d9ce34a203dc2f2ab73"}, + {file = "xattr-1.1.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ded771eaf27bb4eb3c64c0d09866460ee8801d81dc21097269cf495b3cac8657"}, + {file = "xattr-1.1.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ca300c0acca4f0cddd2332bb860ef58e1465d376364f0e72a1823fdd58e90d"}, + {file = "xattr-1.1.0.tar.gz", hash = "sha256:fecbf3b05043ed3487a28190dec3e4c4d879b2fcec0e30bafd8ec5d4b6043630"}, +] + +[package.dependencies] +cffi = ">=1.16.0" + +[package.extras] +test = ["pytest"] + +[[package]] +name = "zipp" +version = "3.20.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, + {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "4a12ae0d8fe857309d1597a675b9db97dfa5805928bd1bcc576113a79ad5553a" diff --git a/prymer.yml b/prymer.yml new file mode 100644 index 0000000..44d0515 --- /dev/null +++ b/prymer.yml @@ -0,0 +1,14 @@ +name: prymer +channels: + - bioconda + - conda-forge +dependencies: + # Python + - python>=3.11.* + - ruff>=0.2.1 + - mypy>=1.8 + - pytest>=8.0.0 + - pytest-workflow=2.1.0 + - poetry=1.7.1 + - primer3=2.6.1 + - pyproject_hooks=1.0.0 diff --git a/prymer/__init__.py b/prymer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prymer/api/__init__.py b/prymer/api/__init__.py new file mode 100644 index 0000000..bccf36c --- /dev/null +++ b/prymer/api/__init__.py @@ -0,0 +1,43 @@ +from prymer.api.clustering import ClusteredIntervals +from prymer.api.clustering import cluster_intervals +from prymer.api.minoptmax import MinOptMax +from prymer.api.picking import FilteringParams +from prymer.api.picking import build_and_pick_primer_pairs +from prymer.api.picking import build_primer_pairs +from prymer.api.picking import pick_top_primer_pairs +from prymer.api.primer import Primer +from prymer.api.primer_like import PrimerLike +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import BedLikeCoords +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.api.variant_lookup import FileBasedVariantLookup +from prymer.api.variant_lookup import SimpleVariant +from prymer.api.variant_lookup import VariantLookup +from prymer.api.variant_lookup import VariantOverlapDetector +from prymer.api.variant_lookup import VariantType +from prymer.api.variant_lookup import cached +from prymer.api.variant_lookup import disk_based + +__all__ = [ + "ClusteredIntervals", + "cluster_intervals", + "MinOptMax", + "FilteringParams", + "build_primer_pairs", + "pick_top_primer_pairs", + "build_and_pick_primer_pairs", + "PrimerLike", + "Primer", + "PrimerPair", + "Span", + "Strand", + "BedLikeCoords", + "VariantType", + "SimpleVariant", + "VariantLookup", + "FileBasedVariantLookup", + "VariantOverlapDetector", + "cached", + "disk_based", +] diff --git a/prymer/api/clustering.py b/prymer/api/clustering.py new file mode 100644 index 0000000..f4a6b6a --- /dev/null +++ b/prymer/api/clustering.py @@ -0,0 +1,227 @@ +""" +# Methods for merging intervals into "clusters" + +This module contains utility functions related to clustering intervals into larger intervals. + +There is currently one public method available: + +- [`cluster_intervals()`][prymer.api.clustering.cluster_intervals] -- clusters a list of + intervals into a set of intervals that cover the input intervals, and are not larger than an input + `max_size`. each original interval will be wholly covered by at least one cluster. The `name` + attribute of each cluster will be set, and the original intervals are returned with a `name` + attribute that matches that of a cluster that wholly contains it. + +## Examples + +```python +>>> from prymer.api.clustering import cluster_intervals +>>> from pybedlite.overlap_detector import Interval +>>> intervals = [Interval("chr1", 1, 2), Interval("chr1", 3, 4)] +>>> cluster_intervals(intervals, 10) +ClusteredIntervals(clusters=[Interval(refname='chr1', start=1, end=4, negative=False, name='chr1:1-4')], intervals=[Interval(refname='chr1', start=1, end=2, negative=False, name='chr1:1-4'), Interval(refname='chr1', start=3, end=4, negative=False, name='chr1:1-4')]) +>>> cluster_intervals(intervals, 2) +ClusteredIntervals(clusters=[Interval(refname='chr1', start=1, end=2, negative=False, name='chr1:1-2'), Interval(refname='chr1', start=3, end=4, negative=False, name='chr1:3-4')], intervals=[Interval(refname='chr1', start=1, end=2, negative=False, name='chr1:1-2'), Interval(refname='chr1', start=3, end=4, negative=False, name='chr1:3-4')]) + +``` +""" # noqa: E501 + +import itertools +from dataclasses import dataclass +from typing import Dict + +from attr import evolve +from pybedlite.overlap_detector import Interval +from pybedlite.overlap_detector import OverlapDetector + +from prymer.api.coordmath import get_locus_string +from prymer.api.coordmath import require_same_refname + + +@dataclass(frozen=True, init=True, slots=True) +class ClusteredIntervals: + """ + The list of clusters (intervals) and the original source intervals. The source intervals must + have the name corresponding to the cluster to which the source interval belongs. Each cluster + must envelop ("wholly contain") the intervals associated with the cluster. + + Attributes: + clusters: the clusters that wholly contain one or more source intervals. + intervals: the source intervals, with name corresponding to the name of the associated + cluster. + """ + + clusters: list[Interval] + intervals: list[Interval] + + def __post_init__(self) -> None: + cluster_names: set[str] = {c.name for c in self.clusters} + interval_names: set[str] = {i.name for i in self.intervals} + # Check that the names of clusters are unique + if len(self.clusters) != len(cluster_names): + raise ValueError("Cluster names are not unique") + # Check that every interval has the name of one cluster + for interval in self.intervals: + if interval.name not in cluster_names: + raise ValueError(f"Interval does not belong to a cluster: {interval}") + # Check that every cluster has at least one interval associated with the cluster + if len(interval_names) != len(cluster_names): + raise ValueError("Cluster and interval names differ.") + + +def _sort_key(interval: Interval) -> tuple[int, int]: + """Returns the sort key to use when sorting intervals from the same reference + + Note that this method assumes that the intervals have the same reference name, but this is NOT + checked. Using it for intervals from different reference will result in undefined behavior. + + Args: + interval: The interval to get the sort key for. + Returns: + A tuple (start, end) of the interval. + """ + return interval.start, interval.end + + +def _cluster_in_contig(intervals: list[Interval], max_size: int) -> ClusteredIntervals: + """ + Cluster a list of intervals (all from one reference) into intervals that overlap the given + intervals and are not larger than `max_size`. + + Implements a greedy algorithm for hierarchical clustering, merging subsequent intervals + (from a sorted list) as long as the maximal size is respected. + Each "cluster" is replaced by an interval that spans it, and the algorithm terminates + when it can no longer merge anything without creating a cluster that is larger than `max_size`. + + Args: + intervals: The intervals to cluster. + max_size: The maximum size (in bp) of the resulting clusters. + + Returns: + A named tuple (`clusters`, `intervals`), where `clusters` contains one (named) interval per + cluster, defining the region spanned by the cluster, and `intervals` contains the original + set of intervals, each adorned with a `name` that agrees with that of a cluster in + `clusters` that wholly contains it. + + Raises: + ValueError: If any of the input intervals are larger than `max_size`. + ValueError: If the input intervals contain between them more than one refname. + """ + if len(intervals) == 0: + return ClusteredIntervals(clusters=[], intervals=[]) + + require_same_refname(*intervals) + max_found = max(i.length() for i in intervals) + if max_found > max_size: + raise ValueError( + f"Intervals provided must be less than {max_size}, " f"but found size {max_found}" + ) + + sorted_intervals = sorted(intervals, key=_sort_key) + # at each step, check if can "legally" merge the current cluster with the next interval, + # and if so, update the current cluster, if not, add the current cluster to the list and + # start a new cluster with the current interval. + clusters: list[Interval] = [] + + curr_cluster = sorted_intervals[0] + for interval in sorted_intervals[1:]: + new_cluster = _convex_hull(curr_cluster, interval) + + if new_cluster.length() <= max_size: + curr_cluster = new_cluster + else: + clusters.append(evolve(curr_cluster, name=get_locus_string(curr_cluster))) + curr_cluster = interval + + clusters.append(evolve(curr_cluster, name=get_locus_string(curr_cluster))) + + # for each original interval find one cluster that it is contained in. + detector: OverlapDetector[Interval] = OverlapDetector() + detector.add_all(clusters) + + enclosing_clusters = [detector.get_enclosing_intervals(si).pop() for si in sorted_intervals] + ann_intervals: list[Interval] = [ + evolve(interval, name=enc_cluster.name) + for interval, enc_cluster in zip(sorted_intervals, enclosing_clusters, strict=True) + ] + + return ClusteredIntervals(clusters=clusters, intervals=ann_intervals) + + +def cluster_intervals( + intervals: list[Interval], + max_size: int, +) -> ClusteredIntervals: + """ + Cluster a list of intervals into intervals that overlap the given + intervals and are not larger than `max_size`. + + Implements a greedy algorithm for hierarchical clustering, merging subsequent intervals + (from a sorted list) as long as the maximal size is respected. + Each "cluster" is replaced by an interval that spans it, and the algorithm terminates + when it can no longer merge anything without creating a cluster that is larger than `max_size`. + + Args: + intervals: The intervals to cluster. + max_size: The maximum size (in bp) of the resulting clusters. + + Returns: + A named tuple (`clusters`, `intervals`), where `clusters` contains one (named) interval per + cluster, defining the region spanned by the cluster, and `intervals` contains the original + set of intervals, each adorned with a `name` that agrees with that of a cluster in + `clusters` that wholly contains it. + + Raises: + ValueError: If any of the input intervals are larger than `max_size`. + """ + + intervals_by_refname: Dict[str, list[Interval]] = { + key: list(group) for key, group in itertools.groupby(intervals, key=lambda x: x.refname) + } + # import ipdb; ipdb.set_trace() + # iterate over the refnames and call _cluster_in_contig() per refname. + + # need to store this in a list since we iterate over it twice. + per_refname_clusters_and_intervals = [ + x + for x in ( + _cluster_in_contig(intervals_in_refname, max_size) + for intervals_in_refname in intervals_by_refname.values() + ) + ] + + # flatten the clusters and intervals + clusters = [ + cluster + for refname_result in per_refname_clusters_and_intervals + for cluster in refname_result.clusters + ] + + intervals_out = [ + interval + for refname_results in per_refname_clusters_and_intervals + for interval in refname_results.intervals + ] + + return ClusteredIntervals(clusters=clusters, intervals=intervals_out) + + +def _convex_hull(interval_a: Interval, interval_b: Interval) -> Interval: + """ + Get the convex hull of two intervals. + + Args: + interval_a: The first interval. + interval_b: The second interval. + Returns: + The convex hull of the two intervals (a new interval). + + Raises: + ValueError: If the intervals do not have the same refname. + """ + require_same_refname(interval_a, interval_b) + return evolve( + interval_a, + start=min(interval_a.start, interval_b.start), + end=max(interval_a.end, interval_b.end), + name="", + ) diff --git a/prymer/api/coordmath.py b/prymer/api/coordmath.py new file mode 100644 index 0000000..2a85179 --- /dev/null +++ b/prymer/api/coordmath.py @@ -0,0 +1,65 @@ +""" +# Methods for coordinate-based math and interval manipulation. + +Contains the following public methods: + +- [`require_same_refname()`][prymer.api.coordmath.require_same_refname] -- ensures that all + provided intervals have the same reference name. +- [`get_locus_string()`][prymer.api.coordmath.get_locus_string] -- returns a formatted + string for an interval (`:-`). +- [`get_closed_end()`][prymer.api.coordmath.get_closed_end] -- gets the closed end of an + interval given its start and length. + +""" + +from pybedlite.overlap_detector import Interval + + +def require_same_refname(*intervals: Interval) -> None: + """ + Require that the input intervals all have the same refname. + + Args: + intervals: one or more intervals + + Raises: + ValueError: if the intervals do not all have the same refname. + """ + + refnames = set(i.refname for i in intervals) + if len(refnames) != 1: + raise ValueError(f"`intervals` must have exactly one refname\n Found {sorted(refnames)}") + + +def get_locus_string(record: Interval) -> str: + """ + Get the locus-string for an interval. + + The output string will have the format `:-` + No conversion on coordinates is performed, so the output is 0-based, open-ended. + + Args: + record: The interval to get the locus-string for. + + Returns: + A locus-string for the interval. + """ + return f"{record.refname}:{record.start}-{record.end}" + + +def get_closed_end(start: int, length: int) -> int: + """ + Get the closed end of an interval given its start and length. + + Args: + start: The start position of the interval. + length: The length of the interval. + + Returns: + The closed end of the interval. + + Example: + >>> get_closed_end(start=10, length=5) + 14 + """ + return start + length - 1 diff --git a/prymer/api/melting.py b/prymer/api/melting.py new file mode 100644 index 0000000..caa6052 --- /dev/null +++ b/prymer/api/melting.py @@ -0,0 +1,54 @@ +""" +# Methods for calculating melting temperatures. + + +There is currently one public method available: + +- [`calculate_long_seq_tm()`][prymer.api.melting.calculate_long_seq_tm] -- Calculates the + melting temperature of an amplicon. + +## Examples + + +```python +>>> calculate_long_seq_tm(seq="GT" * 10, salt_molar_concentration=10.0, percent_formamide=10.0) +78.64999999999999 + +``` +""" + +import math + +from fgpyo.sequence import gc_content + + +def calculate_long_seq_tm( + seq: str, salt_molar_concentration: float = 1.65, percent_formamide: float = 15.0 +) -> float: + """Calculate the melting temperature of an amplicon. + + Uses the formula: + + `Tm = 81.5 + 0.41(%GC) - 675/N + 16.6 x log[Na+] - 0.62(%F)` + + from: + + (Marmur & Doty 1962, J Mol Biol 5: 109-118; Schildkraut & Lifson 1965, Biopolymers 3: 195-208) + + with the added chemical (formamide) correction. + + Args: + seq: the amplicon sequence + salt_molar_concentration: the molar concentration of salt + percent_formamide: the percent formamide + + Returns: + the predicted melting temperature + """ + return ( + 81.5 + + (16.6 * math.log10(salt_molar_concentration)) + + (41.0 * gc_content(seq)) + - (675.0 / len(seq)) + - (0.62 * percent_formamide) + ) diff --git a/prymer/api/minoptmax.py b/prymer/api/minoptmax.py new file mode 100644 index 0000000..ec0b01b --- /dev/null +++ b/prymer/api/minoptmax.py @@ -0,0 +1,80 @@ +""" +# MinOptMax Classes and Methods + +This module contains a class and class methods to hold a range of user-specific thresholds. + +Each of the three class attributes represents a minimal, optimal, and maximal value. +The three values can be either int or float values but all must be of the same type within one +MinOptMax object (for example, `min` cannot be a float while `max` is an int). + +Primer3 will use these values downstream to set an allowable range of specific parameters that +inform primer design. For example, Primer3 can constrain primer melting temperature +to be within a range of 55.0 - 65.0 Celsius based on an input +`MinOptMax(min=55.0, opt=60.0, max=65.0)`. + +## Examples of interacting with the `MinOptMax` class + +```python +>>> thresholds = MinOptMax(min=1.0, opt=2.0, max=4.0) +>>> print(thresholds) +(min:1.0, opt:2.0, max:4.0) +>>> list(thresholds) +[1.0, 2.0, 4.0] + +``` +""" + +from dataclasses import dataclass +from typing import Generic +from typing import Iterator +from typing import TypeVar + +Numeric = TypeVar("Numeric", int, float) + + +@dataclass(slots=True, frozen=True, init=True) +class MinOptMax(Generic[Numeric]): + """Stores a minimum, an optimal, and a maximum value (either all ints or all floats). + + `min` must be less than `max`. `opt` should be greater than the `min` + value and less than the `max` value. + + Attributes: + min: the minimum value + opt: the optimal value + max: the maximum value + + + Raises: + ValueError: if min > max + ValueError: if `min` is not less than `opt` and `opt` is not less than `max` + """ + + min: Numeric + opt: Numeric + max: Numeric + + def __post_init__(self) -> None: + dtype = type(self.min) + if not isinstance(self.max, dtype) or not isinstance(self.opt, dtype): + raise TypeError( + "Min, opt, and max must be the same type; " + f"received min: {dtype}, opt: {type(self.opt)}, max: {type(self.max)}" + ) + if self.min > self.max: + raise ValueError( + f"Min must be no greater than max; received min: {self.min}, max: {self.max}" + ) + if not (self.min <= self.opt <= self.max): + raise ValueError( + "Arguments must satisfy min <= opt <= max: " + f"received min: {self.min}, opt: {self.opt}, max: {self.max}" + ) + + def __iter__(self) -> Iterator[float]: + """Returns an iterator of min, opt, and max""" + return iter([self.min, self.opt, self.max]) + + def __str__(self) -> str: + """Returns a string representation of min, opt, and max""" + return f"(min:{self.min}, opt:{self.opt}, max:{self.max})" diff --git a/prymer/api/picking.py b/prymer/api/picking.py new file mode 100644 index 0000000..93b14ec --- /dev/null +++ b/prymer/api/picking.py @@ -0,0 +1,457 @@ +""" +# Methods and class for building and scoring primer pairs from a list of left and right primers. + +Typically, the first step is to build primer pairs from a list of left and right primers for a +given target with the [`build_primer_pairs()`][prymer.api.picking.build_primer_pairs] +method. The returned primer pairs are automatically scored (using the +[`score()`][prymer.api.picking.score] method), and returned sorted by the penalty +(increasing). + +Next, the list of primer pairs for a given target are filtered based on various criteria using the +[`pick_top_primer_pairs()`][prymer.api.picking.pick_top_primer_pairs] method. Criteria +included but not limited to filtering for desired size and melting temperature ranges (see +[`is_acceptable_primer_pair()`][prymer.api.picking.is_acceptable_primer_pair]), not too +many off-targets, no self-dimers, and so on. A given maximum number of passing primer pairs is +returned. + +These two steps can be performed jointly using the +[`build_and_pick_primer_pairs()`][prymer.api.picking.build_and_pick_primer_pairs] method. + +## Module contents + +Contains the following public classes and methods: + +- [`FilteringParams`][prymer.api.picking.FilteringParams] -- Stores the parameters used + for filtering primers and primer pairs. +- [`score()`][prymer.api.picking.score] -- Scores the amplicon amplified by a primer pair + in a manner similar to Primer3. +- [`check_primer_overlap()`][prymer.api.picking.check_primer_overlap] -- Checks that both + (next) primers have at least `min_difference` bases that do not overlap the other (previous) + primers. +- [`is_acceptable_primer_pair()`][prymer.api.picking.is_acceptable_primer_pair] -- Checks + if a primer pair has the desired size range and melting temperature. +- [`build_primer_pairs()`][prymer.api.picking.build_primer_pairs] -- Builds primer pairs + from individual left and primers. +- [`pick_top_primer_pairs()`][prymer.api.picking.pick_top_primer_pairs] -- Selects up to + the given number of primer pairs from the given list of primer pairs. +- [`build_and_pick_primer_pairs()`][prymer.api.picking.build_and_pick_primer_pairs] -- + Builds primer pairs from individual left and primers and selects up to the given number of + primer pairs from the given list of primer pairs. + +""" + +from dataclasses import dataclass +from typing import Callable +from typing import Iterable +from typing import Optional + +from fgpyo.collections import PeekableIterator +from pysam import FastaFile + +from prymer.api.melting import calculate_long_seq_tm +from prymer.api.minoptmax import MinOptMax +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.ntthal import NtThermoAlign +from prymer.offtarget.offtarget_detector import OffTargetDetector + + +@dataclass(frozen=True, init=True, slots=True) +class FilteringParams: + """Stores the parameters used for filtering primers and primer pairs. + + Attributes: + amplicon_sizes: the min, optimal, and max amplicon size + amplicon_tms: the min, optimal, and max amplicon melting temperatures + product_size_lt: penalty weight for products shorter than the optimal amplicon size + product_size_gt: penalty weight for products longer than the optimal amplicon size + product_tm_lt: penalty weight for products with a Tm smaller the optimal amplicon Tm + product_tm_gt: penalty weight for products with a Tm larger the optimal amplicon Tm + max_dimer_tm: the max primer dimer melting temperature allowed when filtering primer pairs + for dimer formation. Any primer pair with a dimer melting temperature above this + threshold will be discarded. + max_primer_hits: the maximum number of hits an individual primer can have in the genome + before it is considered an invalid primer, and all primer pairs containing the primer + failed. + max_primer_pair_hits: the maximum number of amplicons a primer pair can make and be + considered passing. + three_prime_region_length: the number of bases at the 3' end of the primer in which the + parameter `max_mismatches_in_three_prime_region` is evaluated. + max_mismatches_in_three_prime_region: the maximum number of mismatches that are tolerated in + the three prime region of each primer defined by `three_prime_region_length`. + max_mismatches: the maximum number of mismatches that are tolerated in the full primer. + min_primer_to_target_distance: minimum distance allowed between the end of a primer and + start of the target -- aimed at centering the target in the amplicon. + read_length: sequencing depth and maximum distance allowed between the start of a primer and + the end of the target -- ensuring coverage during sequencing + dist_from_primer_end_weight: weight determining importance of the distance between the + primer and amplicon + target_not_covered_by_read_length_weight: weight determining importance of coverage of the + target by the sequencing depth + """ + + amplicon_sizes: MinOptMax[int] + amplicon_tms: MinOptMax[float] + product_size_lt: int + product_size_gt: int + product_tm_lt: float + product_tm_gt: float + max_dimer_tm: float = 55.0 + max_primer_hits: int = 500 + max_primer_pair_hits: int = 1 + three_prime_region_length: int = 1 + max_mismatches_in_three_prime_region: int = 2 + max_mismatches: int = 2 + min_primer_to_target_distance: int = 30 + read_length: int = 150 + dist_from_primer_end_weight: float = 100.0 + target_not_covered_by_read_length_weight: float = 100.0 + + +def _dist_penalty(start: int, end: int, params: FilteringParams) -> float: + """Returns a penalty that penalizes primers whose innermost base is closer than some + minimum distance from the target. The "distance" is zero if the innermost base overlaps the + target, one if the innermost base abuts the target, etc. + + Args: + start: the 1-based start position (inclusive) + end: the 1-based end position (inclusive) + params: the filtering parameters + """ + diff = end - start + if diff >= params.min_primer_to_target_distance: + return 0.0 + else: + return (params.min_primer_to_target_distance - diff) * params.dist_from_primer_end_weight + + +def _seq_penalty(start: int, end: int, params: FilteringParams) -> float: + """Returns a penalty that penalizes amplicons where the target cannot be fully sequenced + at the given read length starting from both ends of the amplicon. + + Args: + start: the start coordinate of the span to consider + end: the end coordinate of the span to consider + params: the filtering parameters + """ + + amplicon_length = end - start + 1 + if amplicon_length <= params.read_length: + return 0.0 + else: + return ( + amplicon_length - params.read_length + ) * params.target_not_covered_by_read_length_weight + + +def score( + left: Primer, + right: Primer, + target: Span, + amplicon: Span, + amplicon_seq_or_tm: str | float, + params: FilteringParams, +) -> float: + """Score the amplicon in a manner similar to Primer3 + + Sums the following: + + 1. Primer penalties: the provided left and right primer penalties + 2. Amplicon size: The difference between the current amplicon size and optimal amplicon size + scaled by the product size weight. Is zero if the optimal amplicon size is zero. + 3. Amplicon Tm: The difference in melting temperature between the calculated and optimal, + weighted by the product melting temperature. + 4. Inner primer distance: For primers whose innermost base is closer than some minimum distance + from the target, the difference between the two distances scale by the corresponding weight. + 5. Amplicon length: The difference between the amplicon length and provided read length scaled + by the corresponding weight. Is zero when the amplicon is at most the read length. + + Args: + left: the left primer + right: the right primer + target: the target mapping + amplicon: the amplicon mapping + amplicon_seq_or_tm: either the melting temperature of the amplicon, or the amplicon sequence + from which the melting temperature of the amplicon will be calculated with + `calculate_long_seq_tm` + params: the filtering parameters + + Returns: + the penalty for the whole amplicon. + """ + # The penalty for the amplicon size: + # 1. No penalty if the optimal amplicon size is zero + # 2. The difference between the current amplicon size and optimal amplicon size scaled by the + # product size weight. The product size weight is different depending on if the amplicon + # size is greater or less than the optimal amplicons. + size_penalty: float + if params.amplicon_sizes.opt == 0: + size_penalty = 0.0 + elif amplicon.length > params.amplicon_sizes.opt: + size_penalty = (amplicon.length - params.amplicon_sizes.opt) * params.product_size_gt + else: + size_penalty = (params.amplicon_sizes.opt - amplicon.length) * params.product_size_lt + + # The penalty for the amplicon melting temperature. + # The difference in melting temperature between the calculated and optimal is weighted by the + # product melting temperature. + tm: float + if isinstance(amplicon_seq_or_tm, str): + tm = calculate_long_seq_tm(amplicon_seq_or_tm) + else: + tm = float(amplicon_seq_or_tm) + tm_penalty: float + if tm > params.amplicon_tms.opt: + tm_penalty = (tm - params.amplicon_tms.opt) * params.product_tm_gt + else: + tm_penalty = (params.amplicon_tms.opt - tm) * params.product_tm_lt + + # Penalize primers whose innermost base is closer than some minimum distance from the target + left_dist_penalty: float = _dist_penalty(start=left.span.end, end=target.start, params=params) + right_dist_penalty: float = _dist_penalty(start=target.end, end=right.span.start, params=params) + + # Penalize amplicons where the target cannot be fully sequenced at the given read length + # starting from both ends of the amplicon. + left_seq_penalty: float = _seq_penalty(start=left.span.start, end=target.end, params=params) + right_seq_penalty: float = _seq_penalty(start=target.start, end=right.span.end, params=params) + + # Put it all together + return ( + left.penalty + + right.penalty + + size_penalty + + tm_penalty + + left_dist_penalty + + right_dist_penalty + + left_seq_penalty + + right_seq_penalty + ) + + +def check_primer_overlap( + prev_left: Span, + prev_right: Span, + next_left: Span, + next_right: Span, + min_difference: int, +) -> bool: + """Check that both (next) primers have at least `min_difference` bases that do not overlap the + other (previous) primers. + + Args: + prev_left: left primer mapping from the previous primer pair. + prev_right: right primer mapping from the previous primer pair + next_left: left primer mapping from the current primer pair being compared + next_right: right primer mapping from the current primer pair being compared + min_difference: the minimum number of bases that must differ between two primers + + Returns: + True if the primers are sufficiently different + """ + left = prev_left.length_of_overlap_with(next_left) + min_difference + right = prev_right.length_of_overlap_with(next_right) + min_difference + return ( + prev_left.length >= left + and next_left.length >= left + and prev_right.length >= right + and next_right.length >= right + ) + + +def is_acceptable_primer_pair(primer_pair: PrimerPair, params: FilteringParams) -> bool: + """Determine if a primer pair can be kept considering: + + 1. the primer pair is in the desired size range + 2. The primer pair has a melting temperature in the desired range + + Args: + primer_pair: the primer pair. + params: the parameters used for filtering + + Returns: + True if the primer pair passes initial checks and can be considered later on. + """ + return ( + params.amplicon_sizes.min <= primer_pair.amplicon.length <= params.amplicon_sizes.max + and params.amplicon_tms.min <= primer_pair.amplicon_tm <= params.amplicon_tms.max + ) + + +def build_primer_pairs( + lefts: Iterable[Primer], + rights: Iterable[Primer], + target: Span, + params: FilteringParams, + fasta: FastaFile, +) -> list[PrimerPair]: + """Builds primer pairs from individual left and primers. + + Args: + lefts: the left primers + rights: the right primers + target: the genome mapping for the target + params: the parameters used for filtering + fasta: the FASTA file from which the amplicon sequence will be retrieved. + + Returns: + the list of primer pairs, sorted by penalty (increasing) + """ + # generate all the primer pairs + primer_pairs: list[PrimerPair] = [] + for left in lefts: + for right in rights: + if left.span.refname != right.span.refname: + raise ValueError( + "Cannot create a primer pair from left and right primers on different" + f"references: left: '{left.span.refname}' right: '{right.span.refname}'" + ) + amplicon_mapping = Span( + refname=target.refname, start=left.span.start, end=right.span.end + ) + amplicon_bed = amplicon_mapping.get_bedlike_coords() # since fasta.fetch is 0-based + amplicon_sequence = fasta.fetch( + reference=target.refname, start=amplicon_bed.start, end=amplicon_bed.end + ) + amplicon_penalty = score( + left=left, + right=right, + target=target, + amplicon=amplicon_mapping, + amplicon_seq_or_tm=amplicon_sequence, + params=params, + ) + pp = PrimerPair( + left_primer=left, + right_primer=right, + amplicon_sequence=amplicon_sequence, + amplicon_tm=calculate_long_seq_tm(amplicon_sequence), + penalty=amplicon_penalty, + ) + primer_pairs.append(pp) + + # sort by smallest penalty + return sorted(primer_pairs, key=lambda pp: pp.penalty) + + +def pick_top_primer_pairs( + primer_pairs: list[PrimerPair], + num_primers: int, + min_difference: int, + params: FilteringParams, + offtarget_detector: OffTargetDetector, + is_dimer_tm_ok: Callable[[str, str], bool], +) -> list[PrimerPair]: + """Selects up to the given number of primer pairs from the given list of primer pairs. + + The primer pairs are selected in the order of lowest penalty. + + The primer pairs must: + 1. Have an amplicon in desired size range (see `is_acceptable_primer_pair`). + 2. Have an amplicon melting temperature in the desired range (see `is_acceptable_primer_pair`). + 3. Not have too many off-targets (see `OffTargetDetector#check_one()`). + 4. Not have primer pairs that overlap too much (see `check_primer_overlap`). + 5. Not form a dimer with a melting temperature above a specified threshold (see `max_dimer_tm`). + + The _order_ of these checks are important as some of them are expensive to compute. + + Args: + primer_pairs: the list of all primer pairs. + num_primers: the number of primer pairs to return for the target + min_difference: the minimum base difference between two primers that we will tolerate. + params: the parameters used for filtering + offtarget_detector: the off-target detector. + is_dimer_tm_ok: a function to use for checking for dimers. Should return true if the pair + passes (e.g. the dimer check) + + Returns: + Up to `num_primers` primer pairs + """ + selected: list[PrimerPair] = [] + pp_iter = PeekableIterator(primer_pairs) + last_pp: Optional[PrimerPair] = None + while len(selected) < num_primers and pp_iter.can_peek(): + pp = next(pp_iter) + + # Enforce that primers were ordered by penalty (larger value first) + if last_pp is not None and pp.penalty < last_pp.penalty: + raise ValueError("Primers must be given in order by penalty (increasing value).") + last_pp = pp + + # Check some basic properties + if not is_acceptable_primer_pair(primer_pair=pp, params=params): + continue + + # Check for off-targets + if not offtarget_detector.check_one(primer_pair=pp).passes: + continue + + # Check that both primers have at least `min_difference` bases that do not overlap the + # other primer. + okay = all( + check_primer_overlap( + prev.left_primer.span, + prev.right_primer.span, + pp.left_primer.span, + pp.right_primer.span, + min_difference=min_difference, + ) + for prev in selected + ) + if not okay: + continue + + # Check dimer Tm + if not is_dimer_tm_ok(pp.left_primer.bases, pp.right_primer.bases): + continue + + selected.append(pp) + + return selected + + +def build_and_pick_primer_pairs( + lefts: Iterable[Primer], + rights: Iterable[Primer], + target: Span, + num_primers: int, + min_difference: int, + params: FilteringParams, + offtarget_detector: OffTargetDetector, + dimer_checker: NtThermoAlign, + fasta: FastaFile, +) -> list[PrimerPair]: + """Picks up to `num_primers` primer pairs. + + Args: + lefts: the left primers + rights: the right primers + target: the genome mapping for the target + num_primers: the number of primer pairs to return for the target. + min_difference: the minimum base difference between two primers that we will tolerate. + params: the parameters used for filtering. + offtarget_detector: the off-target detector. + dimer_checker: the primer-dimer melting temperature checker. + fasta: the FASTA file from which the amplicon sequence will be retrieved. + + Returns: + the list of primer pairs, sorted by penalty (increasing) + """ + # build the list of primer pairs + primer_pairs = build_primer_pairs( + lefts=lefts, rights=rights, target=target, params=params, fasta=fasta + ) + + # select the primer pairs + selected: list[PrimerPair] = pick_top_primer_pairs( + primer_pairs=primer_pairs, + num_primers=num_primers, + min_difference=min_difference, + params=params, + offtarget_detector=offtarget_detector, + is_dimer_tm_ok=lambda s1, s2: ( + dimer_checker.duplex_tm(s1=s1, s2=s2) <= params.max_dimer_tm + ), + ) + + return selected diff --git a/prymer/api/primer.py b/prymer/api/primer.py new file mode 100644 index 0000000..8ceb5b6 --- /dev/null +++ b/prymer/api/primer.py @@ -0,0 +1,205 @@ +""" +# Primer Class and Methods + +This module contains a class and class methods to represent a primer (e.g. designed by Primer3) + +Class attributes include the primer sequence, melting temperature, and the score of the primer. The +mapping of the primer to the genome is also stored. + +Optional attributes include naming information and a tail sequence to attach to the 5' end of the +primer (if applicable). + +## Examples of interacting with the `Primer` class + +```python +>>> from prymer.api.span import Span, Strand +>>> primer_span = Span(refname="chr1", start=1, end=20) +>>> primer = Primer(tm=70.0, penalty=-123.0, span=primer_span) +>>> primer.longest_hp_length() +0 +>>> primer.length +20 +>>> primer.name is None +True +>>> primer = Primer(tm=70.0, penalty=-123.0, span=primer_span, bases="GACGG"*4) +>>> primer.longest_hp_length() +3 +>>> primer.untailed_length() +20 +>>> primer.tailed_length() +20 +>>> primer = primer.with_tail(tail="GATTACA") +>>> primer.untailed_length() +20 +>>> primer.tailed_length() +27 +>>> primer = primer.with_name(name="foobar") +>>> primer.name +'foobar' + +``` + +Primers may also be written to a file and subsequently read back in, as the `Primer` class is an +`fgpyo` `Metric` class: + +```python +>>> from pathlib import Path +>>> left_span = Span(refname="chr1", start=1, end=20) +>>> left = Primer(tm=70.0, penalty=-123.0, span=left_span, bases="G"*20) +>>> right_span = Span(refname="chr1", start=101, end=120) +>>> right = Primer(tm=70.0, penalty=-123.0, span=right_span, bases="T"*20) +>>> path = Path("/tmp/path/to/primers.txt") +>>> Primer.write(path, left, right) # doctest: +SKIP +>>> primers = Primer.read(path) # doctest: +SKIP +>>> list(primers) # doctest: +SKIP +[ + Primer(tm=70.0, penalty=-123.0, span=amplicon_span, bases="G"*20), + Primer(tm=70.0, penalty=-123.0, span=amplicon_span, bases="T"*20) +] + +``` +""" + +from dataclasses import dataclass +from dataclasses import replace +from typing import Any +from typing import Callable +from typing import Dict +from typing import Optional + +from fgpyo.fasta.sequence_dictionary import SequenceDictionary +from fgpyo.sequence import longest_dinucleotide_run_length +from fgpyo.sequence import longest_homopolymer_length +from fgpyo.util.metric import Metric + +from prymer.api.primer_like import MISSING_BASES_STRING +from prymer.api.primer_like import PrimerLike +from prymer.api.span import Span + + +@dataclass(frozen=True, init=True, kw_only=True, slots=True) +class Primer(PrimerLike, Metric["Primer"]): + """Stores the properties of the designed Primer. + + Attributes: + bases: the base sequence of the primer (excluding any tail) + tm: the calculated melting temperature of the primer + penalty: the penalty or score for the primer + span: the mapping of the primer to the genome + name: an optional name to use for the primer + tail: an optional tail sequence to put on the 5' end of the primer + + Example: + #TODO + <.....> + """ + + tm: float + penalty: float + span: Span + bases: Optional[str] = None + tail: Optional[str] = None + + def __post_init__(self) -> None: + super(Primer, self).__post_init__() + + def longest_hp_length(self) -> int: + """Length of longest homopolymer in the primer.""" + if self.bases is None: + return 0 + else: + return longest_homopolymer_length(self.bases) + + @property + def length(self) -> int: + """Length of un-tailed primer.""" + return self.span.length + + def untailed_length(self) -> int: + """Length of un-tailed primer.""" + return self.span.length + + def tailed_length(self) -> int: + """Length of tailed primer.""" + return self.span.length if self.tail is None else self.span.length + len(self.tail) + + def longest_dinucleotide_run_length(self) -> int: + """Number of bases in the longest dinucleotide run in a primer. + + A dinucleotide run is when length two repeat-unit is repeated. For example, + TCTC (length = 4) or ACACACACAC (length = 10). If there are no such runs, returns 2 + (or 0 if there are fewer than 2 bases).""" + return longest_dinucleotide_run_length(self.bases) + + def with_tail(self, tail: str) -> "Primer": + """Returns a copy of the primer with the tail sequence attached.""" + return replace(self, tail=tail) + + def with_name(self, name: str) -> "Primer": + """Returns copy of primer object with the given name.""" + return replace(self, name=name) + + def bases_with_tail(self) -> Optional[str]: + """ + Returns the sequence of the primer prepended by the tail. + + If either `bases` or `tail` are None, they shall be excluded. Return None if both are None. + """ + if self.bases is None: + return None if self.tail is None else self.tail + if self.tail is None: + return self.bases + return f"{self.tail}{self.bases}" + + def to_bed12_row(self) -> str: + """Returns the BED detail format view: + https://genome.ucsc.edu/FAQ/FAQformat.html#format1.7""" + bed_coord = self.span.get_bedlike_coords() + return "\t".join( + map( + str, + [ + self.span.refname, # contig + bed_coord.start, # start + bed_coord.end, # end + self.id, # name + 500, # score + self.span.strand.value, # strand + bed_coord.start, # thick start + bed_coord.end, # thick end + "100,100,100", # color + 1, # block count + f"{self.length}", # block sizes + "0", # block starts (relative to `start`) + ], + ) + ) + + def __str__(self) -> str: + """ + Returns a string representation of this primer + """ + # If the bases field is None, replace with MISSING_BASES_STRING + bases: str = self.bases if self.bases is not None else MISSING_BASES_STRING + return f"{bases}\t{self.tm}\t{self.penalty}\t{self.span}" + + @classmethod + def _parsers(cls) -> Dict[type, Callable[[str], Any]]: + return { + Span: lambda value: Span.from_string(value), + } + + @staticmethod + def compare(this: "Primer", that: "Primer", seq_dict: SequenceDictionary) -> int: + """Compares this primer to that primer by their span, ordering references using the given + sequence dictionary. + + Args: + this: the first primer + that: the second primer + seq_dict: the sequence dictionary used to order references + + Returns: + -1 if this primer is less than the that primer, 0 if equal, 1 otherwise + """ + return Span.compare(this=this.span, that=that.span, seq_dict=seq_dict) diff --git a/prymer/api/primer_like.py b/prymer/api/primer_like.py new file mode 100644 index 0000000..075fa82 --- /dev/null +++ b/prymer/api/primer_like.py @@ -0,0 +1,130 @@ +""" +# Class and Methods for primer-like objects + +The `PrimerLike` class is an abstract base class designed to represent primer-like objects, +such as individual primers or primer pairs. This class encapsulates common attributes and +provides a foundation for more specialized implementations. + +In particular, the following methods/attributes need to be implemented: + +- [`span()`][prymer.api.primer_like.PrimerLike.span] -- the mapping of the primer-like + object to the genome. +- [`bases()`][prymer.api.primer_like.PrimerLike.bases] -- the bases of the primer-like + object, or `None` if not available. +- [`to_bed12_row()`][prymer.api.primer_like.PrimerLike.to_bed12_row] -- the 12-field BED + representation of this primer-like object. + +See the following concrete implementations: + +- [`Primer`][prymer.api.primer.Primer] -- a class to store an individual primer +- [`PrimerPair`][prymer.api.primer_pair.PrimerPair] -- a class to store a primer pair + +""" + +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from typing import Optional +from typing import TypeVar +from typing import assert_never + +from fgpyo.sequence import gc_content + +from prymer.api.span import Span +from prymer.api.span import Strand + +MISSING_BASES_STRING: str = "*" +"""Used in string representations of primer-like objects when their `bases` property return None""" + + +@dataclass(frozen=True, init=True, slots=True) +class PrimerLike(ABC): + """ + An abstract base class for primer-like objects, such as individual primers or primer pairs. + + Attributes: + name: an optional name to use for the primer + + The `id` field shall be the 'name' field if supplied, or a generated value based on the + location of the primer-like object. + """ + + name: Optional[str] = None + + def __post_init__(self) -> None: + # If supplied, bases must be a non-empty String & the same length as the + # span length + if self.bases is not None: + if len(self.bases) == 0: + raise ValueError("Bases must not be an empty string") + elif self.span.length != len(self.bases): + raise ValueError( + "Conflicting lengths: " + f"span length={self.span.length}," + f" sequence length={len(self.bases)}" + ) + + @property + @abstractmethod + def span(self) -> Span: + """Returns the mapping of the primer-like object to a genome.""" + + @property + @abstractmethod + def bases(self) -> Optional[str]: + """Returns the base sequence of the primer-like object.""" + + @property + def percent_gc_content(self) -> float: + """ + The GC of the amplicon sequence in the range 0-100, or zero if there is no amplicon + sequence. + """ + if self.bases is None: + return 0.0 + else: + return round(gc_content(self.bases) * 100, 3) + + @property + def id(self) -> str: + """ + Returns the identifier for the primer-like object. This shall be the `name` + if one exists, otherwise a generated value based on the location of the object. + """ + if self.name is not None: + return self.name + else: + return self.location_string + + @property + def location_string(self) -> str: + """Returns a string representation of the location of the primer-like object.""" + return ( + f"{self.span.refname}_{self.span.start}_" + + f"{self.span.end}_{self._strand_to_location_string()}" + ) + + @abstractmethod + def to_bed12_row(self) -> str: + """ + Formats the primer-like into 12 tab-separated fields matching the BED 12-column spec. + See: https://genome.ucsc.edu/FAQ/FAQformat.html#format1 + """ + + def _strand_to_location_string(self) -> str: + """ + Returns a string representation appropriate for location_string of the strand of the + primer + """ + match self.span.strand: + case Strand.POSITIVE: + return "F" + case Strand.NEGATIVE: + return "R" + case _: # pragma: no cover + # Not calculating coverage on this line as it should be impossible to reach + assert_never(f"Encountered unhandled Strand value: {self.span.strand}") + + +PrimerLikeType = TypeVar("PrimerLikeType", bound=PrimerLike) +"""Type variable for classes generic over `PrimerLike` types.""" diff --git a/prymer/api/primer_pair.py b/prymer/api/primer_pair.py new file mode 100644 index 0000000..a10074a --- /dev/null +++ b/prymer/api/primer_pair.py @@ -0,0 +1,282 @@ +""" +# Primer Pair Class and Methods + +This module contains the [`PrimerPair`][prymer.api.primer_pair.PrimerPair] class and +class methods to represent a primer pair. The primer pair is comprised of a left and right primer +that work together to amplify an amplicon. + +Class attributes include each of the primers (represented by a +[`Primer`][prymer.api.primer.Primer] object), information about the expected amplicon +(positional information about how the amplicon maps to the genome, the sequence, and its melting +temperature), as well as a score of the primer pair (e.g. as emitted by Primer3). + +Optional attributes include naming information to keep track of the pair and design parameters +used to create the pairing. + +## Examples + +```python +>>> from prymer.api.span import Strand +>>> left_span = Span(refname="chr1", start=1, end=20) +>>> left_primer = Primer(tm=70.0, penalty=-123.0, span=left_span, bases="G"*20) +>>> right_span = Span(refname="chr1", start=101, end=120, strand=Strand.NEGATIVE) +>>> right_primer = Primer(tm=70.0, penalty=-123.0, span=right_span, bases="T"*20) +>>> primer_pair = PrimerPair( \ + left_primer=left_primer, \ + right_primer=right_primer, \ + amplicon_sequence=None, \ + amplicon_tm=70.0, \ + penalty=-123.0, \ + name="foobar" \ +) +>>> primer_pair.amplicon +Span(refname='chr1', start=1, end=120, strand=) +>>> primer_pair.span +Span(refname='chr1', start=1, end=120, strand=) +>>> primer_pair.inner +Span(refname='chr1', start=21, end=100, strand=) + +>>> list(primer_pair) +[Primer(name=None, tm=70.0, penalty=-123.0, span=Span(refname='chr1', start=1, end=20, strand=), bases='GGGGGGGGGGGGGGGGGGGG', tail=None), Primer(name=None, tm=70.0, penalty=-123.0, span=Span(refname='chr1', start=101, end=120, strand=), bases='TTTTTTTTTTTTTTTTTTTT', tail=None)] + +``` +""" # noqa: E501 + +from dataclasses import dataclass +from dataclasses import field +from dataclasses import replace +from typing import Iterator +from typing import Optional + +from fgpyo.fasta.sequence_dictionary import SequenceDictionary + +from prymer.api.primer import Primer +from prymer.api.primer_like import MISSING_BASES_STRING +from prymer.api.primer_like import PrimerLike +from prymer.api.span import Span + + +@dataclass(frozen=True, init=True, kw_only=True, slots=True) +class PrimerPair(PrimerLike): + """ + Represents a pair of primers that work together to amplify an amplicon. The + coordinates of the amplicon are determined to span from the start of the left + primer through the end of the right primer. + + Attributes: + left_primer: the left primer in the pair + right_primer: the right primer in the pair + amplicon_sequence: an optional sequence of the expected amplicon + amplicon_tm: the melting temperature of the expected amplicon + penalty: the penalty value assigned by primer3 to the primer pair + name: an optional name to use for the primer pair + + Raises: + ValueError: if the chromosomes of the left and right primers are not the same + """ + + left_primer: Primer + right_primer: Primer + amplicon_tm: float + penalty: float + amplicon_sequence: Optional[str] = None + _amplicon: Span = field(init=False) + + def __post_init__(self) -> None: + # Derive the amplicon from the left and right primers. This must be done before + # calling super() as `PrimerLike.id` depends on the amplicon being set + object.__setattr__(self, "_amplicon", self._calculate_amplicon()) + super(PrimerPair, self).__post_init__() + + @property + def amplicon(self) -> Span: + """Returns the mapping for the amplicon""" + return self._amplicon + + @property + def span(self) -> Span: + """Returns the mapping for the amplicon""" + return self.amplicon + + @property + def bases(self) -> Optional[str]: + """Returns the bases of the amplicon sequence""" + return self.amplicon_sequence + + @property + def length(self) -> int: + """Returns the length of the amplicon""" + return self.amplicon.length + + @property + def inner(self) -> Span: + """ + Returns the inner region of the amplicon (not including the primers). I.e. the region of + the genome covered by the primer pair, without the primer regions. If the primers + overlap, then the inner mapping is the midpoint at where they overlap + """ + if self.left_primer.span.overlaps(self.right_primer.span): + # Use a flooring division as these values are all ints + midpoint = (self.left_primer.span.end + self.right_primer.span.start) // 2 + return replace( + self.left_primer.span, + start=midpoint, + end=midpoint, + ) + else: + return replace( + self.left_primer.span, + start=self.left_primer.span.end + 1, + end=self.right_primer.span.start - 1, + ) + + def with_tails(self, left_tail: str, right_tail: str) -> "PrimerPair": + """ + Returns a copy of the primer pair where the left and right primers are tailed. + + Args: + left_tail: The tail to add to the left primer + right_tail: The tail to add to the right primer + + Returns: + A copy of the primer pair with the tail(s) added to the primers + """ + return replace( + self, + left_primer=self.left_primer.with_tail(left_tail), + right_primer=self.right_primer.with_tail(right_tail), + ) + + def with_names(self, pp_name: str, lp_name: str, rp_name: str) -> "PrimerPair": + """ + Returns a copy of the primer pair with names assigned to the primer pair, + left primer, and right primer. + + Args: + pp_name: The optional name of the primer pair + lp_name: The optional name of the left primer + rp_name: The optional name of the right primer + + Returns: + A copy of the primer pair with the provided names assigned + """ + return replace( + self, + name=pp_name, + left_primer=self.left_primer.with_name(lp_name), + right_primer=self.right_primer.with_name(rp_name), + ) + + def to_bed12_row(self) -> str: + """ + Returns the BED detail format view: https://genome.ucsc.edu/FAQ/FAQformat.html#format1.7. + + NB: BED is 0-based and Prymer is 1-based, so we need to convert + """ + block_sizes = ",".join( + [ + f"{gm.length}" + for gm in [ + self.left_primer.span, + self.inner, + self.right_primer.span, + ] + ] + ) + + block_starts = ",".join( + [ + f"{self.amplicon.get_offset(gm.start)}" + for gm in [ + self.left_primer.span, + self.inner, + self.right_primer.span, + ] + ], + ) + bed_like_coords = self.span.get_bedlike_coords() + return "\t".join( + map( + str, + [ + self.span.refname, # contig + bed_like_coords.start, # start + bed_like_coords.end, # end + self.id, # name + 500, # score + self.span.strand.value, # strand + bed_like_coords.start, # thick start + bed_like_coords.end, # thick end + "100,100,100", # color + 3, # block count + block_sizes, + block_starts, # relative to `start` + ], + ) + ) + + def __iter__(self) -> Iterator[Primer]: + """Returns an iterator of left and right primers""" + return iter([self.left_primer, self.right_primer]) + + def __str__(self) -> str: + """Returns a string representation of the primer pair""" + sequence = self.amplicon_sequence if self.amplicon_sequence else MISSING_BASES_STRING + return ( + f"{self.left_primer}\t{self.right_primer}\t{sequence}\t" + + f"{self.amplicon_tm}\t{self.penalty}" + ) + + def _calculate_amplicon(self) -> Span: + """ + Calculates the amplicon from the left and right primers, spanning from the start of the + left primer to the end of the right primer. + """ + + # Require that `left_primer` and `right_primer` both map to the same reference sequence + if self.left_primer.span.refname != self.right_primer.span.refname: + raise ValueError( + "The reference must be the same across primers in a pair; received " + f"left primer ref: {self.left_primer.span.refname}, " + f"right primer ref: {self.right_primer.span.refname}" + ) + + # Require that the left primer does not start to the right of the right primer + if self.left_primer.span.start > self.right_primer.span.end: + raise ValueError( + "Left primer start must be less than or equal to right primer end; received " + "left primer genome span: {self.left_primer.span}, " + "right primer genome span: {self.right_primer.span}" + ) + + return replace(self.left_primer.span, end=self.right_primer.span.end) + + @staticmethod + def compare( + this: "PrimerPair", + that: "PrimerPair", + seq_dict: SequenceDictionary, + by_amplicon: bool = True, + ) -> int: + """Compares this primer pair to that primer pair by their span, ordering references using + the given sequence dictionary. + + Args: + this: the first primer pair + that: the second primer pair + seq_dict: the sequence dictionary used to order references + by_amplicon: ture to compare using the amplicon property on a primer pair, false to + compare first using the left primer then the right primer + + Returns: + -1 if this primer pair is less than the that primer pair, 0 if equal, 1 otherwise + """ + if by_amplicon: + return Span.compare(this=this.amplicon, that=that.amplicon, seq_dict=seq_dict) + else: + retval = Primer.compare(this=this.left_primer, that=that.left_primer, seq_dict=seq_dict) + if retval == 0: + retval = Primer.compare( + this=this.right_primer, that=that.right_primer, seq_dict=seq_dict + ) + return retval diff --git a/prymer/api/span.py b/prymer/api/span.py new file mode 100644 index 0000000..2931546 --- /dev/null +++ b/prymer/api/span.py @@ -0,0 +1,339 @@ +""" +# Span Classes and Methods + +The [`Span`][prymer.api.span.Span] class and associated methods represent positional +information from a span of some sequence (like a primer or an amplicon) over a reference sequence +(e.g. genome, target, amplicon). + +Class attributes include `refname`, `start`, `end`, and `strand`. + +The [`Span`][prymer.api.span.Span] class uses 1-based, closed intervals for start and end +positions. The [`get_bedlike_coords()`][prymer.api.span.Span.get_bedlike_coords] method +may be used to conver a `Span` to zero-based open-ended coordinates. + +## Examples of interacting with the `Span` class + + +```python +>>> span_string = "chr1:1-10:+" +>>> span = Span.from_string(span_string) +>>> span +Span(refname='chr1', start=1, end=10, strand=) +>>> print(span.start) +1 +>>> # get 0-based position within span at position 10 +>>> span.get_offset(position=10) +9 +>>> # create a new subspan derived from the source map +>>> new_subspan = span.get_subspan(offset=5, subspan_length=5) +>>> new_subspan +Span(refname='chr1', start=6, end=10, strand=) +>>> print(span.length) +10 + +``` + +A `Span` can be compared to another `Span` to test for overlap, return the length of overlap, and +test if one span fully contains the other: + +```python +>>> span1 = Span(refname="chr1", start=50, end=100) +>>> span2 = Span(refname="chr1", start=75, end=90) +>>> span1.overlaps(span2) +True +>>> span2.overlaps(span1) +True +>>> span1.length_of_overlap_with(span2) +16 +>>> span1.length_of_overlap_with(Span(refname="chr1", start=75, end=125)) +26 +>>> span1.overlaps(Span(refname="chr1", start=200, end=225)) +False +>>> span1.contains(Span(refname="chr1", start=75, end=125)) +False +>>> span1.contains(span2) +True + +``` + +In some cases, it's useful to have the coordinates be converted to zero-based open-ended, for +example with use with the `pysam` module when using the `fetch()` methods for `pysam.AlignmentFile`, +`pysam.FastaFile`, and `pysam.VariantFile`. + +```python +>>> span.get_bedlike_coords() +BedLikeCoords(start=0, end=10) + +``` +""" + +from dataclasses import dataclass +from dataclasses import replace +from enum import StrEnum +from enum import unique +from typing import Optional +from typing import Self +from typing import Tuple + +from fgpyo.fasta.sequence_dictionary import SequenceDictionary +from fgpyo.util.metric import Metric + +from prymer.api import coordmath + + +@unique +class Strand(StrEnum): + """Represents the strand of a span to the genome.""" + + POSITIVE = "+" + NEGATIVE = "-" + + +@dataclass(init=True, frozen=True) +class BedLikeCoords: + """Represents the coordinates (only, no refname or strand) that correspond to a + zero-based open-ended position.""" + + start: int + end: int + + def __post_init__(self) -> None: + if self.start < 0: + raise ValueError(f"Start position must be >=0; received start={self.start}") + if self.end < self.start: + raise ValueError(f"End must be >= start; received start={self.start}, end={self.end}") + + +@dataclass(init=True, frozen=True, eq=True) +class Span(Metric["Span"]): + """Represents the span of some sequence (target, primer, amplicon, etc.) to a genome. + + Attributes: + refname: name of a reference sequence or contig or chromosome + start: the 1-based start position of the Span (inclusive) + end: the 1-based end position of the Span (inclusive) + strand: the strand of the Span (POSITIVE by default) + """ + + refname: str + start: int + end: int + strand: Strand = Strand.POSITIVE + + def __post_init__(self) -> None: + if self.refname.strip() == "": + raise ValueError("Ref name must be populated") + if self.start < 1: + raise ValueError(f"Start position must be >=1; received start={self.start}") + if self.end < self.start: + raise ValueError(f"End must be >= start; received start={self.start}, end={self.end}") + + @classmethod + def from_string(cls, line: str) -> "Span": + """Creates a Span from an input string. + + The input string should be delimited by a colon (":"). If strand information is missing + after splitting the string, the strand is assumed to be positive. + + Args: + line: input string + + Returns: + Span: the Span object generated from the input string + + Raises: + ValueError: if there are not at least 2 colon-delimited fields in string + + Example: + >>> span_string = "chr1:1-10:+" + >>> Span.from_string(span_string) + Span(refname='chr1', start=1, end=10, strand=) + """ + parts = line.strip().split(":") + if len(parts) == 3: + refname, range_, strand_symbol = parts + try: + strand = Strand(strand_symbol[0]) + except ValueError as e: + raise ValueError( + "Did not find valid strand information; " f"received {strand_symbol}" + ) from e + elif len(parts) == 2: + refname, range_ = parts + strand = Strand.POSITIVE + else: + raise ValueError( + f"Could not parse line (expected 2 or 3 colon-delimited fields), received {line}" + ) + try: + start, end = map(int, range_.split("-")) + except ValueError as e: + raise ValueError( + f"Could not cast positional information to int; received {range_}" + ) from e + return Span(refname=refname, start=start, end=end, strand=strand) + + def __str__(self) -> str: + return f"{self.refname}:{self.start}-{self.end}:{self.strand}" + + @property + def length(self) -> int: + """Get the length of the span/interval.""" + return self.end - self.start + 1 + + def overlaps(self, other: "Span") -> bool: + """Returns True if the spans overlap one another, False otherwise.""" + return ( + (self.refname == other.refname) + and (self.start <= other.end) + and (self.end >= other.start) + ) + + def length_of_overlap_with(self, other: "Span") -> int: + """Returns the length of the region which overlaps the other span, or zero if there is + no overlap""" + overlap: int = 0 + if self.overlaps(other=other): + start = max(self.start, other.start) + end = min(self.end, other.end) + overlap = end - start + 1 + return overlap + + def get_offset(self, position: int) -> int: + """Returns a coordinate position that is relative to the current span. + + Args: + position: the (genomic) position to provide relative coordinates for (1-based) + + Returns: + A 0-based position relative to the Span + + Raises: + ValueError: if the provided position is outside the coordinates of the Span + + Example: + + ```python + >>> test_span = Span(refname="chr1", start=10, end=20, strand=Strand.POSITIVE) + >>> print(test_span.get_offset(11)) + 1 + + ``` + """ + if position < self.start or position > self.end: + raise ValueError(f"Position not within this span: {position}") + return position - self.start + + def get_bedlike_coords(self) -> BedLikeCoords: + """Returns the zero-based, open-ended coordinates (start and end) of the + (1-based) Span, for using in bed-like formats. + + Returns: + a BedLikeCoords instance containing the 0-based and open-ended coordinates of + the Span + + Example: + + ```python + >>> test_span = Span(refname="chr1", start=10, end=20, strand=Strand.POSITIVE) + >>> print(test_span.get_bedlike_coords()) + BedLikeCoords(start=9, end=20) + + ``` + """ + return BedLikeCoords(self.start - 1, self.end) + + def get_subspan( + self, offset: int, subspan_length: int, strand: Optional[Strand] = None + ) -> "Span": + """Returns a Span with absolute coords from an offset and a length. + The strand of the new Span will be strand if given, otherwise it will be + self.strand. + + Args: + offset: the difference between the start position of the subspan and that + of the current span (0-based) + subspan_length: the length of the new span + strand: the strand of the new span (if given) + + Returns: + a new Span with objective coords derived from input offset and length + + Raises: + ValueError: if the offset is less than 0 + ValueError: if the length of the subspan is 0 or less + ValueError: if the offset is greater than the length of the source span + + Example: + + ```python + >>> span = Span(refname="chr1", start=1000,end=2000, strand=Strand.POSITIVE) + >>> test = span.get_subspan(offset=5, subspan_length=10) + >>> print(test) + chr1:1005-1014:+ + >>> print(test.length) #length = end - start + 1 + 10 + + ``` + """ + + if offset < 0: + raise ValueError(f"Offset must be > 0, received start={offset}") + if subspan_length <= 0: + raise ValueError( + f"Length of a subspan must be positive, received length={subspan_length}" + ) + if offset >= self.length: + raise ValueError( + "Offset of a relative subspan must be < source span length, " + f"received offset={offset}, length={subspan_length}" + ) + if offset + subspan_length > self.length: + raise ValueError( + f"End of sub-span must be within source span: source start={self.start}, " + f"offset={offset}, sub-span length={subspan_length}" + ) + + absolute_start = self.start + offset + absolute_end = coordmath.get_closed_end(absolute_start, subspan_length) + strand = self.strand if strand is None else strand + return replace(self, start=absolute_start, end=absolute_end, strand=strand) + + def contains(self, comparison: Self) -> bool: + """Checks whether one span is wholly contained within another span. + Returns `True` if one span contains the other, otherwise returns `False`. + Does not use strand information (a span is considered to contain the other + if they are adjacent in position but on opposite strands).""" + return ( + self.refname == comparison.refname + and self.start <= comparison.start + and comparison.end <= self.end + ) + + def _to_tuple(self, seq_dict: SequenceDictionary) -> Tuple[int, int, int, int]: + """Returns a tuple of reference index, start position, end position, and strand + (0 forward, 1 reverse)""" + ref_index = seq_dict.by_name(self.refname).index + strand = 0 if self.strand == Strand.POSITIVE else 1 + return ref_index, self.start, self.end, strand + + @staticmethod + def compare(this: "Span", that: "Span", seq_dict: SequenceDictionary) -> int: + """Compares this span to that span, ordering references using the given sequence dictionary. + + Args: + this: the first span + that: the second span + seq_dict: the sequence dictionary used to order references + + Returns: + -1 if the first span is less than the second span, 0 if equal, 1 otherwise + """ + left_tuple = this._to_tuple(seq_dict=seq_dict) + right_tuple = that._to_tuple(seq_dict=seq_dict) + if left_tuple < right_tuple: + return -1 + elif left_tuple > right_tuple: + return 1 + else: + return 0 diff --git a/prymer/api/variant_lookup.py b/prymer/api/variant_lookup.py new file mode 100644 index 0000000..82f5833 --- /dev/null +++ b/prymer/api/variant_lookup.py @@ -0,0 +1,449 @@ +""" +# Variant Lookup Class and Methods + +This module contains the abstract class +[`VariantLookup`][prymer.api.variant_lookup.VariantLookup] to facilitate retrieval of +variants that overlap a given genomic coordinate range. Concrete implementations must implement the +[`query()`][prymer.api.variant_lookup.VariantLookup.query] method for retrieving variants +that overlap the given range. + +Two concrete implementations are provided that both take a list of VCF files to be queried: + +- [`FileBasedVariantLookup`][prymer.api.variant_lookup.FileBasedVariantLookup] -- performs +disk-based retrieval of variants (using a VCF index). This class is recommended for large VCFs. The +[`disk_based()`][prymer.api.variant_lookup.disk_based] alternative constructor is +provided for easy construction of this object. +- [`VariantOverlapDetector`][prymer.api.variant_lookup.VariantOverlapDetector] -- reads in +variants into memory and uses an +[`pybedlite.overlap_detector.OverlapDetector`](https://pybedlite.readthedocs.io/en/latest/api.html#pybedlite.overlap_detector.OverlapDetector) +for querying. This class is recommended for small VCFs. The +[`cached()`][prymer.api.variant_lookup.cached] alternative constructor is provided for +easy construction of this object. + +Each class can also use minor allele frequency (MAF) to filter variants. + +The helper class `SimpleVariant` is included to facilitate VCF querying and reporting out results. + +## Examples + +```python +>>> from pathlib import Path +>>> lookup = cached(vcf_paths=[Path("./tests/api/data/miniref.variants.vcf.gz")], min_maf=0.00, include_missing_mafs=True) +>>> lookup.query(refname="chr2", start=7999, end=8000) +[SimpleVariant(id='complex-variant-sv-1/1', refname='chr2', pos=8000, ref='T', alt='', end=8000, variant_type=, maf=None)] +>>> variants = lookup.query(refname="chr2", start=7999, end=9900) +>>> len(variants) +14 +>>> for v in variants: \ + print(v) +complex-variant-sv-1/1@chr2:8000[T/ NA] +rare-dbsnp-snp1-1/1@chr2:9000[A/C 0.0010] +common-dbsnp-snp1-1/1@chr2:9010[C/T 0.0100] +common-dbsnp-snp2-1/1@chr2:9020[A/C 0.0200] +rare-ac-an-snp-1/1@chr2:9030[G/A 0.0010] +rare-af-snp-1/1@chr2:9040[C/A 0.0005] +common-ac-an-snp-1/1@chr2:9050[C/G 0.0470] +common-af-snp-1/1@chr2:9060[T/C 0.0400] +common-multiallelic-1/2@chr2:9070[C/A 0.0525] +common-multiallelic-2/2@chr2:9070[C/T 0.0525] +common-insertion-1/1@chr2:9080[A/ACGT 0.0400] +common-deletion-1/1@chr2:9090[CTA/C 0.0400] +common-mixed-1/2@chr2:9100[CA/GG 0.1200] +common-mixed-2/2@chr2:9101[A/ACACA 0.1200] +>>> lookup.query(refname="ch12", start=7999, end=9900) +[] + +``` +""" # noqa: E501 + +import logging +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from dataclasses import field +from enum import auto +from enum import unique +from pathlib import Path +from typing import Optional +from typing import final + +import pysam +from fgpyo.vcf import reader +from pybedlite.overlap_detector import Interval +from pybedlite.overlap_detector import OverlapDetector +from pysam import VariantFile +from pysam import VariantRecord +from strenum import UppercaseStrEnum + +from prymer.api.span import Span +from prymer.api.span import Strand + + +@unique +class VariantType(UppercaseStrEnum): + """Represents the type of variant.""" + + SNP = auto() + MNV = auto() + INSERTION = auto() + DELETION = auto() + OTHER = auto() + + @staticmethod + def from_alleles(ref: str, alt: str) -> "VariantType": + """Builds a variant type from the given reference and alternate allele. + + Args: + ref: the reference allele + alt: the alternate allele + + Returns: + the variant type + """ + variant_type: VariantType + if "<" in alt: + variant_type = VariantType.OTHER + elif (len(ref) == 1) and (len(alt) == 1): + variant_type = VariantType.SNP + elif len(ref) == len(alt): + variant_type = VariantType.MNV + elif len(ref) < len(alt): + variant_type = VariantType.INSERTION + elif len(ref) > len(alt): + variant_type = VariantType.DELETION + else: + raise ValueError(f"Could not determine variant type for ref `{ref}` and alt `{alt}`") + return variant_type + + +@dataclass(slots=True, frozen=True, init=True) +class SimpleVariant: + """Represents a variant from a given genomic range. + + The reference and alternate alleles typically match the VCF from which the variant originated, + differing when the reference and alternate alleles share common leading bases. For example, + given a reference allele of `CACACA` and alternate allele `CACA`, the reference and alternate + alleles have their common leading bases removed except for a single "anchor base" (i.e. `CAC` is + removed), yielding the reference allele `ACA` and alternate allele `A`. This will also change + the reference variant position (`pos` property), which is defined as the position of the first + base in the reference allele. + + The `variant_type` is derived from the reference and alternate alleles after the above (see + [`VariantType.from_alleles()`][prymer.api.variant_lookup.VariantType.from_alleles]). + + Furthermore, the variant end position (`end` property) is defined for insertions as the base + after the insertion. For all other variant types, this is the last base in the reference + allele. + + Attributes: + id: the variant identifier + refname: the reference sequence name + pos: the start position of the variant (1-based inclusive) + end: the end position of the variant (1-based inclusive) + ref: the reference base + alt: the alternate base + variant_type: the variant type + maf: optionally, the minor allele frequency + """ + + id: str + refname: str + pos: int + ref: str + alt: str + end: int = field(init=False) + variant_type: VariantType = field(init=False) + maf: Optional[float] = None + + def __post_init__(self) -> None: + # Simplify the ref/alt alleles + # find the leading prefix of the ref and alternate allele + prefix_length = 0 + for r, a in zip(self.ref, self.alt, strict=False): + if r != a: + break + prefix_length += 1 + if prefix_length > 1: # > 1 to always keep an anchor base + offset = prefix_length - 1 + object.__setattr__(self, "ref", self.ref[offset:]) + object.__setattr__(self, "alt", self.alt[offset:]) + object.__setattr__(self, "pos", self.pos + offset) + + # Derive the end and variant_type from the ref and alt alleles. + object.__setattr__( + self, "variant_type", VariantType.from_alleles(ref=self.ref, alt=self.alt) + ) + + # span all reference bases (e.g. SNP -> 1bp, MNV -> len(MNV), DEL -> len(DEL), + end: int = self.pos + len(self.ref) - 1 + if self.variant_type == VariantType.INSERTION: + # span the base preceding and following the insertion, so add a base at the end + end += 1 + object.__setattr__(self, "end", end) + + def __str__(self) -> str: + """Compact String representation of the variant that includes all relevant info.""" + maf_string = f"{self.maf:.4f}" if self.maf is not None else "NA" + return f"{self.id}@{self.refname}:{self.pos}[{self.ref}/{self.alt} {maf_string}]" + + def to_span(self) -> Span: + """Creates a Span object that represents the genomic span of this variant. + Insertions will span the base preceding and following the inserted bases.""" + return Span(refname=self.refname, start=self.pos, end=self.end, strand=Strand.POSITIVE) + + @staticmethod + def build(variant: VariantRecord) -> list["SimpleVariant"]: + """Convert `pysam.VariantRecord` to `SimpleVariant`. Only the first ALT allele is used.""" + maf = calc_maf_from_filter(variant) + simple_variants: list[SimpleVariant] = [] + + for i, alt in enumerate(variant.alts, start=1): + simple_variant = SimpleVariant( + id=f"{variant.id}-{i}/{len(variant.alts)}", + refname=variant.chrom, + pos=variant.pos, + ref=variant.ref, + alt=alt, + maf=maf, + ) + simple_variants.append(simple_variant) + + return simple_variants + + +@dataclass(slots=True, frozen=True, init=True) +class _VariantInterval(Interval): + """Intermediate class that facilitates use of pybedlite.overlap_detector with a `SimpleVariant`. + This must be an @attr.s because the `pybedlite.Interval` is an @attr.s.""" + + refname: str + start: int + end: int + variant: SimpleVariant + negative: bool = False + name: Optional[str] = None + + @staticmethod + def build(simple_variant: SimpleVariant) -> "_VariantInterval": + return _VariantInterval( + refname=simple_variant.refname, + start=simple_variant.pos - 1, # pybedlite is 0-based open ended + end=simple_variant.pos + len(simple_variant.ref) - 1, + variant=simple_variant, + ) + + +class VariantLookup(ABC): + """Base class to represent a variant from a given genomic range. + + Attributes: + vcf_paths: the paths to the source VCFs for the variants + min_maf: optionally, return only variants with at least this minor allele frequency + include_missing_mafs: when filtering variants with a minor allele frequency, + `True` to include variants with no annotated minor allele frequency, otherwise `False`. + If no minor allele frequency is given, then this parameter does nothing. + """ + + def __init__( + self, + vcf_paths: list[Path], + min_maf: Optional[float], + include_missing_mafs: bool, + ) -> None: + self.vcf_paths: list[Path] = vcf_paths + self.min_maf: Optional[float] = min_maf + self.include_missing_mafs: bool = include_missing_mafs + + @final + def query( + self, + refname: str, + start: int, + end: int, + maf: Optional[float] = None, + include_missing_mafs: bool = None, + ) -> list[SimpleVariant]: + """Gets all variants that overlap a genomic range, optionally filter based on MAF threshold. + + Args: + refname: the reference name + start: the 1-based start position + end: the 1-based end position + maf: the MAF of the variant + include_missing_mafs: whether to include variants with a missing MAF + (overrides self.include_missing_mafs) + """ + if maf is None: + maf = self.min_maf + if include_missing_mafs is None: + include_missing_mafs = self.include_missing_mafs + + variants = self._query(refname=refname, start=start, end=end) + if len(variants) == 0: + logging.debug(f"No variants extracted from region of interest: {refname}:{start}-{end}") + if maf is None or maf <= 0.0: + return variants + elif include_missing_mafs: # return variants with a MAF above threshold or missing + return [v for v in variants if (v.maf is None or v.maf >= maf)] + else: + return [v for v in variants if v.maf is not None and v.maf >= maf] + + @staticmethod + def to_variants( + variants: list[VariantRecord], source_vcf: Optional[Path] = None + ) -> list[SimpleVariant]: + """Converts a list of `pysam.VariantRecords` to a list of `SimpleVariants` for ease of use. + Filters variants based on their FILTER status, and sorts by start position. + + Args: + variants: the variants to convert + source_vcf: the optional path to the source VCF, used only for debug messages + + Returns: + a list of `SimpleVariants`, one per alternate allele per variant. + """ + simple_vars = [] + for variant in variants: + if ( + "PASS" in list(variant.filter) or len(list(variant.filter)) == 0 + ): # if passing or empty filters + simple_variants = SimpleVariant.build(variant) + if any(v.variant_type == VariantType.OTHER for v in simple_variants): + logging.debug( + f"Input VCF file {source_vcf} may contain complex variants: {variant}" + ) + simple_vars.extend(simple_variants) + return sorted(simple_vars, key=lambda v: (v.pos, v.id)) + + @abstractmethod + def _query(self, refname: str, start: int, end: int) -> list[SimpleVariant]: + """Subclasses must implement this method.""" + + +class FileBasedVariantLookup(VariantLookup): + """Implementation of VariantLookup that queries against indexed VCF files each time a query is + performed. Assumes the index is located adjacent to the VCF file and has the same base name with + either a .csi or .tbi suffix.""" + + def __init__(self, vcf_paths: list[Path], min_maf: Optional[float], include_missing_mafs: bool): + self._readers: list[VariantFile] = [] + super().__init__( + vcf_paths=vcf_paths, min_maf=min_maf, include_missing_mafs=include_missing_mafs + ) + if len(vcf_paths) == 0: + raise ValueError("No VCF paths given to query.") + for path in vcf_paths: + if ( + not path.with_suffix(path.suffix + ".csi").is_file() + and not path.with_suffix(path.suffix + ".tbi").is_file() + ): + raise ValueError(f"Cannot perform fetch with missing index file for VCF: {path}") + open_fh = pysam.VariantFile(str(path)) + self._readers.append(open_fh) + + def _query(self, refname: str, start: int, end: int) -> list[SimpleVariant]: + """Queries variants from the VCFs used by this lookup and returns a `SimpleVariant`.""" + simple_variants: list[SimpleVariant] = [] + for fh, path in zip(self._readers, self.vcf_paths, strict=True): + if not fh.header.contigs.get(refname): + logging.debug(f"Header in VCF file {path} does not contain chromosome {refname}.") + continue + # pysam.fetch is 0-based, half-open + variants = [variant for variant in fh.fetch(contig=refname, start=start - 1, end=end)] + simple_variants.extend(self.to_variants(variants, source_vcf=path)) + return sorted(simple_variants, key=lambda x: x.pos) + + +class VariantOverlapDetector(VariantLookup): + """Implements `VariantLookup` by reading the entire VCF into memory and loading the resulting + Variants into an `OverlapDetector`.""" + + def __init__(self, vcf_paths: list[Path], min_maf: Optional[float], include_missing_mafs: bool): + super().__init__( + vcf_paths=vcf_paths, min_maf=min_maf, include_missing_mafs=include_missing_mafs + ) + self._overlap_detector: OverlapDetector[_VariantInterval] = OverlapDetector() + + if len(vcf_paths) == 0: + raise ValueError("No VCF paths given to query.") + + for path in vcf_paths: + if ( + not path.with_suffix(path.suffix + ".csi").is_file() + and not path.with_suffix(path.suffix + ".tbi").is_file() + ): + raise ValueError(f"Cannot perform fetch with missing index file for VCF: {path}") + + with reader(path) as fh: + variant_intervals = iter( + _VariantInterval.build(simple_variant) + for variant in fh + for simple_variant in self.to_variants([variant], source_vcf=path) + ) + self._overlap_detector.add_all(variant_intervals) + + def _query(self, refname: str, start: int, end: int) -> list[SimpleVariant]: + """Queries variants from the VCFs used by this lookup.""" + query = Interval( + refname=refname, start=start - 1, end=end + ) # interval is half-open, 0-based + overlapping_variants = ( + variant_interval.variant + for variant_interval in self._overlap_detector.get_overlaps(query) + ) + return sorted(overlapping_variants, key=lambda v: (v.pos, v.id)) + + +# module-level functions +def calc_maf_from_filter(variant: pysam.VariantRecord) -> Optional[float]: + """Calculates minor allele frequency (MAF) based on whether `VariantRecord` denotes + CAF, AF, AC and AN in the INFO field. In none of those are found, looks in the FORMAT + field for genotypes and calculates MAF. + + If the variant has multiple _alternate_ alleles, then the returned MAF is the sum of MAFs across + all alternate alleles. + + Args: + variant: the variant from which the MAF will be computed + + Returns: + the minor allele frequency (MAF) + """ + + maf: Optional[float] = None + if "CAF" in variant.info: + # Assumes CAF is a list of allele frequencies, with the first being the reference allele + maf = 1 - float(variant.info["CAF"][0]) + elif "AF" in variant.info: + maf = sum(float(af) for af in variant.info["AF"]) + elif "AC" in variant.info and "AN" in variant.info: + ac = sum(int(ac) for ac in variant.info["AC"]) + an = int(variant.info["AN"]) + maf = ac / an + elif len(list(variant.samples)) > 0: # if genotypes are not empty + gts = [idx for sample in variant.samples.values() for idx in sample["GT"] if "GT" in sample] + if len(gts) > 0: + num_alt = sum(1 for idx in gts if idx != 0) + maf = num_alt / len(gts) + + return maf + + +def cached( + vcf_paths: list[Path], min_maf: float, include_missing_mafs: bool = False +) -> VariantOverlapDetector: + """Constructs a `VariantLookup` that caches all variants in memory for fast lookup. + Appropriate for small VCFs.""" + return VariantOverlapDetector( + vcf_paths=vcf_paths, min_maf=min_maf, include_missing_mafs=include_missing_mafs + ) + + +def disk_based( + vcf_paths: list[Path], min_maf: float, include_missing_mafs: bool = False +) -> FileBasedVariantLookup: + """Constructs a `VariantLookup` that queries indexed VCFs on disk for each lookup. + Appropriate for large VCFs.""" + return FileBasedVariantLookup( + vcf_paths=vcf_paths, min_maf=min_maf, include_missing_mafs=include_missing_mafs + ) diff --git a/prymer/ntthal/__init__.py b/prymer/ntthal/__init__.py new file mode 100644 index 0000000..8bfa759 --- /dev/null +++ b/prymer/ntthal/__init__.py @@ -0,0 +1,130 @@ +""" +# Utility Classes and Methods for NtThermoAlign + +This module contains the [`NtThermoAlign`][prymer.ntthal.NtThermoAlign] class for +submitting multiple queries to `ntthal`, a command line tool included with `primer3`. Methods +include functions to open and use subprocesses, check and +manipulate status, and calculate melting temperature of valid oligo sequences. +The class can be optionally used as a context manager. + +## Examples of Calculating Melting Temperature + +```python +>>> from prymer import ntthal +>>> t = ntthal.NtThermoAlign() +>>> print(t.duplex_tm(s1="ATGC", s2="GCAT")) +-54.75042 +>>> from prymer import ntthal +>>> with ntthal.NtThermoAlign() as t: +... print(t.duplex_tm(s1="ATGC", s2="GCAT")) +-54.75042 + +``` +""" + +from pathlib import Path + +from prymer.util.executable_runner import ExecutableRunner + +MONOVALENT_MILLIMOLAR: float = 50.0 +"""The default concentration of monovalent cations in mM""" + +DIVALENT_MILLIMOLAR: float = 0.0 +"""The default concentration of divalent cations in mM""" + +DNTP_MILLIMOLAR: float = 0.0 +"""The default concentration of deoxynycleotide triphosphate in mM""" + +DNA_NANOMOLAR: float = 50.0 +"""The concentration of DNA strands in nM""" + +TEMPERATURE: float = 37.0 +"""The default temperature at which duplex is calculated (Celsius)""" + + +class NtThermoAlign(ExecutableRunner): + """ + Uses the `ntthal` command line tool to calculate the melting temperature of user-provided + oligo sequences. + """ + + def __init__( + self, + executable: str | Path = "ntthal", + monovalent_millimolar: float = MONOVALENT_MILLIMOLAR, + divalent_millimolar: float = DIVALENT_MILLIMOLAR, + dntp_millimolar: float = DNTP_MILLIMOLAR, + dna_nanomolar: float = DNA_NANOMOLAR, + temperature: float = TEMPERATURE, + ): + """ + Args: + executable: string or Path representation of ntthal executable path + monovalent_millimolar: concentration of monovalent cations in mM + divalent_millimolar: concentration of divalent cations in mM + dntp_millimolar: concentration of deoxynycleotide triphosphate in mM + dna_nanomolar: concentration of DNA strands in nM + temperature: temperature at which duplex is calculated (Celsius) + """ + executable_path = ExecutableRunner.validate_executable_path(executable=executable) + command: list[str] = [f"{executable_path}", "-r", "-i"] + + if monovalent_millimolar < 0: + raise ValueError(f"monovalent_millimolar must be >=0, received {monovalent_millimolar}") + if divalent_millimolar < 0: + raise ValueError(f"divalent_millimolar must be >=0, received {divalent_millimolar}") + if dntp_millimolar < 0: + raise ValueError(f"dntp_millimolar must be >=0, received {dntp_millimolar}") + if dna_nanomolar < 0: + raise ValueError(f"dna_nanomolar must be >=0, received {dna_nanomolar}") + if temperature < 0: + raise ValueError(f"temperature must be >=0, received {temperature}") + + command.extend(["-mv", f"{monovalent_millimolar}"]) + command.extend(["-dv", f"{divalent_millimolar}"]) + command.extend(["-n", f"{dntp_millimolar}"]) + command.extend(["-d", f"{dna_nanomolar}"]) + command.extend(["-t", f"{temperature}"]) + + super().__init__(command=command) + + def duplex_tm(self, s1: str, s2: str) -> float: + """ + Calculates the melting temperature (Tm) of two provided oligos. + + Args: + s1: the sequence of oligo 1 (5'->3' orientation) + s2: the sequence of oligo 2 (5'->3' orientation) + + Example: + >>> t = NtThermoAlign() + >>> t.duplex_tm(s1 = "ACGT", s2 = "ACGT") + -46.542706 + + Returns: + result: ntthal-calculated melting temperature + + Raises: + ValueError: if ntthal result cannot be cast to a float + RuntimeError: if underlying subprocess has already been terminated + """ + if not self.is_alive: + raise RuntimeError( + "Error, trying to use a subprocess that has already been " + f"terminated, return code {self._subprocess.returncode}" + ) + if not s1.isalpha() or not s2.isalpha(): + raise ValueError( + "Both input strings must be all alphabetic and non-empty, " + f"received {s1} and {s2}" + ) + + self._subprocess.stdin.write(f"{s1},{s2}\n") + self._subprocess.stdin.flush() # forces the input to be sent to the underlying process. + raw_result = self._subprocess.stdout.readline().rstrip("\r\n") + try: + result = float(raw_result) + except ValueError as e: + raise ValueError(f"Error: {e}, {raw_result} cannot be cast to float") from e + + return result diff --git a/prymer/offtarget/__init__.py b/prymer/offtarget/__init__.py new file mode 100644 index 0000000..8a460e8 --- /dev/null +++ b/prymer/offtarget/__init__.py @@ -0,0 +1,13 @@ +from prymer.offtarget.bwa import BwaAlnInteractive +from prymer.offtarget.bwa import BwaHit +from prymer.offtarget.bwa import BwaResult +from prymer.offtarget.bwa import Query +from prymer.offtarget.offtarget_detector import OffTargetResult + +__all__ = [ + "BwaAlnInteractive", + "Query", + "BwaHit", + "BwaResult", + "OffTargetResult", +] diff --git a/prymer/offtarget/bwa.py b/prymer/offtarget/bwa.py new file mode 100644 index 0000000..7c4bd47 --- /dev/null +++ b/prymer/offtarget/bwa.py @@ -0,0 +1,418 @@ +""" +# Methods and Classes to run and interact with BWA + +The [`BwaAlnInteractive`][prymer.offtarget.bwa.BwaAlnInteractive] class is used to map +a list of queries to the reference genome. A single query may be mapped using +[`map_one()`][prymer.offtarget.bwa.BwaAlnInteractive.map_one], while multiple queries may +be mapped using [`map_all()`][prymer.offtarget.bwa.BwaAlnInteractive.map_all]. The latter +is more efficient than mapping queries one-by-one. + +The queries are provided to these methods using the [`Query`][prymer.offtarget.bwa.Query] +class, which contains the unique identifier for the query and the bases to query. These methods +return a [`BwaResult`][prymer.offtarget.bwa.BwaResult], which represents zero or more hits +(or alignments) found by BWA for the given query. Each hit is represented by a +[`BwaHit`][prymer.offtarget.bwa.BwaHit] object. In some cases, BWA will write fewer (or no) +hits in the "XA" tag than the total number hits reported in the "HN". This occurs when BWA finds more +hits than `max_hits` (see `bwt aln -X`). + + ## Example + +```python +>>> from pathlib import Path +>>> ref_fasta = Path("./tests/offtarget/data/miniref.fa") +>>> query = Query(bases="TCTACTAAAAATACAAAAAATTAGCTGGGCATGATGGCATGCACCTGTAATCCCGCTACT", id="NA") +>>> bwa = BwaAlnInteractive(ref=ref_fasta, max_hits=1) +>>> result = bwa.map_one(query=query.bases, id=query.id) +>>> result.hit_count +1 +>>> len(result.hits) +1 +>>> result.hits[0] +BwaHit(refname='chr1', start=61, negative=False, cigar=Cigar(elements=(CigarElement(length=60, operator=),)), edits=0) +>>> query = Query(bases="AAAAAA", id="NA") +>>> bwa.map_all(queries=[query]) +[BwaResult(query=Query(id='NA', bases='AAAAAA'), hit_count=3968, hits=[])] +>>> bwa.close() + +``` +""" # noqa: E501 + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar +from typing import Optional +from typing import cast + +import pysam +from fgpyo import sam +from fgpyo import sequence +from fgpyo.sam import Cigar +from pysam import AlignedSegment + +from prymer.api import coordmath +from prymer.util.executable_runner import ExecutableRunner + + +@dataclass(init=True, frozen=True) +class Query: + """Represents a single query sequence for mapping. + + Attributes: + id: the identifier for the query (e.g. read name) + bases: the query bases + """ + + id: str + bases: str + + _DEFAULT_BASE_QUALITY: ClassVar[str] = "H" + """The default base quality for a query""" + + def __post_init__(self) -> None: + if self.bases is None or len(self.bases) == 0: + raise ValueError("No bases were provided to query") + + def to_fastq(self, reverse_complement: bool = False) -> str: + """Returns the multi-line FASTQ string representation of this query""" + bases = sequence.reverse_complement(self.bases) if reverse_complement else self.bases + quals = self._DEFAULT_BASE_QUALITY * len(self.bases) + return f"@{self.id}\n{bases}\n+\n{quals}\n" + + +@dataclass(init=True, frozen=True) +class BwaHit: + """Represents a single hit or alignment of a sequence to a location in the genome. + + Attributes: + refname: the reference name of the hit + start: the start position of the hit (1-based inclusive) + negative: whether the hit is on the negative strand + cigar: the cigar string returned by BWA + edits: the number of edits between the read and the reference + """ + + refname: str + start: int + negative: bool + cigar: Cigar + edits: int + + @property + def mismatches(self) -> int: + """The number of mismatches for the hit""" + indel_sum = sum(elem.length for elem in self.cigar.elements if elem.operator.is_indel) + if indel_sum > self.edits: + raise ValueError( + f"indel_sum ({indel_sum}) > self.edits ({self.edits}) with cigar: {self.cigar}" + ) + return self.edits - indel_sum + + @property + def end(self) -> int: + """The end position of the hit (1-based inclusive)""" + return coordmath.get_closed_end(self.start, self.cigar.length_on_target()) + + @staticmethod + def build( + refname: str, start: int, negative: bool, cigar: str, edits: int, revcomp: bool = False + ) -> "BwaHit": + """Generates a hit object. + + Args: + refname: the reference name of the hit + start: the start position of the hit (1-based inclusive) + negative: whether the hit is on the negative strand + cigar: the cigar string returned by BWA + edits: the number of edits between the read and the reference + revcomp: whether the reverse-complement of the query sequence was fed to BWA, in which + case various fields should be negated/reversed in the Hit + + Returns: + A Hit that represents the mapping of the original query sequence that was supplied + """ + negative = negative != revcomp + return BwaHit( + refname=refname, + start=start, + negative=negative, + cigar=Cigar.from_cigarstring(cigar), + edits=edits, + ) + + def __str__(self) -> str: + """Returns the string representation in bwa's XA tag format.""" + # E.g. XA:Z:chr4,-97592047,24M,3;chr8,-32368749,24M,3; + return ",".join( + [ + self.refname, + ("-" if self.negative else "+") + f"{self.start}", + f"{self.cigar}", + f"{self.edits}", + ] + ) + + +@dataclass(init=True, frozen=True) +class BwaResult: + """Represents zero or more hits or alignments found by BWA for the given query. The number of + hits found may be more than the number of hits listed in the `hits` attribute. + + Attributes: + query: the query (as given, no RC'ing) + hit_count: the total number of hits found by bwa (this may be more than len(hits)) + hits: the subset of hits that were reported by bwa + """ + + query: Query + hit_count: int + hits: list[BwaHit] + + +MAX_MISMATCHES: int = 3 +"""The default maximum number of mismatches allowed in the full query sequence""" + +MAX_MISMATCHES_IN_SEED: int = 3 +"""The default maximum number of mismatches allowed in the seed region""" + +MAX_GAP_OPENS: int = 0 +"""The default maximum number of gap opens allowed in the full query sequence""" + +MAX_GAP_EXTENSIONS: int = -1 +"""The default maximum number of gap extensions allowed in the full query sequence""" + +SEED_LENGTH: int = 20 +"""The default length of the seed region""" + +BWA_AUX_EXTENSIONS: list[str] = [".amb", ".ann", ".bwt", ".pac", ".sa"] +"""The file extensiosn for BWA index files""" + + +class BwaAlnInteractive(ExecutableRunner): + """Wrapper around a novel mode of 'bwa aln' that allows for "interactive" use of bwa to keep + the process running and be able to send it chunks of reads periodically and get alignments + back without waiting for a full batch of reads to be sent. + + See: https://github.com/fulcrumgenomics/bwa/tree/interactive_aln + + Attributes: + max_hits: the maximum number of hits to report - if more than this number of seed hits + are found, report only the count and not each hit. + reverse_complement: reverse complement each query sequence before alignment. + include_alt_hits: if True include hits to references with names ending in _alt, otherwise + do not include them. + """ + + def __init__( + self, + ref: Path, + max_hits: int, + executable: str | Path = "bwa", + max_mismatches: int = 3, + max_mismatches_in_seed: int = 3, + max_gap_opens: int = 0, + max_gap_extensions: int = -1, + seed_length: int = 20, + reverse_complement: bool = False, + include_alt_hits: bool = False, + threads: Optional[int] = None, + ): + """ + Args: + ref: the path to the reference FASTA, which must be indexed with bwa. + max_hits: the maximum number of hits to report - if more than this number of seed hits + are found, report only the count and not each hit. + executable: string or Path representation of the `bwa` executable path + max_mismatches: the maximum number of mismatches allowed in the full query sequence + max_mismatches_in_seed: the maximum number of mismatches allowed in the seed region + max_gap_opens: the maximum number of gap opens allowed in the full query sequence + max_gap_extensions: the maximum number of gap extensions allowed in the full query + sequence + seed_length: the length of the seed region + reverse_complement: reverse complement each query sequence before alignment + include_alt_hits: if true include hits to references with names ending in _alt, + otherwise do not include them. + threads: the number of threads to use. If `None`, use all available CPUs. + """ + threads = os.cpu_count() if threads is None else threads + executable_path = ExecutableRunner.validate_executable_path(executable=executable) + self.reverse_complement: bool = reverse_complement + self.include_alt_hits: bool = include_alt_hits + self.max_hits: int = max_hits + + missing_aux_paths = [] + for aux_ext in BWA_AUX_EXTENSIONS: + aux_path = Path(f"{ref}{aux_ext}") + if not aux_path.exists(): + missing_aux_paths.append(aux_path) + if len(missing_aux_paths) > 0: + message: str + if len(missing_aux_paths) > 1: + message = "BWA index files do not exist:\n\t" + else: + message = "BWA index file does not exist:\n\t" + message += "\t\n".join(f"{p}" for p in missing_aux_paths) + raise FileNotFoundError(f"{message}\nPlease index with: `bwa index {ref}`") + + # -N = non-iterative mode: search for all n-difference hits (slooow) + # -S = output SAM (run samse) + # -Z = interactive mode (no input buffer and force processing with empty lines between recs) + command: list[str] = [ + f"{executable_path}", + "aln", + "-t", + f"{threads}", + "-n", + f"{max_mismatches}", + "-o", + f"{max_gap_opens}", + "-e", + f"{max_gap_extensions}", + "-l", + f"{seed_length}", + "-k", + f"{max_mismatches_in_seed}", + "-X", + f"{max_hits}", + "-N", + "-S", + "-Z", + "-D", + f"{ref}", + "/dev/stdin", + ] + + super().__init__(command=command) + + # HACK ALERT + # This is a hack. By trial and error, pysam.AlignmentFile() will block reading unless + # there's at least a few records due to htslib wanting to read a few records for format + # auto-detection. Lame. So a hundred queries are sent to the aligner to align enable the + # htslib auto-detection to complete, and for us to be able to read using pysam. + num_warmup: int = 100 + for i in range(num_warmup): + query = Query(id=f"ignoreme:{i}", bases="A" * 100) + fastq_str = query.to_fastq(reverse_complement=self.reverse_complement) + self._subprocess.stdin.write(fastq_str) + self.__signal_bwa() # forces the input to be sent to the underlying process. + self._reader = sam.reader(path=self._subprocess.stdout, file_type=sam.SamFileType.SAM) + for _ in range(num_warmup): + next(self._reader) + + def __signal_bwa(self) -> None: + """Signals BWA to process the queries""" + for _ in range(3): + self._subprocess.stdin.flush() + self._subprocess.stdin.write("\n\n") + self._subprocess.stdin.flush() + + def map_one(self, query: str, id: str = "unknown") -> BwaResult: + """Maps a single query to the genome and returns the result. + + Args: + query: the query to map with BWA + + Returns: + a `BwaResult` based on mapping the query + """ + return self.map_all([Query(bases=query, id=id)])[0] + + def map_all(self, queries: list[Query]) -> list[BwaResult]: + """Maps multiple queries and returns the results. This is more efficient than using + `map_one` on each query one-by-one as it batches reads to BWA. + + Args: + queries: the queries to map with BWA + + Returns: + one `BwaResult`s for each query + """ + if len(queries) == 0: + return [] + + # Send the reads to BWA + for query in queries: + fastq_str = query.to_fastq(reverse_complement=self.reverse_complement) + self._subprocess.stdin.write(fastq_str) + self.__signal_bwa() # forces the input to be sent to the underlying process. + + # Read back the results + results: list[BwaResult] = [] + for query in queries: + # get the next alignment and convert to a result + results.append(self._to_result(query=query, rec=next(self._reader))) + + return results + + def _to_result(self, query: Query, rec: pysam.AlignedSegment) -> BwaResult: + """Converts the query and alignment to a result. + + Args: + query: the original query + rec: the alignment + """ + if query.id != rec.query_name: + raise ValueError( + "Query and Results are out of order" f"Query=${query.id}, Result=${rec.query_name}" + ) + + num_hits: Optional[int] = int(rec.get_tag("HN")) if rec.has_tag("HN") else None + if rec.is_unmapped: + if num_hits is not None and num_hits > 0: + raise ValueError(f"Read was unmapped but num_hits > 0: {rec}") + return BwaResult(query=query, hit_count=0, hits=[]) + elif num_hits > self.max_hits: + return BwaResult(query=query, hit_count=num_hits, hits=[]) + else: + hits = self.to_hits(rec=rec) + hit_count = num_hits if len(hits) == 0 else len(hits) + return BwaResult(query=query, hit_count=hit_count, hits=hits) + + def to_hits(self, rec: AlignedSegment) -> list[BwaHit]: + """Extracts the hits from the given alignment. Beyond the current alignment + additional alignments are parsed from the XA SAM tag. + + Args: + rec: the given alignment + """ + negative = rec.is_reverse != self.reverse_complement + first_hit: BwaHit = BwaHit( + refname=rec.reference_name, + start=rec.reference_start + 1, # NB: pysam is 0-based, Hit is 1-based + negative=negative, + cigar=Cigar.from_cigartuples(rec.cigartuples), + edits=int(rec.get_tag("NM")), + ) + + # Add the hits + # E.g. XA:Z:chr4,-97592047,24M,3;chr8,-32368749,24M,3; + hits: list[BwaHit] = [first_hit] + if rec.has_tag("XA"): + for xa in cast(str, rec.get_tag("XA")).split(";"): + if xa == "": + continue + fields = xa.split(",") + + # If the reverse-complement of the query sequence was fed to BWA, various fields + # should be negated/reversed in the Hit + negative = fields[1][0] == "-" + if self.reverse_complement: + negative = not negative + + hit: BwaHit = BwaHit( + refname=fields[0], + start=int(fields[1][1:]), # the XA tag is 1-based + negative=negative, + cigar=Cigar.from_cigarstring(fields[2]), + edits=int(fields[3]), + ) + hits.append(hit) + + if not self.include_alt_hits: + hits = [hit for hit in hits if not hit.refname.endswith("_alt")] + + return hits + + def close(self) -> None: + self._reader.close() + super().close() diff --git a/prymer/offtarget/offtarget_detector.py b/prymer/offtarget/offtarget_detector.py new file mode 100644 index 0000000..eb3a9b8 --- /dev/null +++ b/prymer/offtarget/offtarget_detector.py @@ -0,0 +1,352 @@ +""" +# Methods and classes to detect off-target mappings for primers and primer pairs + +The [`OffTargetDetector`][prymer.offtarget.offtarget_detector.OffTargetDetector] uses +[`BwaAlnInteractive`][prymer.offtarget.bwa.BwaAlnInteractive] to search for off targets for +one or more primers or primer pairs. + +The [`filter()`][prymer.offtarget.offtarget_detector.OffTargetDetector.filter] method +filters an iterable of `Primers` to return only those that have less than a given maximum number of off-target hits +to the genome. + +```python +>>> from pathlib import Path +>>> from prymer.api.span import Strand +>>> ref_fasta = Path("./tests/offtarget/data/miniref.fa") +>>> left_primer = Primer(bases="AAAAA", tm=37, penalty=0, span=Span("chr1", start=67, end=71)) +>>> right_primer = Primer(bases="TTTTT", tm=37, penalty=0, span=Span("chr1", start=75, end=79, strand=Strand.NEGATIVE)) +>>> detector = OffTargetDetector(ref=ref_fasta, max_primer_hits=204, max_primer_pair_hits=1, three_prime_region_length=20, max_mismatches_in_three_prime_region=0, max_mismatches=0, max_amplicon_size=250) +>>> len(detector.filter(primers=[left_primer, right_primer])) # keep all +2 +>>> detector.close() +>>> detector = OffTargetDetector(ref=ref_fasta, max_primer_hits=203, max_primer_pair_hits=1, three_prime_region_length=20, max_mismatches_in_three_prime_region=0, max_mismatches=0, max_amplicon_size=250) +>>> len(detector.filter(primers=[left_primer, right_primer])) # keep none +0 +>>> detector.close() + +``` + +The [`check_one()`][prymer.offtarget.offtarget_detector.OffTargetDetector.check_one] and +the [`check_all()`][prymer.offtarget.offtarget_detector.OffTargetDetector.check_all] +methods check one or multiple primer pairs respectively for off-target mappings, returning an +[`OffTargetResult`][prymer.offtarget.offtarget_detector.OffTargetResult], or mapping from +each `PrimerPair` to its corresponding `OffTargetResult`. This result contains information about +the off-target mappings. + +```python +>>> detector = OffTargetDetector(ref=ref_fasta, max_primer_hits=1, max_primer_pair_hits=1, three_prime_region_length=20, max_mismatches_in_three_prime_region=0, max_mismatches=0, max_amplicon_size=250) +>>> primer_pair = PrimerPair(left_primer=left_primer, right_primer=right_primer, amplicon_tm=37, penalty=0.0) +>>> detector.check_one(primer_pair=primer_pair) +OffTargetResult(primer_pair=..., passes=False, cached=False, spans=[], left_primer_spans=[], right_primer_spans=[]) +>>> list(detector.check_all(primer_pairs=[primer_pair]).values()) +[OffTargetResult(primer_pair=..., passes=False, cached=True, spans=[], left_primer_spans=[], right_primer_spans=[])] +>>> detector = OffTargetDetector(ref=ref_fasta, max_primer_hits=204, max_primer_pair_hits=856, three_prime_region_length=20, max_mismatches_in_three_prime_region=0, max_mismatches=0, max_amplicon_size=250) +>>> result = detector.check_one(primer_pair=primer_pair) +>>> len(result.spans) +856 +>>> len(result.left_primer_spans) +204 +>>> len(result.right_primer_spans) +204 + +``` + +Finally, the [`mappings_of()`][prymer.offtarget.offtarget_detector.OffTargetDetector.mappings_of] +method maps individual primers (`Primer`s). + +```python +>>> p1: Primer = Primer(tm=37, penalty=0, span=Span(refname="chr1", start=1, end=30), bases="CAGGTGGATCATGAGGTCAGGAGTTCAAGA") +>>> p2: Primer = Primer(tm=37, penalty=0, span=Span(refname="chr1", start=61, end=93, strand=Strand.NEGATIVE), bases="CATGCCCAGCTAATTTTTTGTATTTTTAGTAGA") +>>> results_dict: dict[str, BwaResult] = detector.mappings_of(primers=[p1, p2]) +>>> list(results_dict.keys()) +['CAGGTGGATCATGAGGTCAGGAGTTCAAGA', 'CATGCCCAGCTAATTTTTTGTATTTTTAGTAGA'] +>>> results = list(results_dict.values()) +>>> results[0] +BwaResult(query=Query(id='CAGGTGGATCATGAGGTCAGGAGTTCAAGA', bases='CAGGTGGATCATGAGGTCAGGAGTTCAAGA'), hit_count=1, hits=[...]) +>>> results[0].hits[0] +BwaHit(refname='chr1', start=1, negative=False, cigar=Cigar(elements=(CigarElement(length=30, operator=),)), edits=0) +>>> results[1] +BwaResult(query=Query(id='CATGCCCAGCTAATTTTTTGTATTTTTAGTAGA', bases='CATGCCCAGCTAATTTTTTGTATTTTTAGTAGA'), hit_count=1, hits=[...]) +>>> results[1].hits[0] +BwaHit(refname='chr1', start=61, negative=True, cigar=Cigar(elements=(CigarElement(length=33, operator=),)), edits=0) + +``` + +""" # noqa: E501 + +import itertools +from dataclasses import dataclass +from dataclasses import field +from dataclasses import replace +from pathlib import Path +from types import TracebackType +from typing import Optional +from typing import Self + +from ordered_set import OrderedSet + +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.offtarget.bwa import BwaAlnInteractive +from prymer.offtarget.bwa import BwaHit +from prymer.offtarget.bwa import BwaResult +from prymer.offtarget.bwa import Query + + +@dataclass(init=True, frozen=True) +class OffTargetResult: + """Information obtained by running a single primer pair through the off-target detector. + + Attributes: + primer_pair: the primer submitted + passes: True if the primer pair passes all checks, False otherwise + cached: True if this result is part of a cache, False otherwise. This is useful for testing + spans: the set of mappings of the primer pair to the genome or an empty list if mappings + were not retained + left_primer_spans: the list of mappings for the left primer, independent of the pair + mappings, or an empty list + right_primer_spans: the list of mappings for the right primer, independent of the pair + mappings, or an empty list + """ + + primer_pair: PrimerPair + passes: bool + cached: bool = False + spans: list[Span] = field(default_factory=list) + left_primer_spans: list[Span] = field(default_factory=list) + right_primer_spans: list[Span] = field(default_factory=list) + + +class OffTargetDetector: + """A class for detecting off-target mappings of primers and primer pairs that uses a custom + version of "bwa aln". + + The off-target detection is faster and more sensitive than traditional isPCR and in addition can + correctly detect primers that are repetitive and contain many thousands or millions of mappings + to the genome. + + Note that while this class invokes BWA with multiple threads, it is not itself thread-safe. + Only one thread at a time should invoke methods on this class without external synchronization. + """ + + def __init__( + self, + ref: Path, + max_primer_hits: int, + max_primer_pair_hits: int, + three_prime_region_length: int, + max_mismatches_in_three_prime_region: int, + max_mismatches: int, + max_amplicon_size: int, + cache_results: bool = True, + threads: Optional[int] = None, + keep_spans: bool = True, + keep_primer_spans: bool = True, + executable: str | Path = "bwa", + ) -> None: + """ + Args: + ref: the reference genome fasta file (must be indexed with BWA) + max_primer_hits: the maximum number of hits an individual primer can have in the genome + before it is considered an invalid primer, and all primer pairs containing the + primer failed. + max_primer_pair_hits: the maximum number of amplicons a primer pair can make and be + considered passing + three_prime_region_length: the number of bases at the 3' end of the primer in which the + parameter max_mismatches_in_three_prime_region is evaluated + max_mismatches_in_three_prime_region: the maximum number of mismatches that are + tolerated in the three prime region of each primer defined by + three_prime_region_length + max_mismatches: the maximum number of mismatches allowed in the full length primer + (including any in the three prime region) + max_amplicon_size: the maximum amplicon size to consider amplifiable + cache_results: if True, cache results for faster re-querying + threads: the number of threads to use when invoking bwa + keep_spans: if True, [[OffTargetResult]] objects will have amplicon spans + populated, otherwise not + keep_primer_spans: if True, [[OffTargetResult]] objects will have left and right + primer spans + executable: string or Path representation of the `bwa` executable path + """ + self._primer_cache: dict[str, BwaResult] = {} + self._primer_pair_cache: dict[PrimerPair, OffTargetResult] = {} + self._bwa = BwaAlnInteractive( + executable=executable, + ref=ref, + reverse_complement=True, + threads=threads, + seed_length=three_prime_region_length, + max_mismatches_in_seed=max_mismatches_in_three_prime_region, + max_mismatches=max_mismatches, + max_hits=max_primer_hits, + ) + self._max_primer_hits = max_primer_hits + self._max_primer_pair_hits: int = max_primer_pair_hits + self._max_amplicon_size: int = max_amplicon_size + self._cache_results: bool = cache_results + self._keep_spans: bool = keep_spans + self._keep_primer_spans: bool = keep_primer_spans + + def filter(self, primers: list[Primer]) -> list[Primer]: + """Filters an iterable of Primers to return only those that have less than + `max_primer_hits` mappings to the genome.""" + results: dict[str, BwaResult] = self.mappings_of(primers) + return [ + primer for primer in primers if results[primer.bases].hit_count <= self._max_primer_hits + ] + + def check_one(self, primer_pair: PrimerPair) -> OffTargetResult: + """Checks a PrimerPair for off-target sites in the genome at which it might amplify.""" + result: dict[PrimerPair, OffTargetResult] = self.check_all([primer_pair]) + return result[primer_pair] + + def check_all(self, primer_pairs: list[PrimerPair]) -> dict[PrimerPair, OffTargetResult]: + """Checks a collection of primer pairs for off-target sites, returning a dictionary of + `PrimerPair`s to `OffTargetResult`. + + Returns: + a Map containing all given primer pairs as keys with the values being the result of + off-target checking. + """ + + primer_pair_results: dict[PrimerPair, OffTargetResult] = {} + result: OffTargetResult + + # Get the primer pairs to map. If the primer pair is found in the cache, use that + primer_pairs_to_map: list[PrimerPair] = [] + if not self._cache_results: + primer_pairs_to_map = primer_pairs + else: + for primer_pair in primer_pairs: + match self._primer_pair_cache.get(primer_pair, None): + case None: + primer_pairs_to_map.append(primer_pair) # Map it! + case result: + primer_pair_results[primer_pair] = result + + # If there are no primer pairs to map, return the results + if len(primer_pairs_to_map) == 0: + return primer_pair_results + + # Get mappings of all the primers + primers = [primer for primer_pair in primer_pairs for primer in primer_pair] + hits_by_primer = self.mappings_of(primers) + + for primer_pair in primer_pairs: + primer_pair_results[primer_pair] = self._build_off_target_result( + primer_pair=primer_pair, hits_by_primer=hits_by_primer + ) + + return primer_pair_results + + def _build_off_target_result( + self, primer_pair: PrimerPair, hits_by_primer: dict[str, BwaResult] + ) -> OffTargetResult: + """Builds an `OffTargetResult` for the given `PrimerPair`. + + If there are too many primer hits for either the left or right primer, set `passes` to + `False` on the returned `OffTargetResult`, otherwise generate all possible amplicons with + size less than the given maximum to generate off target results. + + Args: + primer_pair: the primer pair from which the off-target result is built. + hits_by_primer: mapping of primer bases to `BwaHits`. + """ + result: OffTargetResult + + # Get the mappings for the left primer and right primer respectively + p1: BwaResult = hits_by_primer[primer_pair.left_primer.bases] + p2: BwaResult = hits_by_primer[primer_pair.right_primer.bases] + + # Get all possible amplicons from the left_primer_mappings and right_primer_mappings + # primer hits, filtering if there are too many for either + if p1.hit_count > self._max_primer_hits or p2.hit_count > self._max_primer_hits: + result = OffTargetResult(primer_pair=primer_pair, passes=False) + else: + amplicons = self._to_amplicons(p1.hits, p2.hits, self._max_amplicon_size) + result = OffTargetResult( + primer_pair=primer_pair, + passes=0 < len(amplicons) <= self._max_primer_pair_hits, + spans=amplicons if self._keep_spans else [], + left_primer_spans=( + [self._hit_to_span(h) for h in p1.hits] if self._keep_primer_spans else [] + ), + right_primer_spans=( + [self._hit_to_span(h) for h in p2.hits] if self._keep_primer_spans else [] + ), + ) + + if self._cache_results: + self._primer_pair_cache[primer_pair] = replace(result, cached=True) + + return result + + def mappings_of(self, primers: list[Primer]) -> dict[str, BwaResult]: + """Function to take a set of primers and map any that are not cached, and return mappings + for all of them. Note: the genomics sequence of the returned mappings are on the opposite + strand of that of the strand of the primer. I.e. we map the complementary bases (reversed) + to that of the primer.""" + + primers_to_map: list[Primer] + if not self._cache_results: + primers_to_map = primers + else: + primers_to_map = [ + primer for primer in list(OrderedSet(primers)) if primer not in self._primer_cache + ] + + # Build the unique list of queries to map with BWA + queries: list[Query] = [ + Query(id=primer.bases, bases=primer.bases) for primer in primers_to_map + ] + + # Map the queries with BWA + hits_by_primer: dict[str, BwaResult] = { + result.query.id: result for result in self._bwa.map_all(queries) + } + + # Cache the results, if desired, and get the hits by primer _for all_ primers. If not + # caching, then hits_by_primer already contains all the primers. + if self._cache_results: + self._primer_cache.update(hits_by_primer) + hits_by_primer = {primer.bases: self._primer_cache[primer.bases] for primer in primers} + + return hits_by_primer + + @staticmethod + def _to_amplicons(lefts: list[BwaHit], rights: list[BwaHit], max_len: int) -> list[Span]: + """Takes a set of hits for one or more left primers and right primers and constructs + amplicon mappings anywhere a left primer hit and a right primer hit align in F/R + orientation up to `maxLen` apart on the same reference. Primers may not overlap. + """ + amplicons: list[Span] = [] + for h1, h2 in itertools.product(lefts, rights): + if h1.negative == h2.negative or h1.refname != h2.refname: # not F/R orientation + continue + + plus, minus = (h2, h1) if h1.negative else (h1, h2) + if minus.start > plus.end and (minus.end - plus.start + 1) <= max_len: + amplicons.append(Span(refname=plus.refname, start=plus.start, end=minus.end)) + + return amplicons + + @staticmethod + def _hit_to_span(hit: BwaHit) -> Span: + """Converts a Bwa Hit object to a Span.""" + return Span(refname=hit.refname, start=hit.start, end=hit.end) + + def close(self) -> None: + self._bwa.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """Gracefully terminates any running subprocesses.""" + self.close() diff --git a/prymer/primer3/__init__.py b/prymer/primer3/__init__.py new file mode 100644 index 0000000..d52d7e3 --- /dev/null +++ b/prymer/primer3/__init__.py @@ -0,0 +1,21 @@ +from prymer.primer3.primer3 import Primer3 +from prymer.primer3.primer3 import Primer3Failure +from prymer.primer3.primer3 import Primer3Result +from prymer.primer3.primer3_failure_reason import Primer3FailureReason +from prymer.primer3.primer3_input import Primer3Input +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_task import DesignLeftPrimersTask +from prymer.primer3.primer3_task import DesignPrimerPairsTask +from prymer.primer3.primer3_task import DesignRightPrimersTask + +__all__ = [ + "Primer3", + "Primer3Result", + "Primer3Failure", + "Primer3FailureReason", + "Primer3Input", + "Primer3InputTag", + "DesignLeftPrimersTask", + "DesignPrimerPairsTask", + "DesignRightPrimersTask", +] diff --git a/prymer/primer3/primer3.py b/prymer/primer3/primer3.py new file mode 100644 index 0000000..031c9f2 --- /dev/null +++ b/prymer/primer3/primer3.py @@ -0,0 +1,730 @@ +""" +# Primer3 Class and Methods + +This module contains the [`Primer3`][prymer.primer3.primer3.Primer3] class, a class to +facilitate exchange of input and output data with Primer3, a command line tool. + +Similar to the [`NtThermoAlign`][prymer.ntthal.NtThermoAlign] and +[`BwaAlnInteractive`][prymer.offtarget.bwa.BwaAlnInteractive] classes in the `prymer` +library, the Primer3 class extends the +[`ExecutableRunner`][prymer.util.executable_runner.ExecutableRunner] base class to +initiate an underlying subprocess, read and write input and output data, and gracefully terminate +any remaining subprocesses. + +## Examples + +The genome FASTA must be provided to the `Primer3` constructor, such that design and target +nucleotide sequences can be retrieved. The full path to the `primer3` executable can provided, +otherwise it is assumed to be on the PATH. Furthermore, optionally a +[`VariantLookup`][prymer.api.variant_lookup.VariantLookup] may be provided to +hard-mask the design and target regions as to avoid design primers over polymorphic sites. + +```python +>>> from pathlib import Path +>>> from prymer.api.variant_lookup import VariantLookup, VariantOverlapDetector +>>> genome_fasta = Path("./tests/primer3/data/miniref.fa") +>>> genome_vcf = Path("./tests/primer3/data/miniref.variants.vcf.gz") +>>> variant_lookup: VariantLookup = VariantOverlapDetector(vcf_paths=[genome_vcf], min_maf=0.01, include_missing_mafs=False) +>>> designer = Primer3(genome_fasta=genome_fasta, variant_lookup=variant_lookup) + +``` + +The `get_design_sequences()` method on `Primer3` is used to retrieve the soft and hard masked +sequences for a given region. The hard-masked sequence replaces bases with `N`s that overlap +polymorphic sites found in the `VariantLookup` provided in the constructor. + +```python +>>> design_region = Span(refname="chr2", start=9095, end=9120) +>>> soft_masked, hard_masked = designer.get_design_sequences(region=design_region) +>>> soft_masked +'AGTTACATTACAAAAGGCAGATTTCA' +>>> hard_masked +'AGTTANNNTACAAAAGGCAGATTTCA' + +``` + +The `design_primers()` method on `Primer3` is used to design the primers given a +[`Primer3Input`][prymer.primer3.primer3_input.Primer3Input]. The latter includes all the +parameters and target region. + +```python +>>> from prymer.primer3.primer3_parameters import Primer3Parameters +>>> from prymer.api import MinOptMax +>>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) +>>> params = Primer3Parameters( \ + amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ + amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ + primer_sizes=MinOptMax(min=29, max=31, opt=30), \ + primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ + primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ +) +>>> design_input = Primer3Input( \ + target=target, \ + params=params, \ + task=DesignLeftPrimersTask(), \ +) +>>> left_result = designer.design_primers(design_input=design_input) + +``` + +The `left_result` returns the [`Primer3Result`][prymer.primer3.primer3.Primer3Result] +container class. It contains two attributes: +1. `filtered_designs`: filtered and ordered (by objective function score) list of primer pairs or + single primers that were returned by Primer3. +2. `failures`: ordered list of [`Primer3Failures`][prymer.primer3.primer3.Primer3Failure] + detailing design failure reasons and corresponding count. + +In this case, there are two failures reasons: + +```python +>>> for failure in left_result.failures: \ + print(failure) +Primer3Failure(reason=, count=171) +Primer3Failure(reason=, count=26) + +``` + +While`filtered_designs` attribute on `Primer3Result` may be used to access the list of primers or +primer pairs, it is more convenient to use the `primers()` and `primer_pairs()` methods +to return the designed primers or primer pairs (use the method corresponding to the input task) so +that the proper type is returned (i.e. [`Primer`][prymer.api.primer.Primer] or +[`PrimerPair`][prymer.api.primer_pair.PrimerPair]). + +```python +>>> for primer in left_result.primers(): \ + print(primer) +TCTGAACAGGACGAACTGGATTTCCTCAT 65.686 1.953897 chr1:163-191:+ +CTCTGAACAGGACGAACTGGATTTCCTCAT 66.152 2.293213 chr1:162-191:+ +TCTGAACAGGACGAACTGGATTTCCTCATG 66.33 2.514048 chr1:163-192:+ +AACAGGACGAACTGGATTTCCTCATGGAA 66.099 2.524986 chr1:167-195:+ +CTGAACAGGACGAACTGGATTTCCTCATG 65.47 2.556859 chr1:164-192:+ + +``` + +Finally, the designer should be closed to terminate the sub-process: + +```python +>>> designer.close() +True + +``` + +`Primer3` is also context a manager, and so can be used with a `with` clause: + +```python +>>> with Primer3(genome_fasta=genome_fasta) as designer: \ + pass # use designer here! + +``` + +""" # noqa: E501 + +import logging +import subprocess +import typing +from collections import Counter +from dataclasses import dataclass +from dataclasses import replace +from pathlib import Path +from typing import Generic +from typing import Optional +from typing import TypeVar +from typing import Union +from typing import assert_never + +import pysam +from fgpyo import sam +from fgpyo.fasta.sequence_dictionary import SequenceDictionary +from fgpyo.sam import reader +from fgpyo.sequence import reverse_complement +from fgpyo.util.metric import Metric + +from prymer.api.primer import Primer +from prymer.api.primer_like import PrimerLike +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.api.variant_lookup import SimpleVariant +from prymer.api.variant_lookup import VariantLookup +from prymer.primer3.primer3_failure_reason import Primer3FailureReason +from prymer.primer3.primer3_input import Primer3Input +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_task import DesignLeftPrimersTask +from prymer.primer3.primer3_task import DesignPrimerPairsTask +from prymer.primer3.primer3_task import DesignRightPrimersTask +from prymer.util.executable_runner import ExecutableRunner + + +@dataclass(init=True, slots=True, frozen=True) +class Primer3Failure(Metric["Primer3Failure"]): + """Encapsulates how many designs failed for a given reason. + Extends the `fgpyo.util.metric.Metric` class, which will facilitate writing out results for + primer design QC etc. + + Attributes: + reason: the reason the design failed + count: how many designs failed + """ + + reason: Primer3FailureReason + count: int + + +PrimerLikeType = TypeVar("PrimerLikeType", bound=PrimerLike) +"""Type variable for a `Primer3Result`, which must implement `PrimerLike`""" + + +@dataclass(init=True, slots=True, frozen=True) +class Primer3Result(Generic[PrimerLikeType]): + """Encapsulates Primer3 design results (both valid designs and failures). + + Attributes: + filtered_designs: filtered and ordered (by objective function score) list of primer + pairs or single primers that were returned by Primer3 + failures: ordered list of Primer3Failures detailing design failure reasons and corresponding + count + """ + + filtered_designs: list[PrimerLikeType] + failures: list[Primer3Failure] + + def as_primer_result(self) -> "Primer3Result[Primer]": + """Returns this Primer3Result assuming the design results are of type `Primer`.""" + if len(self.filtered_designs) > 0 and not isinstance(self.filtered_designs[0], Primer): + raise ValueError("Cannot call `as_primer_result` on `PrimerPair` results") + return typing.cast(Primer3Result[Primer], self) + + def as_primer_pair_result(self) -> "Primer3Result[PrimerPair]": + """Returns this Primer3Result assuming the design results are of type `PrimerPair`.""" + if len(self.filtered_designs) > 0 and not isinstance(self.filtered_designs[0], PrimerPair): + raise ValueError("Cannot call `as_primer_pair_result` on `Primer` results") + return typing.cast(Primer3Result[PrimerPair], self) + + def primers(self) -> list[Primer]: + """Returns the design results as a list `Primer`s""" + try: + return self.as_primer_result().filtered_designs + except ValueError as ex: + raise ValueError("Cannot call `primers` on `PrimerPair` results") from ex + + def primer_pairs(self) -> list[PrimerPair]: + """Returns the design results as a list `PrimerPair`s""" + try: + return self.as_primer_pair_result().filtered_designs + except ValueError as ex: + raise ValueError("Cannot call `primer_pairs` on `Primer` results") from ex + + +class Primer3(ExecutableRunner): + """ + Enables interaction with command line tool, primer3. + + Attributes: + _fasta: file handle to the open reference genome file + _dict: the sequence dictionary that corresponds to the provided reference genome file + """ + + def __init__( + self, + genome_fasta: Path, + executable: Optional[str] = None, + variant_lookup: Optional[VariantLookup] = None, + ) -> None: + """ + Args: + genome_fasta: Path to reference genome .fasta file + executable: string representation of the path to primer3_core + variant_lookup: VariantLookup object to facilitate hard-masking variants + + Assumes the sequence dictionary is located adjacent to the .fasta file and has the same + base name with a .dict suffix. + + """ + executable_path = ExecutableRunner.validate_executable_path( + executable="primer3_core" if executable is None else executable + ) + command: list[str] = [f"{executable_path}"] + + self.variant_lookup = variant_lookup + self._fasta = pysam.FastaFile(filename=f"{genome_fasta}") + + dict_path = genome_fasta.with_suffix(".dict") + # TODO: This is a placeholder while waiting for #160 to be resolved + # https://github.com/fulcrumgenomics/fgpyo/pull/160 + with reader(dict_path, file_type=sam.SamFileType.SAM) as fh: + self._dict: SequenceDictionary = SequenceDictionary.from_sam(header=fh.header) + + super().__init__(command=command, stderr=subprocess.STDOUT) + + def close(self) -> bool: + """Closes fasta file regardless of underlying subprocess status. + Logs an error if the underlying subprocess is not successfully closed. + + Returns: + True: if the subprocess was terminated successfully + False: if the subprocess failed to terminate or was not already running + """ + self._fasta.close() + subprocess_close = super().close() + if not subprocess_close: + logging.debug("Did not successfully close underlying subprocess") + return subprocess_close + + def get_design_sequences(self, region: Span) -> tuple[str, str]: + """Extracts the reference sequence that corresponds to the design region. + + Args: + region: the region of the genome to be extracted + + Returns: + A tuple of two sequences: the sequence for the region, and the sequence for the region + with variants hard-masked as Ns + + """ + # pysam.fetch: 0-based, half-open intervals + soft_masked = self._fasta.fetch( + reference=region.refname, start=region.start - 1, end=region.end + ) + + if self.variant_lookup is None: + hard_masked = soft_masked + return soft_masked, hard_masked + + overlapping_variants: list[SimpleVariant] = self.variant_lookup.query( + refname=region.refname, start=region.start, end=region.end + ) + positions: list[int] = [] + for variant in overlapping_variants: + # FIXME + positions.extend(range(variant.pos, variant.end + 1)) + + filtered_positions = [pos for pos in positions if region.start <= pos <= region.end] + soft_masked_list = list(soft_masked) + for pos in filtered_positions: + soft_masked_list[region.get_offset(pos)] = ( + "N" # get relative coord of filtered position and mask to N + ) + # convert list back to string + hard_masked = "".join(soft_masked_list) + return soft_masked, hard_masked + + @staticmethod + def _is_valid_primer(design_input: Primer3Input, primer_design: Primer) -> bool: + return ( + primer_design.longest_dinucleotide_run_length() + <= design_input.params.primer_max_dinuc_bases + ) + + @staticmethod + def _screen_pair_results( + design_input: Primer3Input, designed_primer_pairs: list[PrimerPair] + ) -> tuple[list[PrimerPair], list[Primer]]: + """Screens primer pair designs emitted by Primer3 for dinucleotide run length. + + Args: + design_input: the target region, design task, specifications, and scoring penalties + designed_primer_pairs: the unfiltered primer pair designs emitted by Primer3 + + Returns: + valid_primer_pair_designs: primer pairs within specifications + dinuc_pair_failures: single primer designs that failed the `max_dinuc_bases` threshold + """ + valid_primer_pair_designs: list[PrimerPair] = [] + dinuc_pair_failures: list[Primer] = [] + for primer_pair in designed_primer_pairs: + valid: bool = True + if ( + primer_pair.left_primer.longest_dinucleotide_run_length() + > design_input.params.primer_max_dinuc_bases + ): # if the left primer has too many dinucleotide bases, fail it + dinuc_pair_failures.append(primer_pair.left_primer) + valid = False + if ( + primer_pair.right_primer.longest_dinucleotide_run_length() + > design_input.params.primer_max_dinuc_bases + ): # if the right primer has too many dinucleotide bases, fail it + dinuc_pair_failures.append(primer_pair.right_primer) + valid = False + if valid: # if neither failed, append the pair to a list of valid designs + valid_primer_pair_designs.append(primer_pair) + return valid_primer_pair_designs, dinuc_pair_failures + + def design_primers(self, design_input: Primer3Input) -> Primer3Result: # noqa: C901 + """Designs primers or primer pairs given a target region. + + Args: + design_input: encapsulates the target region, design task, specifications, and scoring + penalties + + Returns: + Primer3Result containing both the valid and failed designs emitted by Primer3 + + Raises: + RuntimeError: if underlying subprocess is not alive + ValueError: if Primer3 returns errors or does not return output + ValueError: if Primer3 output is malformed + ValueError: if an unknown design task is given + """ + + if not self.is_alive: + raise RuntimeError( + f"Error, trying to use a subprocess that has already been " + f"terminated, return code {self._subprocess.returncode}" + ) + + region: Span = self._pad_target_region( + target=design_input.target, + max_amplicon_length=design_input.params.max_amplicon_length, + ) + + soft_masked, hard_masked = self.get_design_sequences(region) + global_primer3_params = { + Primer3InputTag.PRIMER_FIRST_BASE_INDEX: 1, + Primer3InputTag.PRIMER_EXPLAIN_FLAG: 1, + Primer3InputTag.SEQUENCE_TEMPLATE: hard_masked, + } + + assembled_primer3_tags = { + **global_primer3_params, + **design_input.to_input_tags(design_region=region), + } + + # Submit inputs to primer3 + for tag, value in assembled_primer3_tags.items(): + self._subprocess.stdin.write(f"{tag}={value}") + self._subprocess.stdin.write("\n") + self._subprocess.stdin.write("=\n") + self._subprocess.stdin.flush() + + error_lines: list[str] = [] # list of errors as reported by primer3 + primer3_results: dict[str, str] = {} # key-value pairs of results reported by Primer3 + + def primer3_error(message: str) -> None: + """Formats the Primer3 error and raises a ValueError.""" + error_message = f"{message}: " + # add in any reported PRIMER_ERROR + if "PRIMER_ERROR" in primer3_results: + error_message += primer3_results["PRIMER_ERROR"] + # add in any error lines + if len(error_lines) > 0: + error_message += "\n".join(f"\t\t{e}" for e in error_lines) + # raise the exception now + raise ValueError(error_message) + + while True: + # Get the next line. Since we want to distinguish between empty lines, which we ignore, + # and the end-of-file, which is just an empty string, check for an empty string before + # stripping the line of any trailing newline or carriage return characters. + line: str = self._subprocess.stdout.readline() + if line == "": # EOF + primer3_error("Primer3 exited prematurely") + line = line.rstrip("\r\n") + + if line == "=": # stop when we find the line just "=" + break + elif line == "": # ignore empty lines + continue + elif "=" not in line: # error lines do not have the equals character in them, usually + error_lines.append(line) + else: # parse and store the result + key, value = line.split("=", maxsplit=1) + # Because Primer3 will emit both the input given and the output generated, we + # discard the input that is echo'ed back by looking for tags (keys) + # that do not match any Primer3InputTag + if not any(key == item.value for item in Primer3InputTag): + primer3_results[key] = value + + # Check for any errors. Typically, these are in error_lines, but also the results can + # contain the PRIMER_ERROR key. + if "PRIMER_ERROR" in primer3_results or len(error_lines) > 0: + primer3_error("Primer3 failed") + + match design_input.task: + case DesignPrimerPairsTask(): # Primer pair design + all_pair_results: list[PrimerPair] = Primer3._build_primer_pairs( + design_input=design_input, + design_results=primer3_results, + design_region=region, + unmasked_design_seq=soft_masked, + ) + return Primer3._assemble_primer_pairs( + design_input=design_input, + design_results=primer3_results, + unfiltered_designs=all_pair_results, + ) + + case DesignLeftPrimersTask() | DesignRightPrimersTask(): # Single primer design + all_single_results = Primer3._build_primers( + design_input=design_input, + design_results=primer3_results, + design_region=region, + design_task=design_input.task, + unmasked_design_seq=soft_masked, + ) + return Primer3._assemble_primers( + design_input=design_input, + design_results=primer3_results, + unfiltered_designs=all_single_results, + ) + + case _ as unreachable: + assert_never(unreachable) + + @staticmethod + def _build_primers( + design_input: Primer3Input, + design_results: dict[str, str], + design_region: Span, + design_task: Union[DesignLeftPrimersTask, DesignRightPrimersTask], + unmasked_design_seq: str, + ) -> list[Primer]: + """ + Builds a list of left or right primers from Primer3 output. + + Args: + design_input: the target region, design task, specifications, and scoring penalties + design_results: design results emitted by Primer3 and captured by design_primers() + design_region: the padded design region + design_task: the design task + unmasked_design_seq: the reference sequence corresponding to the target region + + Returns: + primers: a list of unsorted and unfiltered primer designs emitted by Primer3 + + Raises: + ValueError: if Primer3 does not return primer designs + """ + count_tag = design_input.task.count_tag + + maybe_count: Optional[str] = design_results.get(count_tag) + if maybe_count is None: # no count tag was found + if "PRIMER_ERROR" in design_results: + primer_error = design_results["PRIMER_ERROR"] + raise ValueError(f"Primer3 returned an error: {primer_error}") + else: + raise ValueError(f"Primer3 did not return the count tag: {count_tag}") + count: int = int(maybe_count) + + primers = [] + for idx in range(count): + key = f"PRIMER_{design_task.task_type}_{idx}" + str_position, str_length = design_results[key].split(",", maxsplit=1) + position, length = int(str_position), int(str_length) # position is 1-based + + match design_task: + case DesignLeftPrimersTask(): + span = design_region.get_subspan( + offset=position - 1, subspan_length=length, strand=Strand.POSITIVE + ) + case DesignRightPrimersTask(): + start = position - length + 1 # start is 1-based + span = design_region.get_subspan( + offset=start - 1, subspan_length=length, strand=Strand.NEGATIVE + ) + case _ as unreachable: + assert_never(unreachable) # pragma: no cover + + slice_offset = design_region.get_offset(span.start) + slice_end = design_region.get_offset(span.end) + 1 + + # remake the primer sequence from the un-masked genome sequence just in case + bases = unmasked_design_seq[slice_offset:slice_end] + if span.strand == Strand.NEGATIVE: + bases = reverse_complement(bases) + + primers.append( + Primer( + bases=bases, + tm=float(design_results[f"PRIMER_{design_task.task_type}_{idx}_TM"]), + penalty=float(design_results[f"PRIMER_{design_task.task_type}_{idx}_PENALTY"]), + span=span, + ) + ) + return primers + + @staticmethod + def _assemble_primers( + design_input: Primer3Input, design_results: dict[str, str], unfiltered_designs: list[Primer] + ) -> Primer3Result: + """Helper function to organize primer designs into valid and failed designs. + + Wraps `Primer3._is_valid_primer()` and `Primer3._build_failures()` to filter out designs + with dinucleotide runs that are too long and extract additional failure reasons emitted by + Primer3. + + Args: + design_input: encapsulates the target region, design task, specifications, + and scoring penalties + unfiltered_designs: list of primers emitted from Primer3 + design_results: key-value pairs of results reported by Primer3 + + Returns: + primer_designs: a `Primer3Result` that encapsulates valid and failed designs + """ + valid_primer_designs = [ + design + for design in unfiltered_designs + if Primer3._is_valid_primer(primer_design=design, design_input=design_input) + ] + dinuc_failures = [ + design + for design in unfiltered_designs + if not Primer3._is_valid_primer(primer_design=design, design_input=design_input) + ] + + failure_strings = [design_results[f"PRIMER_{design_input.task.task_type}_EXPLAIN"]] + failures = Primer3._build_failures(dinuc_failures, failure_strings) + primer_designs: Primer3Result = Primer3Result( + filtered_designs=valid_primer_designs, failures=failures + ) + return primer_designs + + @staticmethod + def _build_primer_pairs( + design_input: Primer3Input, + design_results: dict[str, str], + design_region: Span, + unmasked_design_seq: str, + ) -> list[PrimerPair]: + """ + Builds a list of primer pairs from single primer designs emitted from Primer3. + + Args: + design_input: the target region, design task, specifications, and scoring penalties + design_results: design results emitted by Primer3 and captured by design_primers() + design_region: the padded design region + unmasked_design_seq: the reference sequence corresponding to the target region + + Returns: + primer_pairs: a list of unsorted and unfiltered paired primer designs emitted by Primer3 + + Raises: + ValueError: if Primer3 does not return the same number of left and right designs + """ + left_primers = Primer3._build_primers( + design_input=design_input, + design_results=design_results, + design_region=design_region, + design_task=DesignLeftPrimersTask(), + unmasked_design_seq=unmasked_design_seq, + ) + + right_primers = Primer3._build_primers( + design_input=design_input, + design_results=design_results, + design_region=design_region, + design_task=DesignRightPrimersTask(), + unmasked_design_seq=unmasked_design_seq, + ) + + def _build_primer_pair(num: int, primer_pair: tuple[Primer, Primer]) -> PrimerPair: + """Builds the `PrimerPair` object from input left and right primers.""" + left_primer = primer_pair[0] + right_primer = primer_pair[1] + amplicon = replace(left_primer.span, end=right_primer.span.end) + slice_offset = design_region.get_offset(amplicon.start) + slice_end = slice_offset + amplicon.length + + return PrimerPair( + left_primer=left_primer, + right_primer=right_primer, + amplicon_tm=float(design_results[f"PRIMER_PAIR_{num}_PRODUCT_TM"]), + penalty=float(design_results[f"PRIMER_PAIR_{num}_PENALTY"]), + amplicon_sequence=unmasked_design_seq[slice_offset:slice_end], + ) + + # Primer3 returns an equal number of left and right primers during primer pair design + if len(left_primers) != len(right_primers): + raise ValueError("Primer3 returned a different number of left and right primers.") + primer_pairs: list[PrimerPair] = [ + _build_primer_pair(num, primer_pair) + for num, primer_pair in enumerate(zip(left_primers, right_primers, strict=True)) + ] + return primer_pairs + + @staticmethod + def _assemble_primer_pairs( + design_input: Primer3Input, + design_results: dict[str, str], + unfiltered_designs: list[PrimerPair], + ) -> Primer3Result: + """Helper function to organize primer pairs into valid and failed designs. + + Wraps `Primer3._screen_pair_results()` and `Primer3._build_failures()` to filter out designs + with dinucleotide runs that are too long and extract additional failure reasons emitted by + Primer3. + + Args: + design_input: encapsulates the target region, design task, specifications, + and scoring penalties + unfiltered_designs: list of primer pairs emitted from Primer3 + design_results: key-value pairs of results reported by Primer3 + + Returns: + primer_designs: a `Primer3Result` that encapsulates valid and failed designs + """ + valid_primer_pair_designs: list[PrimerPair] + dinuc_pair_failures: list[Primer] + valid_primer_pair_designs, dinuc_pair_failures = Primer3._screen_pair_results( + design_input=design_input, designed_primer_pairs=unfiltered_designs + ) + + failure_strings = [ + design_results["PRIMER_PAIR_EXPLAIN"], + design_results["PRIMER_LEFT_EXPLAIN"], + design_results["PRIMER_RIGHT_EXPLAIN"], + ] + pair_failures = Primer3._build_failures(dinuc_pair_failures, failure_strings) + primer_designs = Primer3Result( + filtered_designs=valid_primer_pair_designs, failures=pair_failures + ) + + return primer_designs + + @staticmethod + def _build_failures( + dinuc_failures: list[Primer], + failure_strings: list[str], + ) -> list[Primer3Failure]: + """Extracts the reasons why designs that were considered by Primer3 failed + (when there were failures). + + The set of failures is returned sorted from those with most + failures to those with least. + + Args: + dinuc_failures: primer designs with a dinucleotide run longer than the allowed maximum + failure_strings: explanations (strings) emitted by Primer3 about failed designs + + + Returns: + a list of Primer3Failure objects + """ + + by_fail_count: Counter[Primer3FailureReason] = Primer3FailureReason.parse_failures( + *failure_strings + ) + # Count how many individual primers failed for dinuc runs + num_dinuc_failures = len(set(dinuc_failures)) + if num_dinuc_failures > 0: + by_fail_count[Primer3FailureReason.LONG_DINUC] = num_dinuc_failures + return [Primer3Failure(reason, count) for reason, count in by_fail_count.most_common()] + + def _pad_target_region(self, target: Span, max_amplicon_length: int) -> Span: + """ + If the target region is smaller than the max amplicon length, pad to fit. + + When the max amplicon length is odd, the left side of the target region will be padded with + one more base than the right side. + """ + contig_length: int = self._dict[target.refname].length + padding_right: int = max(0, int((max_amplicon_length - target.length) / 2)) + padding_left: int = max(0, max_amplicon_length - target.length - padding_right) + + region: Span = replace( + target, + start=max(1, target.start - padding_left), + end=min(target.end + padding_right, contig_length), + ) + + return region diff --git a/prymer/primer3/primer3_failure_reason.py b/prymer/primer3/primer3_failure_reason.py new file mode 100644 index 0000000..071549e --- /dev/null +++ b/prymer/primer3/primer3_failure_reason.py @@ -0,0 +1,118 @@ +""" +# Primer3FailureReason Class + +This module contains a Primer3FailureReason class. Based on user-specified criteria, Primer3 will +disqualify primer designs if the characteristics of the design are outside an allowable range of +parameters. + +Failure reason strings are documented in the Primer3 source code, accessible here +(at the time of Primer3FailureReason implementation): +https://github.com/bioinfo-ut/primer3_masker/blob/master/src/libprimer3.c#L5581-L5604 + +## Example Primer3 failure reasons emitted + +Primer3 shall return a comma-delimited list of failure explanations. They will have key +`PRIMER_LEFT_EXPLAIN` for designing individual left primers, `PRIMER_RIGHT_EXPLAIN` for designing +individual right primers, and `PRIMER_PAIR_EXPLAIN` for designing primer pairs. + +For individual primers: + +```python +>>> failure_string = 'considered 160, too many Ns 20, low tm 127, ok 13' +>>> Primer3FailureReason.parse_failures(failure_string) +Counter({: 127, : 20}) +>>> failure_string = 'considered 238, low tm 164, high tm 12, high hairpin stability 23, ok 39' +>>> Primer3FailureReason.parse_failures(failure_string) +Counter({: 164, : 23, : 12}) +>>> failure_string = 'considered 166, unacceptable product size 161, ok 5' +>>> Primer3FailureReason.parse_failures(failure_string) +Counter({: 161}) + +``` + +""" # noqa: E501 + +import logging +import re +from collections import Counter +from enum import StrEnum +from enum import unique +from typing import Optional + + +@unique +class Primer3FailureReason(StrEnum): + """ + Enum to represent the various reasons Primer3 removes primers and primer pairs. + + These are taken from: https://github.com/bioinfo-ut/primer3_masker/blob/ + 6a40c4c408dc02b95ac02391457cda760092291a/src/libprimer3.c#L5581-L5605 + + This also contains custom failure values that are not generated by Primer3 but are convenient + to have so that post-processing failures can be tracked in the same way that Primer3 failures + are. These include `LONG_DINUC`, `SECONDARY_STRUCTURE`, and `OFF_TARGET_AMPLIFICATION`. + """ + + # Failure reasons emitted by Primer3 + GC_CONTENT = "GC content failed" + GC_CLAMP = "GC clamp failed" + HAIRPIN_STABILITY = "high hairpin stability" + HIGH_TM = "high tm" + LOW_TM = "low tm" + LOWERCASE_MASKING = "lowercase masking of 3' end" + LONG_POLY_X = "long poly-x seq" + PRODUCT_SIZE = "unacceptable product size" + TOO_MANY_NS = "too many Ns" + HIGH_ANY_COMPLEMENTARITY = "high any compl" + HIGH_END_COMPLEMENTARITY = "high end compl" + # Failure reasons not emitted by Primer3 and beneficial to keep track of + LONG_DINUC = "long dinucleotide run" + SECONDARY_STRUCTURE = "undesirable secondary structure" + OFF_TARGET_AMPLIFICATION = "amplifies off-target regions" + + @classmethod + def from_reason(cls, str_reason: str) -> Optional["Primer3FailureReason"]: + """Returns the first `Primer3FailureReason` with the given reason for failure. + If no failure exists, return `None`.""" + reason: Optional[Primer3FailureReason] = None + try: + reason = cls(str_reason) + except ValueError: + pass + return reason + + @staticmethod + def parse_failures( + *failures: str, + ) -> Counter["Primer3FailureReason"]: + """When Primer3 encounters failures, extracts the reasons why designs that were considered + by Primer3 failed. + + Args: + failures: list of strings, with each string an "explanation" emitted by Primer3 about + why the design failed. Each string may be a comma delimited of failures, or a + single failure. + + Returns: + a `Counter` of each `Primer3FailureReason` reason. + """ + + failure_regex = r"^ ?(.+) ([0-9]+)$" + by_fail_count: Counter[Primer3FailureReason] = Counter() + # parse all failure strings and merge together counts for the same kinds of failures + for failure in failures: + split_failures = [fail.strip() for fail in failure.split(",")] + for item in split_failures: + result = re.match(failure_regex, item) + if result is None: + continue + reason = result.group(1) + count = int(result.group(2)) + if reason in ["ok", "considered"]: + continue + std_reason = Primer3FailureReason.from_reason(reason) + if std_reason is None: + logging.debug(f"Unknown Primer3 failure reason: {reason}") + by_fail_count[std_reason] += count + + return by_fail_count diff --git a/prymer/primer3/primer3_input.py b/prymer/primer3/primer3_input.py new file mode 100644 index 0000000..952ef69 --- /dev/null +++ b/prymer/primer3/primer3_input.py @@ -0,0 +1,121 @@ +""" +# Primer3Input Class and Methods + +This module contains the [`Primer3Input`][prymer.primer3.Primer3Input] class. The class +wraps together different helper classes to assemble user-specified criteria and parameters for +input to Primer3. + +The module uses: + +1. [`Primer3Parameters`][prymer.primer3.primer3_parameters.Primer3Parameters] +to specify user-specified criteria for primer design +2. [`Primer3Weights`][prymer.primer3.primer3_weights.Primer3Weights] to establish penalties +based on those criteria +3. [`Primer3Task`][prymer.primer3.primer3_task.Primer3Task] to organize task-specific + logic. +4. [`Span`](index.md#prymer.api.span.Span] to specify the target region. + +The `Primer3Input.to_input_tags(]` method +The main purpose of this class is to generate the +[`Primer3InputTag`s][prymer.primer3.primer3_input_tag.Primer3InputTag]s required by +`Primer3` for specifying how to design the primers, returned by the `to_input_tags(]` method. + +## Examples + +The following examples builds the `Primer3` tags for designing left primers: + +```python +>>> from prymer.api import MinOptMax, Strand +>>> from prymer.primer3 import DesignLeftPrimersTask +>>> target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) +>>> design_region = Span(refname="chr1", start=150, end=300, strand=Strand.POSITIVE) +>>> params = Primer3Parameters( \ + amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ + amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ + primer_sizes=MinOptMax(min=29, max=31, opt=30), \ + primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ + primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ +) +>>> design_input = Primer3Input(target=target, params=params, task=DesignLeftPrimersTask()) +>>> for tag, value in design_input.to_input_tags(design_region=design_region).items(): \ + print(f"{tag.value} -> {value}") +PRIMER_TASK -> pick_primer_list +PRIMER_PICK_LEFT_PRIMER -> 1 +PRIMER_PICK_RIGHT_PRIMER -> 0 +PRIMER_PICK_INTERNAL_OLIGO -> 0 +SEQUENCE_INCLUDED_REGION -> 1,51 +PRIMER_NUM_RETURN -> 5 +PRIMER_PRODUCT_OPT_SIZE -> 200 +PRIMER_PRODUCT_SIZE_RANGE -> 100-250 +PRIMER_PRODUCT_MIN_TM -> 55.0 +PRIMER_PRODUCT_OPT_TM -> 70.0 +PRIMER_PRODUCT_MAX_TM -> 100.0 +PRIMER_MIN_SIZE -> 29 +PRIMER_OPT_SIZE -> 30 +PRIMER_MAX_SIZE -> 31 +PRIMER_MIN_TM -> 63.0 +PRIMER_OPT_TM -> 65.0 +PRIMER_MAX_TM -> 67.0 +PRIMER_MIN_GC -> 30.0 +PRIMER_OPT_GC_PERCENT -> 45.0 +PRIMER_MAX_GC -> 65.0 +PRIMER_GC_CLAMP -> 0 +PRIMER_MAX_END_GC -> 5 +PRIMER_MAX_POLY_X -> 5 +PRIMER_MAX_NS_ACCEPTED -> 1 +PRIMER_LOWERCASE_MASKING -> 1 +PRIMER_PAIR_WT_PRODUCT_SIZE_LT -> 1 +PRIMER_PAIR_WT_PRODUCT_SIZE_GT -> 1 +PRIMER_PAIR_WT_PRODUCT_TM_LT -> 0.0 +PRIMER_PAIR_WT_PRODUCT_TM_GT -> 0.0 +PRIMER_WT_END_STABILITY -> 0.25 +PRIMER_WT_GC_PERCENT_LT -> 0.25 +PRIMER_WT_GC_PERCENT_GT -> 0.25 +PRIMER_WT_SELF_ANY -> 0.1 +PRIMER_WT_SELF_END -> 0.1 +PRIMER_WT_SIZE_LT -> 0.5 +PRIMER_WT_SIZE_GT -> 0.1 +PRIMER_WT_TM_LT -> 1.0 +PRIMER_WT_TM_GT -> 1.0 +""" + +from dataclasses import dataclass +from typing import Any + +from prymer.api.span import Span +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_parameters import Primer3Parameters +from prymer.primer3.primer3_task import Primer3TaskType +from prymer.primer3.primer3_weights import Primer3Weights + + +@dataclass(frozen=True, init=True, slots=True) +class Primer3Input: + """Assembles necessary inputs for Primer3 to orchestrate primer and/or primer pair design.""" + + target: Span + task: Primer3TaskType + params: Primer3Parameters + weights: Primer3Weights = Primer3Weights() + + def to_input_tags(self, design_region: Span) -> dict[Primer3InputTag, Any]: + """Assembles `Primer3InputTag` and values for input to `Primer3` + + The target region must be wholly contained within design region. + + Args: + design_region: the design region, which wholly contains the target region, in which + primers are to be designed. + + Returns: + a mapping of `Primer3InputTag`s to associated value + """ + primer3_task_params = self.task.to_input_tags( + design_region=design_region, target=self.target + ) + assembled_tags = { + **primer3_task_params, + **self.params.to_input_tags(), + **self.weights.to_input_tags(), + } + return assembled_tags diff --git a/prymer/primer3/primer3_input_tag.py b/prymer/primer3/primer3_input_tag.py new file mode 100644 index 0000000..109614c --- /dev/null +++ b/prymer/primer3/primer3_input_tag.py @@ -0,0 +1,197 @@ +""" +# Primer3InputTag Class and Methods + +This module contains a class, `Primer3InputTag`, to standardize user input to Primer3. Settings that +are controlled here include general parameters that apply to a singular Primer3 session +as well as query-specific settings that can be changed in between primer queries. + +""" + +from enum import auto +from enum import unique + +from strenum import UppercaseStrEnum + + +@unique +class Primer3InputTag(UppercaseStrEnum): + """ + Enumeration of Primer3 input tags. + + Please see the Primer3 manual for additional details: + https://primer3.org/manual.html#commandLineTags + + This class represents two categories of Primer3 input tags: + * `SEQUENCE_` tags are those that control sequence-specific attributes of Primer3 jobs. + These can be modified after each query submitted to Primer3. + * `PRIMER_` tags are those that describe more general parameters of Primer3 jobs. + These attributes persist across queries to Primer3 unless they are explicitly reset. + Errors in these "global" input tags are fatal. + """ + + # Sequence input tags; query-specific + SEQUENCE_EXCLUDED_REGION = auto() + SEQUENCE_INCLUDED_REGION = auto() + SEQUENCE_PRIMER_REVCOMP = auto() + SEQUENCE_FORCE_LEFT_END = auto() + SEQUENCE_INTERNAL_EXCLUDED_REGION = auto() + SEQUENCE_QUALITY = auto() + SEQUENCE_FORCE_LEFT_START = auto() + SEQUENCE_INTERNAL_OLIGO = auto() + SEQUENCE_START_CODON_POSITION = auto() + SEQUENCE_FORCE_RIGHT_END = auto() + SEQUENCE_OVERLAP_JUNCTION_LIST = auto() + SEQUENCE_TARGET = auto() + SEQUENCE_FORCE_RIGHT_START = auto() + SEQUENCE_PRIMER = auto() + SEQUENCE_TEMPLATE = auto() + SEQUENCE_ID = auto() + SEQUENCE_PRIMER_PAIR_OK_REGION_LIST = auto() + + # Global input tags; will persist across primer3 queries + PRIMER_DNA_CONC = auto() + PRIMER_MAX_END_GC = auto() + PRIMER_PAIR_WT_PRODUCT_SIZE_LT = auto() + PRIMER_DNTP_CONC = auto() + PRIMER_MAX_END_STABILITY = auto() + PRIMER_PAIR_WT_PRODUCT_TM_GT = auto() + PRIMER_EXPLAIN_FLAG = auto() + PRIMER_MAX_GC = auto() + PRIMER_PAIR_WT_PRODUCT_TM_LT = auto() + PRIMER_FIRST_BASE_INDEX = auto() + PRIMER_MAX_HAIRPIN_TH = auto() + PRIMER_PAIR_WT_PR_PENALTY = auto() + PRIMER_GC_CLAMP = auto() + PRIMER_MAX_LIBRARY_MISPRIMING = auto() + PRIMER_PAIR_WT_TEMPLATE_MISPRIMING = auto() + PRIMER_INSIDE_PENALTY = auto() + PRIMER_MAX_NS_ACCEPTED = auto() + PRIMER_PAIR_WT_TEMPLATE_MISPRIMING_TH = auto() + PRIMER_INTERNAL_DNA_CONC = auto() + PRIMER_MAX_POLY_X = auto() + PRIMER_PICK_ANYWAY = auto() + PRIMER_INTERNAL_DNTP_CONC = auto() + PRIMER_MAX_SELF_ANY = auto() + PRIMER_PICK_INTERNAL_OLIGO = auto() + PRIMER_INTERNAL_MAX_GC = auto() + PRIMER_MAX_SELF_ANY_TH = auto() + PRIMER_PICK_LEFT_PRIMER = auto() + PRIMER_INTERNAL_MAX_HAIRPIN_TH = auto() + PRIMER_MAX_SELF_END = auto() + PRIMER_PICK_RIGHT_PRIMER = auto() + PRIMER_INTERNAL_MAX_LIBRARY_MISHYB = auto() + PRIMER_MAX_SELF_END_TH = auto() + PRIMER_PRODUCT_MAX_TM = auto() + PRIMER_INTERNAL_MAX_NS_ACCEPTED = auto() + PRIMER_MAX_SIZE = auto() + PRIMER_PRODUCT_MIN_TM = auto() + PRIMER_INTERNAL_MAX_POLY_X = auto() + PRIMER_MAX_TEMPLATE_MISPRIMING = auto() + PRIMER_PRODUCT_OPT_SIZE = auto() + PRIMER_INTERNAL_MAX_SELF_ANY = auto() + PRIMER_MAX_TEMPLATE_MISPRIMING_TH = auto() + PRIMER_PRODUCT_OPT_TM = auto() + PRIMER_INTERNAL_MAX_SELF_ANY_TH = auto() + PRIMER_MAX_TM = auto() + PRIMER_PRODUCT_SIZE_RANGE = auto() + PRIMER_INTERNAL_MAX_SELF_END = auto() + PRIMER_MIN_3_PRIME_OVERLAP_OF_JUNCTION = auto() + PRIMER_QUALITY_RANGE_MAX = auto() + PRIMER_INTERNAL_MAX_SELF_END_TH = auto() + PRIMER_MIN_5_PRIME_OVERLAP_OF_JUNCTION = auto() + PRIMER_QUALITY_RANGE_MIN = auto() + PRIMER_INTERNAL_MAX_SIZE = auto() + PRIMER_MIN_END_QUALITY = auto() + PRIMER_SALT_CORRECTIONS = auto() + PRIMER_INTERNAL_MAX_TM = auto() + PRIMER_MIN_GC = auto() + PRIMER_SALT_DIVALENT = auto() + PRIMER_INTERNAL_MIN_GC = auto() + PRIMER_MIN_LEFT_THREE_PRIME_DISTANCE = auto() + PRIMER_SALT_MONOVALENT = auto() + PRIMER_INTERNAL_MIN_QUALITY = auto() + PRIMER_MIN_QUALITY = auto() + PRIMER_SEQUENCING_ACCURACY = auto() + PRIMER_INTERNAL_MIN_SIZE = auto() + PRIMER_MIN_RIGHT_THREE_PRIME_DISTANCE = auto() + PRIMER_SEQUENCING_INTERVAL = auto() + PRIMER_INTERNAL_MIN_TM = auto() + PRIMER_MIN_SIZE = auto() + PRIMER_SEQUENCING_LEAD = auto() + PRIMER_INTERNAL_MISHYB_LIBRARY = auto() + PRIMER_MIN_THREE_PRIME_DISTANCE = auto() + PRIMER_SEQUENCING_SPACING = auto() + PRIMER_INTERNAL_MUST_MATCH_FIVE_PRIME = auto() + PRIMER_MIN_TM = auto() + PRIMER_TASK = auto() + PRIMER_INTERNAL_MUST_MATCH_THREE_PRIME = auto() + PRIMER_MISPRIMING_LIBRARY = auto() + PRIMER_THERMODYNAMIC_OLIGO_ALIGNMENT = auto() + PRIMER_INTERNAL_OPT_GC_PERCENT = auto() + PRIMER_MUST_MATCH_FIVE_PRIME = auto() + PRIMER_THERMODYNAMIC_PARAMETERS_PATH = auto() + PRIMER_INTERNAL_OPT_SIZE = auto() + PRIMER_MUST_MATCH_THREE_PRIME = auto() + PRIMER_THERMODYNAMIC_TEMPLATE_ALIGNMENT = auto() + PRIMER_INTERNAL_OPT_TM = auto() + PRIMER_NUM_RETURN = auto() + PRIMER_TM_FORMULA = auto() + PRIMER_INTERNAL_SALT_DIVALENT = auto() + PRIMER_OPT_GC_PERCENT = auto() + PRIMER_WT_END_QUAL = auto() + PRIMER_INTERNAL_SALT_MONOVALENT = auto() + PRIMER_OPT_SIZE = auto() + PRIMER_WT_END_STABILITY = auto() + PRIMER_INTERNAL_WT_END_QUAL = auto() + PRIMER_OPT_TM = auto() + PRIMER_WT_GC_PERCENT_GT = auto() + PRIMER_INTERNAL_WT_GC_PERCENT_GT = auto() + PRIMER_OUTSIDE_PENALTY = auto() + PRIMER_WT_GC_PERCENT_LT = auto() + PRIMER_INTERNAL_WT_GC_PERCENT_LT = auto() + PRIMER_PAIR_MAX_COMPL_ANY = auto() + PRIMER_WT_HAIRPIN_TH = auto() + PRIMER_INTERNAL_WT_HAIRPIN_TH = auto() + PRIMER_PAIR_MAX_COMPL_ANY_TH = auto() + PRIMER_WT_LIBRARY_MISPRIMING = auto() + PRIMER_INTERNAL_WT_LIBRARY_MISHYB = auto() + PRIMER_PAIR_MAX_COMPL_END = auto() + PRIMER_WT_NUM_NS = auto() + PRIMER_INTERNAL_WT_NUM_NS = auto() + PRIMER_PAIR_MAX_COMPL_END_TH = auto() + PRIMER_WT_POS_PENALTY = auto() + PRIMER_INTERNAL_WT_SELF_ANY = auto() + PRIMER_PAIR_MAX_DIFF_TM = auto() + PRIMER_WT_SELF_ANY = auto() + PRIMER_INTERNAL_WT_SELF_ANY_TH = auto() + PRIMER_PAIR_MAX_LIBRARY_MISPRIMING = auto() + PRIMER_WT_SELF_ANY_TH = auto() + PRIMER_INTERNAL_WT_SELF_END = auto() + PRIMER_PAIR_MAX_TEMPLATE_MISPRIMING = auto() + PRIMER_WT_SELF_END = auto() + PRIMER_INTERNAL_WT_SELF_END_TH = auto() + PRIMER_PAIR_MAX_TEMPLATE_MISPRIMING_TH = auto() + PRIMER_WT_SELF_END_TH = auto() + PRIMER_INTERNAL_WT_SEQ_QUAL = auto() + PRIMER_PAIR_WT_COMPL_ANY = auto() + PRIMER_WT_SEQ_QUAL = auto() + PRIMER_INTERNAL_WT_SIZE_GT = auto() + PRIMER_PAIR_WT_COMPL_ANY_TH = auto() + PRIMER_WT_SIZE_GT = auto() + PRIMER_INTERNAL_WT_SIZE_LT = auto() + PRIMER_PAIR_WT_COMPL_END = auto() + PRIMER_WT_SIZE_LT = auto() + PRIMER_INTERNAL_WT_TM_GT = auto() + PRIMER_PAIR_WT_COMPL_END_TH = auto() + PRIMER_WT_TEMPLATE_MISPRIMING = auto() + PRIMER_INTERNAL_WT_TM_LT = auto() + PRIMER_PAIR_WT_DIFF_TM = auto() + PRIMER_WT_TEMPLATE_MISPRIMING_TH = auto() + PRIMER_LIBERAL_BASE = auto() + PRIMER_PAIR_WT_IO_PENALTY = auto() + PRIMER_WT_TM_GT = auto() + PRIMER_LIB_AMBIGUITY_CODES_CONSENSUS = auto() + PRIMER_PAIR_WT_LIBRARY_MISPRIMING = auto() + PRIMER_WT_TM_LT = auto() + PRIMER_LOWERCASE_MASKING = auto() + PRIMER_PAIR_WT_PRODUCT_SIZE_GT = auto() diff --git a/prymer/primer3/primer3_parameters.py b/prymer/primer3/primer3_parameters.py new file mode 100644 index 0000000..e45f731 --- /dev/null +++ b/prymer/primer3/primer3_parameters.py @@ -0,0 +1,142 @@ +""" +# Primer3Parameters Class and Methods + +The [`Primer3Parameters`][prymer.primer3.primer3_parameters.Primer3Parameters] class stores +user input and maps it to the correct Primer3 fields. + +Primer3 considers many criteria for primer design, including characteristics of candidate primers +and the resultant amplicon product, as well as potential complications (off-target priming, +primer dimer formation). Users can specify many of these constraints in Primer3, +some of which are used to quantify a "score" for each primer design. + +The Primer3Parameters class stores commonly used constraints for primer design: GC content, melting +temperature, and size of both primers and expected amplicon. Additional criteria include the maximum +homopolymer length, ambiguous bases, and bases in a dinucleotide run within a primer. By default, +primer design avoids masked bases, returns 5 primers, and sets the GC clamp to be no larger than 5. + +The `to_input_tags()` method in `Primer3Parameters` converts these parameters into tag-values pairs +for use when executing `Primer3`. + +## Examples + +```python +>>> params = Primer3Parameters( \ + amplicon_sizes=MinOptMax(min=100, max=250, opt=200), \ + amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), \ + primer_sizes=MinOptMax(min=29, max=31, opt=30), \ + primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), \ + primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), \ +) +>>> for tag, value in params.to_input_tags().items(): \ + print(f"{tag.value} -> {value}") +PRIMER_NUM_RETURN -> 5 +PRIMER_PRODUCT_OPT_SIZE -> 200 +PRIMER_PRODUCT_SIZE_RANGE -> 100-250 +PRIMER_PRODUCT_MIN_TM -> 55.0 +PRIMER_PRODUCT_OPT_TM -> 70.0 +PRIMER_PRODUCT_MAX_TM -> 100.0 +PRIMER_MIN_SIZE -> 29 +PRIMER_OPT_SIZE -> 30 +PRIMER_MAX_SIZE -> 31 +PRIMER_MIN_TM -> 63.0 +PRIMER_OPT_TM -> 65.0 +PRIMER_MAX_TM -> 67.0 +PRIMER_MIN_GC -> 30.0 +PRIMER_OPT_GC_PERCENT -> 45.0 +PRIMER_MAX_GC -> 65.0 +PRIMER_GC_CLAMP -> 0 +PRIMER_MAX_END_GC -> 5 +PRIMER_MAX_POLY_X -> 5 +PRIMER_MAX_NS_ACCEPTED -> 1 +PRIMER_LOWERCASE_MASKING -> 1 + +``` +""" + +from dataclasses import dataclass +from typing import Any + +from prymer.api.minoptmax import MinOptMax +from prymer.primer3.primer3_input_tag import Primer3InputTag + + +@dataclass(frozen=True, init=True, slots=True) +class Primer3Parameters: + """Holds common primer design options that Primer3 uses to inform primer design. + + Attributes: + amplicon_sizes: the min, optimal, and max amplicon size + amplicon_tms: the min, optimal, and max amplicon melting temperatures + primer_sizes: the min, optimal, and max primer size + primer_tms: the min, optimal, and max primer melting temperatures + primer_gcs: the min and maximal GC content for individual primers + gc_clamp: the min and max number of Gs and Cs in the 3' most N bases + primer_max_polyX: the max homopolymer length acceptable within a primer + primer_max_Ns: the max number of ambiguous bases acceptable within a primer + primer_max_dinuc_bases: the maximal number of bases in a dinucleotide run in a primer + avoid_masked_bases: whether Primer3 should avoid designing primers in soft-masked regions + number_primers_return: the number of primers to return + + Please see the Primer3 manual for additional details: https://primer3.org/manual.html#globalTags + + """ + + amplicon_sizes: MinOptMax[int] + amplicon_tms: MinOptMax[float] + primer_sizes: MinOptMax[int] + primer_tms: MinOptMax[float] + primer_gcs: MinOptMax[float] + gc_clamp: tuple[int, int] = (0, 5) + primer_max_polyX: int = 5 + primer_max_Ns: int = 1 + primer_max_dinuc_bases: int = 6 + avoid_masked_bases: bool = True + number_primers_return: int = 5 + + def __post_init__(self) -> None: + if self.primer_max_dinuc_bases % 2 == 1: + raise ValueError("Primer Max Dinuc Bases must be an even number of bases") + if not isinstance(self.amplicon_sizes.min, int) or not isinstance( + self.primer_sizes.min, int + ): + raise TypeError("Amplicon sizes and primer sizes must be integers") + if self.gc_clamp[0] > self.gc_clamp[1]: + raise ValueError("Min primer GC-clamp must be <= max primer GC-clamp") + + def to_input_tags(self) -> dict[Primer3InputTag, Any]: + """Converts input params to Primer3InputTag to feed directly into Primer3.""" + mapped_dict = { + Primer3InputTag.PRIMER_NUM_RETURN: self.number_primers_return, + Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE: self.amplicon_sizes.opt, + Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE: ( + f"{self.amplicon_sizes.min}-{self.amplicon_sizes.max}" + ), + Primer3InputTag.PRIMER_PRODUCT_MIN_TM: self.amplicon_tms.min, + Primer3InputTag.PRIMER_PRODUCT_OPT_TM: self.amplicon_tms.opt, + Primer3InputTag.PRIMER_PRODUCT_MAX_TM: self.amplicon_tms.max, + Primer3InputTag.PRIMER_MIN_SIZE: self.primer_sizes.min, + Primer3InputTag.PRIMER_OPT_SIZE: self.primer_sizes.opt, + Primer3InputTag.PRIMER_MAX_SIZE: self.primer_sizes.max, + Primer3InputTag.PRIMER_MIN_TM: self.primer_tms.min, + Primer3InputTag.PRIMER_OPT_TM: self.primer_tms.opt, + Primer3InputTag.PRIMER_MAX_TM: self.primer_tms.max, + Primer3InputTag.PRIMER_MIN_GC: self.primer_gcs.min, + Primer3InputTag.PRIMER_OPT_GC_PERCENT: self.primer_gcs.opt, + Primer3InputTag.PRIMER_MAX_GC: self.primer_gcs.max, + Primer3InputTag.PRIMER_GC_CLAMP: self.gc_clamp[0], + Primer3InputTag.PRIMER_MAX_END_GC: self.gc_clamp[1], + Primer3InputTag.PRIMER_MAX_POLY_X: self.primer_max_polyX, + Primer3InputTag.PRIMER_MAX_NS_ACCEPTED: self.primer_max_Ns, + Primer3InputTag.PRIMER_LOWERCASE_MASKING: 1 if self.avoid_masked_bases else 0, + } + return mapped_dict + + @property + def max_amplicon_length(self) -> int: + """Max amplicon length""" + return int(self.amplicon_sizes.max) + + @property + def max_primer_length(self) -> int: + """Max primer length""" + return int(self.primer_sizes.max) diff --git a/prymer/primer3/primer3_task.py b/prymer/primer3/primer3_task.py new file mode 100644 index 0000000..e09a769 --- /dev/null +++ b/prymer/primer3/primer3_task.py @@ -0,0 +1,225 @@ +""" +# Primer3Task Class and Methods + +This module contains a Primer3Task class to provide design-specific parameters to Primer3. The +classes are primarily used in [`Primer3Input`][prymer.primer3.Primer3Input]. + +Primer3 can design single primers ("left" and "right") as well as primer pairs. +The design task "type" dictates which type of primers to pick and informs the design region. +These parameters are aligned to the correct Primer3 settings and fed directly into Primer3. + +Three types of tasks are available: + +1. [`DesignPrimerPairsTask`][prymer.primer3.primer3_task.DesignPrimerPairsTask] -- task + for designing _primer pairs_. +2. [`DesignLeftPrimersTask`][prymer.primer3.primer3_task.DesignLeftPrimersTask] -- task + for designing primers to the _left_ (5') of the design region on the top/positive strand. +3. [`DesignRightPrimersTask`][prymer.primer3.primer3_task.DesignRightPrimersTask] -- task + for designing primers to the _right_ (3') of the design region on the bottom/negative strand. + +The main purpose of these classes are to generate the +[`Primer3InputTag`s][prymer.primer3.primer3_input_tag.Primer3InputTag]s required by +`Primer3` for specifying how to design the primers, returned by the `to_input_tags()` method. The +target and design region are provided to this method, where the target region is wholly contained +within design region. This leaves a left and right primer region respectively, that are +the two regions that remain after removing the wholly contained (inner) target regions. + +Therefore, the left primers shall be designed from the start of the design region to the +start of the target region, while right primers shall be designed from the end of the target region +through to the end of the design region. + +In addition to the `to_input_tags()` method, each `Primer3TaskType` provides a `task_type` and +`count_tag` class property. The former is a `TaskType` enumeration that represents the type of +design task, while the latter is the tag returned by Primer3 that provides the number of primers +returned. + +## Examples + +Suppose we have the following design and target regions: + +```python +>>> from prymer.api import Strand +>>> design_region = Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE) +>>> target = Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE) +>>> design_region.contains(target) +True + +``` + +The task for designing primer pairs: + +```python +>>> task = DesignPrimerPairsTask() +>>> task.task_type.value +'PAIR' +>>> task.count_tag +'PRIMER_PAIR_NUM_RETURNED' +>>> [(tag.value, value) for tag, value in task.to_input_tags(target=target, design_region=design_region).items()] +[('PRIMER_TASK', 'generic'), ('PRIMER_PICK_LEFT_PRIMER', 1), ('PRIMER_PICK_RIGHT_PRIMER', 1), ('PRIMER_PICK_INTERNAL_OLIGO', 0), ('SEQUENCE_TARGET', '200,101')] + +``` + +The tasks for designing left primers: + +```python +>>> task = DesignLeftPrimersTask() +>>> task.task_type.value +'LEFT' +>>> task.count_tag +'PRIMER_LEFT_NUM_RETURNED' +>>> [(tag.value, value) for tag, value in task.to_input_tags(target=target, design_region=design_region).items()] +[('PRIMER_TASK', 'pick_primer_list'), ('PRIMER_PICK_LEFT_PRIMER', 1), ('PRIMER_PICK_RIGHT_PRIMER', 0), ('PRIMER_PICK_INTERNAL_OLIGO', 0), ('SEQUENCE_INCLUDED_REGION', '1,199')] + +``` + +The tasks for designing left primers: + +```python +>>> task = DesignRightPrimersTask() +>>> task.task_type.value +'RIGHT' +>>> task.count_tag +'PRIMER_RIGHT_NUM_RETURNED' +>>> [(tag.value, value) for tag, value in task.to_input_tags(target=target, design_region=design_region).items()] +[('PRIMER_TASK', 'pick_primer_list'), ('PRIMER_PICK_LEFT_PRIMER', 0), ('PRIMER_PICK_RIGHT_PRIMER', 1), ('PRIMER_PICK_INTERNAL_OLIGO', 0), ('SEQUENCE_INCLUDED_REGION', '300,200')] + +``` + +""" # noqa: E501 + +from abc import ABC +from abc import abstractmethod +from enum import auto +from enum import unique +from typing import Any +from typing import ClassVar +from typing import TypeAlias +from typing import Union +from typing import final + +from strenum import UppercaseStrEnum + +from prymer.api.span import Span +from prymer.primer3.primer3_input_tag import Primer3InputTag + +Primer3TaskType: TypeAlias = Union[ + "DesignPrimerPairsTask", "DesignLeftPrimersTask", "DesignRightPrimersTask" +] +"""Type alias for all `Primer3Task`s, to enable exhaustiveness checking.""" + + +@unique +class TaskType(UppercaseStrEnum): + """Represents the type of design task, either design primer pairs, or individual primers + (left or right).""" + + # Developer Note: the names of this enum are important, as they are used as-is for the + # count_tag in `Primer3Task`. + + PAIR = auto() + LEFT = auto() + RIGHT = auto() + + +class Primer3Task(ABC): + """Abstract base class from which the other classes derive.""" + + @final + def to_input_tags(self, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: + """Returns the input tags specific to this type of task for Primer3. + + Ensures target region is wholly contained within design region. Subclass specific + implementation aligns the set of input parameters specific to primer pair or + single primer design. + + This implementation mimics the Template method, a Behavioral Design Pattern in which the + abstract base class contains a rough skeleton of methods and the derived subclasses + implement the details of those methods. In this case, each of the derived subclasses + will first use the base class `to_input_tags()` method to check that the target region is + wholly contained within the design region. If so, they will implement task-specific logic + for the `to_input_tags()` method. + + Args: + target: the target region (to be amplified) + design_region: the design region, which wholly contains the target region, in which + primers are to be designed + + + Raises: + ValueError: if the target region is not contained within the design region + + Returns: + The input tags for Primer3 + """ + if not design_region.contains(target): + raise ValueError( + "Target not contained within design region: " + f"target:{target.__str__()}," + f"design_region: {design_region.__str__()}" + ) + + return self._to_input_tags(target=target, design_region=design_region) + + task_type: ClassVar[TaskType] = NotImplemented + """Tracks task type for downstream analysis""" + + count_tag: ClassVar[str] = NotImplemented + """The tag returned by Primer3 that provides the number of primers returned""" + + @classmethod + @abstractmethod + def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: + """Aligns the set of input parameters specific to primer pair or single primer design""" + + @classmethod + def __init_subclass__(cls, task_type: TaskType, **kwargs: Any) -> None: + # See: https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__ + super().__init_subclass__(**kwargs) + + cls.task_type = task_type + cls.count_tag = f"PRIMER_{task_type}_NUM_RETURNED" + + +class DesignPrimerPairsTask(Primer3Task, task_type=TaskType.PAIR): + """Stores task-specific Primer3 settings for designing primer pairs""" + + @classmethod + def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: + return { + Primer3InputTag.PRIMER_TASK: "generic", + Primer3InputTag.PRIMER_PICK_LEFT_PRIMER: 1, + Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER: 1, + Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO: 0, + Primer3InputTag.SEQUENCE_TARGET: f"{target.start - design_region.start + 1}," + f"{target.length}", + } + + +class DesignLeftPrimersTask(Primer3Task, task_type=TaskType.LEFT): + """Stores task-specific characteristics for designing left primers.""" + + @classmethod + def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: + return { + Primer3InputTag.PRIMER_TASK: "pick_primer_list", + Primer3InputTag.PRIMER_PICK_LEFT_PRIMER: 1, + Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER: 0, + Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO: 0, + Primer3InputTag.SEQUENCE_INCLUDED_REGION: f"1,{target.start - design_region.start}", + } + + +class DesignRightPrimersTask(Primer3Task, task_type=TaskType.RIGHT): + """Stores task-specific characteristics for designing right primers""" + + @classmethod + def _to_input_tags(cls, target: Span, design_region: Span) -> dict[Primer3InputTag, Any]: + start = target.end - design_region.start + 1 + length = design_region.end - target.end + return { + Primer3InputTag.PRIMER_TASK: "pick_primer_list", + Primer3InputTag.PRIMER_PICK_LEFT_PRIMER: 0, + Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER: 1, + Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO: 0, + Primer3InputTag.SEQUENCE_INCLUDED_REGION: f"{start},{length}", + } diff --git a/prymer/primer3/primer3_weights.py b/prymer/primer3/primer3_weights.py new file mode 100644 index 0000000..6de7887 --- /dev/null +++ b/prymer/primer3/primer3_weights.py @@ -0,0 +1,82 @@ +""" +# Primer3Weights Class and Methods + +The Primer3Weights class holds the penalty weights that Primer3 uses to score primer designs. + +Primer3 considers the differential between user input (e.g., constraining the optimal +primer size to be 18 bp) and the characteristics of a specific primer design (e.g., if the primer +size is 19 bp). Depending on the "weight" of that characteristic, Primer3 uses an objective function +to score a primer design and help define what an "optimal" design looks like. + +By modifying these weights, users can prioritize specific primer design characteristics. Each of +the defaults provided here are derived from the Primer3 manual: https://primer3.org/manual.html + +## Examples of interacting with the `Primer3Weights` class + + +```python +>>> Primer3Weights(product_size_lt=1, product_size_gt=1) +Primer3Weights(product_size_lt=1, product_size_gt=1, ...) +>>> Primer3Weights(product_size_lt=5, product_size_gt=1) +Primer3Weights(product_size_lt=5, product_size_gt=1, ...) + +``` +""" + +from dataclasses import dataclass +from typing import Any + +from prymer.primer3.primer3_input_tag import Primer3InputTag + + +@dataclass(frozen=True, init=True, slots=True) +class Primer3Weights: + """Holds the weights that Primer3 uses to adjust penalties + that originate from the designed primer(s). + + The weights that Primer3 uses when a parameter is less than optimal are labeled with "_lt". + "_gt" weights are penalties applied when a parameter is greater than optimal. + + Please see the Primer3 manual for additional details: + https://primer3.org/manual.html#globalTags + + Example: + >>> Primer3Weights() #default implementation + Primer3Weights(product_size_lt=1, product_size_gt=1, product_tm_lt=0.0, product_tm_gt=0.0, primer_end_stability=0.25, primer_gc_lt=0.25, primer_gc_gt=0.25, primer_self_any=0.1, primer_self_end=0.1, primer_size_lt=0.5, primer_size_gt=0.1, primer_tm_lt=1.0, primer_tm_gt=1.0) + + >>> Primer3Weights(product_size_lt=5) + Primer3Weights(product_size_lt=5, product_size_gt=1, product_tm_lt=0.0, product_tm_gt=0.0, primer_end_stability=0.25, primer_gc_lt=0.25, primer_gc_gt=0.25, primer_self_any=0.1, primer_self_end=0.1, primer_size_lt=0.5, primer_size_gt=0.1, primer_tm_lt=1.0, primer_tm_gt=1.0) + """ # noqa: E501 + + product_size_lt: int = 1 + product_size_gt: int = 1 + product_tm_lt: float = 0.0 + product_tm_gt: float = 0.0 + primer_end_stability: float = 0.25 + primer_gc_lt: float = 0.25 + primer_gc_gt: float = 0.25 + primer_self_any: float = 0.1 + primer_self_end: float = 0.1 + primer_size_lt: float = 0.5 + primer_size_gt: float = 0.1 + primer_tm_lt: float = 1.0 + primer_tm_gt: float = 1.0 + + def to_input_tags(self) -> dict[Primer3InputTag, Any]: + """Maps weights to Primer3InputTag to feed directly into Primer3.""" + mapped_dict = { + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT: self.product_size_lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT: self.product_size_gt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT: self.product_tm_lt, + Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT: self.product_tm_gt, + Primer3InputTag.PRIMER_WT_END_STABILITY: self.primer_end_stability, + Primer3InputTag.PRIMER_WT_GC_PERCENT_LT: self.primer_gc_lt, + Primer3InputTag.PRIMER_WT_GC_PERCENT_GT: self.primer_gc_gt, + Primer3InputTag.PRIMER_WT_SELF_ANY: self.primer_self_any, + Primer3InputTag.PRIMER_WT_SELF_END: self.primer_self_end, + Primer3InputTag.PRIMER_WT_SIZE_LT: self.primer_size_lt, + Primer3InputTag.PRIMER_WT_SIZE_GT: self.primer_size_gt, + Primer3InputTag.PRIMER_WT_TM_LT: self.primer_tm_lt, + Primer3InputTag.PRIMER_WT_TM_GT: self.primer_tm_gt, + } + return mapped_dict diff --git a/prymer/py.typed b/prymer/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/prymer/util/__init__.py b/prymer/util/__init__.py new file mode 100644 index 0000000..317183a --- /dev/null +++ b/prymer/util/__init__.py @@ -0,0 +1,3 @@ +from prymer.util.executable_runner import ExecutableRunner + +__all__ = ["ExecutableRunner"] diff --git a/prymer/util/executable_runner.py b/prymer/util/executable_runner.py new file mode 100644 index 0000000..969da7f --- /dev/null +++ b/prymer/util/executable_runner.py @@ -0,0 +1,134 @@ +""" +# Base classes and methods for wrapping subprocess + +This module contains a base class to facilitate wrapping subprocess and run command line tools from +Python. Methods include functions to validate executable paths as well as initiate +and interact with subprocesses. This base class implements the context manager protocol. + +""" + +import logging +import os +import shutil +import subprocess +from pathlib import Path +from types import TracebackType +from typing import Optional +from typing import Self + + +class ExecutableRunner: + """ + Base class for interaction with subprocess for all command-line tools. The base class supports + use of the context management protocol and performs basic validation of executable paths. + + The constructor makes the assumption that the first path element of the command will be + the name of the executable being invoked. The constructor initializes a subprocess with + file handles for stdin, stdout, and stderr, each of which is opened in text mode. + + Subclasses of [`ExecutableRunner`][prymer.util.executable_runner.ExecutableRunner] + provide additional type checking of inputs and orchestrate parsing output data from specific + command-line tools. + """ + + __slots__ = ("_command", "_subprocess", "_name") + _command: list[str] + _subprocess: subprocess.Popen[str] + _name: str + + def __init__( + self, + command: list[str], + stdin: int = subprocess.PIPE, + stdout: int = subprocess.PIPE, + stderr: int = subprocess.PIPE, + ) -> None: + if len(command) == 0: + raise ValueError(f"Invocation must not be empty, received {command}") + self._command = command + self._name = command[0] + self._subprocess = subprocess.Popen( + command, + stdin=stdin, + stdout=stdout, + stderr=stderr, + text=True, + bufsize=0, # do not buffer stdin/stdout so that we can read/write immediately + ) + + def __enter__(self) -> Self: + logging.debug(f"Initiating {self._name} with the following params: {self._command}") + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """Gracefully terminates any running subprocesses.""" + self.close() + + @classmethod + def validate_executable_path(cls, executable: str | Path) -> Path: + """Validates user-provided path to an executable. + + If a string is provided, checks whether a Path representation exists. If not, uses + shutil.which() to find the executable based on the name of the command-line tool. + + Args: + executable: string or Path representation of executable + + Returns: + Path: valid path to executable (if found) + + Raises: + ValueError: if path to executable cannot be found + ValueError: if executable is not executable + """ + if isinstance(executable, str): + executable = Path(executable) + if not executable.exists() and executable.name == f"{executable}": + retval = shutil.which(f"{executable}", mode=os.F_OK) # check file existence + if retval is not None: + executable = Path(retval) + + if not executable.exists(): + raise ValueError(f"Executable does not exist: {executable}") + if not os.access(executable, os.X_OK): # check file executability + raise ValueError(f"`{executable}` is not executable: {executable}") + + return executable + + @property + def is_alive(self) -> bool: + """ + Check whether a shell subprocess is still alive. + + Returns: + bool: True if process is alive, False if otherwise + + """ + return self._subprocess.poll() is None + + def close(self) -> bool: + """ + Gracefully terminates the underlying subprocess if it is still + running. + + Returns: + True: if the subprocess was terminated successfully + False: if the subprocess failed to terminate or was not already running + """ + if self.is_alive: + self._subprocess.terminate() + self._subprocess.wait(timeout=10) + if not self.is_alive: + logging.debug("Subprocess terminated successfully.") + return True + else: + logging.debug("Subprocess failed to terminate.") + return False + else: + logging.debug("Subprocess is not running.") + return False diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f77b27c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,153 @@ +[tool.poetry] +name = "prymer" +version = "2.0.0-dev" +description = "Python primer design library" +readme = "README.md" +authors = [ + "Yossi Farjoun ", + "Jeff Gentry ", + "Tim Fennell ", + "Nils Homer ", + "Erin McAuley ", + "Matt Stone ", +] +license = "MIT" +homepage = "https://github.com/fulcrumgenomics/prymer" +repository = "https://github.com/fulcrumgenomics/prymer" +documentation = "https://github.com/fulcrumgenomics/prymer" +keywords = ["bioinformatics", "genomics", "dna"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Software Development :: Documentation", + "Topic :: Software Development :: Libraries :: Python Modules", +] +include = ["LICENSE"] + +[tool.poetry.dependencies] +python = "^3.11" +pyproject_hooks= "^1.0.0,!=1.1.0" +pybedlite = "1.0.0" +strenum = "^0.4.15" +fgpyo = "0.7.1" +pysam = "^0.22.1" +ordered-set = "^4.1.0" + +[tool.poetry.group.dev.dependencies] +poetry = "^1.8.2" +mypy = "^1.5.1" +pytest = "^7.4.4" +pytest-cov = "^4.1.0" +pytest-mypy = "^0.10.3" +pytest-ruff = "^0.3.1" +ruff = "0.3.3" +# dependencies for building docs +mkdocs-autorefs = { version = ">=0.5.0,<1.1.0" } +mkdocs-include-markdown-plugin = { version = ">=6.0.1" } +mkdocs-material = { version = ">=9.2.8" } +mkdocs-table-reader-plugin = { version = ">=2.0.1" } +mkdocs = { version = ">=1.5.2" } +mkdocs-gen-files = { version = ">=0.5.0" } +mkdocs-literate-nav = { version = ">=0.6.1" } +mkdocs-section-index = { version = ">=0.3.9" } +mkdocstrings-python = { version = ">=1.6.2" } +mkdocstrings = { version = ">=0.23.0" } +black = "^24.4.2" +pytest-doctestplus = "^1.2.1" + +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/fulcrumgenomics/prymer/issues" + +[build-system] +requires = ["poetry-core>=1.6"] +build-backend = "poetry.core.masonry.api" + +[tool.git-cliff.changelog] +header = "" +trim = true +body = """ +{% for group, commits in commits | group_by(attribute="group") %} + ## {{ group | upper_first }} + {% for commit in commits %} + - {{ commit.message | upper_first }} ({{ commit.id | truncate(length=8, end="") }})\ + {% endfor %} +{% endfor %}\n +""" + +[tool.git-cliff.git] +conventional_commits = true +commit_parsers = [ + { message = "^.+!:*", group = "Breaking"}, + { message = "^feat*", group = "Features"}, + { message = "^fix*", group = "Bug Fixes"}, + { message = "^docs*", group = "Documentation"}, + { message = "^perf*", group = "Performance"}, + { message = "^refactor*", group = "Refactor"}, + { message = "^style*", group = "Styling"}, + { message = "^test*", group = "Testing"}, + { message = "^chore\\(release\\):*", skip = true}, + { message = "^chore*", group = "Miscellaneous Tasks"}, + { body = ".*security", group = "Security"} +] +filter_commits = false + +[tool.mypy] +files = ["prymer", "tests"] +python_version = "3.11" +strict_optional = false +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_no_return = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = "defopt" +ignore_missing_imports = true + +[tool.pytest.ini_options] +minversion = "7.4" +addopts = [ + "--ignore=docs/scripts", + "--color=yes", + "--mypy", + "--ruff", + "--doctest-plus", + "--doctest-modules", + "-v" +] +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" +doctest_plus = "enabled" +testpaths = [ + "prymer", "tests" +] + +[tool.ruff] +include = ["prymer/**/*.py", "tests/**/*.py"] +line-length = 100 +target-version = "py311" +output-format = "full" + +[tool.ruff.lint] +select = ["C901", "B", "E", "F", "I", "W", "Q"] +ignore = ["E203", "E701"] +unfixable = ["B"] + +[tool.ruff.lint.isort] + +force-single-line = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..cedb92b --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,13 @@ +import pytest +from fgpyo.fasta.sequence_dictionary import SequenceDictionary +from fgpyo.fasta.sequence_dictionary import SequenceMetadata + + +@pytest.fixture +def seq_dict() -> SequenceDictionary: + metadatas: list[SequenceMetadata] = [ + SequenceMetadata(name="chr1", length=1000000, index=0), + SequenceMetadata(name="chr2", length=1000000, index=1), + SequenceMetadata(name="chr3", length=1000000, index=2), + ] + return SequenceDictionary(metadatas) diff --git a/tests/api/data/mini_chr1.vcf.gz b/tests/api/data/mini_chr1.vcf.gz new file mode 100644 index 0000000000000000000000000000000000000000..668dab75868cbb687248a313461838863764302b GIT binary patch literal 485 zcmb2|=3rp}f&Xj_PR>jW$&7O+`{o}q;AuPmG~DIODShOi%9}eGr{9)hoU5Q&xq;-B5V!bzO z*-Hh6$=UZ5!Y{oJR`fW@+GMsQ@tDKK`En+Oo0iPHdS>&%tld9Z-uANJ+{dWzdN%dy z(KC)(cgz!xUexMYJEd*y6uG4f19;(KmOouBFw7wHi9`lCa`nhoFPsZBop z!dbdjuGj6Z>hJpN@6^_=^Y~QncCm8J<$167t9`r_d3>cXtDh0K?5-1YwwLcJ==*=z z(xK}9kvq3nJ2Nvd$fJdE3WN7K4WlO}Mg|Xj*o@XV^a%K+`x!I)+4{MsM=NK$n@2lm zyW2-A>rG(tOP=8+aCqWn*;SX$UUN8q-~h{_V=T*#vAFVHF=4h1?xjWz6{)jpHfm%5)u-ak|cPUP6f;o?U-!b#dA<-o|Awp509om p*F%*V-)6B~?$A1SrhzNqs}lpmye0{YMxY_`Xr@UsFoO*T5daO{8W#Wn literal 0 HcmV?d00001 diff --git a/tests/api/data/mini_chr3.vcf.gz b/tests/api/data/mini_chr3.vcf.gz new file mode 100644 index 0000000000000000000000000000000000000000..45ad8cf5a7ac583a0a8daaca7f7eafab11ec1001 GIT binary patch literal 486 zcmb2|=3rp}f&Xj_PR>jWDU5R`2WB5K5I8>n^>U7HS2=g_nn$!=hzbvQeJk&MWOXjH z;mb+EA8zw6PF>xnXvbRg{r}zn?}V=F?raEp$?N?7Fn>A!;llH!hchOg6?(7Awa3)= zvLl1q^*xU3mRw)%G+`1qORmS^p2kJ?wi%C7JdIb+OmDtg_L=jRH1DQb=D4m|r&o2) zY!2Cxf1qn|s6@0TZ?vYZm-9l2I0*(ul`9{%vd(;ep5x@zmKMeECw&>!t0rA&nBpZ| z#`FHnhP^jeG<9D7yCGUXV!o-gXA4)!_M~MKIE5UVzy92kae--4J;ODl^PYtc64_kY z3Q}H&QXFR-m~893V~gTa?h7sj&C{iKRi4=|vE6i@cITc=4STLFZe)l|d~55cHvQsn zvtNHqKYprZ@W@ccJM-OBM?{gYP&rFOB3?BHfZCZ0+jeuYJ0)gdn%NK86J8|pc*=rYW zT|9rSV7QUXTE!UM2~SVFqQ-feM|>ELo!I%i0T_Yk LmPj)IZH58>-doXB literal 0 HcmV?d00001 diff --git a/tests/api/data/mini_chr3.vcf.gz.tbi b/tests/api/data/mini_chr3.vcf.gz.tbi new file mode 100644 index 0000000000000000000000000000000000000000..48f1e66cabc152f325ad9223875378cebddf2032 GIT binary patch literal 106 zcmb2|=3rp}f&Xj_PR>jWz6{)jpHfm%5)u-ak|cPUP6f;o?U-!b)pJm2o|Awp56?=0 pu7@HszRhB}+@W>uOaoWIS0@IB_5I?OjX*=>(M*$OU7t&u}u$bK#>*@ zO;#WpT;L&GAUIJXkd@^jV9gwx8|(X?Ek~^idbkc z4G<`MlW|@q_22Rs!a4o$=&85oPuMDxUzjrcCQ$~EQdtU+s8y*LWz+Og60$er3LR{O zLkVP{|M2kef`&>z1INO$GC6D&7flKlGqh+@Jdlvqv_%P<9SF^6jo|ju`J~>uX=C)s zrljlA^x$WK7D1e9UU7hOV2Imtxk+Y=8z_eyG=O83kP;;@UCF|#Kq!?VdxCC8j42rj zTG$*N!>iCWw!%KU0ECgS6%Hzkuc{`ecVMw1%X@?CVeBn?{L3_o8!6>ldjIPPr#-4Wb|GRsjXdGXAV2nqO2%F@+hYPX`g42i0eB1V$mt97grI t&Fc>c7k?=FQqyPx001A02m}BC000301^_}s0stET0{{R300000008@F#gYI3 literal 0 HcmV?d00001 diff --git a/tests/api/data/miniref.variants.vcf.gz.tbi b/tests/api/data/miniref.variants.vcf.gz.tbi new file mode 100644 index 0000000000000000000000000000000000000000..7ce281d780e53e00f5f0f86cd2e5a7cf144b534b GIT binary patch literal 106 zcmb2|=3rp}f&Xj_PR>jWz6{)jpHfm%5)u-ak|cPUP6f;o?U-!b)pI~-o|Awp56{+s p($_N`mt0qw;TBRT#}?ACN}P$o+F5jWBhV0eG}ELRn8AjF2mlKZ8H@k` literal 0 HcmV?d00001 diff --git a/tests/api/data/picking.dict b/tests/api/data/picking.dict new file mode 100644 index 0000000..177e969 --- /dev/null +++ b/tests/api/data/picking.dict @@ -0,0 +1,3 @@ +@HD VN:1.0 SO:unsorted +@SQ SN:chr1 LN:577 M5:ef0179df9e1890de460f8aead36f0047 UR:file:///Users/nhomer/work/git/prymer/tests/api/data/picking.fa +@SQ SN:chr2 LN:577 M5:ef0179df9e1890de460f8aead36f0047 UR:file:///Users/nhomer/work/git/prymer/tests/api/data/picking.fa diff --git a/tests/api/data/picking.fa b/tests/api/data/picking.fa new file mode 100644 index 0000000..f35bee0 --- /dev/null +++ b/tests/api/data/picking.fa @@ -0,0 +1,14 @@ +>chr1 +TCCTCCATCAGTGACCTGAAGGAGGTGCCGCGGAAAAACATCACCCTCATTCGGGGTCTGGGCCATGGCGCCTTTGGGGAGGTGTATGAAGGCCAGGTGT +CCGGAATGCCCAACGACCCAAGCCCCCTGCAAGTGGCTGTGAAGACGCTGCCTGAAGTGTGCTCTGAACAGGACGAACTGGATTTCCTCATGGAAGCCCT +GATCATCAGTCTCTCTTGGAAGCAGTTGTTTTGCTTCACCTTCTGCCTGAGGCCTTCCTGGGAGAAATCGACCATGAATTCTAGCACCCAGTCCACGCAC +CAGTATCTGAGAAAGACACTCTCCTGCCCCCTGAGACACTTCCTCACCCAAGTGCCTGAGCAACATCCTCACAGCTCCAGCCACTCTCCTGCAAATGGAA +CTCCTGTGGAGCCTGCTGTGGTTCTTCCCACCTGCTCACCAGCAAGATTCTGGGTTTAGGCTCAGCCCGGGACCCCTGCTGCCCATGTTTACAGAATGCC +TTTATACATTGTAGCTGCTGAAAATGTAACTTTGTATCCTGTTCCTCCCAGTTTAAGATTTGCCCAGACTCAGCTCA +>chr2 +TCCTCCATCAGTGACCTGAAGGAGGTGCCGCGGAAAAACATCACCCTCATTCGGGGTCTGGGCCATGGCGCCTTTGGGGAGGTGTATGAAGGCCAGGTGT +CCGGAATGCCCAACGACCCAAGCCCCCTGCAAGTGGCTGTGAAGACGCTGCCTGAAGTGTGCTCTGAACAGGACGAACTGGATTTCCTCATGGAAGCCCT +GATCATCAGTCTCTCTTGGAAGCAGTTGTTTTGCTTCACCTTCTGCCTGAGGCCTTCCTGGGAGAAATCGACCATGAATTCTAGCACCCAGTCCACGCAC +CAGTATCTGAGAAAGACACTCTCCTGCCCCCTGAGACACTTCCTCACCCAAGTGCCTGAGCAACATCCTCACAGCTCCAGCCACTCTCCTGCAAATGGAA +CTCCTGTGGAGCCTGCTGTGGTTCTTCCCACCTGCTCACCAGCAAGATTCTGGGTTTAGGCTCAGCCCGGGACCCCTGCTGCCCATGTTTACAGAATGCC +TTTATACATTGTAGCTGCTGAAAATGTAACTTTGTATCCTGTTCCTCCCAGTTTAAGATTTGCCCAGACTCAGCTCA diff --git a/tests/api/data/picking.fa.amb b/tests/api/data/picking.fa.amb new file mode 100644 index 0000000..6182d49 --- /dev/null +++ b/tests/api/data/picking.fa.amb @@ -0,0 +1 @@ +1154 2 0 diff --git a/tests/api/data/picking.fa.ann b/tests/api/data/picking.fa.ann new file mode 100644 index 0000000..4623d9e --- /dev/null +++ b/tests/api/data/picking.fa.ann @@ -0,0 +1,5 @@ +1154 2 11 +0 chr1 (null) +0 577 0 +0 chr2 (null) +577 577 0 diff --git a/tests/api/data/picking.fa.bwt b/tests/api/data/picking.fa.bwt new file mode 100644 index 0000000000000000000000000000000000000000..c915bc92fc853320fbe40c36e168a4275ffd4015 GIT binary patch literal 1260 zcmajfKZw&%90%~%_M-R@0>||Z zR+i2$EM2*@^nX`Tm*b3>p1=^%sen1bT*r(eU8RD~@O4;V6TA&x2AMZ!0&!HqLx>4f zRf+!<{E~@eqGOVQ+V+z}MhSu$7G|ES=BqHP;*Vq!Qa|4D3QEsZ1)- zMq6qO-azgc3Kz6Z{R zla1v5_|TF%@nR}!6iL4C7DryVSLv3@E$t)Z?m+)9m>+<-ue-kvJULM(YsG()^T`il5-cV+BX)&!dwP>dhEmz&l56C@- zz9je=>LNDN+@$&(a}#l-B8~~3bIiGDa?i4&kUs$P;Lo6+Av4x-e%12kwBK zuu)6&B1w1BcGG9}@j5cB!FI5H*Q#l6)p{i z5p*o=bo+_Y>3hfrBG|7Q)U*9zrpqjAVrR%kiHyq8WUrFSm+c}s%Q=hTC&+yRSD}9Z za}Nuu>TuIzA|dJ=Q(0yVQGXZ8G7&xUZ{IEG51xZ!N*#xAfnt#`EI6Jci3prKxbUDWz%;od*fckeNqTqab41EPKsQa%03&uevFsT0l Dxtv1U literal 0 HcmV?d00001 diff --git a/tests/api/data/picking.fa.fai b/tests/api/data/picking.fa.fai new file mode 100644 index 0000000..1fc1730 --- /dev/null +++ b/tests/api/data/picking.fa.fai @@ -0,0 +1,2 @@ +chr1 577 6 100 101 +chr2 577 595 100 101 diff --git a/tests/api/data/picking.fa.pac b/tests/api/data/picking.fa.pac new file mode 100644 index 0000000000000000000000000000000000000000..a19889b9816168d3059a99d9cec9f975ff3105de GIT binary patch literal 290 zcmV+-0p0%BQ%i;5qPb>(1kqhRs@|nKW&WtH&I(eyS`JhgR3%rCx_g2d<>0%W;7S-C zkJnCuSB*5ccjzR(|DQ$omx^Crq61-04{}9IQIShGhzLh@|zz?umKsz4b-sMI6$w@3E}r0qd3Y}8coK#OqxI9EmE%n<-TDbH zzvt0?<>HsusDRj0gWQo))MQf4;sO!fc~#;O_0dqdc#utWNz{~gUO?y_d!k-@uYFN@ o(MbsRtMZ*CY883qPW(tYe=$DE=YTs0`!(-fQv4(NQXMBi0^pUC;s5{u literal 0 HcmV?d00001 diff --git a/tests/api/data/picking.fa.sa b/tests/api/data/picking.fa.sa new file mode 100644 index 0000000000000000000000000000000000000000..d3b614136879a8e4b26ccd28a49f3d3e1af5e08f GIT binary patch literal 632 zcmXxgJxfAi7zW@|TIvvixilFG4vpa)F0~a64*iJ6prIvFL83n(k$YP%ZGr{TA}tNA zMJNSAEQOGylx_yK?9FC;wRfk3NA<{BQm~EZ8~Wuh9qa zO1~`Ea*w`quejfX|8hXTx7X8e=x^iic8cT-VQAB$NYr8N$>dH40?7d-v9sr literal 0 HcmV?d00001 diff --git a/tests/api/test_clustering.py b/tests/api/test_clustering.py new file mode 100644 index 0000000..ffcea5d --- /dev/null +++ b/tests/api/test_clustering.py @@ -0,0 +1,101 @@ +import io + +import pytest +from pybedlite.bed_source import BedSource +from pybedlite.overlap_detector import Interval + +from prymer.api.clustering import _cluster_in_contig +from prymer.api.clustering import cluster_intervals + +with BedSource( + io.StringIO(""" +chr1\t3\t6\tone\t0\t+ +chr1\t8\t9\ttwo\t0\t+ +chr1\t5\t7\tthree\t0\t+ +""") +) as bed: + d = [Interval.from_bedrecord(i) for i in bed] + +with BedSource( + io.StringIO(""" +chr1\t3\t6\tone\t0\t+ +chr1\t8\t9\ttwo\t0\t+ +chr1\t5\t7\tthree\t0\t+ +chr2\t3\t6\tone\t0\t+ +chr2\t8\t9\ttwo\t0\t+ +chr2\t5\t7\tthree\t0\t+ +""") +) as bed: + both = [Interval.from_bedrecord(i) for i in bed] + + +def test_clustering_when_no_overlaps() -> None: + no_overlap = [d[i] for i in [0, 1]] + result = cluster_intervals(no_overlap, 10) + + # print(clusters) + + assert len(result.clusters) == 1 + assert len(result.intervals) == 2 + assert len({i.name for i in result.clusters}) == 1 + + +def test_cluster_in_contig() -> None: + result = _cluster_in_contig(d, 10) + + assert len(result.clusters) == 1 + assert len(result.intervals) == len(d) + + +def test_cluster_empty() -> None: + result = _cluster_in_contig([], 10) + + assert len(result.clusters) == 0 + assert len(result.intervals) == 0 + + +def test_cluster_in_contig_small_distance() -> None: + result = _cluster_in_contig(d, 3) + assert len(result.clusters) == 3 + assert len(result.intervals) == len(d) + assert len({i.name for i in result.intervals}) == 3 + + +def test_cluster_fails_on_tiny_distance() -> None: + with pytest.raises(ValueError): + _cluster_in_contig(d, 2) + + +def test_cluster_multi_contig() -> None: + result = cluster_intervals(both, 10) + + assert len(result.clusters) == 2 + assert len({i.refname for i in result.clusters}) == 2 + assert len(result.intervals) == 2 * len(d) + assert len({i.name for i in result.intervals}) == 2 + + +def test_cluster_multi_contig_small_distance() -> None: + result = cluster_intervals(both, 3) + + assert max(i.length() for i in result.clusters) <= 3 + assert len(result.clusters) == 2 * 3 + assert len({i.refname for i in result.clusters}) == 2 + assert len(result.intervals) == 2 * len(d) + assert len({i.name for i in result.intervals}) == 3 * 2 + + +def test_cluster_multi_contig_fails_on_tiny_distance() -> None: + with pytest.raises(ValueError): + cluster_intervals(both, 2) + + +def test_max_size_respected() -> None: + n = 100 + max_size = 30 + interval_size = 5 + intervals = [Interval("chr1", i, i + interval_size) for i in range(n)] + + result = cluster_intervals(intervals, max_size) + + assert max(i.length() for i in result.clusters) <= max_size diff --git a/tests/api/test_coordmath.py b/tests/api/test_coordmath.py new file mode 100644 index 0000000..52648ae --- /dev/null +++ b/tests/api/test_coordmath.py @@ -0,0 +1,21 @@ +import pytest +from pybedlite.overlap_detector import Interval + +from prymer.api.coordmath import get_locus_string +from prymer.api.coordmath import require_same_refname + + +def test_get_locus_string() -> None: + intervals = [Interval("chr1", 1, 2), Interval("chr2", 3, 4)] + assert [get_locus_string(i) for i in intervals] == ["chr1:1-2", "chr2:3-4"] + + +def test_assert_same_refname_works() -> None: + intervals = [Interval("chr1", 1, 2), Interval("chr1", 3, 4)] + require_same_refname(*intervals) + + +def test_assert_same_refname_works_neg() -> None: + intervals = [Interval("chr1", 1, 2), Interval("chr2", 3, 4)] + with pytest.raises(ValueError): + require_same_refname(*intervals) diff --git a/tests/api/test_melting.py b/tests/api/test_melting.py new file mode 100644 index 0000000..2bd0e90 --- /dev/null +++ b/tests/api/test_melting.py @@ -0,0 +1,31 @@ +import pytest + +from prymer.api.melting import calculate_long_seq_tm + + +@pytest.mark.parametrize( + "seq, salt_molar_concentration, percent_formamide, expected_tm", + [ + ("A" * 100, 1.0, 0.0, 74.75), + ("C" * 100, 1.0, 0.0, 115.75), + ("G" * 100, 1.0, 0.0, 115.75), + ("T" * 100, 1.0, 0.0, 74.75), + ("AT" * 50, 1.0, 0.0, 74.75), + ("GC" * 50, 1.0, 0.0, 115.75), + ("AC" * 50, 1.0, 0.0, 95.25), + ("GT" * 50, 1.0, 0.0, 95.25), + ("GT" * 10, 1.0, 0.0, 68.25), + ("GT" * 10, 1.0, 2.0, 67.01), + ("GT" * 10, 1.0, 10.0, 62.05), + ("GT" * 10, 2.0, 10.0, 67.0471), + ("GT" * 10, 10.0, 10.0, 78.65), + ], +) +def test_calculate_long_seq_tm( + seq: str, salt_molar_concentration: float, percent_formamide: float, expected_tm: float +) -> None: + assert pytest.approx(expected_tm) == calculate_long_seq_tm( + seq=seq, + salt_molar_concentration=salt_molar_concentration, + percent_formamide=percent_formamide, + ) diff --git a/tests/api/test_minoptmax.py b/tests/api/test_minoptmax.py new file mode 100644 index 0000000..f9b3e11 --- /dev/null +++ b/tests/api/test_minoptmax.py @@ -0,0 +1,55 @@ +from typing import TypeVar + +import pytest + +from prymer.api.minoptmax import MinOptMax + +Numeric = TypeVar("Numeric", int, float) + + +@pytest.mark.parametrize( + "valid_min,valid_opt,valid_max,expected_obj", + [ + (0.0, 1.0, 2.0, MinOptMax(0.0, 1.0, 2.0)), # min < opt < max + (0.0, 0.0, 2.0, MinOptMax(0.0, 0.0, 2.0)), # min == opt < max + (1.0, 1.0, 2.0, MinOptMax(1.0, 1.0, 2.0)), # min == opt < max non-zero + (1.0, 1.0, 1.0, MinOptMax(1.0, 1.0, 1.0)), # min == opt == max + (-100, -5, 42, MinOptMax(-100, -5, 42)), # min < opt < max (negative ints) + ], +) +def test_minmaxopt_valid( + valid_min: Numeric, valid_opt: Numeric, valid_max: Numeric, expected_obj: MinOptMax[Numeric] +) -> None: + """Test MinOptMax construction with valid input""" + test_obj = MinOptMax(valid_min, valid_opt, valid_max) + assert test_obj == expected_obj + + +@pytest.mark.parametrize( + "invalid_min,invalid_opt, invalid_max", + [ + (3.0, 12.0, 10.0), # opt > max + (10.0, 5.0, 1.0), # min > max + (1, 50.0, "string_value"), # mixed types + (1.0, 0.0, 2.0), # opt is 0.0 and 0.0 None: + """Test that MinMaxOpt constructor raises an error with invalid input""" + with pytest.raises((ValueError, TypeError)): + MinOptMax(min=invalid_min, opt=invalid_opt, max=invalid_max) + + +def test_str_repr() -> None: + test_obj = MinOptMax(min=1, opt=2, max=3) + assert test_obj.__str__() == "(min:1, opt:2, max:3)" + + +def test_iter_repr() -> None: + test_obj = MinOptMax(min=1, opt=2, max=3) + test_iter = test_obj.__iter__() + assert next(test_iter) == 1 + assert next(test_iter) == 2 + assert next(test_iter) == 3 + with pytest.raises(StopIteration): + next(test_iter) diff --git a/tests/api/test_picking.py b/tests/api/test_picking.py new file mode 100644 index 0000000..fae8179 --- /dev/null +++ b/tests/api/test_picking.py @@ -0,0 +1,916 @@ +from collections import Counter +from dataclasses import dataclass +from dataclasses import replace +from pathlib import Path +from typing import Any +from typing import Optional +from typing import Tuple + +import pysam +import pytest +from fgpyo.sequence import reverse_complement + +from prymer.api.melting import calculate_long_seq_tm +from prymer.api.minoptmax import MinOptMax +from prymer.api.picking import FilteringParams +from prymer.api.picking import _dist_penalty +from prymer.api.picking import _seq_penalty +from prymer.api.picking import build_and_pick_primer_pairs +from prymer.api.picking import build_primer_pairs +from prymer.api.picking import check_primer_overlap +from prymer.api.picking import is_acceptable_primer_pair +from prymer.api.picking import pick_top_primer_pairs +from prymer.api.picking import score as picking_score +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.ntthal import NtThermoAlign +from prymer.offtarget.offtarget_detector import OffTargetDetector + + +@pytest.fixture +def filter_params() -> FilteringParams: + return FilteringParams( + amplicon_sizes=MinOptMax(100, 150, 200), + amplicon_tms=MinOptMax(50.0, 55.0, 60.0), + product_size_lt=1, + product_size_gt=1, + product_tm_lt=0.0, + product_tm_gt=0.0, + min_primer_to_target_distance=0, + read_length=100000, + ) + + +@pytest.mark.parametrize( + "start, end, min_primer_to_target_distance, dist_from_primer_end_weight, penalty", + [ + (10, 20, 10, 2.0, 0.0), # 10bp away, at min distance + (10, 19, 10, 2.0, 2.0), # 9bp away, below min distance + (10, 21, 10, 2.0, 0.0), # 11bp away, beyond min distance + (10, 22, 32, 3.0, 60.0), # 2bp away, one over min distance, larger weight + (10, 22, 32, 5.0, 100.0), # 2bp away, one over min distance, large weight + ], +) +def test_dist_penalty( + start: int, + end: int, + min_primer_to_target_distance: int, + dist_from_primer_end_weight: float, + penalty: float, + filter_params: FilteringParams, +) -> None: + params = replace( + filter_params, + min_primer_to_target_distance=min_primer_to_target_distance, + dist_from_primer_end_weight=dist_from_primer_end_weight, + ) + assert _dist_penalty(start=start, end=end, params=params) == penalty + + +@pytest.mark.parametrize( + "start, end, read_length, target_not_covered_by_read_length_weight, penalty", + [ + (10, 10, 1, 2.0, 0.0), # 1bp amplicon_length length, at read length + (10, 10, 2, 2.0, 0.0), # 1bp amplicon_length length, one shorter than read length + (10, 10, 0, 2.0, 2.0), # 1bp amplicon_length length, one longer than read length + (10, 100, 91, 2.0, 0.0), # 91bp amplicon length, at read length + (10, 100, 90, 2.0, 2.0), # 1bp amplicon_length length, one longer than read length + (10, 100, 91, 4.0, 0.0), # 1bp amplicon_length length, one below minimum, larger weight + (10, 100, 50, 5.0, 205.0), # 1bp amplicon_length length, far below minimum, large weight + ], +) +def test_seq_penalty( + start: int, + end: int, + read_length: int, + target_not_covered_by_read_length_weight: float, + penalty: float, + filter_params: FilteringParams, +) -> None: + params = replace( + filter_params, + read_length=read_length, + target_not_covered_by_read_length_weight=target_not_covered_by_read_length_weight, + ) + assert _seq_penalty(start=start, end=end, params=params) == penalty + + +def build_primer_pair(amplicon_length: int, tm: float) -> PrimerPair: + left_primer = Primer( + tm=0, + penalty=0, + span=Span(refname="1", start=1, end=max(1, amplicon_length // 4)), + ) + right_primer = Primer( + tm=0, + penalty=0, + span=Span( + refname="1", start=max(1, amplicon_length - (amplicon_length // 4)), end=amplicon_length + ), + ) + return PrimerPair( + left_primer=left_primer, + right_primer=right_primer, + amplicon_tm=tm, + penalty=0, + ) + + +@pytest.mark.parametrize( + "prev_left, prev_right, next_left, next_right, min_difference, expected", + [ + # no overlap -> True + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 120, 140), + Span("chr1", 220, 240), + 10, + True, + ), + # left at min_difference -> True + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 110, 130), + Span("chr1", 220, 240), + 10, + True, + ), + # left one less than min_difference -> False + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 109, 129), + Span("chr1", 220, 240), + 10, + False, + ), + # right at min_difference -> True + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 120, 140), + Span("chr1", 210, 230), + 10, + True, + ), + # right one less than min_difference -> False + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 120, 140), + Span("chr1", 209, 229), + 10, + False, + ), + # both at min_difference -> True + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 110, 130), + Span("chr1", 210, 230), + 10, + True, + ), + # both one less than min_difference -> False + ( + Span("chr1", 100, 120), + Span("chr1", 200, 220), + Span("chr1", 109, 129), + Span("chr1", 209, 229), + 10, + False, + ), + ], +) +def test_check_primer_overlap( + prev_left: Span, + prev_right: Span, + next_left: Span, + next_right: Span, + min_difference: int, + expected: bool, +) -> None: + assert ( + check_primer_overlap( + prev_left=prev_left, + prev_right=prev_right, + next_left=next_left, + next_right=next_right, + min_difference=min_difference, + ) + == expected + ) + assert ( + check_primer_overlap( + prev_left=next_left, + prev_right=next_right, + next_left=prev_left, + next_right=prev_right, + min_difference=min_difference, + ) + == expected + ) + + +@pytest.mark.parametrize( + "pair, expected", + [ + (build_primer_pair(amplicon_length=150, tm=55), True), # OK at optimal + (build_primer_pair(amplicon_length=100, tm=55), True), # OK, amplicon at lower boundary + (build_primer_pair(amplicon_length=99, tm=55), False), # NOK, amplicon < lower boundary + (build_primer_pair(amplicon_length=200, tm=55), True), # OK, amplicon at upper boundary + (build_primer_pair(amplicon_length=201, tm=55), False), # NOK, amplicon > upper boundary + (build_primer_pair(amplicon_length=150, tm=50), True), # OK, tm at lower boundary + (build_primer_pair(amplicon_length=150, tm=49), False), # NOK, tm < lower boundary + (build_primer_pair(amplicon_length=150, tm=60), True), # OK, tm at upper boundary + (build_primer_pair(amplicon_length=150, tm=61), False), # NOK, tm > upper boundary + ], +) +def test_is_acceptable_primer_pair(pair: PrimerPair, expected: bool) -> None: + params = FilteringParams( + amplicon_sizes=MinOptMax(100, 150, 200), + amplicon_tms=MinOptMax(50.0, 55.0, 60.0), + product_size_lt=1, + product_size_gt=1, + product_tm_lt=0.0, + product_tm_gt=0.0, + min_primer_to_target_distance=0, + read_length=100000, + ) + assert is_acceptable_primer_pair(primer_pair=pair, params=params) == expected + + +@dataclass(init=True, frozen=True) +class ScoreInput: + left: Primer + right: Primer + target: Span + amplicon: Span + amplicon_sequence: str + + def __post_init__(self) -> None: + primer_pair = PrimerPair( + left_primer=self.left, + right_primer=self.right, + amplicon_tm=0, + penalty=0, + ) + object.__setattr__(self, "primer_pair", primer_pair) + + +def _score_input() -> ScoreInput: + l_mapping = Span(refname="1", start=95, end=125) + r_mapping = Span(refname="1", start=195, end=225) + amplicon = Span(refname="1", start=l_mapping.end, end=r_mapping.start) + target = Span(refname="1", start=l_mapping.end + 10, end=r_mapping.start - 20) + return ScoreInput( + left=Primer(penalty=0, tm=0, span=l_mapping), + right=Primer(penalty=0, tm=0, span=r_mapping), + target=target, + amplicon=amplicon, + amplicon_sequence="A" * amplicon.length, + ) + + +@pytest.fixture() +def score_input() -> ScoreInput: + return _score_input() + + +# Developer Note: for `score()`, we must have: +# 1. params.amplicon_sizes.opt == 0 +# 2. params.amplicon_tms.opt is exactly the _calculate_long_seq_tm of the amplicon sequence +# 3. filtering_params.min_primer_to_target_distance is zero, +# (target.start >= left.span.end) +# 4. filtering_params.min_primer_to_target_distance is zero, +# (right.span.start >= target.end) +# 5. filtering_params.read_length is large, (target.end >= left.span.start) +# 6. filtering_params.read_length is large, (right.span.end >= target.start) +def _zero_score_filtering_params(score_input: ScoreInput) -> FilteringParams: + amplicon_tm = calculate_long_seq_tm(score_input.amplicon_sequence) + return FilteringParams( + # for size_penalty to be zero + amplicon_sizes=MinOptMax(0, 0, 200), + product_size_gt=0, + product_size_lt=0, + # for tm_penalty to be zero + amplicon_tms=MinOptMax(amplicon_tm, amplicon_tm, amplicon_tm), + product_tm_gt=0, + product_tm_lt=0, + # for left_dist_penalty and left_dist_penalty to be zero + min_primer_to_target_distance=0, + dist_from_primer_end_weight=0, + # for left_seq_penalty and left_seq_penalty to be zero + read_length=100000, + target_not_covered_by_read_length_weight=0, + ) + + +@pytest.fixture() +def zero_score_filtering_params(score_input: ScoreInput) -> FilteringParams: + return _zero_score_filtering_params(score_input=score_input) + + +def test_zero_score( + score_input: ScoreInput, + zero_score_filtering_params: FilteringParams, +) -> None: + assert ( + picking_score( + left=score_input.left, + right=score_input.right, + target=score_input.target, + amplicon=score_input.amplicon, + amplicon_seq_or_tm=score_input.amplicon_sequence, + params=zero_score_filtering_params, + ) + == 0.0 + ) + + +def test_zero_score_with_amplicon_tm( + score_input: ScoreInput, + zero_score_filtering_params: FilteringParams, +) -> None: + amplicon_tm: float = calculate_long_seq_tm(score_input.amplicon_sequence) + assert ( + picking_score( + left=score_input.left, + right=score_input.right, + target=score_input.target, + amplicon=score_input.amplicon, + amplicon_seq_or_tm=amplicon_tm, + params=zero_score_filtering_params, + ) + == 0.0 + ) + + +# Notes on score_input. +# - amplicon length is 71bp (for size_penalty) +# - amplicon Tm is 66.30319122042972 (for tm_penalty) +# - primer to target distance is 10 for left_dist_penalty and 20 right_dist_penalty. This also +# means we need a read length of at least 81bp (=71 + 10) for the left_seq_penalty to be zero +# and at least 91bp (=71 + 20) for the right_seq_penalty to be zero +@pytest.mark.parametrize( + "kwargs, expected_score", + [ + # size_penalty: amplicon size is too large (71bp > 61bp, diff = 10, so score = (71-61)*5) + ({"product_size_gt": 5.0, "amplicon_sizes": MinOptMax(61, 61, 61)}, (71 - 61) * 5), + # size_penalty: amplicon size is too small (71bp < 81bp, diff = 10, so score = (71-61)*4) + ({"product_size_lt": 4.0, "amplicon_sizes": MinOptMax(81, 81, 81)}, (71 - 61) * 4), + # tm_penalty: amplicon Tm is too large + ( + {"product_tm_gt": 5.0, "amplicon_tms": MinOptMax(60, 60, 60)}, + 5.0 * (66.30319122042972 - 60), + ), + # tm_penalty: amplicon Tm is too small + ( + {"product_tm_lt": 4.0, "amplicon_tms": MinOptMax(70, 70, 70)}, + 4.0 * (70 - 66.30319122042972), + ), + # both [left/right]_dist_penalty are zero: diff > min_primer_to_target_distance + ({"min_primer_to_target_distance": 10, "dist_from_primer_end_weight": 5.0}, 0), + # left_dist_penalty: diff < min_primer_to_target_distance + ({"min_primer_to_target_distance": 11, "dist_from_primer_end_weight": 5.0}, (1 * 5.0)), + ({"min_primer_to_target_distance": 20, "dist_from_primer_end_weight": 5.0}, (10 * 5.0)), + # both [left/right]_dist_penalty: diff < min_primer_to_target_distance, 11bp for left, and + # 1bp for right, so 12bp overall + ({"min_primer_to_target_distance": 21, "dist_from_primer_end_weight": 5.0}, (12 * 5.0)), + # both[left/right]_seq_penalty: zero since read length >= 81bp (left) and >= 91bp (right) + ({"read_length": 91, "target_not_covered_by_read_length_weight": 10.0}, 0), + # right_seq_penalty: less than 91bp for the right but at 81bp for the left + ({"read_length": 81, "target_not_covered_by_read_length_weight": 10.0}, (10 * 10)), + # both[left/right]_seq_penalty: zero since read length <>=> 81bp (left) and < 91bp (right) + ({"read_length": 71, "target_not_covered_by_read_length_weight": 10.0}, 10.0 * (10 + 20)), + ], +) +def test_score( + score_input: ScoreInput, + zero_score_filtering_params: FilteringParams, + kwargs: dict[str, Any], + expected_score: float, +) -> None: + params = replace(zero_score_filtering_params, **kwargs) + assert ( + picking_score( + left=score_input.left, + right=score_input.right, + target=score_input.target, + amplicon=score_input.amplicon, + amplicon_seq_or_tm=score_input.amplicon_sequence, + params=params, + ) + == expected_score + ) + + +@pytest.mark.parametrize( + "left_penalty, right_penalty", [(0, 0), (10.0, 0.0), (0, 12.0), (13.0, 14.0)] +) +def test_score_primer_primer_penalties( + score_input: ScoreInput, + zero_score_filtering_params: FilteringParams, + left_penalty: float, + right_penalty: float, +) -> None: + left = replace(score_input.left, penalty=left_penalty) + right = replace(score_input.right, penalty=right_penalty) + assert picking_score( + left=left, + right=right, + target=score_input.target, + amplicon=score_input.amplicon, + amplicon_seq_or_tm=score_input.amplicon_sequence, + params=zero_score_filtering_params, + ) == (left_penalty + right_penalty) + + +def test_primer_pairs( + score_input: ScoreInput, zero_score_filtering_params: FilteringParams, genome_ref: Path +) -> None: + primer_length: int = 30 + target = Span(refname="chr1", start=100, end=250) + + # tile some left primers + lefts = [] + rights = [] + for offset in range(0, 50, 5): + # left + left_end = target.start - offset + left_start = left_end - primer_length + left = Primer( + penalty=-offset, tm=0, span=Span(refname=target.refname, start=left_start, end=left_end) + ) + lefts.append(left) + # right + right_start = target.end + offset + right_end = right_start + primer_length + right = Primer( + penalty=offset, + tm=0, + span=Span(refname=target.refname, start=right_start, end=right_end), + ) + rights.append(right) + + with pysam.FastaFile(f"{genome_ref}") as fasta: + primer_pairs = build_primer_pairs( + lefts=lefts, + rights=rights, + target=target, + params=zero_score_filtering_params, + fasta=fasta, + ) + assert len(primer_pairs) == len(lefts) * len(rights) + last_penalty = primer_pairs[0].penalty + primer_counter: Counter[Primer] = Counter() + for pp in primer_pairs: + assert pp.left_primer in lefts + assert pp.right_primer in rights + primer_counter[pp.left_primer] += 1 + primer_counter[pp.right_primer] += 1 + # by design, only the left/right penalties contribute to the primer pair penlaty + assert pp.penalty == pp.left_primer.penalty + pp.right_primer.penalty + # at least check that the amplicon Tm is non-zero + assert pp.amplicon_tm > 0 + # at least check that the amplicon sequence retrieved is the correct length + assert len(pp.amplicon_sequence) == pp.amplicon.length + # check that the primer pairs are sorted by increasing penalty + assert last_penalty <= pp.penalty + last_penalty = pp.penalty + # make sure we see all the primers the same # of times! + items = primer_counter.items() + assert len(set(i[0] for i in items)) == len(lefts) + len(rights) # same primers + assert len(set(i[1] for i in items)) == 1 # same counts for each primer + + +def test_primer_pairs_except_different_references( + score_input: ScoreInput, zero_score_filtering_params: FilteringParams, genome_ref: Path +) -> None: + # all primers (both left and right) _should_ be on the same reference (refname). Add a test + # that raises an exception (in PrimerPair) that doesn't. + with pysam.FastaFile(f"{genome_ref}") as fasta: + with pytest.raises(ValueError, match="Cannot create a primer pair"): + # change the reference for the right primer + right = replace(score_input.right, span=Span(refname="Y", start=195, end=225)) + build_primer_pairs( + lefts=[score_input.left], + rights=[right], + target=score_input.target, + params=zero_score_filtering_params, + fasta=fasta, + ) + + +def _picking_ref() -> Path: + return Path(__file__).parent / "data" / "picking.fa" + + +@pytest.fixture(scope="session") +def picking_ref() -> Path: + return _picking_ref() + + +def _target() -> Span: + target: Span = Span(refname="chr1", start=150, end=250) + return target + + +def _primer_pair( + target_offset: int = 0, + primer_length: int = 30, + amplicon_tm: Optional[float] = None, + penalty: int = 0, + target: Optional[Span] = None, + params: Optional[FilteringParams] = None, +) -> PrimerPair: + if target is None: + target = _target() + if params is None: + params = _zero_score_filtering_params(score_input=_score_input()) + fasta = pysam.FastaFile(f"{_picking_ref()}") + if amplicon_tm is None: + amplicon_tm = params.amplicon_tms.opt + left_span = Span( + refname=target.refname, + start=target.start - primer_length - target_offset + 1, # +1 for 1-based inclusive + end=target.start - target_offset, + strand=Strand.POSITIVE, + ) + assert left_span.length == primer_length + left_bases = fasta.fetch( + reference=left_span.refname, start=left_span.start - 1, end=left_span.end + ) + right_span = Span( + refname=target.refname, + start=target.end + target_offset, + end=target.end + primer_length + target_offset - 1, # -1 for 1-based inclusive + strand=Strand.NEGATIVE, + ) + assert right_span.length == primer_length + right_bases = fasta.fetch( + reference=left_span.refname, start=right_span.start - 1, end=right_span.end + ) + right_bases = reverse_complement(right_bases) + fasta.close() + return PrimerPair( + left_primer=Primer(bases=left_bases, penalty=0, tm=0, span=left_span), + right_primer=Primer(bases=right_bases, penalty=0, tm=0, span=right_span), + amplicon_tm=amplicon_tm, + penalty=penalty, + ) + + +def _pick_top_primer_pairs( + params: FilteringParams, + picking_ref: Path, + primer_pairs: list[PrimerPair], + max_primer_hits: int, + max_primer_pair_hits: int, + min_difference: int = 1, +) -> list[PrimerPair]: + offtarget_detector = OffTargetDetector( + ref=picking_ref, + max_primer_hits=max_primer_hits, + max_primer_pair_hits=max_primer_pair_hits, + three_prime_region_length=5, + max_mismatches_in_three_prime_region=0, + max_mismatches=0, + max_amplicon_size=params.amplicon_sizes.max, + ) + dimer_checker = NtThermoAlign() + + picked = pick_top_primer_pairs( + primer_pairs=primer_pairs, + num_primers=len(primer_pairs), + min_difference=min_difference, + params=params, + offtarget_detector=offtarget_detector, + is_dimer_tm_ok=lambda s1, s2: ( + dimer_checker.duplex_tm(s1=s1, s2=s2) <= params.max_dimer_tm + ), + ) + offtarget_detector.close() + dimer_checker.close() + + return picked + + +_PARAMS: FilteringParams = _zero_score_filtering_params(_score_input()) +"""Filter parameters for use in creating the test cases for `test_pick_top_primer_pairs`""" + + +@pytest.mark.parametrize( + "picked, primer_pair", + [ + # primer pair passes all primer/primer-pair-specific filters + (True, _primer_pair()), + # too small amplicon size + (False, _primer_pair(amplicon_tm=_PARAMS.amplicon_sizes.min - 1)), + # too big amplicon size + (False, _primer_pair(amplicon_tm=_PARAMS.amplicon_sizes.max + 1)), + # too low amplicon Tm + (False, _primer_pair(amplicon_tm=_PARAMS.amplicon_tms.min - 1)), + # too large amplicon Tm + (False, _primer_pair(amplicon_tm=_PARAMS.amplicon_tms.max + 1)), + ], +) +def test_pick_top_primer_pairs_individual_primers( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, + picked: bool, + primer_pair: PrimerPair, +) -> None: + expected = [primer_pair] if picked else [] + assert expected == _pick_top_primer_pairs( + params=zero_score_filtering_params, + picking_ref=picking_ref, + primer_pairs=[primer_pair], + max_primer_hits=2, + max_primer_pair_hits=2, + ) + + +def test_pick_top_primer_pairs_dimer_tm_too_large( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, +) -> None: + pp = _primer_pair() + + # get the Tm for the primer pair + duplex_tm = NtThermoAlign().duplex_tm(s1=pp.left_primer.bases, s2=pp.right_primer.bases) + + # at the maximum dimer Tm + params = replace(zero_score_filtering_params, max_dimer_tm=duplex_tm) + assert [pp] == _pick_top_primer_pairs( + params=params, + picking_ref=picking_ref, + primer_pairs=[pp], + max_primer_hits=2, + max_primer_pair_hits=2, + ) + + # **just** over the maximum dimer Tm + params = replace(zero_score_filtering_params, max_dimer_tm=duplex_tm - 0.0001) + assert [] == _pick_top_primer_pairs( + params=params, + picking_ref=picking_ref, + primer_pairs=[pp], + max_primer_hits=2, + max_primer_pair_hits=2, + ) + + +_PRIMER_PAIRS_PICKING: list[PrimerPair] = [ + _primer_pair(target_offset=10, penalty=2), # left.start=111 + _primer_pair(target_offset=6, penalty=2), # 4bp from the previous, left.start=115 + _primer_pair(target_offset=3, penalty=3), # 3bp from the previous, left.start=118 + _primer_pair(target_offset=1, penalty=4), # 2bp from the previous, left.start=120 + _primer_pair(target_offset=0, penalty=5), # 1bp from the previous, left.start=121 + _primer_pair(target_offset=0, penalty=6), # same as the previous, left.start=121 +] + + +@dataclass(frozen=True) +class _PickingTestCase: + min_difference: int + primer_pairs: list[Tuple[bool, PrimerPair]] + + +@pytest.mark.parametrize( + "test_case", + [ + # primer pairs that overlap each other fully, so second one is discarded + _PickingTestCase( + min_difference=1, + primer_pairs=[ + (True, _primer_pair(target_offset=1, penalty=2)), # first primer pair, kept + ( + False, + _primer_pair(target_offset=1, penalty=2), + ), # same as the previous, discarded + ], + ), + # primer pairs that are offset by 1bp (equal to min-difference) so both are kept + _PickingTestCase( + min_difference=1, + primer_pairs=[ + (True, _primer_pair(target_offset=0, penalty=2)), + (True, _primer_pair(target_offset=1, penalty=2)), + ], + ), + # primer pairs that are offset by 1bp (less than min-difference) so the first is kept + _PickingTestCase( + min_difference=2, + primer_pairs=[ + (True, _primer_pair(target_offset=0, penalty=2)), + (False, _primer_pair(target_offset=1, penalty=2)), + ], + ), + _PickingTestCase( + min_difference=3, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, True, True, False, True, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=4, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, True, False, True, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=5, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, True, False, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=6, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, True, False, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=7, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, True, False, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=8, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, False, True, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=9, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, False, True, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=10, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, False, False, True, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + _PickingTestCase( + min_difference=11, + primer_pairs=list( + zip( + # 111, 115, 118, 120, 121, 121 + [True, False, False, False, False, False], + _PRIMER_PAIRS_PICKING, + strict=True, + ) + ), + ), + ], +) +def test_pick_top_primer_pairs_multiple_primers( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, + test_case: _PickingTestCase, +) -> None: + in_primer_pairs = [pp[1] for pp in test_case.primer_pairs] + + # First check that picked _alone_ they will be kept + for primer_pair in in_primer_pairs: + assert [primer_pair] == _pick_top_primer_pairs( + params=zero_score_filtering_params, + picking_ref=picking_ref, + primer_pairs=[primer_pair], + max_primer_hits=2, + max_primer_pair_hits=2, + min_difference=test_case.min_difference, + ) + + # Next, check that picked _together_ only those primer pairs with "True" associated with them + # are kept + expected = [pp[1] for pp in test_case.primer_pairs if pp[0]] + assert expected == _pick_top_primer_pairs( + params=zero_score_filtering_params, + picking_ref=picking_ref, + primer_pairs=in_primer_pairs, + max_primer_hits=2, + max_primer_pair_hits=2, + min_difference=test_case.min_difference, + ) + + +def test_pick_top_primer_pairs_input_order( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, +) -> None: + with pytest.raises(ValueError, match="Primers must be given in order by penalty"): + _pick_top_primer_pairs( + params=zero_score_filtering_params, + picking_ref=picking_ref, + primer_pairs=[_primer_pair(penalty=6), _primer_pair(penalty=5)], + max_primer_hits=2, + max_primer_pair_hits=2, + min_difference=0, + ) + + +def test_pick_top_primer_pairs_too_many_off_targets( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, +) -> None: + """The contig is duplicated wholesale in `picking.fa`, so we should get two hits per primer. + Thus, setting `max_primer_hits=1` will return "too many hits""" + assert [] == _pick_top_primer_pairs( + params=zero_score_filtering_params, + picking_ref=picking_ref, + primer_pairs=[_primer_pair()], + max_primer_hits=1, + max_primer_pair_hits=1, + ) + + +def test_and_pick_primer_pairs( + picking_ref: Path, + zero_score_filtering_params: FilteringParams, +) -> None: + target = _target() + pp = _primer_pair(target=target, amplicon_tm=92.96746617835336) + params = replace( + zero_score_filtering_params, + amplicon_tms=MinOptMax(pp.amplicon_tm, pp.amplicon_tm, pp.amplicon_tm), + ) + + offtarget_detector = OffTargetDetector( + ref=picking_ref, + max_primer_hits=2, + max_primer_pair_hits=2, + three_prime_region_length=5, + max_mismatches_in_three_prime_region=0, + max_mismatches=0, + max_amplicon_size=params.amplicon_sizes.max, + ) + + with pysam.FastaFile(f"{picking_ref}") as fasta: + designed_primer_pairs = build_and_pick_primer_pairs( + lefts=[pp.left_primer], + rights=[pp.right_primer], + target=target, + num_primers=1, + min_difference=1, + params=params, + offtarget_detector=offtarget_detector, + dimer_checker=NtThermoAlign(), + fasta=fasta, + ) + assert len(designed_primer_pairs) == 1 + designed_primer_pair = designed_primer_pairs[0] + assert designed_primer_pair.left_primer == pp.left_primer + assert designed_primer_pair.right_primer == pp.right_primer + assert designed_primer_pair.amplicon == pp.amplicon + assert designed_primer_pair.penalty == pp.penalty + assert designed_primer_pair.amplicon_tm == pp.amplicon_tm + assert len(designed_primer_pair.amplicon_sequence) == pp.amplicon.length diff --git a/tests/api/test_primer.py b/tests/api/test_primer.py new file mode 100644 index 0000000..b7e4c1d --- /dev/null +++ b/tests/api/test_primer.py @@ -0,0 +1,440 @@ +from dataclasses import dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Optional + +import pytest +from fgpyo.fasta.sequence_dictionary import SequenceDictionary + +from prymer.api.primer import Primer +from prymer.api.span import Span +from prymer.api.span import Strand + + +@pytest.mark.parametrize( + "bases,tm,penalty,test_span", + [ + ("AGCT", 1.0, 2.0, Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE)), + ( + "AGCTAGCTAA", + 10.0, + 20.0, + Span(refname="chr2", start=1, end=10, strand=Strand.NEGATIVE), + ), + ], +) +def test_valid_primer_config(bases: str, tm: float, penalty: float, test_span: Span) -> None: + """Test Primer construction with valid input and ensure reported lengths match""" + test_primer = Primer(bases=bases, tm=tm, penalty=penalty, span=test_span) + assert test_primer.length == test_primer.span.length + + +def test_span_returns_span(test_span: Span) -> None: + """Test that the mapping property returns the span object.""" + test_primer = Primer( + bases="AGCTAGCTAA", + tm=1.0, + penalty=2.0, + span=test_span, + ) + assert test_primer.span == test_span + + +def test_invalid_primer_config_raises() -> None: + """Test Primer construction with invalid input raises ValueError""" + with pytest.raises(ValueError, match="Bases must not be an empty string"): + Primer( + bases="", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + ) + + with pytest.raises( + ValueError, match="Conflicting lengths: span length=1000, sequence length=4" + ): + Primer( + bases="ACGT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=1000, strand=Strand.POSITIVE), + ) + + +@dataclass(init=True, frozen=True) +class PrimerTestCase: + """Test case for a `Primer`. + + Attributes: + primer: the primer to test + gc_pct: the expected value for the `Primer.percent_gc_content` method + longest_hp: the expected value for the `Primer.longest_homopolymer` method + longest_dinuc: the expected value for the `Primer.longest_dinucleotide_run` method + str_fields: the fields, that when tab-delimited, are the expected string for the + `Primer.__str__` method. + """ + + primer: Primer + gc_pct: float + longest_hp: int + longest_dinuc: int + str_fields: list[str] + + +def build_primer_test_cases() -> list[PrimerTestCase]: + """Builds a set of test cases for `Primer` methods.""" + return [ + PrimerTestCase( + primer=Primer( + bases="ATAT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + ), + gc_pct=0.0, + longest_hp=1, + longest_dinuc=4, + str_fields=["ATAT", "1.0", "2.0", "chr1:1-4:+"], + ), + PrimerTestCase( + primer=Primer( + bases="ACGTAAAAAATT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=12, strand=Strand.POSITIVE), + ), + gc_pct=16.667, + longest_hp=6, + longest_dinuc=6, + str_fields=["ACGTAAAAAATT", "1.0", "2.0", "chr1:1-12:+"], + ), + PrimerTestCase( + primer=Primer( + bases="ATAC", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + ), + gc_pct=25.0, + longest_hp=1, + longest_dinuc=2, + str_fields=["ATAC", "1.0", "2.0", "chr1:1-4:+"], + ), + PrimerTestCase( + primer=Primer( + bases="ATATCC", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=6, strand=Strand.POSITIVE), + ), + gc_pct=33.333, + longest_hp=2, + longest_dinuc=4, + str_fields=["ATATCC", "1.0", "2.0", "chr1:1-6:+"], + ), + PrimerTestCase( + primer=Primer( + bases="AGCT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + ), + gc_pct=50.0, + longest_hp=1, + longest_dinuc=2, + str_fields=["AGCT", "1.0", "2.0", "chr1:1-4:+"], + ), + PrimerTestCase( + primer=Primer( + bases="GGGGG", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=5, strand=Strand.POSITIVE), + ), + gc_pct=100.0, + longest_hp=5, + longest_dinuc=4, + str_fields=["GGGGG", "1.0", "2.0", "chr1:1-5:+"], + ), + PrimerTestCase( + primer=Primer( + bases="ccgTATGC", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=8, strand=Strand.POSITIVE), + ), + gc_pct=62.5, + longest_hp=2, + longest_dinuc=2, + str_fields=["ccgTATGC", "1.0", "2.0", "chr1:1-8:+"], + ), + PrimerTestCase( + primer=Primer( + bases=None, + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + ), + gc_pct=0.0, + longest_hp=0, + longest_dinuc=0, + str_fields=["*", "1.0", "2.0", "chr1:1-4:+"], + ), + PrimerTestCase( + primer=Primer( + bases="ACACACTCTCTCT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=13, strand=Strand.POSITIVE), + ), + gc_pct=46.154, + longest_hp=1, + longest_dinuc=8, + str_fields=["ACACACTCTCTCT", "1.0", "2.0", "chr1:1-13:+"], + ), + ] + + +PRIMER_TEST_CASES: list[PrimerTestCase] = build_primer_test_cases() + + +@pytest.mark.parametrize("test_case", PRIMER_TEST_CASES) +def test_gc_content_calc(test_case: PrimerTestCase) -> None: + """Test that percent GC content is calculated correctly.""" + assert test_case.primer.percent_gc_content == pytest.approx(test_case.gc_pct) + + +@pytest.mark.parametrize("test_case", PRIMER_TEST_CASES) +def test_longest_homopolymer_len_calc(test_case: PrimerTestCase) -> None: + """Test that longest homopolymer run is calculated correctly.""" + assert test_case.primer.longest_hp_length() == test_case.longest_hp + + +@pytest.mark.parametrize( + "init, value, expected", + [ + # no initial value + (None, "", ""), + (None, "GATTACA", "GATTACA"), + (None, "NNNNN", "NNNNN"), + # update the initial value + ("TTTT", "", ""), + ("TTTT", "GATTACA", "GATTACA"), + ("TTTT", "NNNNN", "NNNNN"), + ], +) +def test_with_tail(init: Optional[str], value: str, expected: Optional[str]) -> None: + """Tests the `with_tail` method, setting the initial value to `init`, updating the + tail using the `with_tail()` method with value `value`, and testing for the execpted + value `expected`.""" + test_primer = Primer( + bases="AGCT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + tail=init, + ) + modified_test_primer = test_primer.with_tail(tail=value) + assert modified_test_primer.tail is expected + assert modified_test_primer.bases == "AGCT" + + +@pytest.mark.parametrize( + "tail_seq, bases, expected_result", + [ + ("TTTT", "AGCT", "TTTTAGCT"), + ("", "AGCT", "AGCT"), + ("AAA", None, "AAA"), + (None, "AGCT", "AGCT"), + ("NNNNNNNNNN", "AGCT", "NNNNNNNNNNAGCT"), + ("GATTACA", "AGCT", "GATTACAAGCT"), + (None, None, None), + ], +) +def test_bases_with_tail( + tail_seq: Optional[str], bases: Optional[str], expected_result: Optional[str] +) -> None: + test_primer = Primer( + bases=bases, + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + tail=tail_seq, + ) + assert test_primer.bases_with_tail() == expected_result + + +@pytest.mark.parametrize( + "init, value, expected", + [ + # no initial value + (None, "", ""), + (None, "GATTACA", "GATTACA"), + (None, "NNNNN", "NNNNN"), + # update the initial value + ("TTTT", "", ""), + ("TTTT", "GATTACA", "GATTACA"), + ("TTTT", "NNNNN", "NNNNN"), + ], +) +def test_with_name(init: Optional[str], value: str, expected: Optional[str]) -> None: + test_primer = Primer( + bases="AGCT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + name=init, + ) + modified_test_primer = test_primer.with_name(name=value) + assert test_primer.name is init + assert modified_test_primer.name is expected + + +@pytest.mark.parametrize( + "name, expected_id", + [ + ("test", "test"), + (None, "chr1_1_10_F"), + ], +) +def test_id_generation( + test_span: Span, + name: Optional[str], + expected_id: str, +) -> None: + """Asserts that the id field is correctly generated based on the name field.""" + + # For each scenario, generate a Primer object and assert that the generated ID + # matches the expected ID. + primer = Primer( + name=name, + span=test_span, + bases="AAAAAAAAAA", + tm=1.0, + penalty=1.0, + tail="AAA", + ) + assert primer.id == expected_id + + +def test_to_bed12_row(test_span: Span) -> None: + """Asserts that the to_bed12_row method exists and returns the expected value.""" + primer = Primer( + name="test", + span=test_span, + bases="AAAAAAAAAA", + tm=1.0, + penalty=1.0, + tail="AAA", + ) + assert primer.to_bed12_row() == "\t".join( + [ + "chr1", + "0", + "10", + "test", + "500", + "+", + "0", + "10", + "100,100,100", + "1", + "10", + "0", + ], + ) + + +@pytest.mark.parametrize("test_case", PRIMER_TEST_CASES) +def test_untailed_length(test_case: PrimerTestCase) -> None: + assert test_case.primer.length == test_case.primer.untailed_length() + + +@pytest.mark.parametrize( + "tail_seq, expected_length", + [ + ("TTTT", 8), + ("", 4), + (None, 4), + ("NNNNNNNNNN", 14), + ("GATTACA", 11), + ], +) +def test_tailed_length(tail_seq: str, expected_length: int) -> None: + test_primer = Primer( + bases="AGCT", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=1, end=4, strand=Strand.POSITIVE), + tail=tail_seq, + ) + assert test_primer.tailed_length() == expected_length + + +@pytest.mark.parametrize("test_case", PRIMER_TEST_CASES) +def test_primer_str(test_case: PrimerTestCase) -> None: + """Test whether the __str__ method returns the expected string representation""" + + # For each of the primer objects supplied, look up the expected set of string values & join + # them with tabs. Then, assert that the string representation of the primer object matches + assert f"{test_case.primer}" == "\t".join(test_case.str_fields) + + +def test_primer_serialization_roundtrip() -> None: + input_primers: list[Primer] = [test_case.primer for test_case in PRIMER_TEST_CASES] + + with NamedTemporaryFile(suffix=".txt", mode="r", delete=True) as write_file: + path = Path(write_file.name) + + # write them to a file + Primer.write(path, *input_primers) + + # read them back in again + output_primers = list(Primer.read(path=path)) + + # make sure they're the same! + assert input_primers == output_primers + + +@pytest.mark.parametrize( + "this, that, expected", + [ + # same primer + ( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ), + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ), + 0, + ), + # different primer (chromosome) + ( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ), + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr2", start=100, end=106, strand=Strand.POSITIVE), + ), + -1, + ), + ], +) +def test_primer_compare( + this: Primer, that: Primer, expected: int, seq_dict: SequenceDictionary +) -> None: + assert expected == Primer.compare(this=this, that=that, seq_dict=seq_dict) + assert -expected == Primer.compare(this=that, that=this, seq_dict=seq_dict) diff --git a/tests/api/test_primer_like.py b/tests/api/test_primer_like.py new file mode 100644 index 0000000..d83f356 --- /dev/null +++ b/tests/api/test_primer_like.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Optional + +import pytest + +from prymer.api.primer_like import PrimerLike +from prymer.api.span import Span + + +@dataclass(frozen=True, init=True, kw_only=True, slots=True) +class PrimerLikeTester(PrimerLike): + """A simple class that inherits from PrimerLike for testing purposes.""" + + span: Span + bases: Optional[str] = None + + def to_bed12_row(self) -> str: + return "a/b/c/d/e/f/g/h/i/j/k/l" + + +@pytest.mark.parametrize( + "name, expected_id", + [ + ("test", "test"), + (None, "chr1_1_10_F"), + ], +) +def test_id_generation( + test_span: Span, + name: Optional[str], + expected_id: str, +) -> None: + """Asserts that the id field is correctly generated based on the name and name_prefix fields.""" + test_primer = PrimerLikeTester(name=name, bases="AATCGATCCA", span=test_span) + assert test_primer.id == expected_id + + +def test_to_bed12_row_exists(test_span: Span) -> None: + """Asserts that the to_bed12_row method exists and returns the expected value.""" + test_primer = PrimerLikeTester( + name="test", + bases="AATCGATCCA", + span=test_span, + ) + assert test_primer.to_bed12_row() == "a/b/c/d/e/f/g/h/i/j/k/l" diff --git a/tests/api/test_primer_pair.py b/tests/api/test_primer_pair.py new file mode 100644 index 0000000..f14a040 --- /dev/null +++ b/tests/api/test_primer_pair.py @@ -0,0 +1,558 @@ +from dataclasses import dataclass +from dataclasses import replace +from typing import Optional + +import pytest +from fgpyo.fasta.sequence_dictionary import SequenceDictionary +from fgpyo.sequence import reverse_complement + +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.api.span import Strand + + +@dataclass(init=True, frozen=True) +class PrimerPairTestCase: + """Test case for a `PrimerPair`. + + Attributes: + primer_pair: the PrimerPair to test + bed_12_fields: the fields, that when tab-delimited, are the expected string + for the `PrimerPair.bed_12` method + gc_pct: the expected value for the `PrimerPair.percent_gc_content` property + inner: the expected value for the `PrimerPair.inner` method + length: the expected value for the `PrimerPair.length` property + str_fields: the fields, that when tab-delimited, are the expected string for the + `Primer.__str__` method + """ + + primer_pair: PrimerPair + bed_12_fields: list[str] + gc_pct: float + inner: Span + length: int + str_fields: list[str] + + @staticmethod + def primer_pair_from_left_primer(left: Primer, right_offset: int = 50) -> PrimerPair: + """ + Generates a PrimerPair for use in unit tests. Will first generate + a right primer using a standard formula, and then calculates the + PrimerPair fields based on the left & right primers. + """ + right: Primer = PrimerPairTestCase.right_primer_from_left_primer( + left=left, right_offset=right_offset + ) + + amplicon = replace(left.span, end=right.span.end) + amplicon_sequence = PrimerPairTestCase._to_amplicon_sequence( + left.bases, + right.bases, + amplicon.length - 1, + ) + amplicon_tm = left.tm + right.tm + penalty = left.penalty + right.penalty + + return PrimerPair( + left_primer=left, + right_primer=right, + amplicon_sequence=amplicon_sequence, + amplicon_tm=amplicon_tm, + penalty=penalty, + ) + + @staticmethod + def right_primer_from_left_primer(left: Primer, right_offset: int) -> Primer: + """ + Provides a standard conversion for a left primer to a right primer for use in + tests of PrimerPair. + """ + return replace( + left, + bases=reverse_complement(left.bases) if left.bases is not None else None, + tm=left.tm + 10, + penalty=left.penalty + 10, + span=replace( + left.span, + start=left.span.start + right_offset, + end=left.span.end + right_offset, + strand=Strand.NEGATIVE, + ), + ) + + @staticmethod + def _to_amplicon_sequence( + left: Optional[str], right: Optional[str], length: int + ) -> Optional[str]: + """ + Provides a consistent mechanism to generate a valid amplicon sequence. + There's no inherent meaning to the algorithm. + """ + if left is None or right is None: + return None + + # Combine the bases from left and right primers + bases = "".join( + f"{left_base}{right_base}" for left_base, right_base in zip(left, right, strict=True) + ) + # Repeat the combined bases enough times to cover the required length + times = (length // len(bases)) + 1 + extended_bases = (bases * times)[: length + 1] + return extended_bases + + +def build_primer_pair_test_cases() -> list[PrimerPairTestCase]: + """ + Builds a set of test cases for `PrimerPair` methods using a + supplied left primer. + """ + return [ + PrimerPairTestCase( + primer_pair=PrimerPairTestCase.primer_pair_from_left_primer( + left=Primer( + bases="GATTACA", + tm=12.34, + penalty=56.78, + span=Span(refname="chr1", start=1, end=7, strand=Strand.POSITIVE), + ) + ), + bed_12_fields=[ + "chr1", + "0", + "57", + "chr1_1_57_F", + "500", + "+", + "0", + "57", + "100,100,100", + "3", + "7,43,7", + "0,7,50", + ], + gc_pct=29.825, + inner=Span(refname="chr1", start=8, end=50), + length=57, + str_fields=[ + "GATTACA", + "12.34", + "56.78", + "chr1:1-7:+", + "TGTAATC", + "22.34", + "66.78", + "chr1:51-57:-", + "GTAGTTTAAACTACGTAGTTTAAACTACGTAGTTTAAACTACGTAGTTTAAACTACG", + "34.68", + "123.56", + ], + ), + PrimerPairTestCase( + primer_pair=PrimerPairTestCase.primer_pair_from_left_primer( + left=Primer( + bases="TGTAATC", + tm=87.65, + penalty=43.21, + span=Span(refname="chr22", start=100, end=106, strand=Strand.POSITIVE), + ) + ), + bed_12_fields=[ + "chr22", + "99", + "156", + "chr22_100_156_F", + "500", + "+", + "99", + "156", + "100,100,100", + "3", + "7,43,7", + "0,7,50", + ], + gc_pct=28.07, + inner=Span(refname="chr22", start=107, end=149), + length=57, + str_fields=[ + "TGTAATC", + "87.65", + "43.21", + "chr22:100-106:+", + "GATTACA", + "97.65", + "53.21", + "chr22:150-156:-", + "TGGATTATAATCCATGGATTATAATCCATGGATTATAATCCATGGATTATAATCCAT", + "185.3", + "96.42", + ], + ), + PrimerPairTestCase( + primer_pair=PrimerPairTestCase.primer_pair_from_left_primer( + left=Primer( + bases=None, + tm=12.34, + penalty=56.78, + span=Span(refname="chr1", start=1, end=40, strand=Strand.POSITIVE), + ) + ), + bed_12_fields=[ + "chr1", + "0", + "90", + "chr1_1_90_F", + "500", + "+", + "0", + "90", + "100,100,100", + "3", + "40,10,40", + "0,40,50", + ], + gc_pct=0.0, + inner=Span(refname="chr1", start=41, end=50), + length=90, + str_fields=[ + "*", + "12.34", + "56.78", + "chr1:1-40:+", + "*", + "22.34", + "66.78", + "chr1:51-90:-", + "*", + "34.68", + "123.56", + ], + ), + PrimerPairTestCase( + primer_pair=PrimerPairTestCase.primer_pair_from_left_primer( + left=Primer( + bases="GGGGGGG", + tm=12.34, + penalty=56.78, + span=Span(refname="chr1", start=1, end=7, strand=Strand.POSITIVE), + ) + ), + bed_12_fields=[ + "chr1", + "0", + "57", + "chr1_1_57_F", + "500", + "+", + "0", + "57", + "100,100,100", + "3", + "7,43,7", + "0,7,50", + ], + gc_pct=100.0, + inner=Span(refname="chr1", start=8, end=50), + length=57, + str_fields=[ + "GGGGGGG", + "12.34", + "56.78", + "chr1:1-7:+", + "CCCCCCC", + "22.34", + "66.78", + "chr1:51-57:-", + "GCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGCG", + "34.68", + "123.56", + ], + ), + # This one is written out long form in order to override the automated + # generation of the right primer. We want to handle a case where the primers + # overlap + PrimerPairTestCase( + primer_pair=PrimerPair( + left_primer=Primer( + bases="GATTACA", + tm=12.34, + penalty=56.78, + span=Span(refname="chr1", start=1, end=7, strand=Strand.POSITIVE), + ), + right_primer=Primer( + bases="TGTAATC", + tm=87.65, + penalty=43.21, + span=Span(refname="chr1", start=5, end=11, strand=Strand.NEGATIVE), + ), + amplicon_tm=25, + penalty=99.99, + ), + bed_12_fields=[ + "chr1", + "0", + "11", + "chr1_1_11_F", + "500", + "+", + "0", + "11", + "100,100,100", + "3", + "7,1,7", + "0,5,4", + ], + gc_pct=0, + inner=Span(refname="chr1", start=6, end=6), + length=11, + str_fields=[ + "GATTACA", + "12.34", + "56.78", + "chr1:1-7:+", + "TGTAATC", + "87.65", + "43.21", + "chr1:5-11:-", + "*", + "25", + "99.99", + ], + ), + ] + + +PRIMER_PAIR_TEST_CASES = build_primer_pair_test_cases() + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_inner(test_case: PrimerPairTestCase) -> None: + """Test the `PrimerPair.inner` property.""" + assert test_case.primer_pair.inner == test_case.inner + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_gc_content(test_case: PrimerPairTestCase) -> None: + """Test the `PrimerPair.percent_gc_content` property.""" + assert test_case.primer_pair.percent_gc_content == test_case.gc_pct + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_str(test_case: PrimerPairTestCase) -> None: + """Test the `PrimerPair.__str__` method.""" + assert str(test_case.primer_pair) == "\t".join(test_case.str_fields) + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_length(test_case: PrimerPairTestCase) -> None: + """Test the `PrimerPair.length` property""" + assert test_case.primer_pair.length == test_case.length + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_to_bed12_row(test_case: PrimerPairTestCase) -> None: + """Test the `PrimerPair.to_bed12_row` method.""" + assert test_case.primer_pair.to_bed12_row() == "\t".join(test_case.bed_12_fields) + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_span(test_case: PrimerPairTestCase) -> None: + """ + Test that the `PrimerPair.span property returns the amplicon field + """ + assert test_case.primer_pair.span == test_case.primer_pair.amplicon + + +@pytest.mark.parametrize("test_case", PRIMER_PAIR_TEST_CASES) +def test_primer_pair_bases(test_case: PrimerPairTestCase) -> None: + """ + Test that the `PrimerPair.bases property returns the amplicon_sequence field + """ + assert test_case.primer_pair.bases == test_case.primer_pair.amplicon_sequence + + +@pytest.mark.parametrize( + "left_tail,right_tail", + [ + ("AAA", "GGG"), + ("GGG", "AAAAA"), + ], +) +def test_with_tails(left_tail: str, right_tail: str) -> None: + """ + Test that the with_tails method adds the correct tail(s) to + the PrimerPair and both Primers + """ + pp = PRIMER_PAIR_TEST_CASES[0].primer_pair + + assert pp.left_primer.tail is None + assert pp.right_primer.tail is None + + pp_with_tails = pp.with_tails(left_tail, right_tail) + + assert pp_with_tails.left_primer.tail == left_tail + assert pp_with_tails.right_primer.tail == right_tail + + pp_empty_str = pp_with_tails.with_tails("", "") + assert pp_empty_str.left_primer.tail == "" + assert pp_empty_str.right_primer.tail == "" + + +def test_with_names() -> None: + """Test that the with_names method adds names to the PrimerPair and both Primers""" + # create a primer pair with no names + pp = PRIMER_PAIR_TEST_CASES[0].primer_pair + assert pp.name is None + assert pp.left_primer.name is None + assert pp.right_primer.name is None + + # add names to all of them + pp_with_names = pp.with_names("pp_name", "lp_name", "rp_name") + assert pp_with_names.name == "pp_name" + assert pp_with_names.left_primer.name == "lp_name" + assert pp_with_names.right_primer.name == "rp_name" + + # set them all to _empty_ names + pp_reset = pp_with_names.with_names("", "", "") + assert pp_reset.name == "" + assert pp_reset.left_primer.name == "" + assert pp_reset.right_primer.name == "" + + +def test_reference_mismatch() -> None: + """ + Test that a PrimerPair and both Primers all have a Span with + the same reference sequence + """ + + pp = PRIMER_PAIR_TEST_CASES[0].primer_pair + + with pytest.raises(ValueError, match="The reference must be the same across primers in a pair"): + replace( + pp, + left_primer=replace( + pp.left_primer, + span=replace(pp.left_primer.span, refname="no-name"), + ), + ) + + with pytest.raises(ValueError, match="The reference must be the same across primers in a pair"): + replace( + pp, + right_primer=replace( + pp.right_primer, + span=replace(pp.right_primer.span, refname="no-name"), + ), + ) + + +def test_right_primer_before_left_primer() -> None: + """Test that an exception is raised if the left primer starts after the right primer ends""" + pp = PRIMER_PAIR_TEST_CASES[0].primer_pair + with pytest.raises( + ValueError, match="Left primer start must be less than or equal to right primer end" + ): + replace( + pp, + left_primer=pp.right_primer, + right_primer=pp.left_primer, + ) + + +def test_iter() -> None: + """Test that the iter dunder method returns the left and right primers""" + pp = PRIMER_PAIR_TEST_CASES[0].primer_pair + assert list(iter(pp)) == [pp.left_primer, pp.right_primer] + + +@pytest.mark.parametrize( + "this, that, expected_by_amplicon_true, expected_by_amplicon_false", + [ + # same primer + ( + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ) + ), + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ) + ), + 0, + 0, + ), + # different primer (chromosome) + ( + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ) + ), + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr2", start=100, end=106, strand=Strand.POSITIVE), + ) + ), + -1, + -1, + ), + # same primer when by amplicon, but different by primer + ( + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTAC", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=105, strand=Strand.POSITIVE), + ), + right_offset=51, + ), + PrimerPairTestCase.primer_pair_from_left_primer( + Primer( + bases="GATTACA", + tm=1.0, + penalty=2.0, + span=Span(refname="chr1", start=100, end=106, strand=Strand.POSITIVE), + ), + right_offset=50, + ), + 0, + -1, + ), + ], +) +def test_primer_pair_compare( + this: PrimerPair, + that: PrimerPair, + expected_by_amplicon_true: int, + expected_by_amplicon_false: int, + seq_dict: SequenceDictionary, +) -> None: + # expected_by_amplicon_true + assert expected_by_amplicon_true == PrimerPair.compare( + this=this, that=that, seq_dict=seq_dict, by_amplicon=True + ) + assert -expected_by_amplicon_true == PrimerPair.compare( + this=that, that=this, seq_dict=seq_dict, by_amplicon=True + ) + # expected_by_amplicon_false + assert expected_by_amplicon_false == PrimerPair.compare( + this=this, that=that, seq_dict=seq_dict, by_amplicon=False + ) + assert -expected_by_amplicon_false == PrimerPair.compare( + this=that, that=this, seq_dict=seq_dict, by_amplicon=False + ) diff --git a/tests/api/test_span.py b/tests/api/test_span.py new file mode 100644 index 0000000..963cf1d --- /dev/null +++ b/tests/api/test_span.py @@ -0,0 +1,208 @@ +import pytest +from fgpyo.fasta.sequence_dictionary import SequenceDictionary + +from prymer.api.span import BedLikeCoords +from prymer.api.span import Span +from prymer.api.span import Strand + + +@pytest.mark.parametrize( + "refname, invalid_start, invalid_end, strand", + [ + ("chr1", 0, 10, "+"), # start < 1 + ("chr1", 30, 20, "-"), # start > end + (" ", 0, 10, "+"), # empty refname + ], +) +def test_invalid_span(refname: str, invalid_start: int, invalid_end: int, strand: str) -> None: + """Test that invalid spans raise an error""" + with pytest.raises(ValueError): + Span(refname, invalid_start, invalid_end, Strand(strand)) + + +@pytest.mark.parametrize( + "invalid_start, invalid_end", + [ + (-1, 10), # start < 0 + (30, 20), # start > end + (21, 20), # start > end + ], +) +def test_invalid_coords(invalid_start: int, invalid_end: int) -> None: + """Test that invalid spans raise an error""" + with pytest.raises(ValueError): + BedLikeCoords(invalid_start, invalid_end) + + +@pytest.mark.parametrize( + "valid_start, valid_end", + [ + (0, 10), # start == 0 + (20, 20), # start == end + ], +) +def test_valid_coords(valid_start: int, valid_end: int) -> None: + """Test that valid spans are OK""" + BedLikeCoords(valid_start, valid_end) + + +@pytest.mark.parametrize( + "base_idx, expected_rel_coords", + [ + (100, 0), # 1 based start + (101, 1), # second base + (150, 50), # span midpoint + (199, 99), # second to last base + (200, 100), # 1 based + inclusive end + ], +) +def test_get_offset_valid(base_idx: int, expected_rel_coords: int) -> None: + """Test whether a Span position is correctly converted to a relative coord""" + span = Span("chr1", 100, 200, Strand.POSITIVE) + assert span.get_offset(position=base_idx) == expected_rel_coords + + +@pytest.mark.parametrize( + "base_idx", + [ + (999), # base_idx < self.start + (2001), # base_idx > self.end + ], +) +def test_get_offset_raises(base_idx: int) -> None: + """Test whether get_offset() raises error with invalid inputs""" + span = Span("chr1", 1000, 2000, Strand.POSITIVE) + with pytest.raises(ValueError): + span.get_offset(position=base_idx) + + +@pytest.mark.parametrize( + "offset, submap_length, expected_start, expected_end", + [ + (0, 1, 1, 1), # 1-based inclusive, start == end + (1, 2, 2, 3), # wholly contained + (5, 5, 6, 10), # 1-based start + offset of 6 = 10 == length + ], +) +def test_get_submap_from_offset_valid( + offset: int, submap_length: int, expected_start: int, expected_end: int +) -> None: + """Test whether relative sub-span coords are correctly converted to genomic coords""" + + test_map = Span(refname="chr1", start=1, end=10, strand=Strand.POSITIVE) + assert test_map.length == 10 + assert test_map.get_subspan(offset=offset, subspan_length=submap_length) == Span( + refname="chr1", start=expected_start, end=expected_end, strand=Strand.POSITIVE + ) + + +@pytest.mark.parametrize( + "offset, submap_length", + [ + (-1, 2), # offset < 0 + (5, 0), # length < 1 + (12, 1), # offset > length + (2, 11), # length outside source span + (6, 6), # end of sub-span outside source span + ], +) +def test_get_subspan_from_offset_raises(offset: int, submap_length: int) -> None: + """Test if get_submap_from_offset() raises error with invalid sub-span coords""" + + test_map = Span(refname="chr1", start=1, end=10, strand=Strand.POSITIVE) + with pytest.raises(ValueError): + test_map.get_subspan(offset=offset, subspan_length=submap_length) + + +def test_round_trip_conversion() -> None: + """Test if span <-> subspan modifies positional data unexpectedly""" + span = Span(refname="chr1", start=100, end=200, strand=Strand.POSITIVE) + offset = span.get_offset(position=span.start) + length = span.length + test_submap = span.get_subspan(offset=offset, subspan_length=length) + assert span == test_submap + + +@pytest.mark.parametrize( + "line, expected_span, expected_length", + [ + ("chr1:1-10:+", Span(refname="chr1", start=1, end=10), 10), + ("chr1:1-10", Span(refname="chr1", start=1, end=10), 10), + ("chr2:20-30:-", Span(refname="chr2", start=20, end=30, strand=Strand.NEGATIVE), 11), + ("chr2:20 - 30", Span(refname="chr2", start=20, end=30), 11), + ("chr1:1-10:--", Span(refname="chr1", start=1, end=10, strand=Strand.NEGATIVE), 10), + ], +) +def test_span_from_valid_string(line: str, expected_span: Span, expected_length: int) -> None: + """Test whether from_string() yields expected Span objects""" + span = Span.from_string(line) + assert span == expected_span + assert span.length == expected_length + bedlike_coords = span.get_bedlike_coords() + assert bedlike_coords.end - bedlike_coords.start == expected_length + assert bedlike_coords.end == span.end + + +@pytest.mark.parametrize( + "invalid_line", + [ + ("chr1:1-10:+:abc"), + ("chr2:20-30:x"), # invalid strand + ("chr1:1-10:--:abc:def"), # >3 colon-delimited fields + ("chr1:1-x:+"), # string end + ], +) +def test_spanfrom_invalid_string_raises(invalid_line: str) -> None: + """Test whether from_string() yields Span objects as expected from string input""" + with pytest.raises((ValueError, TypeError)): + Span.from_string(invalid_line) + + +@pytest.mark.parametrize( + "span1, span2, length_of_overlap", + [ + (Span("chr1", 500, 1000), Span("chr1", 500, 1000), 501), + (Span("chr1", 500, 1000), Span("chr1", 600, 700), 101), + (Span("chr1", 500, 1000), Span("chr1", 400, 2000), 501), + (Span("chr1", 500, 1000), Span("chr1", 750, 850), 101), + (Span("chr1", 500, 1000), Span("chr1", 1000, 1001), 1), + (Span("chr1", 500, 1000), Span("chr2", 500, 1000), 0), + (Span("chr1", 500, 1000), Span("chr1", 5000, 10000), 0), + (Span("chr1", 500, 1000), Span("chr1", 1, 499), 0), + ( + Span("chr1", 500, 1000), + Span("chr1", 1001, 1100, strand=Strand.NEGATIVE), + 0, + ), + ], +) +def test_span_overlap(span1: Span, span2: Span, length_of_overlap: int) -> None: + overlaps: bool = length_of_overlap > 0 + assert span1.overlaps(span2) == overlaps + assert span2.overlaps(span1) == overlaps + assert span1.length_of_overlap_with(span2) == length_of_overlap + assert span2.length_of_overlap_with(span1) == length_of_overlap + + +@pytest.mark.parametrize( + "this, that, expected", + [ + # same span + (Span("chr1", 100, 200), Span("chr1", 100, 200), 0), + (Span("chr1", 100, 200, Strand.POSITIVE), Span("chr1", 100, 200, Strand.POSITIVE), 0), + (Span("chr1", 100, 200, Strand.NEGATIVE), Span("chr1", 100, 200, Strand.NEGATIVE), 0), + # earlier reference + (Span("chr1", 100, 200, Strand.NEGATIVE), Span("chr2", 1, 2, Strand.POSITIVE), -1), + (Span("chr1", 100, 200, Strand.NEGATIVE), Span("chr3", 1, 2, Strand.POSITIVE), -1), + (Span("chr2", 100, 200, Strand.NEGATIVE), Span("chr3", 1, 2, Strand.POSITIVE), -1), + # same reference, earlier start + (Span("chr1", 99, 200, Strand.NEGATIVE), Span("chr1", 100, 150, Strand.POSITIVE), -1), + # same reference and start, earlier end + (Span("chr1", 100, 199, Strand.NEGATIVE), Span("chr1", 100, 200, Strand.POSITIVE), -1), + # same reference, start, and end, but earlier strand + (Span("chr1", 100, 200, Strand.POSITIVE), Span("chr1", 100, 200, Strand.NEGATIVE), -1), + ], +) +def test_span_compare(this: Span, that: Span, expected: int, seq_dict: SequenceDictionary) -> None: + assert expected == Span.compare(this=this, that=that, seq_dict=seq_dict) + assert -expected == Span.compare(this=that, that=this, seq_dict=seq_dict) diff --git a/tests/api/test_variant_lookup.py b/tests/api/test_variant_lookup.py new file mode 100644 index 0000000..a6eda6c --- /dev/null +++ b/tests/api/test_variant_lookup.py @@ -0,0 +1,618 @@ +import logging +import random +from dataclasses import dataclass +from dataclasses import replace +from pathlib import Path +from typing import Optional +from typing import Type + +import fgpyo.vcf.builder +import pytest +from fgpyo.vcf.builder import VariantBuilder +from fgpyo.vcf.builder import VcfFieldNumber +from fgpyo.vcf.builder import VcfFieldType +from pysam import VariantRecord + +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.api.variant_lookup import FileBasedVariantLookup +from prymer.api.variant_lookup import SimpleVariant +from prymer.api.variant_lookup import VariantLookup +from prymer.api.variant_lookup import VariantOverlapDetector +from prymer.api.variant_lookup import VariantType +from prymer.api.variant_lookup import cached +from prymer.api.variant_lookup import calc_maf_from_filter +from prymer.api.variant_lookup import disk_based + + +@pytest.mark.parametrize( + "ref, alt, variant_type", + [ + ("A", "C", VariantType.SNP), + ("AA", "CC", VariantType.MNV), + ("ACAC", "ACAC", VariantType.MNV), + ("A", "ACAC", VariantType.INSERTION), + ("AC", "ACAC", VariantType.INSERTION), + ("ACA", "ACAC", VariantType.INSERTION), + ("ACAC", "ACA", VariantType.DELETION), + ("ACAC", "AC", VariantType.DELETION), + ("ACAC", "A", VariantType.DELETION), + ("A", "", VariantType.OTHER), + ], +) +def test_variant_type_build(ref: str, alt: str, variant_type: VariantType) -> None: + assert variant_type == VariantType.from_alleles(ref=ref, alt=alt) + + +@pytest.fixture(scope="session") +def vcf_path(data_dir: Path) -> Path: + """Test VCF data: returns a path to a VCF containing 12 variants. + 8 SNPs, 1 deletion, 1 insertion, 1 mixed variant, and 1 complex variant.""" + return Path(__file__).parent / "data" / "miniref.variants.vcf.gz" + + +@pytest.fixture +def temp_missing_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Invalid path to test VCF.""" + return tmp_path_factory.mktemp("test_missing_vcf") + + +@pytest.fixture() +def mini_chr1_vcf() -> Path: + """Create an in-memory mini VCF using `fgpyo.variant_builder` for testing. These variants + are different from what is contained in the miniref.variants.vcf.gz file.""" + return Path(__file__).parent / "data" / "mini_chr1.vcf.gz" + + +@pytest.fixture() +def mini_chr3_vcf() -> Path: + """Create an in-memory mini VCF using `fgpyo.variant_builder` for testing. These variants + are different from what is contained in the miniref.variants.vcf.gz file.""" + return Path(__file__).parent / "data" / "mini_chr3.vcf.gz" + + +@pytest.fixture() +def sample_vcf() -> list[VariantRecord]: + """Create an in-memory VCF using `fgpyo.variant_builder` for testing. + These variants here are the same as what is contained in the miniref.variants.vcf.gz file.""" + variant_builder = VariantBuilder( + sd={"chr1": {"ID": "chr1", "length": 577}, "chr2": {"ID": "chr2", "length": 9821}} + ) + variant_builder.add_info_header(name="AC", field_type=VcfFieldType.INTEGER, number=2) + variant_builder.add_info_header( + name="AF", field_type=VcfFieldType.FLOAT, number=VcfFieldNumber.NUM_ALT_ALLELES + ) + variant_builder.add_info_header(name="AN", field_type=VcfFieldType.INTEGER, number=1) + variant_builder.add_info_header(name="CAF", field_type=VcfFieldType.STRING, number=1) + variant_builder.add_info_header(name="COMMON", field_type=VcfFieldType.INTEGER) + variant_builder.add_info_header(name="SVTYPE", field_type=VcfFieldType.STRING) + variant_builder.add_info_header(name="SVLEN", field_type=VcfFieldType.INTEGER) + variant_builder.add( + contig="chr2", + pos=8000, + id="complex-variant-sv", + ref="T", + alts="", + info={"SVTYPE": "DEL", "SVLEN": -105}, + ) + variant_builder.add( + contig="chr2", + pos=9000, + id="rare-dbsnp-snp1", + ref="A", + alts="C", + info={"CAF": "0.999,0.001", "COMMON": 0}, + ) + variant_builder.add( + contig="chr2", + pos=9010, + id="common-dbsnp-snp1", + ref="C", + alts="T", + info={"CAF": "0.99,0.01", "COMMON": 1}, + ) + variant_builder.add( + contig="chr2", + pos=9020, + id="common-dbsnp-snp2", + ref="A", + alts="C", + info={"CAF": "0.98,0.02", "COMMON": 0}, + ) + variant_builder.add( + contig="chr2", + pos=9030, + id="rare-ac-an-snp", + ref="G", + alts="A", + info={"AC": [1, 0], "AN": 1000}, + ) + variant_builder.add( + contig="chr2", pos=9040, id="rare-af-snp", ref="C", alts="A", info={"AF": 0.0004815} + ) + variant_builder.add( + contig="chr2", + pos=9050, + id="common-ac-an-snp", + ref="C", + alts="G", + info={"AC": [47, 0], "AN": 1000}, + ) + variant_builder.add( + contig="chr2", pos=9060, id="common-af-snp", ref="T", alts="C", info={"AF": 0.04} + ) + variant_builder.add( + contig="chr2", + pos=9070, + id="common-multiallelic", + ref="C", + alts=("A", "T"), + info={"AC": [3, 18], "AN": 400}, + ) + variant_builder.add( + contig="chr2", pos=9080, id="common-insertion", ref="A", alts="ACGT", info={"AF": 0.04} + ) + variant_builder.add( + contig="chr2", pos=9090, id="common-deletion", ref="CTA", alts="C", info={"AF": 0.04} + ) + variant_builder.add( + contig="chr2", + pos=9100, + id="common-mixed", + ref="CA", + alts=("GG", "CACACA"), + info={"AF": [0.04, 0.08]}, + ) + + return variant_builder.to_sorted_list() + + +@dataclass(init=True, frozen=True) +class SimpleVariantTestCase: + """Test case for a `SimpleVariant`. + + Attributes: + simple_variant: the `SimpleVariant` to test + str_rep: the expected string representation + span: the expected span + var_type: the expected variant type + """ + + simple_variant: SimpleVariant + str_rep: str + span: Span + var_type: VariantType + + +def build_simple_variant_test_cases() -> list[SimpleVariantTestCase]: + """Builds a list of test cases for `SimpleVariant` methods.""" + return [ + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="complex-variant-sv-1/1", + refname="chr2", + pos=8000, + ref="T", + alt="", + maf=None, + ), + str_rep="complex-variant-sv-1/1@chr2:8000[T/ NA]", + span=Span(refname="chr2", start=8000, end=8000, strand=Strand.POSITIVE), + var_type=VariantType.OTHER, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="rare-dbsnp-snp1-1/1", + refname="chr2", + pos=9000, + ref="A", + alt="C", + maf=0.001, + ), + str_rep="rare-dbsnp-snp1-1/1@chr2:9000[A/C 0.0010]", + span=Span(refname="chr2", start=9000, end=9000, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + SimpleVariant( + id="common-dbsnp-snp1-1/1", + refname="chr2", + pos=9010, + ref="C", + alt="T", + maf=0.01, + ), + str_rep="common-dbsnp-snp1-1/1@chr2:9010[C/T 0.0100]", + span=Span(refname="chr2", start=9010, end=9010, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-dbsnp-snp2-1/1", + refname="chr2", + pos=9020, + ref="A", + alt="C", + maf=0.02, + ), + str_rep="common-dbsnp-snp2-1/1@chr2:9020[A/C 0.0200]", + span=Span(refname="chr2", start=9020, end=9020, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="rare-ac-an-snp-1/1", refname="chr2", pos=9030, ref="G", alt="A", maf=0.001 + ), + str_rep="rare-ac-an-snp-1/1@chr2:9030[G/A 0.0010]", + span=Span(refname="chr2", start=9030, end=9030, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="rare-af-snp-1/1", + refname="chr2", + pos=9040, + ref="C", + alt="A", + maf=0.00048149999929592013, + ), + str_rep="rare-af-snp-1/1@chr2:9040[C/A 0.0005]", + span=Span(refname="chr2", start=9040, end=9040, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-ac-an-snp-1/1", refname="chr2", pos=9050, ref="C", alt="G", maf=0.047 + ), + str_rep="common-ac-an-snp-1/1@chr2:9050[C/G 0.0470]", + span=Span(refname="chr2", start=9050, end=9050, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-af-snp-1/1", + refname="chr2", + pos=9060, + ref="T", + alt="C", + maf=0.03999999910593033, + ), + str_rep="common-af-snp-1/1@chr2:9060[T/C 0.0400]", + span=Span(refname="chr2", start=9060, end=9060, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-multiallelic-1/2", refname="chr2", pos=9070, ref="C", alt="A", maf=0.0525 + ), + str_rep="common-multiallelic-1/2@chr2:9070[C/A 0.0525]", + span=Span(refname="chr2", start=9070, end=9070, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-multiallelic-2/2", refname="chr2", pos=9070, ref="C", alt="T", maf=0.0525 + ), + str_rep="common-multiallelic-2/2@chr2:9070[C/T 0.0525]", + span=Span(refname="chr2", start=9070, end=9070, strand=Strand.POSITIVE), + var_type=VariantType.SNP, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-insertion-1/1", + refname="chr2", + pos=9080, + ref="A", + alt="ACGT", + maf=0.03999999910593033, + ), + str_rep="common-insertion-1/1@chr2:9080[A/ACGT 0.0400]", + span=Span(refname="chr2", start=9080, end=9081, strand=Strand.POSITIVE), + var_type=VariantType.INSERTION, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-deletion-1/1", + refname="chr2", + pos=9090, + ref="CTA", + alt="C", + maf=0.03999999910593033, + ), + str_rep="common-deletion-1/1@chr2:9090[CTA/C 0.0400]", + span=Span(refname="chr2", start=9090, end=9092, strand=Strand.POSITIVE), # end adjusted + var_type=VariantType.DELETION, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-mixed-1/2", + refname="chr2", + pos=9100, + ref="CA", + alt="GG", + maf=0.12, + ), + str_rep="common-mixed-1/2@chr2:9100[CA/GG 0.1200]", + span=Span(refname="chr2", start=9100, end=9101, strand=Strand.POSITIVE), + var_type=VariantType.MNV, + ), + SimpleVariantTestCase( + simple_variant=SimpleVariant( + id="common-mixed-2/2", + refname="chr2", + pos=9101, + ref="A", + alt="ACACA", + maf=0.12, + ), + str_rep="common-mixed-2/2@chr2:9101[A/ACACA 0.1200]", + span=Span(refname="chr2", start=9101, end=9102, strand=Strand.POSITIVE), + var_type=VariantType.INSERTION, + ), + ] + + +def _round_simple_variant(simple_variant: SimpleVariant, digits: int = 5) -> SimpleVariant: + """Returns a copy of the simple variant after rounding the minor allele frequency + (maf attribute) to five decimals""" + maf: Optional[float] = None + if simple_variant.maf is not None: + maf = round(simple_variant.maf, digits) + return replace(simple_variant, maf=maf) + + +VALID_SIMPLE_VARIANT_TEST_CASES: list[SimpleVariantTestCase] = build_simple_variant_test_cases() +"""Test cases for `SimpleVariant` corresponding to the variants in the fixture `vcf_path`""" + +VALID_SIMPLE_VARIANTS_APPROX: list[SimpleVariant] = [ + _round_simple_variant(test_case.simple_variant) for test_case in VALID_SIMPLE_VARIANT_TEST_CASES +] +"""`SimpleVariant`s corresponding to VALID_SIMPLE_VARIANT_TEST_CASES but rounded to five digits""" + + +def get_simple_variant_approx_by_id(*variant_id: str) -> list[SimpleVariant]: + return [ + next( + simple_variant + for simple_variant in VALID_SIMPLE_VARIANTS_APPROX + if simple_variant.id == _id + ) + for _id in variant_id + ] + + +def variant_overlap_detector_query( + detector: VariantOverlapDetector, + refname: str, + start: int, + end: int, + maf: Optional[float] = None, + include_missing_mafs: bool = None, +) -> list[SimpleVariant]: + return [ + _round_simple_variant(variant) + for variant in detector.query( + refname=refname, + start=start, + end=end, + maf=maf, + include_missing_mafs=include_missing_mafs, + ) + ] + + +@pytest.mark.parametrize("test_case", VALID_SIMPLE_VARIANT_TEST_CASES) +def test_simple_variant_var_type(test_case: SimpleVariantTestCase) -> None: + """Test the `SimpleVariant.variant_type` property.""" + assert test_case.simple_variant.variant_type == test_case.var_type + + +@pytest.mark.parametrize("test_case", VALID_SIMPLE_VARIANT_TEST_CASES) +def test_simple_variant_str_repr(test_case: SimpleVariantTestCase) -> None: + """Test the `SimpleVariant.__str__` property.""" + assert test_case.simple_variant.__str__() == test_case.str_rep + + +@pytest.mark.parametrize("test_case", VALID_SIMPLE_VARIANT_TEST_CASES) +def test_simple_variant_to_span(test_case: SimpleVariantTestCase) -> None: + """Test the `SimpleVariant.span` property.""" + assert test_case.simple_variant.to_span() == test_case.span + + +def test_simple_variant_conversion(vcf_path: Path, sample_vcf: list[VariantRecord]) -> None: + """Test that `pysam.VariantRecords` are converted properly to `SimpleVariants`. + We pass a valid test data path here to leverage an existing .vcf.gz.tbi file, + which is required for class instantiation. + We use the in-memory `VariantBuilder` here to keep test data consistent.""" + + variant_overlap_detector = VariantOverlapDetector( + vcf_paths=[vcf_path], min_maf=0.0, include_missing_mafs=True + ) + # overcome rounding differences + actual_simple_variants = [ + _round_simple_variant(v) for v in variant_overlap_detector.to_variants(sample_vcf, vcf_path) + ] + assert actual_simple_variants == VALID_SIMPLE_VARIANTS_APPROX + + +@pytest.mark.parametrize("variant_lookup_class", [FileBasedVariantLookup, VariantOverlapDetector]) +def test_simple_variant_conversion_logs( + variant_lookup_class: Type[VariantLookup], vcf_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Test that `to_variants()` logs a debug message with no pysam.VariantRecords to convert.""" + caplog.set_level(logging.DEBUG) + variant_lookup = variant_lookup_class( + vcf_paths=[vcf_path], min_maf=0.01, include_missing_mafs=False + ) + variant_lookup.query(refname="foo", start=1, end=2) + assert "No variants extracted from region of interest" in caplog.text + + +def test_missing_index_file_raises(temp_missing_path: Path) -> None: + """Test that both VariantLookup objects raise an error with a missing index file.""" + with pytest.raises(ValueError, match="Cannot perform fetch with missing index file for VCF"): + disk_based(vcf_paths=[temp_missing_path], min_maf=0.01, include_missing_mafs=False) + with pytest.raises(ValueError, match="Cannot perform fetch with missing index file for VCF"): + cached(vcf_paths=[temp_missing_path], min_maf=0.01, include_missing_mafs=False) + + +def test_missing_vcf_files_raises() -> None: + """Test that an error is raised when no VCF_paths are provided.""" + with pytest.raises(ValueError, match="No VCF paths given to query"): + disk_based(vcf_paths=[], min_maf=0.01, include_missing_mafs=False) + with pytest.raises(ValueError, match="No VCF paths given to query"): + cached(vcf_paths=[], min_maf=0.01, include_missing_mafs=False) + + +@pytest.mark.parametrize("random_seed", [1, 10, 100, 1000, 10000]) +def test_vcf_header_missing_chrom( + mini_chr1_vcf: Path, + mini_chr3_vcf: Path, + vcf_path: Path, + caplog: pytest.LogCaptureFixture, + random_seed: int, +) -> None: + """Test whether a missing chromosome of interest from one VCF (in a list of VCFs) will + log a debug message. + `vcf_path` contains only variants from chr2 + `mini_chr1_vcf` contains only 3 variants from chr1 (at positions 200, 300, and 400), + `mini_chr3_vcf` contains only 3 variants from chr3 (at positions 6000, 6010, and 6020).""" + caplog.set_level(logging.DEBUG) + vcf_paths = [vcf_path, mini_chr1_vcf, mini_chr3_vcf] + random.Random(random_seed).shuffle(vcf_paths) + variant_lookup = FileBasedVariantLookup( + vcf_paths=vcf_paths, min_maf=0.00, include_missing_mafs=True + ) + variants_of_interest = variant_lookup.query( + refname="chr2", start=7999, end=9900 + ) # (chr2 only in vcf_path) + # Should find all 12 variants from vcf_path (no filtering), with two variants having two + # alternate alleles + assert len(variants_of_interest) == 14 + expected_error_msg = "does not contain chromosome" + assert expected_error_msg in caplog.text + + +@pytest.mark.parametrize("test_case", VALID_SIMPLE_VARIANT_TEST_CASES) +def test_calc_maf_from_filter( + test_case: SimpleVariantTestCase, sample_vcf: list[VariantRecord] +) -> None: + """Test that calculating MAF function is working as expected. Importantly, VALID_SIMPLE_VARIANTS + are in the same order as the records in the sample_vcf.""" + calculated_mafs: list[float] = [] + expected_mafs = [testcase.simple_variant.maf for testcase in VALID_SIMPLE_VARIANT_TEST_CASES] + + for record in sample_vcf: + maf = calc_maf_from_filter(record) + calculated_mafs.extend(maf for _ in record.alts) + assert calculated_mafs == pytest.approx(expected_mafs) + + +def test_calc_maf_from_gt_only() -> None: + """Test that calc_maf using GTs works in the explicit absence of INFO.""" + variant_builder = fgpyo.vcf.builder.VariantBuilder( + sample_ids=["sample_0", "sample_1", "sample_2"] + ) + variant_builder._build_header_string() + variant_builder.add( + samples={"sample_0": {"GT": (1, 0)}, "sample_1": {"GT": (1, 1)}, "sample_2": {"GT": (0, 0)}} + ) + for rec in variant_builder.to_sorted_list(): + assert calc_maf_from_filter(rec) == 0.5 + + +def test_variant_overlap_detector_query(vcf_path: Path) -> None: + """Test `VariantOverlapDetector.query()` positional filtering.""" + variant_overlap_detector = VariantOverlapDetector( + vcf_paths=[vcf_path], min_maf=0.0, include_missing_mafs=True + ) + + # query for all variants + assert VALID_SIMPLE_VARIANTS_APPROX == variant_overlap_detector_query( + variant_overlap_detector, refname="chr2", start=8000, end=9101 + ) + + # query for all variants, except for the last one + assert VALID_SIMPLE_VARIANTS_APPROX[:-1] == variant_overlap_detector_query( + variant_overlap_detector, refname="chr2", start=8000, end=9100 + ) + + # query for variants up to position 7999 (none) + assert [] == variant_overlap_detector_query( + variant_overlap_detector, refname="chr2", start=7000, end=7999 + ) + + # query for variants between positions 8000 and 8999 (just the complex-sv variant) + assert variant_overlap_detector_query( + variant_overlap_detector, refname="chr2", start=8000, end=8999 + ) == get_simple_variant_approx_by_id("complex-variant-sv-1/1") + + # query for variants up to and including position 9000 (complex-sv + rare-dbsnp-snp1) + assert variant_overlap_detector_query( + variant_overlap_detector, refname="chr2", start=8000, end=9000 + ) == get_simple_variant_approx_by_id("complex-variant-sv-1/1", "rare-dbsnp-snp1-1/1") + + +@pytest.mark.parametrize("include_missing_mafs", [False, True]) +def test_variant_overlap_query_maf_filter(vcf_path: Path, include_missing_mafs: bool) -> None: + """Test that `VariantOverlapDetector.query()` MAF filtering is as expected. + `include_missing_mafs` is parameterized in both the class constructor and in the query to + demonstrate that it is only the query_method setting that changes the test results. + """ + variant_overlap_detector = VariantOverlapDetector( + vcf_paths=[vcf_path], min_maf=0.0, include_missing_mafs=include_missing_mafs + ) + query = variant_overlap_detector_query( + variant_overlap_detector, + refname="chr2", + start=8000, + end=9101, + maf=0.1, + include_missing_mafs=include_missing_mafs, + ) + + if not include_missing_mafs: + assert query == get_simple_variant_approx_by_id( + "common-mixed-1/2", + "common-mixed-2/2", + ) + else: + assert query == get_simple_variant_approx_by_id( + "complex-variant-sv-1/1", + "common-mixed-1/2", + "common-mixed-2/2", + ) + + +@pytest.mark.parametrize("include_missing_mafs", [False, True]) +def test_file_based_variant_query(vcf_path: Path, include_missing_mafs: bool) -> None: + """Test that `FileBasedVariantLookup.query()` MAF filtering is as expected.""" + file_based_vcf_query = FileBasedVariantLookup( + vcf_paths=[vcf_path], min_maf=0.0, include_missing_mafs=include_missing_mafs + ) + query = [ + _round_simple_variant(simple_variant) + for simple_variant in file_based_vcf_query.query( + refname="chr2", + start=8000, + end=9100, # while "common-mixed-2/2" starts at 9101, in the VCf is starts at 9100 + maf=0.05, + include_missing_mafs=include_missing_mafs, + ) + ] + + if not include_missing_mafs: + assert query == get_simple_variant_approx_by_id( + "common-multiallelic-1/2", + "common-multiallelic-2/2", + "common-mixed-1/2", + "common-mixed-2/2", + ) + else: + assert query == get_simple_variant_approx_by_id( + "complex-variant-sv-1/1", + "common-multiallelic-1/2", + "common-multiallelic-2/2", + "common-mixed-1/2", + "common-mixed-2/2", + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fe11698 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +""" +Fixtures intended to be shared across multiple files in the tests directory. +""" + +from pathlib import Path + +import pytest + +from prymer.api.span import Span +from prymer.api.span import Strand + + +@pytest.fixture +def test_span() -> Span: + """A basic Span for use in tests that don't depend on specific values""" + return Span(refname="chr1", start=1, end=10, strand=Strand.POSITIVE) + + +@pytest.fixture(scope="session") +def data_dir() -> Path: + return Path(__file__).parent / "data" + + +@pytest.fixture(scope="session") +def genome_ref(data_dir: Path) -> Path: + return data_dir / "miniref.fa" diff --git a/tests/data/miniref.dict b/tests/data/miniref.dict new file mode 100644 index 0000000..d798c30 --- /dev/null +++ b/tests/data/miniref.dict @@ -0,0 +1,3 @@ +@HD VN:1.5 SO:unsorted +@SQ SN:chr1 LN:577 M5:ef0179df9e1890de460f8aead36f0047 AS:miniref UR:file:fgprimer/src/test/resources/com/fulcrumegenomics/primerdesign/primer3/miniref.fa +@SQ SN:chr2 LN:9821 M5:0b0a7e507a574dc21824fe2efa2d0838 AS:miniref UR:file:fgprimer/src/test/resources/com/fulcrumegenomics/primerdesign/primer3/miniref.fa diff --git a/tests/data/miniref.fa b/tests/data/miniref.fa new file mode 100644 index 0000000..ab26803 --- /dev/null +++ b/tests/data/miniref.fa @@ -0,0 +1,107 @@ +>chr1 +TCCTCCATCAGTGACCTGAAGGAGGTGCCGCGGAAAAACATCACCCTCATTCGGGGTCTGGGCCATGGCGCCTTTGGGGAGGTGTATGAAGGCCAGGTGT +CCGGAATGCCCAACGACCCAAGCCCCCTGCAAGTGGCTGTGAAGACGCTGCCTGAAGTGTGCTCTGAACAGGACGAACTGGATTTCCTCATGGAAGCCCT +GATCATCAGTCTCTCTTGGAAGCAGTTGTTTTGCTTCACCTTCTGCCTGAGGCCTTCCTGGGAGAAATCGACCATGAATTCTAGCACCCAGTCCACGCAC +CAGTATCTGAGAAAGACACTCTCCTGCCCCCTGAGACACTTCCTCACCCAAGTGCCTGAGCAACATCCTCACAGCTCCAGCCACTCTCCTGCAAATGGAA +CTCCTGTGGAGCCTGCTGTGGTTCTTCCCACCTGCTCACCAGCAAGATTCTGGGTTTAGGCTCAGCCCGGGACCCCTGCTGCCCATGTTTACAGAATGCC +TTTATACATTGTAGCTGCTGAAAATGTAACTTTGTATCCTGTTCCTCCCAGTTTAAGATTTGCCCAGACTCAGCTCA +>chr2 +GCCGGAGTCCCGAGCTAGCCCCGGCGGCCGCCGCCGCCCAGACCGGACGACAGGCCACCTCGTCGGCGTCCGCCCGAGTCCCCGCCTCGCCGCCAACGCC +ACAACCACCGCGCACGGCCCCCTGACTCCGTCCAGTATTGATCGGGAGAGCCGGAGCGAGCTCTTCGGGGAGCAGCGATGCGACCCTCCGGGACGGCCGG +GGCAGCGCTCCTGGCGCTGCTGGCTGCGCTCTGCCCGGCGAGTCGGGCTCTGGAGGAAAAGAAAGTTTGCCAAGGCACGAGTAACAAGCTCACGCAGTTG +GGCACTTTTGAAGATCATTTTCTCAGCCTCCAGAGGATGTTCAATAACTGTGAGGTGGTCCTTGGGAATTTGGAAATTACCTATGTGCAGAGGAATTATG +ATCTTTCCTTCTTAAAGACCATCCAGGAGGTGGCTGGTTATGTCCTCATTGCCCTCAACACAGTGGAGCGAATTCCTTTGGAAAACCTGCAGATCATCAG +AGGAAATATGTACTACGAAAATTCCTATGCCTTAGCAGTCTTATCTAACTATGATGCAAATAAAACCGGACTGAAGGAGCTGCCCATGAGAAATTTACAG +GAAATCCTGCATGGCGCCGTGCGGTTCAGCAACAACCCTGCCCTGTGCAACGTGGAGAGCATCCAGTGGCGGGACATAGTCAGCAGTGACTTTCTCAGCA +ACATGTCGATGGACTTCCAGAACCACCTGGGCAGCTGCCAAAAGTGTGATCCAAGCTGTCCCAATGGGAGCTGCTGGGGTGCAGGAGAGGAGAACTGCCA +GAAACTGACCAAAATCATCTGTGCCCAGCAGTGCTCCGGGCGCTGCCGTGGCAAGTCCCCCAGTGACTGCTGCCACAACCAGTGTGCTGCAGGCTGCACA +GGCCCCCGGGAGAGCGACTGCCTGGTCTGCCGCAAATTCCGAGACGAAGCCACGTGCAAGGACACCTGCCCCCCACTCATGCTCTACAACCCCACCACGT +ACCAGATGGATGTGAACCCCGAGGGCAAATACAGCTTTGGTGCCACCTGCGTGAAGAAGTGTCCCCGTAATTATGTGGTGACAGATCACGGCTCGTGCGT +CCGAGCCTGTGGGGCCGACAGCTATGAGATGGAGGAAGACGGCGTCCGCAAGTGTAAGAAGTGCGAAGGGCCTTGCCGCAAAGTGTGTAACGGAATAGGT +ATTGGTGAATTTAAAGACTCACTCTCCATAAATGCTACGAATATTAAACACTTCAAAAACTGCACCTCCATCAGTGGCGATCTCCACATCCTGCCGGTGG +CATTTAGGGGTGACTCCTTCACACATACTCCTCCTCTGGATCCACAGGAACTGGATATTCTGAAAACCGTAAAGGAAATCACAGGGTTTTTGCTGATTCA +GGCTTGGCCTGAAAACAGGACGGACCTCCATGCCTTTGAGAACCTAGAAATCATACGCGGCAGGACCAAGCAACATGGTCAGTTTTCTCTTGCAGTCGTC +AGCCTGAACATAACATCCTTGGGATTACGCTCCCTCAAGGAGATAAGTGATGGAGATGTGATAATTTCAGGAAACAAAAATTTGTGCTATGCAAATACAA +TAAACTGGAAAAAACTGTTTGGGACCTCCGGTCAGAAAACCAAAATTATAAGCAACAGAGGTGAAAACAGCTGCAAGGCCACAGGCCAGGTCTGCCATGC +CTTGTGCTCCCCCGAGGGCTGCTGGGGCCCGGAGCCCAGGGACTGCGTCTCTTGCCGGAATGTCAGCCGAGGCAGGGAATGCGTGGACAAGTGCAACCTT +CTGGAGGGTGAGCCAAGGGAGTTTGTGGAGAACTCTGAGTGCATACAGTGCCACCCAGAGTGCCTGCCTCAGGCCATGAACATCACCTGCACAGGACGGG +GACCAGACAACTGTATCCAGTGTGCCCACTACATTGACGGCCCCCACTGCGTCAAGACCTGCCCGGCAGGAGTCATGGGAGAAAACAACACCCTGGTCTG +GAAGTACGCAGACGCCGGCCATGTGTGCCACCTGTGCCATCCAAACTGCACCTACGGATGCACTGGGCCAGGTCTTGAAGGCTGTCCAACGAATGGGCCT +AAGATCCCGTCCATCGCCACTGGGATGGTGGGGGCCCTCCTCTTGCTGCTGGTGGTGGCCCTGGGGATCGGCCTCTTCATGCGAAGGCGCCACATCGTTC +GGAAGCGCACGCTGCGGAGGCTGCTGCAGGAGAGGGAGCTTGTGGAGCCTCTTACACCCAGTGGAGAAGCTCCCAACCAAGCTCTCTTGAGGATCTTGAA +GGAAACTGAATTCAAAAAGATCAAAGTGCTGGGCTCCGGTGCGTTCGGCACGGTGTATAAGGGACTCTGGATCCCAGAAGGTGAGAAAGTTAAAATTCCC +GTCGCTATCAAGGAATTAAGAGAAGCAACATCTCCGAAAGCCAACAAGGAAATCCTCGATGAAGCCTACGTGATGGCCAGCGTGGACAACCCCCACGTGT +GCCGCCTGCTGGGCATCTGCCTCACCTCCACCGTGCAGCTCATCACGCAGCTCATGCCCTTCGGCTGCCTCCTGGACTATGTCCGGGAACACAAAGACAA +TATTGGCTCCCAGTACCTGCTCAACTGGTGTGTGCAGATCGCAAAGGGCATGAACTACTTGGAGGACCGTCGCTTGGTGCACCGCGACCTGGCAGCCAGG +AACGTACTGGTGAAAACACCGCAGCATGTCAAGATCACAGATTTTGGGCTGGCCAAACTGCTGGGTGCGGAAGAGAAAGAATACCATGCAGAAGGAGGCA +AAGTGCCTATCAAGTGGATGGCATTGGAATCAATTTTACACAGAATCTATACCCACCAGAGTGATGTCTGGAGCTACGGGGTGACTGTTTGGGAGTTGAT +GACCTTTGGATCCAAGCCATATGACGGAATCCCTGCCAGCGAGATCTCCTCCATCCTGGAGAAAGGAGAACGCCTCCCTCAGCCACCCATATGTACCATC +GATGTCTACATGATCATGGTCAAGTGCTGGATGATAGACGCAGATAGTCGCCCAAAGTTCCGTGAGTTGATCATCGAATTCTCCAAAATGGCCCGAGACC +CCCAGCGCTACCTTGTCATTCAGGGGGATGAAAGAATGCATTTGCCAAGTCCTACAGACTCCAACTTCTACCGTGCCCTGATGGATGAAGAAGACATGGA +CGACGTGGTGGATGCCGACGAGTACCTCATCCCACAGCAGGGCTTCTTCAGCAGCCCCTCCACGTCACGGACTCCCCTCCTGAGCTCTCTGAGTGCAACC +AGCAACAATTCCACCGTGGCTTGCATTGATAGAAATGGGCTGCAAAGCTGTCCCATCAAGGAAGACAGCTTCTTGCAGCGATACAGCTCAGACCCCACAG +GCGCCTTGACTGAGGACAGCATAGACGACACCTTCCTCCCAGTGCCTGAATACATAAACCAGTCCGTTCCCAAAAGGCCCGCTGGCTCTGTGCAGAATCC +TGTCTATCACAATCAGCCTCTGAACCCCGCGCCCAGCAGAGACCCACACTACCAGGACCCCCACAGCACTGCAGTGGGCAACCCCGAGTATCTCAACACT +GTCCAGCCCACCTGTGTCAACAGCACATTCGACAGCCCTGCCCACTGGGCCCAGAAAGGCAGCCACCAAATTAGCCTGGACAACCCTGACTACCAGCAGG +ACTTCTTTCCCAAGGAAGCCAAGCCAAATGGCATCTTTAAGGGCTCCACAGCTGAAAATGCAGAATACCTAAGGGTCGCGCCACAAAGCAGTGAATTTAT +TGGAGCATGACCACGGAGGATAGTATGAGCCCTAAAAATCCAGACTCTTTCGATACCCAGGACCAAGCCACAGCAGGTCCTCCATCCCAACAGCCATGCC +CGCATTAGCTCTTAGACCCACAGACTGGTTTTGCAACGTTTACACCGACTAGCCAGGAAGTACTTCCACCTCGGGCACATTTTGGGAAGTTGCATTCCTT +TGTCTTCAAACTGTGAAGCATTTACAGAAACGCATCCAGCAAGAATATTGTCCCTTTGAGCAGAAATTTATCTTTCAAAGAGGTATATTTGAAAAAAAAA +AAAAGTATATGTGAGGATTTTTATTGATTGGGGATCTTGGAGTTTTTCATTGTCGCTATTGATTTTTACTTCAATGGGCTCTTCCAACAAGGAAGAAGCT +TGCTGGTAGCACTTGCTACCCTGAGTTCATCCAGGCCCAACTGTGAGCAAGGAGCACAAGCCACAAGTCTTCCAGAGGATGCTTGATTCCAGTGGTTCTG +CTTCAAGGCTTCCACTGCAAAACACTAAAGATCCAAGAAGGCCTTCATGGCCCCAGCAGGCCGGATCGGTACTGTATCAAGTCATGGCAGGTACAGTAGG +ATAAGCCACTCTGTCCCTTCCTGGGCAAAGAAGAAACGGAGGGGATGGAATTCTTCCTTAGACTTACTTTTGTAAAAATGTCCCCACGGTACTTACTCCC +CACTGATGGACCAGTGGTTTCCAGTCATGAGCGTTAGACTGACTTGTTTGTCTTCCATTCCATTGTTTTGAAACTCAGTATGCTGCCCCTGTCTTGCTGT +CATGAAATCAGCAAGAGAGGATGACACATCAAATAATAACTCGGATTCCAGCCCACATTGGATTCATCAGCATTTGGACCAATAGCCCACAGCTGAGAAT +GTGGAATACCTAAGGATAGCACCGCTTTTGTTCTCGCAAAAACGTATCTCCTAATTTGAGGCTCAGATGAAATGCATCAGGTCCTTTGGGGCATAGATCA +GAAGACTACAAAAATGAAGCTGCTCTGAAATCTCCTTTAGCCATCACCCCAACCCCCCAAAATTAGTTTGTGTTACTTATGGAAGATAGTTTTCTCCTTT +TACTTCACTTCAAAAGCTTTTTACTCAAAGAGTATATGTTCCCTCCAGGTCAGCTGCCCCCAAACCCCCTCCTTACGCTTTGTCACACAAAAAGTGTCTC +TGCCTTGAGTCATCTATTCAAGCACTTACAGCTCTGGCCACAACAGGGCATTTTACAGGTGCGAATGACAGTAGCATTATGAGTAGTGTGGAATTCAGGT +AGTAAATATGAAACTAGGGTTTGAAATTGATAATGCTTTCACAACATTTGCAGATGTTTTAGAAGGAAAAAAGTTCCTTCCTAAAATAATTTCTCTACAA +TTGGAAGATTGGAAGATTCAGCTAGTTAGGAGCCCACCTTTTTTCCTAATCTGTGTGTGCCCTGTAACCTGACTGGTTAACAGCAGTCCTTTGTAAACAG +TGTTTTAAACTCTCCTAGTCAATATCCACCCCATCCAATTTATCAAGGAAGAAATGGTTCAGAAAATATTTTCAGCCTACAGTTATGTTCAGTCACACAC +ACATACAAAATGTTCCTTTTGCTTTTAAAGTAATTTTTGACTCCCAGATCAGTCAGAGCCCCTACAGCATTGTTAAGAAAGTATTTGATTTTTGTCTCAA +TGAAAATAAAACTATATTCATTTCCACTCTATTATGCTCTCAAATACCCCTAAGCATCTATACTAGCCTGGTATGGGTATGAAAGATACAAAGATAAATA +AAACATAGTCCCTGATTCTAAGAAATTCACAATTTAGCAAAGGAAATGGACTCATAGATGCTAACCTTAAAACAACGTGACAAATGCCAGACAGGACCCA +TCAGCCAGGCACTGTGAGAGCACAGAGCAGGGAGGTTGGGTCCTGCCTGAGGAGACCTGGAAGGGAGGCCTCACAGGAGGATGACCAGGTCTCAGTCAGC +GGGGAGGTGGAAAGTGCAGGTGCATCAGGGGCACCCTGACCGAGGAAACAGCTGCCAGAGGCCTCCACTGCTAAAGTCCACATAAGGCTGAGGTCAGTCA +CCCTAAACAACCTGCTCCCTCTAAGCCAGGGGATGAGCTTGGAGCATCCCACAAGTTCCCTAAAAGTTGCAGCCCCCAGGGGGATTTTGAGCTATCATCT +CTGCACATGCTTAGTGAGAAGACTACACAACATTTCTAAGAATCTGAGATTTTATATTGTCAGTTAACCACTTTCATTATTCATTCACCTCAGGACATGC +AGAAATATTTCAGTCAGAACTGGGAAACAGAAGGACCTACATTCTGCTGTCACTTATGTGTCAAGAAGCAGATGATCGATGAGGCAGGTCAGTTGTAAGT +GAGTCACATTGTAGCATTAAATTCTAGTATTTTTGTAGTTTGAAACAGTAACTTAATAAAAGAGCAAAAGCTATTCTAGCTTTCTTCTTCATATTTTAAT +TTTCCACCATAAAGTTTAGTTGCTAAATTCTATTAATTTTAAGATTGTGCTTCCCAAAATAGTTCTCACTTCATCTGTCCAGGGAGGCACAGTTCTGTCT +GGTAGAAGCCGCAAAGCCCTTAGCCTCTTCACGGATCTGGCGACTGTGATGGGCAGGTCAGGAGAGGAGCTGCCCAAAGTCCCATGATTTTCACCTAACA +GCCCTGATCAGTCAGTACTCAAAGCTTGGACTCCATCCCTGAAGGTCTTCCTGATTGATAGCCTGGCCTTAATACCCTACAGAAAGCCTGTCCATTGGCT +GTTTCTTCCTCAGTCAGTTCCTGGAAGACCTTACCCCATGACCCCAGCTTCAGATGTGGTCTTTGGAAACAGAGGTCGAAGGAAAGTAAGGAGCTGAGAG +CTCACATTCATAGGTGCCGCCAGCCTTCGTGCATCTTCTTGCATCATCTCTAAGGAGCTCCTCTAATTACACCATGCCCGTCACCCCATGAGGGATCAGA +GAAGGGATGAGTCTTCTAAACTCTATATTCGCTGTGAGTCCAGGTTGTAAGGGGGAGCACTGTGGATGCATCCTATTGCACTCCAGCTGATGACACCAAA +GCTTAGGTGTTTGCTGAAAGTTCTTGATGTTGTGACTTACCACCCCTGCCTCACAACTGCAGACATAAGGGGACTATGGATTGCTTAGCAGGAAAGGCAC +TGGTTCTCAAGGGCGGCTGCCCTTGGGAATCTTCTGGTCCCAACCAGAAAGACTGTGGCTTGATTTTCTCAGGTGCAGCCCAGCCGTAGGGCCTTTTCAG +AGCACCCCCTGGTTATTGCAACATTCATCAAAGTTTCTAGAACCTCTGGCCTAAAGGAAGGGCCTGGTGGGATCTACTTGGCACTCGCTGGGGGGCCACC +CCCCAGTGCCACTCTCACTAGGCCTCTGATTGCACTTGTGTAGGATGAAGCTGGTGGGTGATGGGAACTCAGCACCTCCCCTCAGGCAGAAAAGAATCAT +CTGTGGAGCTTCAAAAGAAGGGGCCTGGAGTCTCTGCAGACCAATTCAACCCAAATCTCGGGGGCTCTTTCATGATTCTAATGGGCAACCAGGGTTGAAA +CCCTTATTTCTAGGGTCTTCAGTTGTACAAGACTGTGGGTCTGTACCAGAGCCCCCGTCAGAGTAGAATAAAAGGCTGGGTAGGGTAGAGATTCCCATGT +GCAGTGGAGAGAACAATCTGCAGTCACTGATAAGCCTGAGACTTGGCTCATTTCAAAAGCGTTCAATTCATCCTCACCAGCAGTTCAGCTGGAAAGGGGC +AAATACCCCCACCTGAGCTTTGAAAACGCCCTGGGACCCTCTGCATTCTCTAAGTAAGTTATAGAAACCAGTCTCTTCCCTCCTTTGTGAGTGAGCTGCT +ATTCCACGTAGGCAACACCTGTTGAAATTGCCCTCAATGTCTACTCTGCATTTCTTTCTTGTGATAAGCACACACTTTTATTGCAACATAATGATCTGCT +CACATTTCCTTGCCTGGGGGCTGTAAAACCTTACAGAACAGAAATCCTTGCCTCTTTCACCAGCCACACCTGCCATACCAGGGGTACAGCTTTGTACTAT +TGAAGACACAGACAGGATTTTTAAATGTAAATCTATTTTTGTAACTTTGTTGCGGGATATAGTTCTCTTTATGTAGCACTGAACTTTGTACAATATATTT +TTAGAAACTCATTTTTCTACTAAAACAAACACAGTTTACTTTAGAGAGACTGCAATAGAATCAAAATTTGAAACTGAAATCTTTGTTTAAAAGGGTTAAG +TTGAGGCAAGAGGAAAGCCCTTTCTCTCTCTTATAAAAAGGCACAACCTCATTGGGGAGCTAAGCTAGGTCATTGTCATGGTGAAGAAGAGAAGCATCGT +TTTTATATTTAGGAAATTTTAAAAGATGATGGAAAGCACATTTAGCTTGGTCTGAGGCAGGTTCTGTTGGGGCAGTGTTAATGGAAAGGGCTCACTGTTG +TTACTACTAGAAAAATCCAGTTGCATGCCATACTCTCATCATCTGCCAGTGTAACCCTGTACATGTAAGAAAAGCAATAACATAGCACTTTGTTGGTTTA +TATATATAATGTGACTTCAATGCAAATTTTATTTTTATATTTACAATTGATATGCATTTACCAGTATAAACTAGACATGTCTGGAGAGCCTAATAATGTT +CAGCACACTTTGGTTAGTTCACCAACAGTCTTACCAAGCCTGGGCCCAGCCACCCTAGAGAAGTTATTCAGCCCTGGCTGCAGTGACATCACCTGAGGAG +CTTTTAAAAGCTTGAAGCCCAGCTACACCTCAGACCGATTAAACGCAAATCTCTGGGGCTGAAACCCAAGCATTCGTAGTTTTTAAAGCTCCTGAGGTCA +TTCCAATGTGCGGCCAAAGTTGAGAACTACTGGCCTAGGGATTAGCCACAAGGACATGGACTTGGAGGCAAATTCTGCAGGTGTATGTGATTCTCAGGCC +TAGAGAGCTAAGACACAAAGACCTCCACATCTGTCGCTGAGAGTCAAGAACCTGAACAGAGTTTCCATGAAGGTTCTCCAAGCACTAGAAGGGAGAGTGT +CTAAACAATGGTTGAAAAGCAAAGGAAATATAAAACAGACACCTCTTTCCATTTCCTAAGGTTTCTCTCTTTATTAAGGGTGGACTAGTAATAAAATATA +ATATTCTTGCTGCTTATGCAGCTGACATTGTTGCCCTCCCTAAAGCAACCAAGTAGCCTTTATTTCCCACAGTGAAAGAAAACGCTGGCCTATCAGTTAC +ATTACAAAAGGCAGATTTCAAGAGGATTGAGTAAGTAGTTGGATGGCTTTCATAAAAACAAGAATTCAAGAAGAGGATTCATGCTTTAAGAAACATTTGT +TATACATTCCTCACAAATTATACCTGGGATAAAAACTATGTAGCAGGCAGTGTGTTTTCCTTCCATGTCTCTCTGCACTACCTGCAGTGTGTCCTCTGAG +GCTGCAAGTCTGTCCTATCTGAATTCCCAGCAGAAGCACTAAGAAGCTCCACCCTATCACCTAGCAGATAAAACTATGGGGAAAACTTAAATCTGTGCAT +ACATTTCTGGATGCATTTACTTATCTTTAAAAAAAAAGGAATCCTATGACCTGATTTGGCCACAAAAATAATCTTGCTGTACAATACAATCTCTTGGAAA +TTAAGAGATCCTATGGATTTGATGACTGGTATTAGAGGTGACAATGTAACCGATTAACAACAGACAGCAATAACTTCGTTTTAGAAACATTCAAGCAATA +GCTTTATAGCTTCAACATATGGTACGTTTTAACCTTGAAAGTTTTGCAATGATGAAAGCAGTATTTGTACAAATGAAAAGCAGAATTCTCTTTTATATGG +TTTATACTGTTGATCAGAAATGTTGATTGTGCATTGAGTATTAAAAAATTAGATGTATATTATTCATTGTTCTTTACTCCTGAGTACCTTATAATAATAA +TAATGTATTCTTTGTTAACAA diff --git a/tests/data/miniref.fa.fai b/tests/data/miniref.fa.fai new file mode 100644 index 0000000..1d8ebd0 --- /dev/null +++ b/tests/data/miniref.fa.fai @@ -0,0 +1,2 @@ +chr1 577 6 100 101 +chr2 9821 595 100 101 diff --git a/tests/ntthal/__init__.py b/tests/ntthal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ntthal/test_ntthal.py b/tests/ntthal/test_ntthal.py new file mode 100644 index 0000000..70769e3 --- /dev/null +++ b/tests/ntthal/test_ntthal.py @@ -0,0 +1,115 @@ +import pytest + +from prymer import ntthal + + +@pytest.mark.parametrize( + "s1,s2,tm", + [ + ("AAAAAAAAAAAAAAAAAAAA", "CCCCCCCCCCCCCCCCCCCC", 0.0), # no match + ( + "AAAACCCGCTTTGCTAGCTACG", + "AACCCGCTTGCTAGCAAAAAAAAAACCCGCTTGCTAGCAAAAAAAAAACCCGCTTGCTAGC" + "AAAAAAAAAACCCGCTTGCTAGCAAAAAAAAAACCCGCTTGCTAGCAA", + 23.974756, # significantly different lengths + ), + ("AAAACCCGCTTTGCTAGCTACG", "CGTAGCTAGCAAAGCGGGTTTT", 55.903276), # reverse complements + ("AAAACCCGCTTTGCTAGCTACGNNNNN", "NNNNNCGTAGCTAGCAAAGCGGGTTTT", 55.903276), # NNNs + ("GTCAGTCA", "ACGTACGT", -89.739318), # negative Tm return + ("XYZZZZZZZZZNNNNNNN", "QUEEEEEEENNNNN", 0.000000), # non-ACGT characters + ("A", "T", -437.071890), # 1 nt, + ], +) +def test_duplex_tm_default_params(s1: str, s2: str, tm: float) -> None: + """Test that NtThermoAlign().duplexTm() will return Tm across valid conditions""" + with ntthal.NtThermoAlign() as t: + assert t.duplex_tm(s1, s2) == pytest.approx(tm) + + +@pytest.mark.parametrize( + "invalid_s1,invalid_s2", + [ + ("A123", "T456"), + ("", ""), + ("--A", "--T"), + ("AGCT", ""), + ("", "ACGT"), + ], +) +def test_duplex_tm_invalid_input_raises(invalid_s1: str, invalid_s2: str) -> None: + """Test that NtThermoAlign().duplexTm() will raise an error with invalid inputs""" + t = ntthal.NtThermoAlign() + with pytest.raises(ValueError): + t.duplex_tm(invalid_s1, invalid_s2) + + +def test_invalid_path() -> None: + """Test that NtThermoAlign().duplexTm() will raise an error if given an invalid path""" + with pytest.raises(ValueError): + ntthal.NtThermoAlign(executable="invalid_path") + + +@pytest.mark.parametrize( + "monovalent_millimolar,divalent_millimolar,dntp_millimolar,dna_nanomolar,temp,expected_tm", + [ + (50.0, 0.0, 0.8, 50.0, 37.0, -54.750420), + (500.0, 0.0, 0.8, 50.0, 37.0, -49.372162), + (50.0, 2.0, 0.8, 50.0, 37.0, -51.771985), + (50.0, 0.0, 2.0, 50.0, 37.0, -54.750420), + (50.0, 0.0, 0.8, 2.0, 37.0, -67.205211), + (50.0, 0.0, 0.8, 50.0, 500.0, -54.750420), + ], +) +def test_valid_ntthal_params( + monovalent_millimolar: float, + divalent_millimolar: float, + dntp_millimolar: float, + dna_nanomolar: float, + temp: float, + expected_tm: float, +) -> None: + """Test class instantiation with valid params and context manager methods""" + with ntthal.NtThermoAlign( + monovalent_millimolar=monovalent_millimolar, + divalent_millimolar=divalent_millimolar, + dntp_millimolar=dntp_millimolar, + dna_nanomolar=dna_nanomolar, + temperature=temp, + ) as t: + assert t.duplex_tm("ATGC", "GCAT") == pytest.approx(expected_tm) + + +def test_ntthal_teardown() -> None: + """Test teardown of NtThermoAlign() with context manager methods""" + with ntthal.NtThermoAlign() as t: + assert t.is_alive + assert not t.is_alive + with pytest.raises(RuntimeError): + t.duplex_tm(s1="ACGT", s2="ACGT") + + +@pytest.mark.parametrize( + "monovalent_millimolar,divalent_millimolar,dntp_millimolar,dna_nanomolar,temp", + [ + (-50.0, 0.0, 0.8, 0.0, 37.0), + (0.0, -50.0, 0.8, 0.0, 37.0), + (0.0, 0.0, -50.0, 0.0, 37.0), + (0.0, 0.0, 0.8, -50.0, 37.0), + (0.0, 0.0, 0.8, 0.0, -50.0), + ], +) +def test_invalid_ntthal_params( + monovalent_millimolar: float, + divalent_millimolar: float, + dntp_millimolar: float, + dna_nanomolar: float, + temp: float, +) -> None: + with pytest.raises(ValueError): + ntthal.NtThermoAlign( + monovalent_millimolar=monovalent_millimolar, + divalent_millimolar=divalent_millimolar, + dntp_millimolar=dntp_millimolar, + dna_nanomolar=dna_nanomolar, + temperature=temp, + ) diff --git a/tests/offtarget/__init__.py b/tests/offtarget/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/offtarget/conftest.py b/tests/offtarget/conftest.py new file mode 100644 index 0000000..acb3bc3 --- /dev/null +++ b/tests/offtarget/conftest.py @@ -0,0 +1,8 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture +def ref_fasta() -> Path: + return Path(__file__).parent / "data" / "miniref.fa" diff --git a/tests/offtarget/data/miniref.dict b/tests/offtarget/data/miniref.dict new file mode 100644 index 0000000..701497d --- /dev/null +++ b/tests/offtarget/data/miniref.dict @@ -0,0 +1,2 @@ +@HD VN:1.5 +@SQ SN:chr1 LN:10001 M5:8b21989af9dd1d4081b77fbc3261fb5e UR:file:fgprimer/src/test/resources/com/fulcrumgenomics/primerdesign/util/miniref.fa diff --git a/tests/offtarget/data/miniref.fa b/tests/offtarget/data/miniref.fa new file mode 100644 index 0000000..df026ca --- /dev/null +++ b/tests/offtarget/data/miniref.fa @@ -0,0 +1,168 @@ +>chr1 +CAGGTGGATCATGAGGTCAGGAGTTCAAGACCAGCCTGGCCAACATGGTGAAGCCCCACC +TCTACTAAAAATACAAAAAATTAGCTGGGCATGATGGCATGCACCTGTAATCCCGCTACT +TGTGAGGCTGAAGCAGGAGAATTGCTTGAACCCAGAAGGTGGAGGTTGCAGTGAGCCGAG +ATTGCGCCATTGCACTCTAGCCAGGGAGACAAAGCAAGACTCCATCTTGAAAAAAAATAA +TTAAGCTAGCAGACTGGGCAGGTGGCTCACGCCTATAATCCCAGCACTTTGGGAGGCCGA +GGTGGGTGGATCACCTGAAGTCAGGAGTTTGAGACCAGCCTGGCCAACATGGTGAAATAC +CCCATCTCTACTAAAAGTACAAAAATTAGCTAGGCATGGTGGCTCATGCCTGTAGTCCCA +GCTAATTGGGAGGCTGAGGCACGAGAATCGCTTGAACCTGAGAGGTGGAGGTTGCAGTGA +GCCCAGATCACGACACTGCACTCTAGCCTGGGCAACAGCATGAGACTTGGTCTCAAAAAA +AATAAGTCAATAAGACAAAAAAAAAAATTCAGCTAGCAATCTTGAATTTTACTGATTTAC +TTGAATATATAATTAACTTTAAATTTATATGCTTGTTCATGTACATGTAAGGAAAATACA +TTCCATAAAAAATCACAGATATAGTACTTAGATCTTTAAACTGTAGGGTCTAGGAAAATA +ATAAATGATCGCCTCTTTTTTTGTTGTTGTTGTTGAGATGGAGTCTCTCTCTGTTGCCCA +GGCTAGAGTGCAGTGGTGCGATCTCGGCTCACTGCAACCTCTGCCTCCTGAGTTCAAGCA +ATTCTCCTGCTTCAGCCTCCTGAGTAGCTGGTATTACAGGTGCCCACCACCTCTCCTGGC +TAATTTTTGTATTTTTAATAGAGACGGAGTTTTACCATCTTGGCCAGGCTGGTCTTGAAC +TCCTGACCTCGTGATCCACCCGCCTTGGCCTCCCAAAGTGCTGGGATTACAGGCGTGAGC +TACCGTGCCTGGCTGATTGCCTCTTTTTTAGTAGTAGAAGTGAGGTAAATGCCTGTTGGC +AGTGATGCTAAGGGTCAAATAAGTCACCAGCAAATACACAGCACACATCTCATGATGTGC +TCCAGCTGGCATCTCATTTGAGGGCAGAAAATCACTCCCTTTTGTCTAAAAACTAGTGTT +CAGGAACTGATCCTGCAGCTCCCACCCGGGCTCTGGCATCACTCTCTCCCAGCATTTGCC +AAACCGCAGTGAGAAGAAAGGCAGCTTGTCTTTGCACAAAGAAGCAAGTTTACTTGGGTT +TTTTGAGATAGGGTCTTGCTCTGTCGCTCAGGCTGTAGTGCAGTGGTGCGATCATGGCTC +ACTGCAGCCTTCACTTCCTGGGCTCAGGCGATCCTCCCACCTCAGCCTTCCAAGTAGCTG +GGACAACAGGTGCATACCACCACACTTGGCCAATTTTTCAATTTTTTTGTAGAGACGGTG +TCTTGCTGTGTTGTCCAGGCTGGTCTCAAACTCCTGGCCACATGCAATCCTCCTGCCTCT +GCCTTGCTTGGGTTTTAATATTTGGAGCTAACCTGGGATTTGGGAGTTCCTGGTGTACCC +CCACAGCAGCCAAGGACACTGAAGGTGCGTGCTTCAGAAATGGAGAAGCCCCATGTTAAT +GCCCACAGATATTGACTAACTTGTGCAAGTCCTCGCCTTTAGTCCTGTTATGAACCCAAG +AAGGTGGTGGTGTTACTAACTTGTCCCAGATTACTGTGTACACTGGGATATATTTATTTA +ATTTAATTAGTTTATTTATTTATTTATTTATTTATTTATTTATTTTTGAGACAGAGTCTC +CCTTTGTTGCCCAGTCTGGAGTGCAGTGGCACGGATCTTGGCTCACTGCAACTCCGTCTC +CCGGGTTCAAGCAATTCTCCTGCCTCAGCCTCCCAAGTAGCTGGGATTACAGGCACACGC +CCCCACACCCCGTTAATTTTATATTTTTAGTACAGACAGGGTTTCACCACGTTGGCCAGG +CTGGTCTCGAACTCCTGAGCTCAGGTGACCTGCCTGCCTCGGCCTCCTAAAGTGCTGGGA +TTACAGGCATGAGCCACCACATCTGGCCCTACACTGGGATTTAAAGGGATCCCTTCTTGC +CTTCAACCCACATTGCCTTGAGATTAGAAGTGGCTTTGAGATTGAAGGTTAATATAAACA +TCTGAAGCTTAGATAAATGTACGTTTGTGTGGACTCCTTTGAGATCCTACCTTCAGGTGT +ATATGCTCACACATTTGTAATAGCACGTGCATCAGGCTGTTCCCTCCTACTCGAATGTCT +TATTTTCTATTTAACATAATCTAGTAGATGAAAAAGCATGGCTTGACCTGGGTAACAGCC +TTATGAGGAATATGGCCTTTTGGACTGTTGGACTGTTGAGGTTCTAGTAAGTGTGGACCT +GGCAGAAAGTGACCAGAACTTATGCTAATTTATTCATTTTATTTTATTTTTATTTTTTGA +GATAGGATCTCACTCTGTTGCCCAGGCTGGAGTGTAGTGGCACCATCTTGGCTCATTGCA +ACCTCCTCCTTCTAGGCTCAAGCGATCCTCCCACCTCAGCCTCCCCAGCAGCCAGGACTG +CAGGTGCACACCACCATGCCCAGCTAATTTTTATTTTATTTTTTGGTAGAGATGCGGTTT +CACCATGTTGGCCAGGCTGGTCTTGAACTCCTGGCCTCAAGTGATCTGCCTGCCTCGGCT +TCCCAAAGTGCTGGAATTACAGGCCTGAGCCACTGGACCTGGCCCAGAACTTATGCTAAT +TCAAAGTAAATTTTGGTATTTAAAGAGGCCAGCCTTGTAACTGCAAATCTGTGAAGTGAC +AATGTTGCAACATGGTAGTGAGGTAGGAGGCAGGGCTCAACTCCAGAAGCCAGAAGCGGG +GCTTGGGACAGCGGACCAAACTGAGGACTAACTAAAACAGGGATAGGATGGAAGCAGCTT +TTCATAAAACACATAAAACAGTGTGCCATATCAGTTTACCATTGCCATGGCAACACCTGG +AGTTAGCACCCCTTTCCATGGCAATGACCAGAGGACCCAAAAGTTACTACCCCTTCCCTA +GAAATGTCTGCATAAACCACCCGTTAGCCGGGCATGGTGGCTCACGCCTGTAATCCCAGC +ACTTTGGGAGGCTGAGGTGGGTGGATCACCTGAGGTCAGGAGTTCGAGACCAGCCTGGCC +AACATGGTGAAACCCATCTCTACCAAAAATACAAAAATTAGCTGGGCGTGGTGGTGGGCA +CCTGTAGTCTCAGCTACTCAGGAGACTGAGGCAGGAGAATCATTTGAATCCGGAGGCAGA +GGTTGCAGTGAGCCAAGATCGTGCTGCCATTGCACTCCAGCCTGGGTGACAAGAGCAAAA +CTCTGTCTGAATAAACAAACAAACAAACAAACAAAAACAAAAAAACCACCCCTTACTCTG +CATGTAACTAGAAGTGGGTATAAATATGACTACAAAACTGCCCTGAGCTGCTACTTTCTG +CCTATGGGGTAGCTCTTTTCTGCGGGAGCAGTCACAGAGCTGTGACACTGCTTCTTCAAT +AAAGCTGTTTTCTTCTCCCTCTGGCTTGCCCTTGAATTCTTTCCTGGGCAAAGCCAAGAA +CCTCTGCAGGCTAATCCCCGCTCTGGGGCTCACCTGCCCTACATGAGTAGTGCAAATTGT +AAATTTGCTAACAACAAATTGCCTCACATATTTTATTTTATTTATTTATTTTTTGAGATG +GATTCTTGCTCTGTCACCTAGGCTGGAGTGCAGTAGCGAGATTTCAGCTTGCTGCAACCT +CCACCTCCCGGGTTCAAGAGATTCTCCTGCCTAAGCCTCCCGAGTAGCTGGGATTACAGG +CACCCCCCACCACGCCTGACTAATTCTTGTATTTTTAGTAGAGATGGGGTTTTGCCATGT +TGGCCAGGCTGGTCTCGAACTCCTGACCTCAGGTGATCTGCCCTCCTCAGCCTCCCAAAG +TGTTAGGATTACAGGTGTGAACTACCACGCCTGGCCTGCCTCACAATTTTTTTTTTTTTT +TTTTTTTTTTTAGATGGAGTTTTGCTCTTGTTGCCCAGGCTGGAGTGCAATGGCGGGATC +TCGGCTCACCGCAACTTCCGTCTCCCCGGTTCAAACAATTCTCCTGCTTCAGCCTCCTGA +GTAGCTGGGATTGCAGGCATGCCCCACCACGCCCAGCTAATTTTGTATTTTTAGTAGAGA +CGGGGTTTCTCCATATTGGTCAGGCTGGTCTCCAACTCCCGACCTCAGGTGATCGCCCAC +CTCTGCCTCCCAAAGTGCTGGGATTAAGGCATGAGCCACTGCGCCCAGCCAGATTGATGG +ATTGATTGATTTTGAGATGGAGTTTCCCTCTTGTTGCCCAGGCTGGAGTGCAATGGTGCA +ATCTCAACTCACCTCAACCTCTGCCTCCCAGGTTCAAGCGACTCTCCTGCCTCAGCCTCT +GGAGTAGCTGGGATTACAGGCATGCGCCACCATGCCCGGCTAATTTTGTATTTTTAGTAG +AGACGGGGTTTCTCCATATTGGTCAGGCTGGTCGCGTTGGTCTGCCCGCCTCGGCCTCCC +GAAGTGCTGGGATTACAGGCATGAGCAACCGTGCCCGGCGCCTCACAGATTTTAAAAGCG +TAACTCTAAACTCATTGTTAGTCTAAAGTTATTGGGTTTTGATTTGCTTACATAATAGGG +TTTAAGGAAAGTCAGCAGTAAGTTTGGCTTGGTCATATTAATAATAGGAAATGAGCCTGA +GTAACATGGTGAAACTTCATCTCTACCAAAGAAAAATTCAAAAATTAGCCAGGTGTGGTG +GCACATGCCTGTAGCCCCAGCTACTTGGGAGGCTGAGGTGGGAAGATCTCTCGAGCCTGG +GAAGCAAAGGCTGCAGTGAGCCGAGATTGCACCACTGCAGTCCAGCCTGGGCAACAGAAT +GAGACCCTGTCTCAAAAAAATAATAATAATAGGAAGTGATTTTAAGGTTTTGGTCTCAAT +ACTTAAATATTTAAATATTGTTGAAAACCAGTAAAGCCTGGATCATATTGCATCTCAAAC +TAAAAACTGGAGTTCTAGATTTAAACACACACACACAAGCTTTTTTATTTGCAGCTGAGA +CTACAGGCATGTACCACTATGCCCAGCTGTTTTTTTGAGATATTTGTTTGTTTGTTCATT +TGTTTGTTTTTGAGATGGAGTTTTACTCCGTCCCCAGGCTACAGTGCAGTGGCTCGACCT +CAGCTCACTGCAACCTCCGCCTCCTGGGTTCAACTGATTCTCCTGCCTCAGCCCGACCCA +GGTGTGAGCCACCATGCCCAGCCTCAGCTGTTTTTATTTTTTTATAGAAATGGGGTCTTG +CTATGTTGTCCAGGCTGGTCTTGAACTCCTGGGCTCAAGTGATCCTCCCACCTTGGCCTC +CAGAAGTGTTGGGATTACAGGTATGAGCCACTGTGCCTGGCTACAAAAATTTTTTCTTAG +GATGAGGACATTTATACTATTGTTTTATTTTTGGTTGTTGTTGTTTGGGTTTTTTTTTTT +TTGAGACGGAGTCTTGCTCTGTTGCCCAGGCTGGAGTGCAGTCGCACGATCTCGGCTCAC +TGCAGCCTCCACCTCCTGGGTTCAAGCAATTCTCCTGCCACAGTCTCCCGAGTAGCTGGG +ATTACAGGGGTGCACCACCATGCCCAGCTAATTTTTGTTTTTCAGTAGAGATGGGGTTTT +GCCATGTTGCCCAGGCTGGTTTTGAACTCCTGACCTCAGGTGATCCACCTGCCTCTGCCT +CCCAAAGTGCTGGGATTACAGGCATGAGCCACCACGGCTGGCAGAATCACGTCTCATCTC +TAACTCCTCTCCTTCCTCCTCCCTCTCCCCGATTCTGCGGCAGATACACTAGGCTCCTCA +GAGTTCCCGAAACACACTGGCACACCCTTCCTCAGAGTCCCAGGGCTCCCTGGATTTCTG +CCTTAAACTATTTTCCCAGAAATCTGTGTGGCTTGATTCTTTACTTCTTTCAGCTCCCCG +CTGAGACGTCACCTGGCCATTATTTAAAATAGTGGTGTTTATTTCTAGCTACATAATATG +CTCAAAGGCAGTAAGTGGAACTGGGATTCCAAACCCTGATCTTCATCTTCTTAGCATTCC +ATGTTTCCCTGTGGAACTCTTCTTTAAAGCTTATTTAAGAATTCTAGCCGAGCAGGGTGG +CTCATGTCTGTAATCCCAGCACTTTGGGAGGCTGAGGTGAGAGGATTGCTTGAGCCCAAG +AGTTCGAGACCAACCTGGGCAACATAGTGAGACTTGATCTCTACAAAAAAATATTTAAAA +CATTTCCAGGCATGGTGGCATGTGCCTGTAGTCCCAGCTATTCGAGAGGCTGAGATAGGA +GAATCACTTGAGCCTGAAAGGTTGAGGCTACAGTGAGCCATGATTGCACCACTGCACTTC +AGCCTGGGTGACAGAATGAGATCCTGTCTCAAAAAAAGAAAAAAATTATAAATACATAGT +AGTATATAGCTGGGTGTGGTgatgcaagcctgtaattccagttaCTCAGGAGACTGAGGC +AAGAGAATTGCTTGAACCCGGGAGTGGAGGTTGCAGTGAGCTGAAATCGTGCCACTGCTC +TCCCCAACCTGGGCGACAGAGTGAGACTGTGTCTCGGAAAAAAAAAAAGAAAAAAAAAAG +TATATACGTGTGTGTGTGTGTGTGTGTGTGTGTATATATATATATAGAGAGAGAGAGAGA +GAGAGGTATATACATGAATCCAGTGGTTTTTGAGTTTTGTTTTCATTCTTGTTGAGAAAC +TGTTCAAATGCTCTCTTAATTCAAAGTGTAAATACATAAGGCAGAAAAAGGCAGAGTTAT +GACTGAGGCTGGGTTGGGGGGCCTAAGCCCTGTCCCTTTGGTTTTCTTTTTCTTTCTTTT +TTTTTGAGATGGAGTCTCGCTCTGTTGCTCAGGCTGGAGTGCAGTGGTGTGATCTTGGCT +CACTGCAACCTCCGCCTCCTGGGTTCAAGCAATTCTCCTACTTCAGCCTCCAAAGTAGCT +GGGATTACAGGTATGTGCCACCATGCCCGGCTAATTTTGTATTTTTAGTAGAGATGGGGT +TTCTACATGTTGATCAGGCTGGTCTCAAACTCCTGACCTCAGGTGATCCGCCCTCCTCAG +CCTCCCAAAAGTGCTGGGATTACAGGTGTGAGCCACTGCACCTGGCCTACAGTTTTTATT +TTTTTATAGAGACAGGGTCTTGCTATGTTGCCCAGGCTGGTCTCAAACTCCTAAGCTCAA +ACAATCCTCCTGTCTTCTGTGTCCCAAAGTGCTGGAATTACTGCACCTGGCATTTGCAAA +CTTTTTAATCAGGCTGTGGTTGGCAGTTTGCCAAGACGATTCCTTGTAGATCTGATTTTG +GCAGCAAACAACATAGAAGTCGTACAGGAAATGCTAACAATTACATGTGGTGATTTTGAG +AACAGCTACCAAATTCTTCACTTTTGTATCTCAAGCGAATGTTCAAATATTTTTAAAAAT +TATTTTTAAGGTATTGACTTTGCCACTCGTAAAATAGCCAAGTTGCTGAAGCCACAGAAA +GTGATTGAGCAGAATGGGGATTCTTTTACCATCCACACGAACAGCAGCCTAAGGAACTAC +TTTGTGAAATTTAAAGTTGGAGAAGAATTTGATGAAGATAACAGAGGCCTGGACAACAGA +AAATGCAAGGTAAAAgatgcaagcctgtaattccagttaGATTACGCTTGTAATCCTAAC +ACTTTGGGAGGCCAACGCAGGCGGACCACCTGAGGTCAGTAGTTTGAGACCAGCCTGGGC +AACACGGCAAAACCCTGTCTCTACAGAAAAAAATTCAAAAAGTAGGGGGGCGTGCTGGCA +GGAGCCTGTAATCCCAGCTACTTAGGAGGCTGAGGCAGGAGAATCACTTGAACCCGGGAG +GTGGAGGTTGGTTGCAGTAAGCCAAGATCGTGCCACTGCACTCCAGCCTGGGTGACAGAG +TGGGACTCCATCTCAAAAAAAAAAAAAAGCAGTAAGTAGGCTGTTGATTTTGCAAGGGTA +ACTTGGCATTCTACTTCGTAACACTTGAGGATCCTGCCAGGACAAGCTAACATTTTCTCC +TCTCTTCATGCAGAGTTTGGTTATCTGGGACAATGACAGGCTCACCTGTATCCAGAAGGG +AGAAAAGAAGAACAGAGGCTGGACCCATTGGATCGAAGGAGACAAACTCCACCTGGTATC +CACCACATTTTGTTCTTAATGAGATGATACAGTATTAAAGGAAACATCAGGCCAAGCGTG +GTGGCTCACACCTGTAATCCCAGCATTTTAGGAGGCCGAGGTGGGTGTATCACTTGAGGT +CAGGAGACTAGCCTGGCCAACATGGTGAAACCCCATCTCTACTATTTTTTTTTTTTTTTT +GAGATGGAGTATCGCTGTGTCACCAGGCTGGAGTGCAGTGGCGCGATCTCGGCTCACTGC +AACCTCCACCTCCTGGGTCCAAGCGATTCTCCTGCCTCAGCCTCCCGAGTAGCTGGGACT +ACAGGCACGCACCACCACACCCAGCTAATTTTTGTATTTTTAGTGGAGACGGGGTTTCAC +CATGTTGGCCAGGATGGTCTCGATCTCTTGACCTCATGATCCACCCGCCTCGGCCTCCCA +AAGTGCTGGGATTACAGGCATGAGCCACCACCACACCTGGCCCATCTCTACTGAAAATAC +AAAAATTAGCCGGGCATAGTGGCGCATGCCTATACTCACTCTCATCTTATATTAAATGAA +ACAGCCGAATATTCCGACAGAGGCAGGAAGATAACAgatgcaagGctgtaaGtccagtta +TACCTATTATATGTAATAGCTACTTTATGTATACATAGATATGCATAGATAGATATAGTA +GCTCACATCTTTGGAGTGATTATTTTGGGCCCAATTACTGTGCTCAATCCTTTGAGTGCA +TTATCTCATCTAATCTTCACAACCCTGTGAAAAGGACGCCATTTTTCCCATTCACAAATA +AATTGGGATTTTGAAATTCCCCAAGGCTGCTGTCAGAAGCATCAGAATCCAGTTTAAAAG +GGTTTATTCAGACTGGGCGAGGTGGCTCACGCCTGTAATCCCAGCACTTTGGGAGGCTGA +CGTGGGCGGATCACGAGGTCAGGAGATCAAGATCATCCTGGCCAACATGGTGAAACCCCG +TCTCTACTAAAAATACAAAAATTAGCTGGGCATGGTGGCACATGTCTGTAATCCCAGCTA +CTTGGGAGGCTGAGGCAGGAGAATTGCTAGAACCAGTTAGTCGGAGGTTGCAGTGAGCCA +AGATCGCACCACTGCCCTACGGCCTGGTGACAGAGGCCGTTTCAAAAAAAAATAAAACAA +AATAAGGGTTTATTCAGGCATGAAGATGAGAATGGCCACCCAGGAAACACAGACTCCAAA +GAAATGGGGTCAGTACACCCAAGCTGAAAAGTTAATGTCTTATTTTTTTTTTTTTTTTGA +GATGGAGTCTCTCCCTCTGTCACCCAGTGTCACCCACGCTGGAGTGCAGTGGTGTGATCT +CAGCTCACTGCAACCTCTGCCTCCTGGGTTCAAGCGATCCTCCCACCTCAGCCTCCCGAG +TAGCTGGGACTACAGGCATGCACCACCACACCCAGCTAGTTTTTGTATTTTTAGCAGAAA +CGGGATTTTACCATATTGGCCAGGCTGGTCTCGAACaatgcaagcctgtaattccagttg +CCTGGGCCTCCCAAAGTGTTGGGATTACAGGCGTGGCCGCTTGTAATAAAAATTTAATTT +CTTGGAATGTAATTCTTGGAGTTTTTCTTTTCCTTTCTTTTTCTTTTTCTTCTTTTACTT +TTAAGCTGTTGGACTTGAGGTGTTTTTCTTTAATGGCTTTATTGAGGTATACTTTATGTA +CCAAAAAATGCACCTGTTTTAAGTGTACAGTTTGATAATTT \ No newline at end of file diff --git a/tests/offtarget/data/miniref.fa.amb b/tests/offtarget/data/miniref.fa.amb new file mode 100644 index 0000000..a38f1a3 --- /dev/null +++ b/tests/offtarget/data/miniref.fa.amb @@ -0,0 +1 @@ +10001 1 0 diff --git a/tests/offtarget/data/miniref.fa.ann b/tests/offtarget/data/miniref.fa.ann new file mode 100644 index 0000000..6dc2d30 --- /dev/null +++ b/tests/offtarget/data/miniref.fa.ann @@ -0,0 +1,3 @@ +10001 1 11 +0 chr1 (null) +0 10001 0 diff --git a/tests/offtarget/data/miniref.fa.bwt b/tests/offtarget/data/miniref.fa.bwt new file mode 100644 index 0000000000000000000000000000000000000000..6efb5de78d3a31772583a5ff50d2f45297f7ed09 GIT binary patch literal 10100 zcmajldt8j^@m|CZYY%5vX_&xysT+B)Z&2#s{Gh=(ve#5i};hi##a8L#ERcC)+XRRl@%*dq}FE5c!o5_;nZ` zf-XUi-`;lP$$;=4gh&T{<)9~ezV?ory_b9~{VFC(YE!)iJ{RMa=z8>7618-^ZM%qe z4n5IvcvMVWn>3Q+NYr$$$Ts5pEUJOOhw(q4+tG|kK?+finIv8FBGmPBpUSnO;>9KU z@^X0(kton()U^uuYK%`q522LSU*2V-Nb=As=0?)X{bqIO$7t!^@QCDfG~6(>w*|gG zng@3dy&&J0ldX>4pZ9Xu<7ju6u6xSF3n$Rr?SVv1I@WH>@N0M-+%42e-{pgR zzda%A#KKRkGu{}sxYIpb z_wQ|fASD4QQ`da62YxAht80hnHPSw}Y{SlllzPmjd-es6 z({&WL%}r@DK^#uEUnk#oFmTQfPUJ1!K2D49xQy`w(RehP`|0?|JDtOZUc5vWKU#f$ ziI}YFGqIsia;(}&LufGkON<|Z?nM(4vPQ98;WT;^W0V?$ZC6mE+#}jfIxwlPLP-u! zgYS#ap@2J#rgZtvrgXC0lTD*Kn~p?7mgLh&N)=foYp#DZy{Ceo3BLgDBpRtqjHfh9 zF*M)QdEVId5a=w2&6~G+vcZq)-G71q8GZwt5w*D{leJS&Bbkwt!-X-WGb3;3 zydAk#NvR*DbCqY{OW=QndyPJf>ag>`Sa;k0HO{4bN~R<|ii%moFKOt#4>p;?Yn-?X zZ-Ljs+2c96(BaL?yAHN>3Z;6hkH0T>+hg~wW8qk4B1w{sFVte&PwUSEuTuu^37@;G z&0Md%^t3`PW12{bh847Mt~-`@(_?%v+J` zU2E%|&f)^*tC=Y)@8Ikq@S*TQaHG-co!=bjrk8FN*8Oe!#l{)IzrX#?u|N~5X2SbP zSsfbzFKkx-J?6D87iHbzijDK?4Plhhn5emwMjAszkumq=848-^HnYQb@TcHc!p%Wx zVT{rL5v9uA#yh>{J@&D1uCi<5JoJ{`(gKgkmsY~RhEIpvj3(^bzcwVqNU6$W9?hEC zXkk@!mh7|kA3u**1a+gz4KV{+qILPAF6 z7S@v{&}a-KF?5rn7Oo1N0{;l!O>x3KyH{K3bC&LZpbc9TcQoGNAaj3B;?Ia(M9FChE&CkB3ME_@^uNQr@RCk= z9UH~a)I6^0dFNj%m)dV3@~lhs|C2hjW$nAA4IFL5Ho*C!d*Hr65BFX_iv8q$PaOpe z`hEMQ(!=gP-1lf5+(fin-A9ccd zh=HeFwHl>x{Ih%(v*e!>q55&5zU{MPX+lTOvU7|z`nt6FH2+5Ue(;I#rzJTOl9N&_ ze1m#PAJ<+vWp)!$B-i-gZ>f?JCI4L1q>TNPD*;m(-8Pi9@6OqFx))2y%VR~?gFZQ)A# zRHYZb)&H9|k_%r0_XFy0P|Q^b8_vKz7M?#SWlcD1{AT~R&BoqzN#ozL7QDd;~!1=ccnZ{;%ksdw3rlGO$t;Zubk-l3G^%UV)TPEy1F^S)}*D2@fz2x!_{BP=hwoL|HYZt<| zVEHX+arca=jyD*~WlU0-PS?-RA2(=H^1vLk*KUoQS0Q{Zng`z=&AlJJht|h*rU^B3 za=)%`_rRQMs-i6! zPu5raZ>jk9Ib0q31b#dkBAjH5VyuQe|B#VzFzLXe6I&+)^mVlf8zfqLo~3mo!jAp% z(Ft|KdYlH|qU<};P3Nin@~N$ZMxB%P?2m|3#vR#J(bFjvu63lJ!;M6J;N#)x37J)R z?_o4fF+JSZo;a8XM z4;QwbIli*N3+oK|6mAoy$IoNIT9asn+$3Z_!RHli}pH4!kD8yYtb?#6Qu_D zUYBHc5T_R=IeHo3>QEbO2Xc6>E!N2MuD7t~T*P%34NUd+J9hqhpHpAD6#Fpd@(NBk zuKxG%hhG5yR(HL@fw5wC#8I;%Cc!&<#w)k3w_{VfKR%si*r}|s#RacL_H+qZK3AW?*wqJ~ zm0A`97mwb6e+IAm)tm5$jTLdZn>jP>JgXT$d;Ort=9+VpR$uXq{%qk2xJ=Xx?|=@8 ze3@b8-1T$LTXvp!Wz5T;_7e*(VDyx53={UvHE_k~XIKwI;3X20JfAAFZm^%4J$@{1 zAmnx!(WesOnC`qXF^e9AD@TXGkB4t6O$rIIQM@5RbTwaDFQtR6%|%vOGM{dY<;!et zmB86Mep>%u!Y9Lvg|FOIrC-*&J+ttzc?x=Vg~LYgtbFV4R`T^8RxQ z88uSs1$PO(0&j%BHI5qj25M(aXx^C~e4+N}gxZ_o%iq=>y^?j{w5Mon09-X%3I759 z(a;|8cIl>TouAz|JPo#WYrIi4|B6ncy++brHu2s4BH?V?eVYHan9rVgo!QQ{o{Pk* zx#RVMUd3(C@y_}aqxHW;x7p-O$SvPDzpRDt4(|yc0&gCnYVV}ipSAb}-4d=!ku$G{cmi z`3!yl{5SBe$2BjaCr=sSvE1Pdsi^QgXYl&((zbb|KHKYP3-^ZeL+8M^u7`aAVj{9v zn?%LKX0k1R7o9%Hnw(WkQf|&QDg}GznKc_CSJ!4u45hr z5!sg4`f}krgpc6-d$@_{Uig8itmHw{pS9Js8K$Qr_!bt-@}8Em*uWf(gh|Y=)D5X{ zVdx#WDD(wal?Q!hEzoMxBqmGLpAt0_J@a7_??aOGbf(qrg^NbrFrUleZ#bzloO*k0 z&76=fvhu9lr%F+gD5pWnFJ+w}(mBI#!?k`6U-&Heii_e5Qb!1Lb#fGT16HtEq#3yA z!SF8}2b|?+D!n-TVsr-lG5F$!A)0h15w4icS`pdCb!Jzcy2*FAkLfI_eqUec<@9O0 zT@Jqvz6xG5r&hS(Ad?Wn`7e@w#B}6z`cpsGlSfi=+0i2_j>zCw!k>b7MmcW|QpuWl z@F7E!DmWl@r*PeMnz0u$`$1>&wCrXCyc+%`+*jyzi8op1q<3)|`G?xEwzoKsu^d^k z?~&$Nd)}6)%D-I=p8@ZJE`VPx+%Vv-UG+pLU1G z;QFFF;cu7|oaOB&Wss%5y-A%7QHi94tIY{RFPhCIeFpY?1)m2u7`*^5S(TEZO>aC` zQlt*hde^XkrgkcgC?`!WOuH+)XzM8)2R=SDa5K?|@T|AhOTsj|T!|!C!v-DSr(+jS zJL-osX8(mGkEM)&KLl5RzJ*T@W_9G~E@rigWvO0zSK(Fyd42peb5Rd`Rihi|I2XPM z?jN)Rn$&Fhw`!{9zTaVQu{h6bX26lMlor0sHo`~JRLN4T8{u^r-+~6Bq_#Kz_}*Tq zH7OE}cOa1*k!x9QAourHH#Hj)|9~&W_yL&DC_L9!b1q)!YQMO-QRP5VTNv{e7o;<$ zZ5ME!oo;;mH+&h!PlsQF=Vf*FnyW$A4Q)t8rf^Ro$h6sB?QoDO+5#2nbtoaQnH{vmt@{9Qb6der=n zSE`j0FJU=ldf~>#ryG3w9|yEm>3Q`g_$rLw4&R99R-M)N*ntiXTsYvxD;}tXt@(nk z*20Bz64k$L$Gppdw_yBU_=zZQF(~I z?}+iI(ALi(A*w|4mS9uKd)@UHWljy1V&bG568u!S3CU|XH{F4Efit4lQDU3ASv-)7 z$yJ@SRwi)~r}2wsgTBml>&al}G9QzZ>!jQjfn;7 z!ve-AhclB*R>T?yv(pU$;!O1bQI6ht4Bi)hC0rhwu9qz)JqFEF2l~1ZQH3N+_nj#2 zkC;Z{_O6fx4JTFbqu}?#okb2PwmqYB9d=Rbr}q_G(-}EW#I+hf_%JNL3C;%FMfJt& zV#bB5SOPiZTcj53hxa!5)G7+-&%N2DQj$Ttqc;|PqG#N$~UG1L5YOP3N7R7Pqxrt!ogIGA|t;z2HRf)Vt$?6K~EvFfp;e zI0s%09}2e>Ew1k~<-C*Q5#}coZh1>i8-ClrE1y}$(6Hn@H3^}5_}%a;;TT$CaSFBk zU)P-2d#XQFX3kwU%_;r)+PNP8d+0j0$;LDOG5i_$Ot=T=KAQC|X7ZfLs=IV@Y*&No zS;B2Pes(h7aqRE)|FcX|JXR|v)fr+dY@J?lp0mtQ+EA^89|BL{5?h!5!n)VMYqi3; z{v(?BCg#m+WmrqB|B^jcm0^bRwq+LlY~EozjF%MN|8!j1{YsyXEAogSmY>_UWV zkILR;Q&e)pUn%fe@O|J)TYbIoTcQv5>VNqCU>xl{YL<=ieEgN$vp&#ecG36gaK(0b zBm8i<3Uu0uo=eEUqcI`MQNpjWvXZX^w=u#aTwJdhMIZLPl+ZC@T_^K}5W6;QDd*70vO?JBG6h^OGl%1cr+8%r5&fYka2G*pCutdN&!XJlM!6&L@GD%;*M{Q^P zsufOx4OG;?J)(BZXU#gl&pI7o^WeL|-9n4u;}<1RUKu-JW2Zm4p`zKj>7AKNd_=+> zxl`@;OxtyK6@2UW&^!2t@H8vNZs@D3Mafi2*KPTNdKad>`0e?&#?EK5JJnV_&~Acn zo%b$q7Bn=0QZ`_t6Uo;#BqYQxNl-e6)mwgi$qq8U_x*^!_rd>+S zS4CHq<++>p{X^+p_)7RV_!xMWcylrJBZbbw1-xz5F5wVrGvdTwJEA8h3cJG_&THV? z!)-v5;B!V>c38D0ns|yy5-u;@rkxgVxp=nH`5zfCwE48~ciBjzz zVc+%WuSlS(g?-fh;fvs&qL*8jpU~deZiZy$f)*}o{&3G&rJVld>mL@&3x;d%?poWw zA{hQY97kWmr+e-EIc`i=ezx!nV8wtfZElw*{OCM2)*)L&6kl&Pt7gEr!1aaWXeZ%B zZOXeDDs%m#W#y}ds{p;VuG(yHt=Msmv zv&@ThIuz+k)D2vhWeql;J_6qx?rU^D{7Z>>aM@zNd%Wjk{*|?yFK){@yYGFnuZCT> z@O^Kt!283^M)$+ZsE5Ls6kF-ktz!1>hov@;yVn0{%Ys`T`|R;woP0d75+9&t_|J2Wb35hX4N>Ea&EnEb1$ki`-ZC8;_su@!mUBy!3(#~_zwRSx*fW% z(_g>%yaPK*#GOO5!j-tLZeX7!tS9_#I1SnZ@5}NbtJ7T%a_i+dZu+$_w_CZe-LLM< zc~9fC)H9DSa4CE#TnRcJo|l)GuAiXd%db3Ez1lIjt(gxv^jD^tcN{i!>#b$xz=`mW z;r>EX;lp`@a0~F<$YViumaq4}Jz+MK5R{P1&r)7hPy-!@5Fge#u6;|@G@wf?JbQ}?#SI)sdW zKl$i6jFZ3*Ko_-oKE-MuR9Dlpyz(Ny!3}kF2JtX!(>p6S7@AeQ`98)Cfop9^t8bAB Ww?C}q!fv2%{aZRejBRb}&;K7xA^JT4 literal 0 HcmV?d00001 diff --git a/tests/offtarget/data/miniref.fa.fai b/tests/offtarget/data/miniref.fa.fai new file mode 100644 index 0000000..18201e5 --- /dev/null +++ b/tests/offtarget/data/miniref.fa.fai @@ -0,0 +1 @@ +chr1 10001 6 60 61 diff --git a/tests/offtarget/data/miniref.fa.pac b/tests/offtarget/data/miniref.fa.pac new file mode 100644 index 0000000000000000000000000000000000000000..3847df7412ac84ef5b1c84f4533d47a532a4c392 GIT binary patch literal 2502 zcmX|@e^iq99>*WSj+vT@cGzzDBY!LjQ7SG#HAQ^({!PxaobHX#G&BAA9ztjp8HTc_wzUBdmi3DUY`$b z=2`Vf&CvPS+01>Djii^S(Lul>bgAxA125pnM#`ELgR^vIb5{J@2&g_Y!_`EvA9>e{orX`6uF zByJt(I-bUgbyk zC0sTVHq8%uPxvk-rEOhPhm{NU*pbfU=3ruL|JF-1-k3hDvR&sOdTY=0O`_@ZCg9`b-w$yj#TBP6-=JR2D7o6B!CZ@*iMedo1SffSihoXgL^vo9h7w~A ztOCNymUui=(&B9uDc^T?Jw*jKF`Ms9(V9#=hNs#q0^sg-8xs>_M7XYy4yJGpPds0td_BgH^IC3 zapQFshNyY3=cP~XPwl8B3tVsR)$Vk~`BCE&XPwW|lb)cfN?PeEpJ&|Ajes3kWOgbW zQhBk=VrO-U@=EP^6;zR-5E&2GH;_f6lJC7Mg@x@IfYyI^#gq&0Za)x#CZY)|UO6bR zHrX`prSXwDqUVEygMHzp)g8iOTw3Va#vR?TP-nB*a&0zuv$9wdgWql#%u;V*Q@0n(H&6pq?$}ZGX?$esV`iWy+x@Ahh z>)92@g2UciQ1aCB>^4e=CMrZ8;a^e=NAG1 z5CEvbf4zP)gM)*8LS$|Y(gSktxD(gb_pcefY#C;*b|^v9`%nbnqLwaHLtQr|zTlX< z+pxY4!^VBPUQDcxNNYq^l|ezi$STtRFm%yzv@CYiZ^w-Ve46-RqatvPxY$?sgCnGC zkK*=FSP2VXTf{*C=}{K3)dWf~unry+(%Q{w(@5aG^u9wudeFeTpV2XsUW%2wGs@>j zq}JKQ>5V7!bG1`$QAXx&zt3psrl5s1CDG{Jl(*krJ~!@L`2bCvN9$a8Ld0}ftG_r= z>YFW4t%x??aJUXK-oa2$UnzD^5>d{`%9Y8lF*qoQg1&eQmFmk2WG<$koZH)jAShGx zT_}jpNi>EciT)HQY%h)k_5<-ZubbsEP@CWMH)O3M1|X8E@m5o-IQnyjiGw&0WeNLe z2mk=l!za8cch;>4xg+AU;FW72^V|+^m@{rS)i{@+b05&y;LntuksBi^imL}_{vCJD zhXMUe3UNEM!YPanCHM%J^|_i&LefBdf;+D6+{4Gyw)aDMbk&A~+-)9+2xsn{>?9!! zN%$6FNa==A7IfdY-m`QLw?P*$4GpAVgdubAfb0)QGbtEC z&IJL&*1yXKuG5b`CbJF6nn}#zJ$&Rm9PsFs6%JtmmpfwgZ4B z$bYBHE}yK6oF&n`Fku-2%a3=r_)hw1+jWQ-o|~kQN6Psz|D?>Mex2gsMk&(ox7!V2 z^fnru6iT8FAW)4FoFYC-e#7X^o_chFEeNBLEv!DW<`1}t{K{6xOt)jbyxako!4dOP cXGFDa-L5)8@L`sMvU3Jxk$v8VCqsy-DxlNflQR}pgBV=;faXH2k zIn$0U3~BCknzZV%9h}n$RpTbj53jYQfc4*gj5pk8>GeioZ7ss~v@53Bt!Q!V)XtPyL0{0))FK3qQ6O zwvf-xU$K{qu!B53BNbnY7RHVirg;d9dJ8M4JCi)2)OXfZ-~Spftm-RlgO6wQ=S96| zh|8#NpQrY_1PZr=3yt(~qnG00)M*u~_#*dzHA->A7-1Ug6M8Cc9WETnJ;-9%mZbIV z@Uvi`;=&N&a{9DE-y%1yS4|Y24;P-W6Q=kHEw2b4n1z+pGlTpk#5Gg&-Ijb#)RBxY z`m?_ko@UUuZ?Mi8=x<}9;?#b^_t2>$RPlN0PlU(hAg%w*IU~9pfv0klzB}3qQ}B5c zecp$kG<>w2I-gVj=^i?_Wti}vKEgrN^NfDB;@f=s{hIT|gSG!}cuAqp-PAh|y;s|4 z-x4oj2K(CatqFf0AlOb+*v_cY(FuDPRxD#_@s=! ztKlJveJK&z=Y&pP)VbP0>vsf(Kj8BsI=4`NAn(v8oGUczfDQa^rv6R%?i}w@F#TB2 z@zF?~E8yP1Zi?fagumf`BYoWq()u8HKTAIm)Mw^>xPo36$bS?coeI(Uc6e!JeG~7` zX6CsVz8&B(3!SDjr@7=kW3PON(4&;!neOOV&bfH@nc}p+ls^9K(usMu!+-m^CyMWF z@EnV7g}k2u@yhoFeKdF~_F$ef0~Fuk{(1Cu6CdrsKk1y?2@md^Z!_!sH`LP!f2ruv zh_8H^rwaNV0UyQWxdE>);QbW$J>h!*=kkrZZ!mo>rayalxy$d^8hHE=Kj!g{Sm6tA z`m7nHd?&jLbKuc}uX=fFeK9=EnlvR6K+s=3pYHIzsHe`~4HZ_P`;~abPR#p-D8;ks zr=EHapl1;C;*SrnG5YwPxYjShdlLQjfcJ&y>4!fy^Ly9X zL+6(9J{+dr_o+V{zpRI^I?k=*-8sbjF$-PSPgTAX#Bu0#fWH?jcrQxnzZqR~;o&s( zW%7Q$gD->WXF-tiFX8vViagKBA4%Qr+}}i<&Ghvv^Ldy$F5>SZ;@kA+ioQ3|H4ERk zp-T=vS%wZnd&48XD@7Mmgw{>eyA6Fj$Xm&~(m{PcP;WVWf681}k+;rG`TXH&rl(>z zc&et3LHIBYJ}mUP##{SeCBHrV9i!i1=I=UuGuDRD_;9X{8|tU%KZ*3;PW_S0%`p5H zz`k?nW5RDapjVRe_z?d{omuqLKtH9tO9#OO_NOwJLHK;BQTZ&qm-)%Nu3&!*`%2(<1M7*@v6A~M@kIu@z5yP?4@P(? z!tZ(HG2+t#{Pd9b$e(`7@Q*$B?Lo&9{8}5L{(GT&!C1ws;IA2iZ9QQ=54J literal 0 HcmV?d00001 diff --git a/tests/offtarget/test_bwa.py b/tests/offtarget/test_bwa.py new file mode 100644 index 0000000..daccd13 --- /dev/null +++ b/tests/offtarget/test_bwa.py @@ -0,0 +1,256 @@ +import shutil +from dataclasses import replace +from pathlib import Path +from tempfile import NamedTemporaryFile + +import pytest +from fgpyo.sam import Cigar +from fgpyo.sam.builder import SamBuilder + +from prymer.offtarget.bwa import BWA_AUX_EXTENSIONS +from prymer.offtarget.bwa import BwaAlnInteractive +from prymer.offtarget.bwa import BwaHit +from prymer.offtarget.bwa import Query + + +@pytest.mark.parametrize("bases", [None, ""]) +def test_query_no_bases(bases: str | None) -> None: + with pytest.raises(ValueError, match="No bases were provided to query"): + Query(id="foo", bases=bases) + + +def test_bwa_hit_mismatches_indels_gt_edits() -> None: + hit = BwaHit.build("chr1", 1081, False, "20M5I30M", 0, revcomp=False) + with pytest.raises(ValueError, match="indel_sum"): + assert 0 == hit.mismatches + + +def test_bwa_hit_mismatches() -> None: + hit = BwaHit.build("chr1", 1081, False, "20M5I30M", 5, revcomp=False) + assert 0 == hit.mismatches + hit = BwaHit.build("chr1", 1081, False, "22M3I30M", 5, revcomp=False) + assert 2 == hit.mismatches + hit = BwaHit.build("chr1", 1081, False, "55M", 5, revcomp=False) + assert 5 == hit.mismatches + + +def test_missing_index_files(genome_ref: Path) -> None: + # copy to temp dir + with NamedTemporaryFile(suffix=".fa", mode="w", delete=True) as temp_file: + ref_fasta = Path(temp_file.name) + shutil.copy(genome_ref, ref_fasta) + + # no files exist + with pytest.raises(FileNotFoundError, match="BWA index files do not exist"): + BwaAlnInteractive(ref=ref_fasta, max_hits=1) + + # all but one missing + for aux_ext in BWA_AUX_EXTENSIONS[1:]: + aux_path = Path(f"{ref_fasta}{aux_ext}") + with aux_path.open("w"): + pass + with pytest.raises(FileNotFoundError, match="BWA index file does not exist"): + BwaAlnInteractive(ref=ref_fasta, max_hits=1) + + +def test_hit_build_rc() -> None: + hit_f = BwaHit.build("chr1", 1081, False, "20M5I30M", 5, revcomp=False) + hit_r = BwaHit.build("chr1", 1081, False, "20M5I30M", 5, revcomp=True) + # things that stay the same + assert hit_f.refname == hit_r.refname + assert hit_f.start == hit_r.start + assert hit_f.end == hit_r.end + assert hit_f.mismatches == hit_r.mismatches + assert hit_f.edits == hit_r.edits + assert hit_f.cigar == hit_r.cigar + # things that change + assert hit_f.negative != hit_r.negative + assert hit_r.negative is True + + +def test_map_one_uniquely_mapped(ref_fasta: Path) -> None: + """Tests that bwa maps one hit when a query uniquely maps.""" + query = Query(bases="TCTACTAAAAATACAAAAAATTAGCTGGGCATGATGGCATGCACCTGTAATCCCGCTACT", id="NA") + with BwaAlnInteractive(ref=ref_fasta, max_hits=1) as bwa: + result = bwa.map_one(query=query.bases, id=query.id) + assert result.hit_count == 1 + assert result.hits[0].refname == "chr1" + assert result.hits[0].start == 61 + assert result.hits[0].negative is False + assert f"{result.hits[0].cigar}" == "60M" + assert result.query == query + + +def test_map_one_unmapped(ref_fasta: Path) -> None: + """Tests that bwa returns an unmapped alignment. The hit count should be zero and the list + of hits empty.""" + with BwaAlnInteractive(ref=ref_fasta, max_hits=1) as bwa: + query = Query(bases="A" * 50, id="NA") + result = bwa.map_one(query=query.bases, id=query.id) + assert result.hit_count == 0 + assert len(result.hits) == 0 + assert result.query == query + + +def test_map_one_multi_mapped_max_hits_one(ref_fasta: Path) -> None: + """Tests that a query that returns too many hits (>max_hits) returns the number of hits but + not the list of hits themselves.""" + with BwaAlnInteractive(ref=ref_fasta, max_hits=1) as bwa: + query = Query(bases="A" * 5, id="NA") + result = bwa.map_one(query=query.bases, id=query.id) + assert result.hit_count == 7508 + assert len(result.hits) == 0 # hit_count > max_hits + assert result.query == query + + +def test_map_one_multi_mapped_max_hits_many(ref_fasta: Path) -> None: + """Tests a query that aligns to many locations, but fewer than max_hits, returns the number of + hits and the hits themselves""" + with BwaAlnInteractive(ref=ref_fasta, max_hits=10000) as bwa: + query = Query(bases="A" * 5, id="NA") + result = bwa.map_one(query=query.bases, id=query.id) + assert result.hit_count == 7504 + assert len(result.hits) == 7504 # hit_count <= max_hits + assert result.query == query + + +def test_map_all(ref_fasta: Path) -> None: + """Tests aligning multiple queries.""" + with BwaAlnInteractive(ref=ref_fasta, max_hits=10000) as bwa: + # empty queries + assert bwa.map_all([]) == [] + + # many queries + queries = [] + for length in range(5, 101): + queries.append(Query(bases="A" * length, id="NA")) + results = bwa.map_all(queries) + assert len(results) == len(queries) + assert all(results[i].query == queries[i] for i in range(len(results))) + assert results[0].hit_count == 7504 + assert len(results[0].hits) == 7504 # hit_count <= max_hits + assert results[0].query == queries[0] + assert results[-1].hit_count == 0 + assert len(results[-1].hits) == 0 + assert results[-1].query == queries[-1] + + +_PERFECT_BASES: str = "AGTGATGCTAAGGGTCAAATAAGTCACCAGCAAATACACAGCACACATCTCATGATGTGC" +"""Bases for perfect matching hit to the miniref.fa""" + +_PERFECT_HIT: BwaHit = BwaHit.build("chr1", 1081, False, "60M", 0) +"""Perfect matching hit to the miniref.fa""" + + +# TODO: # of indels +@pytest.mark.parametrize( + "mismatches, end, bases, hit", + [ + # Perfect-matching hit + (0, 1140, _PERFECT_BASES, _PERFECT_HIT), + # One mismatch hit + (1, 1140, _PERFECT_BASES[:30] + "N" + _PERFECT_BASES[31:], replace(_PERFECT_HIT, edits=1)), + # Five mismatch hit + ( + 5, + 1140, + _PERFECT_BASES[:30] + "NNNNN" + _PERFECT_BASES[35:], + replace(_PERFECT_HIT, edits=5), + ), + # Deletion + ( + 0, + 1140, + _PERFECT_BASES[:31] + _PERFECT_BASES[36:], + replace(_PERFECT_HIT, edits=5, cigar=Cigar.from_cigarstring("31M5D24M")), + ), + # Insertion + ( + 0, + 1140, + _PERFECT_BASES[:31] + "TTTTT" + _PERFECT_BASES[31:], + replace(_PERFECT_HIT, edits=5, cigar=Cigar.from_cigarstring("31M5I29M")), + ), + ], +) +@pytest.mark.parametrize("reverse_complement", [True, False]) +def test_map_single_hit( + ref_fasta: Path, mismatches: int, end: int, bases: str, hit: BwaHit, reverse_complement: bool +) -> None: + """Tests that bwa maps one hit when a query uniquely maps. Checks for the hit's edit + and mismatches properties.""" + with BwaAlnInteractive( + ref=ref_fasta, + max_hits=1, + max_mismatches=5, + max_gap_opens=1, + reverse_complement=reverse_complement, + ) as bwa: + query = Query(bases=bases, id=f"{hit}") + result = bwa.map_one(query=query.bases, id=query.id) + assert result.hit_count == 1, "hit_count" + assert result.query == query, "query" + assert result.hits[0].refname == hit.refname, "chr" + assert result.hits[0].start == hit.start, "start" + assert result.hits[0].negative == hit.negative, "negative" + assert result.hits[0].cigar == hit.cigar, "cigar" + assert result.hits[0].edits == hit.edits, "edits" + assert result.hits[0].end == end, "end" + assert result.hits[0].mismatches == mismatches, "mismatches" + assert result.hits[0].mismatches == hit.mismatches, "hit.mismatches" + + +@pytest.mark.parametrize("reverse_complement", [True, False]) +def test_map_multi_hit(ref_fasta: Path, reverse_complement: bool) -> None: + mismatches: int = 0 + expected_hits: list[BwaHit] = [ + BwaHit.build("chr1", 2090, False, "31M", 0), + BwaHit.build("chr1", 5825, False, "31M", 0), + BwaHit.build("chr1", 8701, False, "31M", 0), + ] + bases: str = "AAGTGCTGGGATTACAGGCATGAGCCACCAC" + with BwaAlnInteractive( + ref=ref_fasta, + max_hits=len(expected_hits), + max_mismatches=mismatches, + max_gap_opens=0, + reverse_complement=reverse_complement, + ) as bwa: + query = Query(bases=bases, id="test") + actual = bwa.map_one(query=query.bases, id=query.id) + assert actual.hit_count == 3, "hit_count" + assert actual.query == query, "query" + actual_hits = sorted(actual.hits, key=lambda hit: (hit.refname, hit.start)) + assert len(actual_hits) == len(expected_hits) + for actual_hit, expected_hit in zip(actual_hits, expected_hits, strict=True): + assert actual_hit.refname == expected_hit.refname, "chr" + assert actual_hit.start == expected_hit.start, "start" + assert actual_hit.negative == expected_hit.negative, "negative" + assert actual_hit.cigar == expected_hit.cigar, "cigar" + assert actual_hit.edits == expected_hit.edits, "edits" + assert actual_hit.end == expected_hit.start + 30, "end" + assert actual_hit.mismatches == mismatches, "mismatches" + assert actual_hit.mismatches == expected_hit.mismatches, "hit.mismatches" + + +def test_to_result_out_of_order(ref_fasta: Path) -> None: + with BwaAlnInteractive(ref=ref_fasta, max_hits=1) as bwa: + query = Query(bases="GATTACA", id="foo") + rec = SamBuilder().add_single(name="bar") + with pytest.raises(ValueError, match="Query and Results are out of order"): + bwa._to_result(query=query, rec=rec) + + +def test_to_result_num_hits_on_unmapped(ref_fasta: Path) -> None: + with BwaAlnInteractive(ref=ref_fasta, max_hits=1) as bwa: + query = Query(bases="GATTACA", id="foo") + # Exception: HN cannot be non-zero + rec = SamBuilder().add_single(name=query.id, attrs={"HN": 42}) + with pytest.raises(ValueError, match="Read was unmapped but num_hits > 0"): + bwa._to_result(query=query, rec=rec) + # OK: HN tag is zero + rec = SamBuilder().add_single(name=query.id, attrs={"HN": 0}) + bwa._to_result(query=query, rec=rec) + # OK: no HN tag + rec = SamBuilder().add_single(name=query.id) + bwa._to_result(query=query, rec=rec) diff --git a/tests/offtarget/test_offtarget.py b/tests/offtarget/test_offtarget.py new file mode 100644 index 0000000..f1c809c --- /dev/null +++ b/tests/offtarget/test_offtarget.py @@ -0,0 +1,230 @@ +from pathlib import Path + +import pytest +from fgpyo.sam import Cigar + +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.offtarget.bwa import BwaHit +from prymer.offtarget.bwa import BwaResult +from prymer.offtarget.offtarget_detector import OffTargetDetector +from prymer.offtarget.offtarget_detector import OffTargetResult + + +def _build_detector( + ref_fasta: Path, + max_primer_hits: int = 1, + max_primer_pair_hits: int = 1, + three_prime_region_length: int = 20, + max_mismatches_in_three_prime_region: int = 0, + max_mismatches: int = 0, + max_amplicon_size: int = 250, + cache_results: bool = True, +) -> OffTargetDetector: + """Builds an `OffTargetDetector` with strict defaults""" + return OffTargetDetector( + ref=ref_fasta, + max_primer_hits=max_primer_hits, + max_primer_pair_hits=max_primer_pair_hits, + three_prime_region_length=three_prime_region_length, + max_mismatches_in_three_prime_region=max_mismatches_in_three_prime_region, + max_mismatches=max_mismatches, + max_amplicon_size=max_amplicon_size, + cache_results=cache_results, + keep_spans=True, + keep_primer_spans=True, + ) + + +@pytest.fixture +def multimap_primer_pair() -> PrimerPair: + """A primer pair that maps to many locations (204 for each primer, 856 as a pair)""" + return PrimerPair( + left_primer=Primer( + bases="AAAAA", + tm=37, + penalty=0, + span=Span("chr1", start=67, end=71), + ), + right_primer=Primer( + bases="TTTTT", + tm=37, + penalty=0, + span=Span("chr1", start=75, end=79, strand=Strand.NEGATIVE), + ), + amplicon_sequence="AAAAAtacAAAAA", + amplicon_tm=37, + penalty=0.0, + ) + + +# Test that using the cache (or not) does not affect the results +@pytest.mark.parametrize("cache_results", [True, False]) +def test_filter(ref_fasta: Path, multimap_primer_pair: PrimerPair, cache_results: bool) -> None: + primers = list(multimap_primer_pair) + # keep all, # of mappings found is at the limit + with _build_detector( + ref_fasta=ref_fasta, max_primer_hits=204, cache_results=cache_results + ) as d: + assert d.filter(primers=primers) == primers + # keep all, # of mappings found is one beyond the limit + with _build_detector( + ref_fasta=ref_fasta, max_primer_hits=203, cache_results=cache_results + ) as d: + assert len(d.filter(primers=primers)) == 0 + + +# Test that using the cache (or not) does not affect the results +@pytest.mark.parametrize("cache_results", [True, False]) +def test_ok(ref_fasta: Path, multimap_primer_pair: PrimerPair, cache_results: bool) -> None: + result: OffTargetResult + with _build_detector( + ref_fasta=ref_fasta, + max_primer_hits=204, + max_primer_pair_hits=856, + cache_results=cache_results, + ) as d: + result = d.check_one(primer_pair=multimap_primer_pair) + assert result.primer_pair == multimap_primer_pair, id + assert len(result.left_primer_spans) == 204 + assert len(result.right_primer_spans) == 204 + assert len(result.spans) == 856 + + +# Test that using the cache (or not) does not affect the results +@pytest.mark.parametrize("cache_results", [True, False]) +@pytest.mark.parametrize( + "id, max_primer_hits, max_primer_pair_hits, passes", + [ + ("too many primer hits", 203, 856, False), + ("too many primer pair hits", 204, 855, False), + ("at the maximum", 204, 856, True), + ], +) +def test_check_too_many_primer_pair_hits( + ref_fasta: Path, + multimap_primer_pair: PrimerPair, + id: str, + max_primer_hits: int, + max_primer_pair_hits: int, + passes: bool, + cache_results: bool, +) -> None: + result: OffTargetResult + with _build_detector( + ref_fasta=ref_fasta, + max_primer_hits=max_primer_hits, + max_primer_pair_hits=max_primer_pair_hits, + cache_results=cache_results, + ) as d: + # when checking caching, calls check_one twice, with the second time the result being. Do + # not run twice when we aren't caching, since the second time we map a read, we get + # different multi-mappings due to how BWA uses a random seed at the start of its execution. + num_rounds = 2 if cache_results else 1 + for i in range(num_rounds): + result = d.check_one(primer_pair=multimap_primer_pair) + assert result.primer_pair == multimap_primer_pair, id + assert result.passes is passes, id + # only retrieved from the cache on the first loop iteration, and if using the cache + cached = cache_results and (i == 1) + assert result.cached is cached, id + + +# Test that using the cache (or not) does not affect the results +@pytest.mark.parametrize("cache_results", [True, False]) +def test_mappings_of(ref_fasta: Path, cache_results: bool) -> None: + with _build_detector(ref_fasta=ref_fasta, cache_results=cache_results) as detector: + p1: Primer = Primer( + tm=37, + penalty=0, + span=Span(refname="chr1", start=1, end=30), + bases="CAGGTGGATCATGAGGTCAGGAGTTCAAGA", + ) + # NB: the expected hit is returned on the _opposite_ strand + expected_hit1: BwaHit = BwaHit( + refname="chr1", start=1, negative=False, cigar=Cigar.from_cigarstring("30M"), edits=0 + ) + + p2: Primer = Primer( + tm=37, + penalty=0, + span=Span(refname="chr1", start=61, end=93, strand=Strand.NEGATIVE), + bases="CATGCCCAGCTAATTTTTTGTATTTTTAGTAGA", + ) + # NB: the expected hit is returned on the _opposite_ strand + expected_hit2: BwaHit = BwaHit( + refname="chr1", start=61, negative=True, cigar=Cigar.from_cigarstring("33M"), edits=0 + ) + + # Test running the same primers through mappings_of to ensure we get the same results + for _ in range(10): + results_dict: dict[str, BwaResult] = detector.mappings_of(primers=[p1, p2]) + assert len(results_dict) == 2 + assert results_dict[p1.bases].hit_count == 1 + assert results_dict[p1.bases].hits[0] == expected_hit1 + assert results_dict[p2.bases].hit_count == 1 + assert results_dict[p2.bases].hits[0] == expected_hit2 + + +# Test that using the cache (or not) does not affect the results +@pytest.mark.parametrize("cache_results", [True, False]) +@pytest.mark.parametrize( + "test_id, left, right, expected", + [ + ( + "No mappings - different refnames", + BwaHit.build("chr1", 100, False, "100M", 0), + BwaHit.build("chr2", 100, True, "100M", 0), + [], + ), + ( + "No mappings - FF pair", + BwaHit.build("chr1", 100, True, "100M", 0), + BwaHit.build("chr1", 100, True, "100M", 0), + [], + ), + ( + "No mappings - RR pair", + BwaHit.build("chr1", 100, False, "100M", 0), + BwaHit.build("chr1", 100, False, "100M", 0), + [], + ), + ( + "No mappings - overlapping primers (1bp overlap)", + BwaHit.build("chr1", 100, False, "100M", 0), + BwaHit.build("chr1", 199, True, "100M", 0), + [], + ), + ( + "No mappings - amplicon size too big (1bp too big)", + BwaHit.build("chr1", 100, False, "100M", 0), + BwaHit.build("chr1", 151, True, "100M", 0), + [], + ), + ( + "Mappings - FR pair (R1 F)", + BwaHit.build("chr1", 100, False, "100M", 0), + BwaHit.build("chr1", 200, True, "100M", 0), + [Span(refname="chr1", start=100, end=299)], + ), + ( + "Mappings - FR pair (R1 R)", + BwaHit.build("chr1", 200, True, "100M", 0), + BwaHit.build("chr1", 100, False, "100M", 0), + [Span(refname="chr1", start=100, end=299)], + ), + ], +) +def test_to_amplicons( + ref_fasta: Path, + test_id: str, + left: BwaHit, + right: BwaHit, + expected: list[Span], + cache_results: bool, +) -> None: + with _build_detector(ref_fasta=ref_fasta, cache_results=cache_results) as detector: + actual = detector._to_amplicons(lefts=[left], rights=[right], max_len=250) + assert actual == expected, test_id diff --git a/tests/primer3/__init__.py b/tests/primer3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/primer3/data/miniref.dict b/tests/primer3/data/miniref.dict new file mode 100644 index 0000000..d798c30 --- /dev/null +++ b/tests/primer3/data/miniref.dict @@ -0,0 +1,3 @@ +@HD VN:1.5 SO:unsorted +@SQ SN:chr1 LN:577 M5:ef0179df9e1890de460f8aead36f0047 AS:miniref UR:file:fgprimer/src/test/resources/com/fulcrumegenomics/primerdesign/primer3/miniref.fa +@SQ SN:chr2 LN:9821 M5:0b0a7e507a574dc21824fe2efa2d0838 AS:miniref UR:file:fgprimer/src/test/resources/com/fulcrumegenomics/primerdesign/primer3/miniref.fa diff --git a/tests/primer3/data/miniref.fa b/tests/primer3/data/miniref.fa new file mode 100644 index 0000000..ab26803 --- /dev/null +++ b/tests/primer3/data/miniref.fa @@ -0,0 +1,107 @@ +>chr1 +TCCTCCATCAGTGACCTGAAGGAGGTGCCGCGGAAAAACATCACCCTCATTCGGGGTCTGGGCCATGGCGCCTTTGGGGAGGTGTATGAAGGCCAGGTGT +CCGGAATGCCCAACGACCCAAGCCCCCTGCAAGTGGCTGTGAAGACGCTGCCTGAAGTGTGCTCTGAACAGGACGAACTGGATTTCCTCATGGAAGCCCT +GATCATCAGTCTCTCTTGGAAGCAGTTGTTTTGCTTCACCTTCTGCCTGAGGCCTTCCTGGGAGAAATCGACCATGAATTCTAGCACCCAGTCCACGCAC +CAGTATCTGAGAAAGACACTCTCCTGCCCCCTGAGACACTTCCTCACCCAAGTGCCTGAGCAACATCCTCACAGCTCCAGCCACTCTCCTGCAAATGGAA +CTCCTGTGGAGCCTGCTGTGGTTCTTCCCACCTGCTCACCAGCAAGATTCTGGGTTTAGGCTCAGCCCGGGACCCCTGCTGCCCATGTTTACAGAATGCC +TTTATACATTGTAGCTGCTGAAAATGTAACTTTGTATCCTGTTCCTCCCAGTTTAAGATTTGCCCAGACTCAGCTCA +>chr2 +GCCGGAGTCCCGAGCTAGCCCCGGCGGCCGCCGCCGCCCAGACCGGACGACAGGCCACCTCGTCGGCGTCCGCCCGAGTCCCCGCCTCGCCGCCAACGCC +ACAACCACCGCGCACGGCCCCCTGACTCCGTCCAGTATTGATCGGGAGAGCCGGAGCGAGCTCTTCGGGGAGCAGCGATGCGACCCTCCGGGACGGCCGG +GGCAGCGCTCCTGGCGCTGCTGGCTGCGCTCTGCCCGGCGAGTCGGGCTCTGGAGGAAAAGAAAGTTTGCCAAGGCACGAGTAACAAGCTCACGCAGTTG +GGCACTTTTGAAGATCATTTTCTCAGCCTCCAGAGGATGTTCAATAACTGTGAGGTGGTCCTTGGGAATTTGGAAATTACCTATGTGCAGAGGAATTATG +ATCTTTCCTTCTTAAAGACCATCCAGGAGGTGGCTGGTTATGTCCTCATTGCCCTCAACACAGTGGAGCGAATTCCTTTGGAAAACCTGCAGATCATCAG +AGGAAATATGTACTACGAAAATTCCTATGCCTTAGCAGTCTTATCTAACTATGATGCAAATAAAACCGGACTGAAGGAGCTGCCCATGAGAAATTTACAG +GAAATCCTGCATGGCGCCGTGCGGTTCAGCAACAACCCTGCCCTGTGCAACGTGGAGAGCATCCAGTGGCGGGACATAGTCAGCAGTGACTTTCTCAGCA +ACATGTCGATGGACTTCCAGAACCACCTGGGCAGCTGCCAAAAGTGTGATCCAAGCTGTCCCAATGGGAGCTGCTGGGGTGCAGGAGAGGAGAACTGCCA +GAAACTGACCAAAATCATCTGTGCCCAGCAGTGCTCCGGGCGCTGCCGTGGCAAGTCCCCCAGTGACTGCTGCCACAACCAGTGTGCTGCAGGCTGCACA +GGCCCCCGGGAGAGCGACTGCCTGGTCTGCCGCAAATTCCGAGACGAAGCCACGTGCAAGGACACCTGCCCCCCACTCATGCTCTACAACCCCACCACGT +ACCAGATGGATGTGAACCCCGAGGGCAAATACAGCTTTGGTGCCACCTGCGTGAAGAAGTGTCCCCGTAATTATGTGGTGACAGATCACGGCTCGTGCGT +CCGAGCCTGTGGGGCCGACAGCTATGAGATGGAGGAAGACGGCGTCCGCAAGTGTAAGAAGTGCGAAGGGCCTTGCCGCAAAGTGTGTAACGGAATAGGT +ATTGGTGAATTTAAAGACTCACTCTCCATAAATGCTACGAATATTAAACACTTCAAAAACTGCACCTCCATCAGTGGCGATCTCCACATCCTGCCGGTGG +CATTTAGGGGTGACTCCTTCACACATACTCCTCCTCTGGATCCACAGGAACTGGATATTCTGAAAACCGTAAAGGAAATCACAGGGTTTTTGCTGATTCA +GGCTTGGCCTGAAAACAGGACGGACCTCCATGCCTTTGAGAACCTAGAAATCATACGCGGCAGGACCAAGCAACATGGTCAGTTTTCTCTTGCAGTCGTC +AGCCTGAACATAACATCCTTGGGATTACGCTCCCTCAAGGAGATAAGTGATGGAGATGTGATAATTTCAGGAAACAAAAATTTGTGCTATGCAAATACAA +TAAACTGGAAAAAACTGTTTGGGACCTCCGGTCAGAAAACCAAAATTATAAGCAACAGAGGTGAAAACAGCTGCAAGGCCACAGGCCAGGTCTGCCATGC +CTTGTGCTCCCCCGAGGGCTGCTGGGGCCCGGAGCCCAGGGACTGCGTCTCTTGCCGGAATGTCAGCCGAGGCAGGGAATGCGTGGACAAGTGCAACCTT +CTGGAGGGTGAGCCAAGGGAGTTTGTGGAGAACTCTGAGTGCATACAGTGCCACCCAGAGTGCCTGCCTCAGGCCATGAACATCACCTGCACAGGACGGG +GACCAGACAACTGTATCCAGTGTGCCCACTACATTGACGGCCCCCACTGCGTCAAGACCTGCCCGGCAGGAGTCATGGGAGAAAACAACACCCTGGTCTG +GAAGTACGCAGACGCCGGCCATGTGTGCCACCTGTGCCATCCAAACTGCACCTACGGATGCACTGGGCCAGGTCTTGAAGGCTGTCCAACGAATGGGCCT +AAGATCCCGTCCATCGCCACTGGGATGGTGGGGGCCCTCCTCTTGCTGCTGGTGGTGGCCCTGGGGATCGGCCTCTTCATGCGAAGGCGCCACATCGTTC +GGAAGCGCACGCTGCGGAGGCTGCTGCAGGAGAGGGAGCTTGTGGAGCCTCTTACACCCAGTGGAGAAGCTCCCAACCAAGCTCTCTTGAGGATCTTGAA +GGAAACTGAATTCAAAAAGATCAAAGTGCTGGGCTCCGGTGCGTTCGGCACGGTGTATAAGGGACTCTGGATCCCAGAAGGTGAGAAAGTTAAAATTCCC +GTCGCTATCAAGGAATTAAGAGAAGCAACATCTCCGAAAGCCAACAAGGAAATCCTCGATGAAGCCTACGTGATGGCCAGCGTGGACAACCCCCACGTGT +GCCGCCTGCTGGGCATCTGCCTCACCTCCACCGTGCAGCTCATCACGCAGCTCATGCCCTTCGGCTGCCTCCTGGACTATGTCCGGGAACACAAAGACAA +TATTGGCTCCCAGTACCTGCTCAACTGGTGTGTGCAGATCGCAAAGGGCATGAACTACTTGGAGGACCGTCGCTTGGTGCACCGCGACCTGGCAGCCAGG +AACGTACTGGTGAAAACACCGCAGCATGTCAAGATCACAGATTTTGGGCTGGCCAAACTGCTGGGTGCGGAAGAGAAAGAATACCATGCAGAAGGAGGCA +AAGTGCCTATCAAGTGGATGGCATTGGAATCAATTTTACACAGAATCTATACCCACCAGAGTGATGTCTGGAGCTACGGGGTGACTGTTTGGGAGTTGAT +GACCTTTGGATCCAAGCCATATGACGGAATCCCTGCCAGCGAGATCTCCTCCATCCTGGAGAAAGGAGAACGCCTCCCTCAGCCACCCATATGTACCATC +GATGTCTACATGATCATGGTCAAGTGCTGGATGATAGACGCAGATAGTCGCCCAAAGTTCCGTGAGTTGATCATCGAATTCTCCAAAATGGCCCGAGACC +CCCAGCGCTACCTTGTCATTCAGGGGGATGAAAGAATGCATTTGCCAAGTCCTACAGACTCCAACTTCTACCGTGCCCTGATGGATGAAGAAGACATGGA +CGACGTGGTGGATGCCGACGAGTACCTCATCCCACAGCAGGGCTTCTTCAGCAGCCCCTCCACGTCACGGACTCCCCTCCTGAGCTCTCTGAGTGCAACC +AGCAACAATTCCACCGTGGCTTGCATTGATAGAAATGGGCTGCAAAGCTGTCCCATCAAGGAAGACAGCTTCTTGCAGCGATACAGCTCAGACCCCACAG +GCGCCTTGACTGAGGACAGCATAGACGACACCTTCCTCCCAGTGCCTGAATACATAAACCAGTCCGTTCCCAAAAGGCCCGCTGGCTCTGTGCAGAATCC +TGTCTATCACAATCAGCCTCTGAACCCCGCGCCCAGCAGAGACCCACACTACCAGGACCCCCACAGCACTGCAGTGGGCAACCCCGAGTATCTCAACACT +GTCCAGCCCACCTGTGTCAACAGCACATTCGACAGCCCTGCCCACTGGGCCCAGAAAGGCAGCCACCAAATTAGCCTGGACAACCCTGACTACCAGCAGG +ACTTCTTTCCCAAGGAAGCCAAGCCAAATGGCATCTTTAAGGGCTCCACAGCTGAAAATGCAGAATACCTAAGGGTCGCGCCACAAAGCAGTGAATTTAT +TGGAGCATGACCACGGAGGATAGTATGAGCCCTAAAAATCCAGACTCTTTCGATACCCAGGACCAAGCCACAGCAGGTCCTCCATCCCAACAGCCATGCC +CGCATTAGCTCTTAGACCCACAGACTGGTTTTGCAACGTTTACACCGACTAGCCAGGAAGTACTTCCACCTCGGGCACATTTTGGGAAGTTGCATTCCTT +TGTCTTCAAACTGTGAAGCATTTACAGAAACGCATCCAGCAAGAATATTGTCCCTTTGAGCAGAAATTTATCTTTCAAAGAGGTATATTTGAAAAAAAAA +AAAAGTATATGTGAGGATTTTTATTGATTGGGGATCTTGGAGTTTTTCATTGTCGCTATTGATTTTTACTTCAATGGGCTCTTCCAACAAGGAAGAAGCT +TGCTGGTAGCACTTGCTACCCTGAGTTCATCCAGGCCCAACTGTGAGCAAGGAGCACAAGCCACAAGTCTTCCAGAGGATGCTTGATTCCAGTGGTTCTG +CTTCAAGGCTTCCACTGCAAAACACTAAAGATCCAAGAAGGCCTTCATGGCCCCAGCAGGCCGGATCGGTACTGTATCAAGTCATGGCAGGTACAGTAGG +ATAAGCCACTCTGTCCCTTCCTGGGCAAAGAAGAAACGGAGGGGATGGAATTCTTCCTTAGACTTACTTTTGTAAAAATGTCCCCACGGTACTTACTCCC +CACTGATGGACCAGTGGTTTCCAGTCATGAGCGTTAGACTGACTTGTTTGTCTTCCATTCCATTGTTTTGAAACTCAGTATGCTGCCCCTGTCTTGCTGT +CATGAAATCAGCAAGAGAGGATGACACATCAAATAATAACTCGGATTCCAGCCCACATTGGATTCATCAGCATTTGGACCAATAGCCCACAGCTGAGAAT +GTGGAATACCTAAGGATAGCACCGCTTTTGTTCTCGCAAAAACGTATCTCCTAATTTGAGGCTCAGATGAAATGCATCAGGTCCTTTGGGGCATAGATCA +GAAGACTACAAAAATGAAGCTGCTCTGAAATCTCCTTTAGCCATCACCCCAACCCCCCAAAATTAGTTTGTGTTACTTATGGAAGATAGTTTTCTCCTTT +TACTTCACTTCAAAAGCTTTTTACTCAAAGAGTATATGTTCCCTCCAGGTCAGCTGCCCCCAAACCCCCTCCTTACGCTTTGTCACACAAAAAGTGTCTC +TGCCTTGAGTCATCTATTCAAGCACTTACAGCTCTGGCCACAACAGGGCATTTTACAGGTGCGAATGACAGTAGCATTATGAGTAGTGTGGAATTCAGGT +AGTAAATATGAAACTAGGGTTTGAAATTGATAATGCTTTCACAACATTTGCAGATGTTTTAGAAGGAAAAAAGTTCCTTCCTAAAATAATTTCTCTACAA +TTGGAAGATTGGAAGATTCAGCTAGTTAGGAGCCCACCTTTTTTCCTAATCTGTGTGTGCCCTGTAACCTGACTGGTTAACAGCAGTCCTTTGTAAACAG +TGTTTTAAACTCTCCTAGTCAATATCCACCCCATCCAATTTATCAAGGAAGAAATGGTTCAGAAAATATTTTCAGCCTACAGTTATGTTCAGTCACACAC +ACATACAAAATGTTCCTTTTGCTTTTAAAGTAATTTTTGACTCCCAGATCAGTCAGAGCCCCTACAGCATTGTTAAGAAAGTATTTGATTTTTGTCTCAA +TGAAAATAAAACTATATTCATTTCCACTCTATTATGCTCTCAAATACCCCTAAGCATCTATACTAGCCTGGTATGGGTATGAAAGATACAAAGATAAATA +AAACATAGTCCCTGATTCTAAGAAATTCACAATTTAGCAAAGGAAATGGACTCATAGATGCTAACCTTAAAACAACGTGACAAATGCCAGACAGGACCCA +TCAGCCAGGCACTGTGAGAGCACAGAGCAGGGAGGTTGGGTCCTGCCTGAGGAGACCTGGAAGGGAGGCCTCACAGGAGGATGACCAGGTCTCAGTCAGC +GGGGAGGTGGAAAGTGCAGGTGCATCAGGGGCACCCTGACCGAGGAAACAGCTGCCAGAGGCCTCCACTGCTAAAGTCCACATAAGGCTGAGGTCAGTCA +CCCTAAACAACCTGCTCCCTCTAAGCCAGGGGATGAGCTTGGAGCATCCCACAAGTTCCCTAAAAGTTGCAGCCCCCAGGGGGATTTTGAGCTATCATCT +CTGCACATGCTTAGTGAGAAGACTACACAACATTTCTAAGAATCTGAGATTTTATATTGTCAGTTAACCACTTTCATTATTCATTCACCTCAGGACATGC +AGAAATATTTCAGTCAGAACTGGGAAACAGAAGGACCTACATTCTGCTGTCACTTATGTGTCAAGAAGCAGATGATCGATGAGGCAGGTCAGTTGTAAGT +GAGTCACATTGTAGCATTAAATTCTAGTATTTTTGTAGTTTGAAACAGTAACTTAATAAAAGAGCAAAAGCTATTCTAGCTTTCTTCTTCATATTTTAAT +TTTCCACCATAAAGTTTAGTTGCTAAATTCTATTAATTTTAAGATTGTGCTTCCCAAAATAGTTCTCACTTCATCTGTCCAGGGAGGCACAGTTCTGTCT +GGTAGAAGCCGCAAAGCCCTTAGCCTCTTCACGGATCTGGCGACTGTGATGGGCAGGTCAGGAGAGGAGCTGCCCAAAGTCCCATGATTTTCACCTAACA +GCCCTGATCAGTCAGTACTCAAAGCTTGGACTCCATCCCTGAAGGTCTTCCTGATTGATAGCCTGGCCTTAATACCCTACAGAAAGCCTGTCCATTGGCT +GTTTCTTCCTCAGTCAGTTCCTGGAAGACCTTACCCCATGACCCCAGCTTCAGATGTGGTCTTTGGAAACAGAGGTCGAAGGAAAGTAAGGAGCTGAGAG +CTCACATTCATAGGTGCCGCCAGCCTTCGTGCATCTTCTTGCATCATCTCTAAGGAGCTCCTCTAATTACACCATGCCCGTCACCCCATGAGGGATCAGA +GAAGGGATGAGTCTTCTAAACTCTATATTCGCTGTGAGTCCAGGTTGTAAGGGGGAGCACTGTGGATGCATCCTATTGCACTCCAGCTGATGACACCAAA +GCTTAGGTGTTTGCTGAAAGTTCTTGATGTTGTGACTTACCACCCCTGCCTCACAACTGCAGACATAAGGGGACTATGGATTGCTTAGCAGGAAAGGCAC +TGGTTCTCAAGGGCGGCTGCCCTTGGGAATCTTCTGGTCCCAACCAGAAAGACTGTGGCTTGATTTTCTCAGGTGCAGCCCAGCCGTAGGGCCTTTTCAG +AGCACCCCCTGGTTATTGCAACATTCATCAAAGTTTCTAGAACCTCTGGCCTAAAGGAAGGGCCTGGTGGGATCTACTTGGCACTCGCTGGGGGGCCACC +CCCCAGTGCCACTCTCACTAGGCCTCTGATTGCACTTGTGTAGGATGAAGCTGGTGGGTGATGGGAACTCAGCACCTCCCCTCAGGCAGAAAAGAATCAT +CTGTGGAGCTTCAAAAGAAGGGGCCTGGAGTCTCTGCAGACCAATTCAACCCAAATCTCGGGGGCTCTTTCATGATTCTAATGGGCAACCAGGGTTGAAA +CCCTTATTTCTAGGGTCTTCAGTTGTACAAGACTGTGGGTCTGTACCAGAGCCCCCGTCAGAGTAGAATAAAAGGCTGGGTAGGGTAGAGATTCCCATGT +GCAGTGGAGAGAACAATCTGCAGTCACTGATAAGCCTGAGACTTGGCTCATTTCAAAAGCGTTCAATTCATCCTCACCAGCAGTTCAGCTGGAAAGGGGC +AAATACCCCCACCTGAGCTTTGAAAACGCCCTGGGACCCTCTGCATTCTCTAAGTAAGTTATAGAAACCAGTCTCTTCCCTCCTTTGTGAGTGAGCTGCT +ATTCCACGTAGGCAACACCTGTTGAAATTGCCCTCAATGTCTACTCTGCATTTCTTTCTTGTGATAAGCACACACTTTTATTGCAACATAATGATCTGCT +CACATTTCCTTGCCTGGGGGCTGTAAAACCTTACAGAACAGAAATCCTTGCCTCTTTCACCAGCCACACCTGCCATACCAGGGGTACAGCTTTGTACTAT +TGAAGACACAGACAGGATTTTTAAATGTAAATCTATTTTTGTAACTTTGTTGCGGGATATAGTTCTCTTTATGTAGCACTGAACTTTGTACAATATATTT +TTAGAAACTCATTTTTCTACTAAAACAAACACAGTTTACTTTAGAGAGACTGCAATAGAATCAAAATTTGAAACTGAAATCTTTGTTTAAAAGGGTTAAG +TTGAGGCAAGAGGAAAGCCCTTTCTCTCTCTTATAAAAAGGCACAACCTCATTGGGGAGCTAAGCTAGGTCATTGTCATGGTGAAGAAGAGAAGCATCGT +TTTTATATTTAGGAAATTTTAAAAGATGATGGAAAGCACATTTAGCTTGGTCTGAGGCAGGTTCTGTTGGGGCAGTGTTAATGGAAAGGGCTCACTGTTG +TTACTACTAGAAAAATCCAGTTGCATGCCATACTCTCATCATCTGCCAGTGTAACCCTGTACATGTAAGAAAAGCAATAACATAGCACTTTGTTGGTTTA +TATATATAATGTGACTTCAATGCAAATTTTATTTTTATATTTACAATTGATATGCATTTACCAGTATAAACTAGACATGTCTGGAGAGCCTAATAATGTT +CAGCACACTTTGGTTAGTTCACCAACAGTCTTACCAAGCCTGGGCCCAGCCACCCTAGAGAAGTTATTCAGCCCTGGCTGCAGTGACATCACCTGAGGAG +CTTTTAAAAGCTTGAAGCCCAGCTACACCTCAGACCGATTAAACGCAAATCTCTGGGGCTGAAACCCAAGCATTCGTAGTTTTTAAAGCTCCTGAGGTCA +TTCCAATGTGCGGCCAAAGTTGAGAACTACTGGCCTAGGGATTAGCCACAAGGACATGGACTTGGAGGCAAATTCTGCAGGTGTATGTGATTCTCAGGCC +TAGAGAGCTAAGACACAAAGACCTCCACATCTGTCGCTGAGAGTCAAGAACCTGAACAGAGTTTCCATGAAGGTTCTCCAAGCACTAGAAGGGAGAGTGT +CTAAACAATGGTTGAAAAGCAAAGGAAATATAAAACAGACACCTCTTTCCATTTCCTAAGGTTTCTCTCTTTATTAAGGGTGGACTAGTAATAAAATATA +ATATTCTTGCTGCTTATGCAGCTGACATTGTTGCCCTCCCTAAAGCAACCAAGTAGCCTTTATTTCCCACAGTGAAAGAAAACGCTGGCCTATCAGTTAC +ATTACAAAAGGCAGATTTCAAGAGGATTGAGTAAGTAGTTGGATGGCTTTCATAAAAACAAGAATTCAAGAAGAGGATTCATGCTTTAAGAAACATTTGT +TATACATTCCTCACAAATTATACCTGGGATAAAAACTATGTAGCAGGCAGTGTGTTTTCCTTCCATGTCTCTCTGCACTACCTGCAGTGTGTCCTCTGAG +GCTGCAAGTCTGTCCTATCTGAATTCCCAGCAGAAGCACTAAGAAGCTCCACCCTATCACCTAGCAGATAAAACTATGGGGAAAACTTAAATCTGTGCAT +ACATTTCTGGATGCATTTACTTATCTTTAAAAAAAAAGGAATCCTATGACCTGATTTGGCCACAAAAATAATCTTGCTGTACAATACAATCTCTTGGAAA +TTAAGAGATCCTATGGATTTGATGACTGGTATTAGAGGTGACAATGTAACCGATTAACAACAGACAGCAATAACTTCGTTTTAGAAACATTCAAGCAATA +GCTTTATAGCTTCAACATATGGTACGTTTTAACCTTGAAAGTTTTGCAATGATGAAAGCAGTATTTGTACAAATGAAAAGCAGAATTCTCTTTTATATGG +TTTATACTGTTGATCAGAAATGTTGATTGTGCATTGAGTATTAAAAAATTAGATGTATATTATTCATTGTTCTTTACTCCTGAGTACCTTATAATAATAA +TAATGTATTCTTTGTTAACAA diff --git a/tests/primer3/data/miniref.fa.fai b/tests/primer3/data/miniref.fa.fai new file mode 100644 index 0000000..1d8ebd0 --- /dev/null +++ b/tests/primer3/data/miniref.fa.fai @@ -0,0 +1,2 @@ +chr1 577 6 100 101 +chr2 9821 595 100 101 diff --git a/tests/primer3/data/miniref.variants.vcf.gz b/tests/primer3/data/miniref.variants.vcf.gz new file mode 100644 index 0000000000000000000000000000000000000000..1669066a1ce30775f6ca8932c216693780818ed2 GIT binary patch literal 900 zcmV-~1AF`*iwFb&00000{{{d;LjnL|0;N-3Yui8&d{%zN64N(fV$+fqs(^5!)Znk$ zZu)%It)zp~t$MeLQGb28QxwNZD22XBy1AL1+0kfJD2)Z#T5$3(%N{?Drx&Bqe3h+} zuk+g^oyDuRKA=s~c;7Xc%nf6SHoirdTh*{4lU$}+qejT7H4Mr?iH5jq(8W-Y1r&J& z>0%Ee;9?Kq3c-mQfviLe!hu$fQC{Ah$!D+RGfjfu70)dGYSHA~e`~G=&B#5 z6M#U)lZ^8+xqp_Y5YFjmCr{&^f5%pt@|7vm4~a5{oa$PDM6GJYD8tYvNhqF-D|B!W z4keI*$<@`>JsPV04jc>1%9LcIuM%C8o|S%^GQ8TGsNhV z%}KYl8N<&CErNKd`Gy0O14BGqmxtYI^A5@(#{qD-o21STJX6aQ}=E0tF;1+q|+_2p@e zMC%|Y!&V(y#(rUf&pDq7$HBjct1={zS9TI>G$mKbr|aujIEVE?cS)^`vRJ%@PY3vy z&(q0!_}RDZdKqojyJ&tJZSS%uUF@UpKhi~%%@_N-ZRF#>_yhN6?YIF2001A02m}BC z000301^_}s0ssU7ZIDfFgD?&F9nU+-m$VtEmvoI1FP* zt^$VcK^7>Js-l{|s`>NXQlP~AMcImc0$o;t^|AQ>zu?Mi-I(UQmwcQKLk} zqE!2c?zc@T&CtYjad>WjCSk32_Xn)?B65d~8-D%G_1;_W3^0)BVtpr6SyQWvlrJ~U zh1pIwHwGXeZn}3zs`R7Q+Xd#1-VRcj2Dv|b0VZe~!NkP$!2bhER(K;L0ssIXiwFb& a00000{{{d;LjnLB00RI3000000000jWJ`CK2pHfm%5)u-ak|cPUP6f;o?U-!b)pI~-o|Ay9gW`%^ oJZSPx# literal 0 HcmV?d00001 diff --git a/tests/primer3/test_primer3.py b/tests/primer3/test_primer3.py new file mode 100644 index 0000000..2409dfe --- /dev/null +++ b/tests/primer3/test_primer3.py @@ -0,0 +1,580 @@ +import logging +from dataclasses import replace +from pathlib import Path + +import pysam +import pytest +from fgpyo.sequence import reverse_complement + +from prymer.api.minoptmax import MinOptMax +from prymer.api.primer import Primer +from prymer.api.primer_pair import PrimerPair +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.api.variant_lookup import cached +from prymer.primer3.primer3 import Primer3 +from prymer.primer3.primer3 import Primer3Failure +from prymer.primer3.primer3 import Primer3Result +from prymer.primer3.primer3_input import Primer3Input +from prymer.primer3.primer3_parameters import Primer3Parameters +from prymer.primer3.primer3_task import DesignLeftPrimersTask +from prymer.primer3.primer3_task import DesignPrimerPairsTask +from prymer.primer3.primer3_task import DesignRightPrimersTask + + +@pytest.fixture(scope="session") +def genome_ref() -> Path: + return Path(__file__).parent / "data" / "miniref.fa" + + +@pytest.fixture +def vcf_path() -> Path: + return Path(__file__).parent / "data" / "miniref.variants.vcf.gz" + + +@pytest.fixture +def single_primer_params() -> Primer3Parameters: + return Primer3Parameters( + amplicon_sizes=MinOptMax(min=100, max=250, opt=200), + amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=70.0), + primer_sizes=MinOptMax(min=29, max=31, opt=30), + primer_tms=MinOptMax(min=63.0, max=67.0, opt=65.0), + primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), + primer_max_polyX=4, + number_primers_return=1000, + ) + + +@pytest.fixture +def pair_primer_params() -> Primer3Parameters: + return Primer3Parameters( + amplicon_sizes=MinOptMax(min=100, max=200, opt=150), + amplicon_tms=MinOptMax(min=55.0, max=100.0, opt=72.5), + primer_sizes=MinOptMax(min=20, max=30, opt=25), + primer_tms=MinOptMax(min=55.0, max=75.0, opt=65.0), + primer_gcs=MinOptMax(min=30.0, max=65.0, opt=45.0), + primer_max_polyX=4, + number_primers_return=10, + ) + + +@pytest.fixture +def design_fail_gen_primer3_params() -> Primer3Parameters: + return Primer3Parameters( + amplicon_sizes=MinOptMax(min=200, max=300, opt=250), + amplicon_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), + primer_sizes=MinOptMax(min=24, max=27, opt=26), + primer_tms=MinOptMax(min=65.0, max=75.0, opt=74.0), + primer_gcs=MinOptMax(min=55.0, max=65.0, opt=62.0), + ) + + +def make_primer(bases: str, refname: str, start: int, end: int) -> Primer: + return Primer( + bases=bases, + tm=55, + penalty=5, + span=Span(refname=refname, start=start, end=end), + ) + + +def make_primer_pair(left: Primer, right: Primer, genome_ref: Path) -> PrimerPair: + ref = pysam.FastaFile(str(genome_ref)) # pysam expects a str instead of Path + amplicon_span = Span( + refname=left.span.refname, + start=left.span.start, + end=right.span.end, + ) + amplicon_sequence = ref.fetch( + region=f"{amplicon_span.refname}:{amplicon_span.start}-{amplicon_span.end}" + ) + return PrimerPair( + left_primer=left, + right_primer=right, + amplicon_sequence=amplicon_sequence, + penalty=5.0, + amplicon_tm=60.0, + ) + + +@pytest.fixture(scope="session") +def valid_left_primers() -> list[Primer]: + lefts: list[Primer] = [ + make_primer(bases="ACATTTGCTTCTGACACAAC", refname="chr1", start=1, end=20), + make_primer(bases="TGTGTTCACTAGCAACCTCA", refname="chr1", start=21, end=40), + ] + return lefts + + +@pytest.fixture(scope="session") +def valid_right_primers() -> list[Primer]: + rights: list[Primer] = [ + make_primer( + bases=reverse_complement("TCAAGGTTACAAGACAGGTT"), refname="chr1", start=150, end=169 + ), + make_primer( + bases=reverse_complement("TAAGGAGACCAATAGAAACT"), refname="chr1", start=170, end=189 + ), + ] + return rights + + +@pytest.fixture(scope="session") +def valid_primer_pairs( + valid_left_primers: list[Primer], valid_right_primers: list[Primer], genome_ref: Path +) -> list[PrimerPair]: + primer_pairs = [ + make_primer_pair(left=left, right=right, genome_ref=genome_ref) + for left, right in list(zip(valid_left_primers, valid_right_primers, strict=True)) + ] + return primer_pairs + + +def test_design_primers_raises( + genome_ref: Path, + single_primer_params: Primer3Parameters, +) -> None: + """Test that design_primers() raises when given an invalid argument.""" + + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + + illegal_primer3_params = replace( + single_primer_params, + number_primers_return="invalid", # type: ignore + ) + invalid_design_input = Primer3Input( + target=target, params=illegal_primer3_params, task=DesignLeftPrimersTask() + ) + with pytest.raises(ValueError, match="Primer3 failed"): + Primer3(genome_fasta=genome_ref).design_primers(design_input=invalid_design_input) + # TODO: add other Value Errors + + +def test_left_primer_valid_designs( + genome_ref: Path, + single_primer_params: Primer3Parameters, +) -> None: + """Test that left primer designs are within the specified design specifications.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + design_input = Primer3Input( + target=target, + params=single_primer_params, + task=DesignLeftPrimersTask(), + ) + + with Primer3(genome_fasta=genome_ref) as designer: + for _ in range(10): # run many times to ensure we can re-use primer3 + left_result = designer.design_primers(design_input=design_input) + designed_lefts: list[Primer] = left_result.primers() + assert all(isinstance(design, Primer) for design in designed_lefts) + for actual_design in designed_lefts: + assert ( + actual_design.longest_dinucleotide_run_length() + <= single_primer_params.primer_max_dinuc_bases + ) + assert ( + single_primer_params.primer_sizes.min + <= actual_design.length + <= single_primer_params.primer_sizes.max + ) + assert ( + single_primer_params.primer_tms.min + <= actual_design.tm + <= single_primer_params.primer_tms.max + ) + assert ( + single_primer_params.primer_gcs.min + <= actual_design.percent_gc_content + <= single_primer_params.primer_gcs.max + ) + assert actual_design.span.start < actual_design.span.end + assert actual_design.span.end < target.start + underlying_ref_seq = designer._fasta.fetch( # pysam is 0-based, half-open + reference=actual_design.span.refname, + start=actual_design.span.start - 1, + end=actual_design.span.end, + ) + assert actual_design.bases == underlying_ref_seq + assert designer.is_alive + + +def test_right_primer_valid_designs( + genome_ref: Path, + single_primer_params: Primer3Parameters, +) -> None: + """Test that right primer designs are within the specified design specifications.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + design_input = Primer3Input( + target=target, + params=single_primer_params, + task=DesignRightPrimersTask(), + ) + with Primer3(genome_fasta=genome_ref) as designer: + for _ in range(10): # run many times to ensure we can re-use primer3 + right_result: Primer3Result = designer.design_primers(design_input=design_input) + designed_rights: list[Primer] = right_result.primers() + assert all(isinstance(design, Primer) for design in designed_rights) + + for actual_design in designed_rights: + assert ( + actual_design.longest_dinucleotide_run_length() + <= single_primer_params.primer_max_dinuc_bases + ) + assert ( + single_primer_params.primer_sizes.min + <= actual_design.length + <= single_primer_params.primer_sizes.max + ) + assert ( + single_primer_params.primer_tms.min + <= actual_design.tm + <= single_primer_params.primer_tms.max + ) + assert ( + single_primer_params.primer_gcs.min + <= actual_design.percent_gc_content + <= single_primer_params.primer_gcs.max + ) + assert actual_design.span.start < actual_design.span.end + assert actual_design.span.end > actual_design.span.start + assert actual_design.span.start > 250 + underlying_ref_seq = designer._fasta.fetch( # pysam is 0-based, half-open + reference=actual_design.span.refname, + start=actual_design.span.start - 1, + end=actual_design.span.end, + ) + assert actual_design.bases == reverse_complement(underlying_ref_seq) + assert designer.is_alive + + +def test_primer_pair_design(genome_ref: Path, pair_primer_params: Primer3Parameters) -> None: + """Test that paired primer design produces left and right primers within design constraints. + Additionally, assert that `PrimerPair.amplicon_sequence()` matches reference sequence.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + design_input = Primer3Input( + target=target, + params=pair_primer_params, + task=DesignPrimerPairsTask(), + ) + with Primer3(genome_fasta=genome_ref) as designer: + pair_result: Primer3Result = designer.design_primers(design_input=design_input) + designed_pairs: list[PrimerPair] = pair_result.primer_pairs() + assert all(isinstance(design, PrimerPair) for design in designed_pairs) + lefts = [primer_pair.left_primer for primer_pair in designed_pairs] + rights = [primer_pair.right_primer for primer_pair in designed_pairs] + + assert len(lefts) == 10 + assert len(rights) == 10 + for pair_design in designed_pairs: + if pair_design.amplicon_sequence is not None: + assert len(pair_design.amplicon_sequence) <= pair_primer_params.amplicon_sizes.max + assert ( + pair_design.amplicon_sequence.upper() + == designer._fasta.fetch( # pysam is 0-based, half-open + reference=pair_design.left_primer.span.refname, + start=pair_design.left_primer.span.start - 1, + end=pair_design.right_primer.span.end, + ).upper() + ) + # check left primers + assert ( + pair_primer_params.primer_sizes.min + <= pair_design.left_primer.length + <= pair_primer_params.primer_sizes.max + ) + assert ( + pair_primer_params.primer_tms.min + <= pair_design.left_primer.tm + <= pair_primer_params.primer_tms.max + ) + assert ( + pair_primer_params.primer_gcs.min + <= pair_design.left_primer.percent_gc_content + <= pair_primer_params.primer_gcs.max + ) + assert pair_design.left_primer.bases is not None + # check right primers + assert ( + pair_primer_params.primer_sizes.min + <= pair_design.right_primer.length + <= pair_primer_params.primer_sizes.max + ) + assert ( + pair_primer_params.primer_tms.min + <= pair_design.right_primer.tm + <= pair_primer_params.primer_tms.max + ) + assert ( + pair_primer_params.primer_gcs.min + <= pair_design.right_primer.percent_gc_content + <= pair_primer_params.primer_gcs.max + ) + assert pair_design.right_primer.bases is not None + + left_from_ref = designer._fasta.fetch( # pysam is 0-based, half-open + reference=pair_design.left_primer.span.refname, + start=pair_design.left_primer.span.start - 1, + end=pair_design.left_primer.span.end, + ) + + right_from_ref = reverse_complement( + designer._fasta.fetch( # pysam is 0-based, half-open + reference=pair_design.right_primer.span.refname, + start=pair_design.right_primer.span.start - 1, + end=pair_design.right_primer.span.end, + ) + ) + assert pair_design.left_primer.bases.upper() == left_from_ref.upper() + assert pair_design.right_primer.bases.upper() == right_from_ref.upper() + + +def test_fasta_close_valid(genome_ref: Path, single_primer_params: Primer3Parameters) -> None: + """Test that fasta file is closed when underlying subprocess is terminated.""" + designer = Primer3(genome_fasta=genome_ref) + assert designer._fasta.is_open() + designer.close() + assert designer._fasta.closed + assert not designer.is_alive + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + design_input = Primer3Input( + target=target, + params=single_primer_params, + task=DesignLeftPrimersTask(), + ) + + with pytest.raises( + RuntimeError, match="Error, trying to use a subprocess that has already been terminated" + ): + designer.design_primers(design_input=design_input) + + +@pytest.mark.parametrize( + "region, expected_hard_masked, expected_soft_masked", + [ + ( + Span(refname="chr2", start=9000, end=9110), + # 9000 9010 9020 9030 9040 9050 9060 9070 9080 9090 9100 9110 # noqa + "AATATTCTTGNTGCTTATGCNGCTGACATTGTTGCCCTCCCTAAAGCAACNAAGTAGCCTNTATTTCCCANAGTGAAAGANNACGCTGGCNNNTCAGTTANNNTACAAAAG", + "AATATTCTTGCTGCTTATGCAGCTGACATTGTTGCCCTCCCTAAAGCAACCAAGTAGCCTTTATTTCCCACAGTGAAAGAAAACGCTGGCCTATCAGTTACATTACAAAAG", + ), # expected masked positions: 9010, 9020, 9050, 9060, 9070, + # 9080 (2bp insertion: 3 bases), 9090 (2bp deletion: 2 bases), 9100 (mixed: 3 bases) + # do not expect positions 9000 (MAF = 0.001), 9030 (MAF = 0.001), or 9040 (MAF = 0.0004814) + # to be masked (MAF below the provided min_maf) + ( + Span(refname="chr2", start=9095, end=9120), + "AGTTANNNTACAAAAGGCAGATTTCA", + "AGTTACATTACAAAAGGCAGATTTCA", + ), + # 9100 (common-mixed -- alt1: CA->GG, and alt2: CA->CACACA). The first alt masks the + # positions [9100,9101], and the second alt masks the positions [9100,9102] (an extra + # base for the insertion). But the second alt is not added to variant lookup, while the + # first variant is classified as OTHER, so [9100,9102] are masked. FIXME: this could be + # improved by more faithfully parsing the input VCF and representing each alternate as its + # own simple variant. + ], +) +def test_variant_lookup( + genome_ref: Path, + vcf_path: Path, + region: Span, + expected_hard_masked: str, + expected_soft_masked: str, +) -> None: + """Test that MAF filtering and masking are working as expected.""" + with Primer3( + genome_fasta=genome_ref, variant_lookup=cached([vcf_path], min_maf=0.01) + ) as designer: + actual_soft_masked, actual_hard_masked = designer.get_design_sequences(region=region) + assert actual_hard_masked == expected_hard_masked + assert actual_soft_masked == expected_soft_masked + + # with no variant lookup should all be soft-masked + with Primer3(genome_fasta=genome_ref, variant_lookup=None) as designer: + actual_soft_masked, actual_hard_masked = designer.get_design_sequences(region=region) + assert actual_hard_masked == expected_soft_masked + assert actual_soft_masked == expected_soft_masked + + +def test_screen_pair_results( + valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: Primer3Parameters +) -> None: + """Test that `_is_valid_primer()` and `_screen_pair_results()` use + `Primer3Parameters.primer_max_dinuc_bases` to disqualify primers when applicable. + Create 2 sets of design input, the only difference being the length of allowable dinucleotide + run in a primer (high_threshold = 6, low_threshold = 2). + If one primer of a primer pair should have a dinucleotide run above the set threshold, + then the pair is considered invalid.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + design_input = Primer3Input( + target=target, + params=pair_primer_params, + task=DesignPrimerPairsTask(), + ) + + lower_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 + altered_design_input = Primer3Input( + target=target, + params=lower_dinuc_thresh, + task=DesignPrimerPairsTask(), + ) + with Primer3(genome_fasta=genome_ref) as designer: + # all PrimerPairs have acceptable dinucleotide run lengths (threshold = 6) + base_primer_pair_designs, base_dinuc_pair_failures = designer._screen_pair_results( + design_input=design_input, designed_primer_pairs=valid_primer_pairs + ) + assert len(base_dinuc_pair_failures) == 0 + for primer_pair in base_primer_pair_designs: + assert ( + primer_pair.left_primer.longest_dinucleotide_run_length() + <= design_input.params.primer_max_dinuc_bases + ) + assert ( + primer_pair.right_primer.longest_dinucleotide_run_length() + <= design_input.params.primer_max_dinuc_bases + ) + assert Primer3._is_valid_primer( + design_input=design_input, primer_design=primer_pair.left_primer + ) + assert Primer3._is_valid_primer( + design_input=design_input, primer_design=primer_pair.right_primer + ) + + # 1 primer from every pair will fail lowered dinuc threshold of 2 + # As a result, no valid primer pairs will be emitted + altered_designs, altered_dinuc_failures = designer._screen_pair_results( + design_input=altered_design_input, designed_primer_pairs=valid_primer_pairs + ) + assert [ + design.longest_dinucleotide_run_length() + > altered_design_input.params.primer_max_dinuc_bases + for design in altered_dinuc_failures + ] + assert len(altered_designs) == 0 + + +def test_build_failures( + valid_primer_pairs: list[PrimerPair], genome_ref: Path, pair_primer_params: Primer3Parameters +) -> None: + """Test that `build_failures()` parses Primer3 `failure_strings` correctly and includes failures + related to long dinucleotide runs.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + + low_dinuc_thresh = replace(pair_primer_params, primer_max_dinuc_bases=2) # lower from 6 to 2 + altered_design_input = Primer3Input( + target=target, + params=low_dinuc_thresh, + task=DesignPrimerPairsTask(), + ) + designer = Primer3(genome_fasta=genome_ref) + primer_pair_designs, dinuc_pair_failures = designer._screen_pair_results( + design_input=altered_design_input, designed_primer_pairs=valid_primer_pairs + ) + # 3 primers fail for dinucleotide runs that are longer than `primer_max_dinuc_bases` + assert len(dinuc_pair_failures) == 3 + test_failure_strings = [ + "considered 228, low tm 159, high tm 12, high hairpin stability 23, ok 34" + ] + fail_cases: list[Primer3Failure] = designer._build_failures( + dinuc_pair_failures, test_failure_strings + ) + # these fail_cases should match the cases in test_failure_strings excluding "considered" + "ok" + for fail in fail_cases: + if fail.reason == "low tm": + assert fail.count == 159 + elif fail.reason == "high tm": + assert fail.count == 12 + elif fail.reason == "high hairpin stability": + assert fail.count == 23 + elif fail.reason == "long dinucleotide run": # expect 3 failures due to dinucleotide run + assert fail.count == 3 + + +def test_build_failures_debugs( + valid_primer_pairs: list[PrimerPair], + genome_ref: Path, + pair_primer_params: Primer3Parameters, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we log a debug message in the event of an unknown Primer3Failure reason.""" + caplog.set_level(logging.DEBUG) + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + + design_input = Primer3Input( + target=target, + params=pair_primer_params, + task=DesignPrimerPairsTask(), + ) + designer = Primer3(genome_fasta=genome_ref) + primer_pair_designs, dinuc_pair_failures = designer._screen_pair_results( + design_input=design_input, designed_primer_pairs=valid_primer_pairs + ) + test_failure_strings = ["fabricated fail reason 1"] + designer._build_failures(dinuc_pair_failures, test_failure_strings) + expected_error_msg = "Unknown Primer3 failure reason" + assert expected_error_msg in caplog.text + + +def test_primer3_result_primers_ok( + valid_left_primers: list[Primer], valid_right_primers: list[Primer] +) -> None: + primers: list[Primer] = valid_left_primers + valid_right_primers + assert primers == Primer3Result(filtered_designs=primers, failures=[]).primers() + + +def test_primer3_result_primers_exception(valid_primer_pairs: list[PrimerPair]) -> None: + result = Primer3Result(filtered_designs=valid_primer_pairs, failures=[]) + with pytest.raises(ValueError, match="Cannot call `primers` on `PrimerPair` results"): + result.primers() + + +def test_primer3_result_as_primer_result_exception(valid_primer_pairs: list[PrimerPair]) -> None: + result = Primer3Result(filtered_designs=valid_primer_pairs, failures=[]) + with pytest.raises(ValueError, match="Cannot call `as_primer_result` on `PrimerPair` results"): + result.as_primer_result() + + +def test_primer3_result_primer_pairs_ok(valid_primer_pairs: list[PrimerPair]) -> None: + assert valid_primer_pairs == ( + Primer3Result(filtered_designs=valid_primer_pairs, failures=[]).primer_pairs() + ) + + +def test_primer3_result_primer_pairs_exception( + valid_left_primers: list[Primer], valid_right_primers: list[Primer] +) -> None: + primers: list[Primer] = valid_left_primers + valid_right_primers + result = Primer3Result(filtered_designs=primers, failures=[]) + with pytest.raises(ValueError, match="Cannot call `primer_pairs` on `Primer` results"): + result.primer_pairs() + + +def test_primer3_result_as_primer_pair_result_exception( + valid_left_primers: list[Primer], valid_right_primers: list[Primer] +) -> None: + primers: list[Primer] = valid_left_primers + valid_right_primers + result = Primer3Result(filtered_designs=primers, failures=[]) + with pytest.raises(ValueError, match="Cannot call `as_primer_pair_result` on `Primer` results"): + result.as_primer_pair_result() + + +@pytest.mark.parametrize("max_amplicon_length", [100, 101]) +def test_pad_target_region(max_amplicon_length: int, genome_ref: Path) -> None: + """If the target region is shorter than the max amplicon length, it should be padded to fit.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + + with Primer3(genome_fasta=genome_ref) as designer: + padded_region: Span = designer._pad_target_region( + target=target, max_amplicon_length=max_amplicon_length + ) + + assert padded_region.length == max_amplicon_length + + +def test_pad_target_region_doesnt_pad(genome_ref: Path) -> None: + """If the target region is larger than the max amplicon length, no padding should occur.""" + target = Span(refname="chr1", start=201, end=250, strand=Strand.POSITIVE) + + with Primer3(genome_fasta=genome_ref) as designer: + padded_region: Span = designer._pad_target_region(target=target, max_amplicon_length=10) + + assert padded_region == target diff --git a/tests/primer3/test_primer3_failure_reason.py b/tests/primer3/test_primer3_failure_reason.py new file mode 100644 index 0000000..42fc7e9 --- /dev/null +++ b/tests/primer3/test_primer3_failure_reason.py @@ -0,0 +1,69 @@ +from collections import Counter +from typing import Optional + +import pytest + +from prymer.primer3.primer3_failure_reason import Primer3FailureReason + +_FAILURE_REASONS_AND_ENUM_VALUES: list[tuple[str, Primer3FailureReason]] = [ + ("GC content failed", Primer3FailureReason.GC_CONTENT), + ("unknown failure reason", None), + ("long dinucleotide run", Primer3FailureReason.LONG_DINUC), + ("undesirable secondary structure", Primer3FailureReason.SECONDARY_STRUCTURE), + ("amplifies off-target regions", Primer3FailureReason.OFF_TARGET_AMPLIFICATION), + ("amplifies off-target regions", Primer3FailureReason.OFF_TARGET_AMPLIFICATION), +] + + +@pytest.fixture +def failure_counter() -> Counter: + counter: Counter = Counter() + count = 1 + for failure_reason, enum_value in _FAILURE_REASONS_AND_ENUM_VALUES: + if enum_value is not None: # for the "unknown failure reason" above + counter[failure_reason] += count + count += 1 + return counter + + +@pytest.mark.parametrize("failure_reason, expected_enum_value", _FAILURE_REASONS_AND_ENUM_VALUES) +def test_from_reason( + failure_reason: str, expected_enum_value: Optional[Primer3FailureReason] +) -> None: + assert Primer3FailureReason.from_reason(failure_reason) == expected_enum_value + + +@pytest.mark.parametrize("failure_reason, expected_enum_value", _FAILURE_REASONS_AND_ENUM_VALUES) +def test_parse_failures_individually( + failure_reason: str, expected_enum_value: Optional[Primer3FailureReason] +) -> None: + if expected_enum_value is None: + # test that the failure reason cannot be parsed + failure_string = failure_reason + counter = Primer3FailureReason.parse_failures(failure_string) + assert expected_enum_value not in counter + else: + failure_string = f"{failure_reason} {1}" + counter = Primer3FailureReason.parse_failures(failure_string) + assert counter[expected_enum_value] == 1 + + +def test_parse_failures_combined(failure_counter: Counter) -> None: + # build the failure strings + failure_strings = [] + for failure_reason, count in failure_counter.items(): + if count % 2 == 0: + # just have one reason with a count + failure_string = f"{failure_reason} {count}" + failure_strings.append(failure_string) + else: + # add the same reason _count_ # of times + for _ in range(count): + failure_string = f"{failure_reason} 1" + failure_strings.append(failure_string) + + # test it giving one string at a time + assert Primer3FailureReason.parse_failures(*failure_strings) == failure_counter + + # test it giving all the strings, comma-delimited + assert Primer3FailureReason.parse_failures(", ".join(failure_strings)) == failure_counter diff --git a/tests/primer3/test_primer3_parameters.py b/tests/primer3/test_primer3_parameters.py new file mode 100644 index 0000000..d2f70a3 --- /dev/null +++ b/tests/primer3/test_primer3_parameters.py @@ -0,0 +1,88 @@ +from dataclasses import replace + +import pytest + +from prymer.api.minoptmax import MinOptMax +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_parameters import Primer3Parameters + + +@pytest.fixture +def valid_primer3_params() -> Primer3Parameters: + return Primer3Parameters( + amplicon_sizes=MinOptMax(min=200, opt=250, max=300), + amplicon_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), + primer_sizes=MinOptMax(min=18, opt=21, max=27), + primer_tms=MinOptMax(min=55.0, opt=60.0, max=65.0), + primer_gcs=MinOptMax(min=45.0, opt=55.0, max=60.0), + ) + + +def test_primer3_param_construction_valid(valid_primer3_params: Primer3Parameters) -> None: + """Test Primer3Parameters class instantiation with valid input""" + assert valid_primer3_params.amplicon_sizes.min == 200 + assert valid_primer3_params.amplicon_sizes.opt == 250 + assert valid_primer3_params.amplicon_sizes.max == 300 + assert valid_primer3_params.primer_gcs.min == 45.0 + assert valid_primer3_params.primer_gcs.opt == 55.0 + assert valid_primer3_params.primer_gcs.max == 60.0 + + +def test_primer3_param_construction_raises(valid_primer3_params: Primer3Parameters) -> None: + """Test that Primer3Parameters post_init raises with invalid input.""" + # overriding mypy here to test a case that normally would be caught by mypy + with pytest.raises(ValueError, match="Primer Max Dinuc Bases must be an even number of bases"): + # replace will create a new Primer instance with the provided/modified arguments + replace(valid_primer3_params, primer_max_dinuc_bases=5) + with pytest.raises(TypeError, match="Amplicon sizes and primer sizes must be integers"): + replace(valid_primer3_params, amplicon_sizes=MinOptMax(min=200.0, opt=250.0, max=300.0)) # type: ignore + with pytest.raises(TypeError, match="Amplicon sizes and primer sizes must be integers"): + replace(valid_primer3_params, primer_sizes=MinOptMax(min=18.0, opt=21.0, max=27.0)) # type: ignore + with pytest.raises(ValueError, match="Min primer GC-clamp must be <= max primer GC-clamp"): + replace(valid_primer3_params, gc_clamp=(5, 0)) + + +def test_to_input_tags_primer3_params(valid_primer3_params: Primer3Parameters) -> None: + """Test that to_input_tags() works as expected""" + test_dict = valid_primer3_params.to_input_tags() + assert test_dict[Primer3InputTag.PRIMER_NUM_RETURN] == 5 + assert test_dict[Primer3InputTag.PRIMER_PRODUCT_SIZE_RANGE] == "200-300" + assert test_dict[Primer3InputTag.PRIMER_PRODUCT_OPT_SIZE] == 250 + assert test_dict[Primer3InputTag.PRIMER_PRODUCT_MIN_TM] == 55.0 + assert test_dict[Primer3InputTag.PRIMER_PRODUCT_OPT_TM] == 60.0 + assert test_dict[Primer3InputTag.PRIMER_PRODUCT_MAX_TM] == 65.0 + assert test_dict[Primer3InputTag.PRIMER_MIN_SIZE] == 18 + assert test_dict[Primer3InputTag.PRIMER_OPT_SIZE] == 21 + assert test_dict[Primer3InputTag.PRIMER_MAX_SIZE] == 27 + assert test_dict[Primer3InputTag.PRIMER_MIN_TM] == 55.0 + assert test_dict[Primer3InputTag.PRIMER_OPT_TM] == 60.0 + assert test_dict[Primer3InputTag.PRIMER_MAX_TM] == 65.0 + assert test_dict[Primer3InputTag.PRIMER_MIN_GC] == 45.0 + assert test_dict[Primer3InputTag.PRIMER_OPT_GC_PERCENT] == 55.0 + assert test_dict[Primer3InputTag.PRIMER_MAX_GC] == 60.0 + assert test_dict[Primer3InputTag.PRIMER_GC_CLAMP] == 0 + assert test_dict[Primer3InputTag.PRIMER_MAX_END_GC] == 5 + assert test_dict[Primer3InputTag.PRIMER_MAX_POLY_X] == 5 + assert test_dict[Primer3InputTag.PRIMER_MAX_NS_ACCEPTED] == 1 + assert test_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 1 + ambiguous_primer_design = replace(valid_primer3_params, avoid_masked_bases=False) + ambiguous_dict = ambiguous_primer_design.to_input_tags() + assert ambiguous_dict[Primer3InputTag.PRIMER_LOWERCASE_MASKING] == 0 + + +def test_max_ampl_length(valid_primer3_params: Primer3Parameters) -> None: + """Test that max_amplicon_length() returns expected int""" + assert valid_primer3_params.max_amplicon_length == 300 + change_max_length = replace( + valid_primer3_params, amplicon_sizes=MinOptMax(min=200, opt=500, max=1000) + ) + assert change_max_length.max_amplicon_length == 1000 + + +def test_max_primer_length(valid_primer3_params: Primer3Parameters) -> None: + """Test that max_primer_length() returns expected int""" + assert valid_primer3_params.max_primer_length == 27 + change_max_length = replace( + valid_primer3_params, primer_sizes=MinOptMax(min=18, opt=35, max=50) + ) + assert change_max_length.max_primer_length == 50 diff --git a/tests/primer3/test_primer3_task.py b/tests/primer3/test_primer3_task.py new file mode 100644 index 0000000..e05dd9d --- /dev/null +++ b/tests/primer3/test_primer3_task.py @@ -0,0 +1,164 @@ +import pytest + +from prymer.api.span import Span +from prymer.api.span import Strand +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_task import DesignLeftPrimersTask +from prymer.primer3.primer3_task import DesignPrimerPairsTask +from prymer.primer3.primer3_task import DesignRightPrimersTask +from prymer.primer3.primer3_task import Primer3Task + + +def test_pair_design_construction() -> None: + test_pair_design = DesignPrimerPairsTask() + assert test_pair_design.task_type == "PAIR" + assert test_pair_design.count_tag == "PRIMER_PAIR_NUM_RETURNED" + + +def test_right_design_construction() -> None: + test_right_primer_design = DesignRightPrimersTask() + assert test_right_primer_design.task_type == "RIGHT" + assert test_right_primer_design.count_tag == "PRIMER_RIGHT_NUM_RETURNED" + + +def test_left_design_construction() -> None: + test_left_primer_design = DesignLeftPrimersTask() + assert test_left_primer_design.task_type == "LEFT" + assert test_left_primer_design.count_tag == "PRIMER_LEFT_NUM_RETURNED" + + +@pytest.mark.parametrize( + "design_region, target_region, expected_seq_target", + [ + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), + "200,101", + ), + ( + Span(refname="chr1", start=200, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=220, end=480, strand=Strand.POSITIVE), + "21,261", + ), + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=1, end=500, strand=Strand.NEGATIVE), + "1,500", + ), + ], +) +def test_pair_design_to_input_tags( + design_region: Span, target_region: Span, expected_seq_target: str +) -> None: + test_task = DesignPrimerPairsTask() + test_tags = test_task.to_input_tags(design_region=design_region, target=target_region) + assert test_tags[Primer3InputTag.SEQUENCE_TARGET] == expected_seq_target + assert test_tags[Primer3InputTag.PRIMER_TASK] == "generic" + assert test_tags[Primer3InputTag.PRIMER_PICK_LEFT_PRIMER] == 1 + assert test_tags[Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER] == 1 + assert test_tags[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 0 + + +@pytest.mark.parametrize( + "design_region, target_region, expected_seq_target", + [ + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), + "1,199", + ), + ( + Span(refname="chr1", start=200, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=220, end=480, strand=Strand.POSITIVE), + "1,20", + ), + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=1, end=500, strand=Strand.NEGATIVE), + "1,0", + ), + ], +) +def test_left_design_to_input_tags( + design_region: Span, target_region: Span, expected_seq_target: str +) -> None: + test_task = DesignLeftPrimersTask() + test_tags = test_task.to_input_tags(design_region=design_region, target=target_region) + assert test_tags[Primer3InputTag.SEQUENCE_INCLUDED_REGION] == expected_seq_target + assert test_tags[Primer3InputTag.PRIMER_TASK] == "pick_primer_list" + assert test_tags[Primer3InputTag.PRIMER_PICK_LEFT_PRIMER] == 1 + assert test_tags[Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER] == 0 + assert test_tags[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 0 + + +@pytest.mark.parametrize( + "design_region, target_region, expected_seq_target", + [ + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), + "300,200", + ), + ( + Span(refname="chr1", start=200, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=220, end=480, strand=Strand.POSITIVE), + "281,20", + ), + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=1, end=500, strand=Strand.NEGATIVE), + "500,0", + ), + ], +) +def test_right_design_to_input_tags( + design_region: Span, target_region: Span, expected_seq_target: str +) -> None: + test_task = DesignRightPrimersTask() + test_tags = test_task.to_input_tags(design_region=design_region, target=target_region) + assert test_tags[Primer3InputTag.SEQUENCE_INCLUDED_REGION] == expected_seq_target + assert test_tags[Primer3InputTag.PRIMER_TASK] == "pick_primer_list" + assert test_tags[Primer3InputTag.PRIMER_PICK_LEFT_PRIMER] == 0 + assert test_tags[Primer3InputTag.PRIMER_PICK_RIGHT_PRIMER] == 1 + assert test_tags[Primer3InputTag.PRIMER_PICK_INTERNAL_OLIGO] == 0 + + +@pytest.mark.parametrize( + "design_region, target_region", + [ + ( + Span(refname="chr1", start=1, end=500, strand=Strand.POSITIVE), + Span(refname="chr1", start=400, end=600, strand=Strand.POSITIVE), + ), # target region is outside design region, right side + ( + Span(refname="chr1", start=1, end=10, strand=Strand.POSITIVE), + Span(refname="chr1", start=9, end=20, strand=Strand.POSITIVE), + ), # target region outside design region, right side + ( + Span(refname="chr1", start=100, end=200, strand=Strand.POSITIVE), + Span(refname="chr1", start=200, end=300, strand=Strand.POSITIVE), + ), # target region outside design region, right side + ( + Span(refname="chr1", start=100, end=200, strand=Strand.POSITIVE), + Span(refname="chr1", start=1, end=101, strand=Strand.POSITIVE), + ), # target region outside design, left side + ( + Span(refname="chr1", start=100, end=200, strand=Strand.POSITIVE), + Span(refname="chr1", start=1, end=100, strand=Strand.POSITIVE), + ), # target region outside design, left side + ], +) +@pytest.mark.parametrize( + "task_type", [DesignRightPrimersTask(), DesignLeftPrimersTask(), DesignPrimerPairsTask()] +) +def test_invalid_design_target_combo( + design_region: Span, + target_region: Span, + task_type: Primer3Task, +) -> None: + """Test that all Primer3Tasks raise an error when the target region is not wholly + contained by the design region.""" + assert design_region.overlaps(target_region) + test_task = task_type + with pytest.raises(ValueError, match="Target not contained within design region:"): + test_task.to_input_tags(design_region=design_region, target=target_region) diff --git a/tests/primer3/test_primer3_weights.py b/tests/primer3/test_primer3_weights.py new file mode 100644 index 0000000..1919a0f --- /dev/null +++ b/tests/primer3/test_primer3_weights.py @@ -0,0 +1,30 @@ +from prymer.primer3.primer3_input_tag import Primer3InputTag +from prymer.primer3.primer3_weights import Primer3Weights + + +def test_primer_weights_valid() -> None: + """Test instantiation of Primer3Weights object with valid input""" + test_weights = Primer3Weights() + test_dict = test_weights.to_input_tags() + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_GT] == 1 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_LT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_TM_GT] == 0.0 + assert test_dict[Primer3InputTag.PRIMER_WT_END_STABILITY] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_LT] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_GC_PERCENT_GT] == 0.25 + assert test_dict[Primer3InputTag.PRIMER_WT_SELF_ANY] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_SELF_END] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_LT] == 0.5 + assert test_dict[Primer3InputTag.PRIMER_WT_SIZE_GT] == 0.1 + assert test_dict[Primer3InputTag.PRIMER_WT_TM_LT] == 1.0 + assert test_dict[Primer3InputTag.PRIMER_WT_TM_GT] == 1.0 + assert len((test_dict.values())) == 13 + + +def test_primer_weights_to_input_tags() -> None: + """Test results from to_input_tags() with and without default values""" + default_map = Primer3Weights().to_input_tags() + assert default_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 1 + customized_map = Primer3Weights(product_size_lt=5).to_input_tags() + assert customized_map[Primer3InputTag.PRIMER_PAIR_WT_PRODUCT_SIZE_LT] == 5 diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/util/test_executable_runner.py b/tests/util/test_executable_runner.py new file mode 100644 index 0000000..0d75dca --- /dev/null +++ b/tests/util/test_executable_runner.py @@ -0,0 +1,64 @@ +import os +from pathlib import Path +from tempfile import NamedTemporaryFile + +import pytest + +from prymer.util import ExecutableRunner + + +def test_no_command() -> None: + with pytest.raises(ValueError, match="Invocation must not be empty"): + ExecutableRunner(command=[]) + + +def test_close_twice() -> None: + exec = ExecutableRunner(command=["sleep", "5"]) + assert exec.close() is True + assert exec.close() is False + + +def test_validate_executable_path_does_not_eexist() -> None: + with pytest.raises(ValueError, match="Executable does not exist"): + ExecutableRunner.validate_executable_path(executable="/path/to/nowhere") + + +def test_validate_executable_path_not_executable() -> None: + with NamedTemporaryFile(suffix=".exe", mode="w", delete=True) as tmpfile: + with pytest.raises(ValueError, match="is not executable"): + ExecutableRunner.validate_executable_path(executable=tmpfile.name) + + +def test_validate_executable_path() -> None: + exec = "yes" + exec_full_str = f"/usr/bin/{exec}" + exec_full_path = Path(exec_full_str) + assert exec_full_path.is_absolute() + + # find it on the PATH + assert exec_full_path == ExecutableRunner.validate_executable_path(executable=exec) + # find it given an absolute path as a string + assert exec_full_path == ExecutableRunner.validate_executable_path(executable=exec_full_str) + # find it given an absolute path as a Path + assert exec_full_path == ExecutableRunner.validate_executable_path(executable=exec_full_path) + + # do not find it on the PATH if given as a Path + with pytest.raises(ValueError, match="Executable does not exist"): + ExecutableRunner.validate_executable_path(executable=Path(exec)) + + +def test_validate_executable_path_new_file() -> None: + with NamedTemporaryFile(suffix=".exe", mode="w", delete=True) as tmpfile: + exec_str: str = tmpfile.name + exec_path: Path = Path(exec_str) + # not an executable + with pytest.raises(ValueError, match="is not executable"): + ExecutableRunner.validate_executable_path(executable=exec_str) + # make it executable and test again + os.chmod(exec_str, 755) + exec_full_path: Path = exec_path.absolute() + assert exec_full_path == ExecutableRunner.validate_executable_path(executable=exec_str) + assert exec_full_path == ExecutableRunner.validate_executable_path(executable=exec_path) + assert exec_full_path == ExecutableRunner.validate_executable_path( + executable=exec_full_path + )