From 4443297f47b785eee42bb2f5a4313f599ddeadf5 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Wed, 2 Sep 2026 23:55:31 -0700 Subject: [PATCH 1/4] Migrate package to hatch --- .coveragerc | 4 +- .github/dependabot.yml | 15 ++ .github/workflows/pre-commit.yml | 18 ++ .github/workflows/publish-pypi.yml | 99 +++++--- .github/workflows/run-tests.yml | 21 +- .gitignore | 56 +++++ .pre-commit-config.yaml | 15 +- .readthedocs.yml | 23 -- CONTRIBUTING.md | 377 +---------------------------- docs/conf.py | 73 +++--- docs/requirements.txt | 4 +- pyproject.toml | 125 +++++++++- setup.cfg | 143 ----------- setup.py | 20 -- src/genomicranges/GenomicRanges.py | 119 ++++----- src/genomicranges/grangeslist.py | 25 +- src/genomicranges/io/gtf.py | 74 +++--- src/genomicranges/io/ucsc.py | 9 +- src/genomicranges/py.typed | 0 src/genomicranges/sequence_info.py | 81 ++++--- src/genomicranges/utils.py | 66 ++++- tests/test_io_gtf.py | 59 +++++ tests/test_io_ucsc.py | 36 +++ tests/test_utils.py | 57 +++++ tox.ini | 65 +++-- 25 files changed, 747 insertions(+), 837 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/pre-commit.yml delete mode 100644 .readthedocs.yml delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/genomicranges/py.typed create mode 100644 tests/test_io_gtf.py create mode 100644 tests/test_io_ucsc.py create mode 100644 tests/test_utils.py diff --git a/.coveragerc b/.coveragerc index 7febb784..7235976c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,8 @@ # .coveragerc to control coverage.py [run] branch = True -source = genomicranges -# omit = bad_file.py +source = src +omit = tests/* [paths] source = diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..12eea0de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..b91e8360 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,18 @@ +name: pre-commit + +on: + pull_request: + push: + branches: + - master # for legacy repos + - main + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 405fee01..f0be5037 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,52 +1,91 @@ -name: Publish to PyPI +name: Publish to PyPI and GitHub Pages on: push: tags: "*" jobs: - build: + build-and-test: + name: Build and Test runs-on: ubuntu-latest - permissions: - id-token: write - repository-projects: write - contents: write - pages: write - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox + - name: Install tox + run: python -m pip install tox - - name: Test with tox - run: | - tox + - name: Test + run: tox -e default - - name: Build Project and Publish - run: | - python -m tox -e clean,build + - name: Build Project + run: tox -e build - # This uses the trusted publisher workflow so no token is required. - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + build-docs: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install tox + run: python -m pip install tox - name: Build docs - run: | - tox -e docs + run: tox -e docs - - run: touch ./docs/_build/html/.nojekyll + - name: Add .nojekyll + run: touch ./docs/_build/html/.nojekyll - - name: GH Pages Deployment - uses: JamesIves/github-pages-deploy-action@v4 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 with: - branch: gh-pages # The branch the action should deploy to. - folder: ./docs/_build/html - clean: true # Automatically remove deleted files from the deploy branch + path: ./docs/_build/html + + publish-pypi: + name: Publish to PyPI + needs: build-and-test + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/genomicranges + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + deploy-pages: + name: Deploy GitHub Pages + needs: build-docs + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 86651250..ca79fe49 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -28,7 +28,7 @@ jobs: test: strategy: matrix: - python: ["3.10", "3.11", "3.12", "3.13"] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] platform: - ubuntu-latest - macos-latest @@ -36,21 +36,18 @@ jobs: runs-on: ${{ matrix.platform }} name: Python ${{ matrix.python }}, ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 - id: setup-python + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox coverage + - name: Install tox + run: python -m pip install tox coverage - name: Run tests run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' tox -- -rFEx --durations 10 --color yes --cov --cov-branch --cov-report=xml # pytest args @@ -65,9 +62,9 @@ jobs: fi - name: Upload coverage reports to Codecov with GitHub Action - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 if: ${{ steps.codecov-check.outputs.codecov == 'true' }} - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + token: ${{ secrets.CODECOV_TOKEN }} slug: ${{ github.repository }} flags: ${{ matrix.platform }} - py${{ matrix.python }} diff --git a/.gitignore b/.gitignore index 19d4b9b4..bbf3ba0e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,59 @@ MANIFEST # Per-project virtualenvs .venv*/ .conda*/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*$py.class +# C extensions +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +# PyInstaller +*.manifest +*.spec +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.cache +nosetests.xml +*.cover +*.py,cover +.hypothesis/ +cover/ +# Sphinx documentation +docs/_build/ +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +# mypy, ruff, etc +.mypy_cache/ +.ruff_cache/ +.pyre/ +# Editors +.vscode/ +.idea/ +*.swp +*.swo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f914852..af3eb039 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,10 +33,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.6 + rev: v0.16.2 hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] + # Run the linter. + - id: ruff-check + args: [--fix, --exit-zero] + # Run the formatter. - id: ruff-format ## If like to embrace black styles even in the docs: @@ -51,3 +53,10 @@ repos: # rev: v2.2.5 # hooks: # - id: codespell + +- repo: https://github.com/PyCQA/bandit + rev: 1.7.9 + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 21b08145..00000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required -version: 2 - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Build documentation with MkDocs -#mkdocs: -# configuration: mkdocs.yml - -# Optionally build your docs in additional formats such as PDF -formats: - - pdf - -python: - version: 3.8 - install: - - requirements: docs/requirements.txt - - {path: ., method: pip} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2df8b839..02b45465 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,371 +1,20 @@ -```{todo} THIS IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! - - The document assumes you are using a source repository service that promotes a - contribution model similar to [GitHub's fork and pull request workflow]. - While this is true for the majority of services (like GitHub, GitLab, - BitBucket), it might not be the case for private repositories (e.g., when - using Gerrit). - - Also notice that the code examples might refer to GitHub URLs or the text - might use GitHub specific terminology (e.g., *Pull Request* instead of *Merge - Request*). - - Please make sure to check the document having these assumptions in mind - and update things accordingly. -``` - -```{todo} Provide the correct links/replacements at the bottom of the document. -``` - -```{todo} You might want to have a look on [PyScaffold's contributor's guide], - - especially if your project is open source. The text should be very similar to - this template, but there are a few extra contents that you might decide to - also include, like mentioning labels of your issue tracker or automated - releases. -``` - # Contributing -Welcome to `GenomicRanges` contributor's guide. - -This document focuses on getting any potential contributor familiarized with -the development processes, but [other kinds of contributions] are also appreciated. - -If you are new to using [git] or have never collaborated in a project previously, -please have a look at [contribution-guide.org]. Other resources are also -listed in the excellent [guide created by FreeCodeCamp] [^contrib1]. - -Please notice, all users and contributors are expected to be **open, -considerate, reasonable, and respectful**. When in doubt, -[Python Software Foundation's Code of Conduct] is a good reference in terms of -behavior guidelines. - -## Issue Reports - -If you experience bugs or general issues with `GenomicRanges`, please have a look -on the [issue tracker]. -If you don't see anything useful there, please feel free to fire an issue report. - -:::{tip} -Please don't forget to include the closed issues in your search. -Sometimes a solution was already reported, and the problem is considered -**solved**. -::: - -New issue reports should include information about your programming environment -(e.g., operating system, Python version) and steps to reproduce the problem. -Please try also to simplify the reproduction steps to a very minimal example -that still illustrates the problem you are facing. By removing other factors, -you help us to identify the root cause of the issue. - -## Documentation Improvements - -You can help improve `GenomicRanges` docs by making them more readable and coherent, or -by adding missing information and correcting mistakes. - -`GenomicRanges` documentation uses [Sphinx] as its main documentation compiler. -This means that the docs are kept in the same repository as the project code, and -that any documentation update is done in the same way was a code contribution. - -```{todo} Don't forget to mention which markup language you are using. - - e.g., [reStructuredText] or [CommonMark] with [MyST] extensions. -``` - -```{todo} If your project is hosted on GitHub, you can also mention the following tip: - - :::{tip} - Please notice that the [GitHub web interface] provides a quick way of - propose changes in `GenomicRanges`'s files. While this mechanism can - be tricky for normal code contributions, it works perfectly fine for - contributing to the docs, and can be quite handy. - - If you are interested in trying this method out, please navigate to - the `docs` folder in the source [repository], find which file you - would like to propose changes and click in the little pencil icon at the - top, to open [GitHub's code editor]. Once you finish editing the file, - please write a message in the form at the bottom of the page describing - which changes have you made and what are the motivations behind them and - submit your proposal. - ::: -``` - -When working on documentation changes in your local machine, you can -compile them using [tox] : - -``` -tox -e docs -``` - -and use Python's built-in web server for a preview in your web browser -(`http://localhost:8000`): - -``` -python3 -m http.server --directory 'docs/_build/html' -``` - -## Code Contributions - -```{todo} Please include a reference or explanation about the internals of the project. - - An architecture description, design principles or at least a summary of the - main concepts will make it easy for potential contributors to get started - quickly. -``` - -### Submit an issue - -Before you work on any non-trivial code contribution it's best to first create -a report in the [issue tracker] to start a discussion on the subject. -This often provides additional considerations and avoids unnecessary work. - -### Create an environment - -Before you start coding, we recommend creating an isolated [virtual environment] -to avoid any problems with your installed Python packages. -This can easily be done via either [virtualenv]: - -``` -virtualenv -source /bin/activate -``` - -or [Miniconda]: - -``` -conda create -n GenomicRanges python=3 six virtualenv pytest pytest-cov -conda activate GenomicRanges -``` - -### Clone the repository - -1. Create an user account on GitHub if you do not already have one. - -2. Fork the project [repository]: click on the *Fork* button near the top of the - page. This creates a copy of the code under your account on GitHub. - -3. Clone this copy to your local disk: - - ``` - git clone git@github.com:YourLogin/GenomicRanges.git - cd GenomicRanges - ``` - -4. You should run: - - ``` - pip install -U pip setuptools -e . - ``` - - to be able to import the package under development in the Python REPL. - - ```{todo} if you are not using pre-commit, please remove the following item: - ``` - -5. Install [pre-commit]: - - ``` - pip install pre-commit - pre-commit install - ``` - - `GenomicRanges` comes with a lot of hooks configured to automatically help the - developer to check the code being written. - -### Implement your changes - -1. Create a branch to hold your changes: - - ``` - git checkout -b my-feature - ``` - - and start making changes. Never work on the main branch! - -2. Start your work on this branch. Don't forget to add [docstrings] to new - functions, modules and classes, especially if they are part of public APIs. - -3. Add yourself to the list of contributors in `AUTHORS.rst`. - -4. When you’re done editing, do: - - ``` - git add - git commit - ``` - - to record your changes in [git]. - - ```{todo} if you are not using pre-commit, please remove the following item: - ``` - - Please make sure to see the validation messages from [pre-commit] and fix - any eventual issues. - This should automatically use [flake8]/[black] to check/fix the code style - in a way that is compatible with the project. - - :::{important} - Don't forget to add unit tests and documentation in case your - contribution adds an additional feature and is not just a bugfix. - - Moreover, writing a [descriptive commit message] is highly recommended. - In case of doubt, you can check the commit history with: - - ``` - git log --graph --decorate --pretty=oneline --abbrev-commit --all - ``` - - to look for recurring communication patterns. - ::: - -5. Please check that your changes don't break any unit tests with: - - ``` - tox - ``` - - (after having installed [tox] with `pip install tox` or `pipx`). - - You can also use [tox] to run several other pre-configured tasks in the - repository. Try `tox -av` to see a list of the available checks. - -### Submit your contribution - -1. If everything works fine, push your local branch to the remote server with: - - ``` - git push -u origin my-feature - ``` - -2. Go to the web page of your fork and click "Create pull request" - to send your changes for review. - - ```{todo} if you are using GitHub, you can uncomment the following paragraph - - Find more detailed information in [creating a PR]. You might also want to open - the PR as a draft first and mark it as ready for review after the feedbacks - from the continuous integration (CI) system or any required fixes. - - ``` - -### Troubleshooting - -The following tips can be used when facing problems to build or test the -package: - -1. Make sure to fetch all the tags from the upstream [repository]. - The command `git describe --abbrev=0 --tags` should return the version you - are expecting. If you are trying to run CI scripts in a fork repository, - make sure to push all the tags. - You can also try to remove all the egg files or the complete egg folder, i.e., - `.eggs`, as well as the `*.egg-info` folders in the `src` folder or - potentially in the root of your project. - -2. Sometimes [tox] misses out when new dependencies are added, especially to - `setup.cfg` and `docs/requirements.txt`. If you find any problems with - missing dependencies when running a command with [tox], try to recreate the - `tox` environment using the `-r` flag. For example, instead of: - - ``` - tox -e docs - ``` - - Try running: - - ``` - tox -r -e docs - ``` - -3. Make sure to have a reliable [tox] installation that uses the correct - Python version (e.g., 3.7+). When in doubt you can run: - - ``` - tox --version - # OR - which tox - ``` - - If you have trouble and are seeing weird errors upon running [tox], you can - also try to create a dedicated [virtual environment] with a [tox] binary - freshly installed. For example: - - ``` - virtualenv .venv - source .venv/bin/activate - .venv/bin/pip install tox - .venv/bin/tox -e all - ``` - -4. [Pytest can drop you] in an interactive session in the case an error occurs. - In order to do that you need to pass a `--pdb` option (for example by - running `tox -- -k --pdb`). - You can also setup breakpoints manually instead of using the `--pdb` option. - -## Maintainer tasks - -### Releases - -```{todo} This section assumes you are using PyPI to publicly release your package. - - If instead you are using a different/private package index, please update - the instructions accordingly. -``` - -If you are part of the group of maintainers and have correct user permissions -on [PyPI], the following steps can be used to release a new version for -`GenomicRanges`: - -1. Make sure all unit tests are successful. -2. Tag the current commit on the main branch with a release tag, e.g., `v1.2.3`. -3. Push the new tag to the upstream [repository], - e.g., `git push upstream v1.2.3` -4. Clean up the `dist` and `build` folders with `tox -e clean` - (or `rm -rf dist build`) - to avoid confusion with old builds and Sphinx docs. -5. Run `tox -e build` and check that the files in `dist` have - the correct version (no `.dirty` or [git] hash) according to the [git] tag. - Also check the sizes of the distributions, if they are too big (e.g., > - 500KB), unwanted clutter may have been accidentally included. -6. Run `tox -e publish -- --repository pypi` and check that everything was - uploaded to [PyPI] correctly. - -[^contrib1]: Even though, these resources focus on open source projects and - communities, the general ideas behind collaborating with other developers - to collectively create software are general and can be applied to all sorts - of environments, including private companies and proprietary code bases. +Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. +## Report Bugs +Report bugs at the issue tracker. -[black]: https://pypi.org/project/black/ -[commonmark]: https://commonmark.org/ -[contribution-guide.org]: http://www.contribution-guide.org/ -[creating a pr]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request -[descriptive commit message]: https://chris.beams.io/posts/git-commit -[docstrings]: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html -[first-contributions tutorial]: https://github.com/firstcontributions/first-contributions -[flake8]: https://flake8.pycqa.org/en/stable/ -[git]: https://git-scm.com -[github web interface]: https://docs.github.com/en/github/managing-files-in-a-repository/managing-files-on-github/editing-files-in-your-repository -[github's code editor]: https://docs.github.com/en/github/managing-files-in-a-repository/managing-files-on-github/editing-files-in-your-repository -[github's fork and pull request workflow]: https://guides.github.com/activities/forking/ -[guide created by freecodecamp]: https://github.com/freecodecamp/how-to-contribute-to-open-source -[miniconda]: https://docs.conda.io/en/latest/miniconda.html -[myst]: https://myst-parser.readthedocs.io/en/latest/syntax/syntax.html -[other kinds of contributions]: https://opensource.guide/how-to-contribute -[pre-commit]: https://pre-commit.com/ -[pypi]: https://pypi.org/ -[pyscaffold's contributor's guide]: https://pyscaffold.org/en/stable/contributing.html -[pytest can drop you]: https://docs.pytest.org/en/stable/usage.html#dropping-to-pdb-python-debugger-at-the-start-of-a-test -[python software foundation's code of conduct]: https://www.python.org/psf/conduct/ -[restructuredtext]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/ -[sphinx]: https://www.sphinx-doc.org/en/master/ -[tox]: https://tox.readthedocs.io/en/stable/ -[virtual environment]: https://realpython.com/python-virtual-environments-a-primer/ -[virtualenv]: https://virtualenv.pypa.io/en/stable/ +## Fix Bugs +Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. +## Implement Features +Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. -```{todo} Please review and change the following definitions: -``` +## Submit Feedback +The best way to send feedback is to file an issue. -[repository]: https://github.com//GenomicRanges -[issue tracker]: https://github.com//GenomicRanges/issues +If you are proposing a feature: +- Explain in detail how it would work. +- Keep the scope as narrow as possible, to make it easier to implement. +- Remember that this is a volunteer-driven project, and that contributions are welcome! diff --git a/docs/conf.py b/docs/conf.py index 5360e3b8..7a4a4db4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -72,7 +72,6 @@ "sphinx.ext.ifconfig", "sphinx.ext.mathjax", "sphinx.ext.napoleon", - "sphinx_autodoc_typehints", ] # Add any paths that contain templates here, relative to this directory. @@ -80,8 +79,7 @@ # Enable markdown -# extensions.append("myst_parser") -extensions.append("myst_nb") +extensions.append("myst_parser") # Configure MyST-Parser myst_enable_extensions = [ @@ -107,8 +105,8 @@ master_doc = "index" # General information about the project. -project = "GenomicRanges" -copyright = "2023, jkanche" +project = "genomicranges" +copyright = "2023, Jayaram Kancherla" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -119,9 +117,10 @@ # If you don’t need the separation provided between version and release, # just set them both to the same value. try: - from genomicranges import __version__ as version -except ImportError: - version = "" + from importlib.metadata import version as get_version + version = get_version("genomicranges") +except Exception: + version = "unknown" if not version or version.lower() == "unknown": version = os.getenv("READTHEDOCS_VERSION", "unknown") # automatically set by RTD @@ -168,29 +167,28 @@ # If this is True, todo emits a warning for each TODO entries. The default is False. todo_emit_warnings = True -autodoc_default_options = { - # 'members': 'var1, var2', - # 'member-order': 'bysource', - "special-members": True, - "undoc-members": True, - "exclude-members": "__weakref__, __dict__, __str__, __module__", -} - -autosummary_generate = True -autosummary_imported_members = True # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "furo" +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - "sidebar_width": "300px", - "page_width": "1200px" + "light_css_variables": { + "color-brand-primary": "#0052cc", + "color-brand-content": "#0052cc", + }, + "dark_css_variables": { + "color-brand-primary": "#4c9aff", + "color-brand-content": "#4c9aff", + }, + "source_repository": "https://github.com/biocpy/genomicranges", + "source_branch": "main", + "source_directory": "docs/", } # Add any paths that contain custom themes here, relative to this directory. @@ -259,7 +257,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = "GenomicRanges-doc" +htmlhelp_basename = "genomicranges-doc" # -- Options for LaTeX output ------------------------------------------------ @@ -276,7 +274,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ("index", "user_guide.tex", "GenomicRanges Documentation", "jkanche", "manual") + ("index", "user_guide.tex", "genomicranges Documentation", "Jayaram Kancherla", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -310,12 +308,31 @@ "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), "setuptools": ("https://setuptools.pypa.io/en/stable/", None), - "pyscaffold": ("https://pyscaffold.org/en/stable", None), - "biocframe": ("https://biocpy.github.io/BiocFrame", None), - "biocutils": ("https://biocpy.github.io/BiocUtils", None), - "iranges": ("https://biocpy.github.io/IRanges", None), + "biocframe": ("https://biocpy.github.io/biocframe", None), + "biocutils": ("https://biocpy.github.io/biocutils", None), + "iranges": ("https://biocpy.github.io/iranges", None), "polars": ("https://docs.pola.rs/api/python/stable/", None), "compressed-lists": ("https://biocpy.github.io/compressed-lists", None), } -print(f"loading configurations for {project} {version} ...", file=sys.stderr) \ No newline at end of file +print(f"loading configurations for {project} {version} ...", file=sys.stderr) + +# -- Biocsetup configuration ------------------------------------------------- + +# Enable execution of code chunks in markdown +extensions.remove('myst_parser') +extensions.append('myst_nb') + +# Less verbose api documentation +extensions.append('sphinx_autodoc_typehints') + +autodoc_default_options = { + "special-members": True, + "undoc-members": True, + "exclude-members": "__weakref__, __dict__, __str__, __module__", +} + +autosummary_generate = True +autosummary_imported_members = True + +html_theme = "furo" diff --git a/docs/requirements.txt b/docs/requirements.txt index c20cf60b..a1b9d2bd 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,9 +1,9 @@ -furo -myst-nb # Requirements file for ReadTheDocs, check .readthedocs.yml. # To build the module reference correctly, make sure every external package # under `install_requires` in `setup.cfg` is also listed here! # sphinx_rtd_theme myst-parser[linkify] sphinx>=3.2.1 +myst-nb +furo sphinx-autodoc-typehints diff --git a/pyproject.toml b/pyproject.toml index 81150d61..6c6822d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,127 @@ +[project] +name = "genomicranges" +dynamic = [ + "version", +] +description = "Container class to represent and operate over genomic regions and annotations." +readme = "README.md" +authors = [ + { name = "Jayaram Kancherla", email = "jayaram.kancherla@gmail.com" }, +] +requires-python = ">=3.9" +keywords = [ + "bioinformatics", + "genomics", + "bioconductor", + "biocpy", + "genomicranges", + "ranges", + "intervals", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", +] +dependencies = [ + "biocframe>=0.8.0", + "biocutils>=0.4.0", + "compressed-lists>=0.4.3", + "iranges>=0.7.2", + "numpy", +] + + +[project.license] +file = "LICENSE.txt" + + +[project.urls] +Homepage = "https://github.com/BiocPy/genomicranges" +Documentation = "https://biocpy.github.io/genomicranges/" +Source = "https://github.com/BiocPy/genomicranges" +"Bug Tracker" = "https://github.com/BiocPy/genomicranges/issues" + +[project.optional-dependencies] +optional = [ + "biobear", + "joblib", + "matplotlib", + "pandas", + "polars", +] +testing = [ + "biobear", + "joblib", + "matplotlib", + "pandas", + "polars", + "pytest", + "pytest-cov", + "rich", + "seaborn", +] + [build-system] -# AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! -requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] -build-backend = "setuptools.build_meta" +requires = [ + "hatchling", + "hatch-vcs", +] +build-backend = "hatchling.build" -[tool.setuptools_scm] -# See configuration details in https://github.com/pypa/setuptools_scm -version_scheme = "no-guess-dev" +[tool.hatch.version] +source = "vcs" +fallback-version = "0.1.0" [tool.ruff] line-length = 120 -src = ["src"] -# exclude = ["tests"] -lint.extend-ignore = ["F821"] +src = [ + "src", +] +exclude = [ + "tests", + "docs", +] + +[tool.ruff.lint] +extend-ignore = [ + "F821", +] [tool.ruff.lint.pydocstyle] convention = "google" +[tool.ruff.lint.per-file-ignores] +"__init__.py" = [ + "E402", + "F401", +] + [tool.ruff.format] docstring-code-format = true docstring-code-line-length = 20 -[tool.ruff.lint.per-file-ignores] -"__init__.py" = ["E402", "F401"] +[tool.mypy] +ignore_missing_imports = true + +[tool.pytest.ini_options] +addopts = "--cov --cov-report term-missing" +testpaths = [ + "tests", +] + +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B110"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2145c070..00000000 --- a/setup.cfg +++ /dev/null @@ -1,143 +0,0 @@ -# This file is used to configure your project. -# Read more about the various options under: -# https://setuptools.pypa.io/en/latest/userguide/declarative_config.html -# https://setuptools.pypa.io/en/latest/references/keywords.html - -[metadata] -name = GenomicRanges -description = Container class to represent and operate over genomic regions and annotations. -author = Jayaram Kancherla -author_email = jayaram.kancherla@gmail.com -license = MIT -license_files = LICENSE.txt -long_description = file: README.md -long_description_content_type = text/markdown; charset=UTF-8; variant=GFM -url = https://github.com/BiocPy/GenomicRanges -# Add here related links, for example: -project_urls = - Documentation = https://biocpy.github.io/GenomicRanges/ - Source = https://github.com/BiocPy/GenomicRanges -# Changelog = https://pyscaffold.org/en/latest/changelog.html -# Tracker = https://github.com/pyscaffold/pyscaffold/issues -# Conda-Forge = https://anaconda.org/conda-forge/pyscaffold -# Download = https://pypi.org/project/PyScaffold/#files -# Twitter = https://twitter.com/PyScaffold - -# Change if running only on Windows, Mac or Linux (comma-separated) -platforms = any - -# Add here all kinds of additional classifiers as defined under -# https://pypi.org/classifiers/ -classifiers = - Development Status :: 4 - Beta - Programming Language :: Python - - -[options] -zip_safe = False -packages = find_namespace: -include_package_data = True -package_dir = - =src - -# Require a min/specific Python version (comma-separated conditions) -python_requires = >=3.9 - -# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. -# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in -# new major versions. This works if the required packages follow Semantic Versioning. -# For more information, check out https://semver.org/. -install_requires = - importlib-metadata; python_version<"3.8" - biocframe>=0.7.1 - iranges>=0.7.2 - biocutils>=0.3.3 - compressed_lists>=0.4.3 - numpy - -[options.packages.find] -where = src -exclude = - tests - -[options.extras_require] -# Add here additional requirements for extra features, to install with: -# `pip install GenomicRanges[PDF]` like: -optional = - joblib - pandas - polars - matplotlib - biobear - -# Add here test requirements (semicolon/line-separated) -testing = - setuptools - pytest - pytest-cov - pandas - polars - matplotlib - joblib - rich - seaborn - biobear - -[options.entry_points] -# Add here console scripts like: -# console_scripts = -# script_name = genomicranges.module:function -# For example: -# console_scripts = -# fibonacci = genomicranges.skeleton:run -# And any other entry points, for example: -# pyscaffold.cli = -# awesome = pyscaffoldext.awesome.extension:AwesomeExtension - -[tool:pytest] -# Specify command line options as you would do when invoking pytest directly. -# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml -# in order to write a coverage file that can be read by Jenkins. -# CAUTION: --cov flags may prohibit setting breakpoints while debugging. -# Comment those flags to avoid this pytest issue. -addopts = - --cov genomicranges --cov-report term-missing - --verbose -norecursedirs = - dist - build - .tox -testpaths = tests -# Use pytest markers to select/deselect specific tests -# markers = -# slow: mark tests as slow (deselect with '-m "not slow"') -# system: mark end-to-end system tests - -[devpi:upload] -# Options for the devpi: PyPI server and packaging tool -# VCS export must be deactivated since we are using setuptools-scm -no_vcs = 1 -formats = bdist_wheel - -[flake8] -# Some sane defaults for the code style checker flake8 -max_line_length = 100 -extend_ignore = E203, W503 -# ^ Black-compatible -# E203 and W503 have edge cases handled by black -exclude = - .tox - build - dist - .eggs - docs/conf.py -per-file-ignores = __init__.py:F401 - -[pyscaffold] -# PyScaffold's parameters when the project was created. -# This will be used when updating. Do not change! -version = 4.5 -package = genomicranges -extensions = - markdown - pre_commit diff --git a/setup.py b/setup.py deleted file mode 100644 index dae5e5cf..00000000 --- a/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Setup file for GenomicRanges. Use setup.cfg to configure your project. - -This file was generated with PyScaffold 4.5. -PyScaffold helps you to put up the scaffold of your new Python project. -Learn more under: https://pyscaffold.org/ -""" - -from setuptools import setup - -if __name__ == "__main__": - try: - setup(use_scm_version={"version_scheme": "no-guess-dev"}) - except: # noqa - print( - "\n\nAn error occurred while building the project, " - "please ensure you have the most updated version of setuptools, " - "setuptools_scm and wheel with:\n" - " pip install -U setuptools setuptools_scm wheel\n\n" - ) - raise diff --git a/src/genomicranges/GenomicRanges.py b/src/genomicranges/GenomicRanges.py index acb1ae6a..89b3ce1b 100644 --- a/src/genomicranges/GenomicRanges.py +++ b/src/genomicranges/GenomicRanges.py @@ -1,10 +1,15 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Sequence from multiprocessing import Pool, cpu_count -from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Literal from warnings import warn + +if TYPE_CHECKING: + from .grangeslist import CompressedGenomicRangesList + import biocutils as ut import numpy as np from biocframe import BiocFrame @@ -129,11 +134,11 @@ def __init__( self, seqnames: Sequence[str], ranges: IRanges, - strand: Optional[Union[Sequence[str], Sequence[int], np.ndarray]] = None, - names: Optional[Union[ut.Names, Sequence[str]]] = None, - mcols: Optional[BiocFrame] = None, - seqinfo: Optional[SeqInfo] = None, - metadata: Optional[Union[Dict[str, Any], ut.NamedList]] = None, + strand: Sequence[str] | Sequence[int] | np.ndarray | None = None, + names: ut.Names | Sequence[str] | None = None, + mcols: BiocFrame | None = None, + seqinfo: SeqInfo | None = None, + metadata: dict[str, Any] | ut.NamedList | None = None, _validate: bool = True, ): """Initialize a ``GenomicRanges`` object. @@ -437,7 +442,7 @@ def __str__(self) -> str: ######>> seqnames <<###### ########################## - def get_seqnames(self, as_type: Literal["factor", "list"] = "list") -> Union[ut.Factor, List[str]]: + def get_seqnames(self, as_type: Literal["factor", "list"] = "list") -> ut.Factor | list[str]: """Access sequence names. Args: @@ -459,7 +464,7 @@ def get_seqnames(self, as_type: Literal["factor", "list"] = "list") -> Union[ut. else: raise ValueError("Argument 'as_type' must be 'factor' or 'list'.") - def set_seqnames(self, seqnames: Union[Sequence[str], np.ndarray], in_place: bool = False) -> GenomicRanges: + def set_seqnames(self, seqnames: Sequence[str] | np.ndarray, in_place: bool = False) -> GenomicRanges: """Set new sequence names. Args: @@ -486,12 +491,12 @@ def set_seqnames(self, seqnames: Union[Sequence[str], np.ndarray], in_place: boo return output @property - def seqnames(self) -> Union[Union[np.ndarray, List[str]], np.ndarray]: + def seqnames(self) -> np.ndarray | list[str]: """Alias for :py:meth:`~get_seqnames`.""" return self.get_seqnames() @seqnames.setter - def seqnames(self, seqnames: Union[Sequence[str], np.ndarray]): + def seqnames(self, seqnames: Sequence[str] | np.ndarray): """Alias for :py:meth:`~set_seqnames` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -558,7 +563,7 @@ def ranges(self, ranges: IRanges): def get_strand( self, as_type: Literal["numpy", "factor", "list"] = "numpy" - ) -> Union[Tuple[np.ndarray, dict], List[str]]: + ) -> tuple[np.ndarray, dict] | list[str]: """Access strand information. Args: @@ -598,7 +603,7 @@ def get_strand( raise ValueError("Argument 'as_type' must be 'factor' or 'list'.") def set_strand( - self, strand: Optional[Union[Sequence[str], Sequence[int], np.ndarray]], in_place: bool = False + self, strand: Sequence[str] | Sequence[int] | np.ndarray | None, in_place: bool = False ) -> GenomicRanges: """Set new strand information. @@ -635,7 +640,7 @@ def strand(self) -> np.ndarray: return self.get_strand() @strand.setter - def strand(self, strand: Optional[Union[Sequence[str], Sequence[int], np.ndarray]]): + def strand(self, strand: Sequence[str] | Sequence[int] | np.ndarray | None): """Alias for :py:meth:`~set_strand` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -657,7 +662,7 @@ def get_names(self) -> ut.Names: """ return self._names - def set_names(self, names: Optional[Union[ut.Names, Sequence[str]]], in_place: bool = False) -> GenomicRanges: + def set_names(self, names: ut.Names | Sequence[str] | None, in_place: bool = False) -> GenomicRanges: """Set new names. Args: @@ -688,7 +693,7 @@ def names(self) -> ut.Names: return self.get_names() @names.setter - def names(self, names: Optional[Union[ut.Names, Sequence[str]]]): + def names(self, names: ut.Names | Sequence[str] | None): """Alias for :py:meth:`~set_names` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -710,7 +715,7 @@ def get_mcols(self) -> BiocFrame: """ return self._mcols - def set_mcols(self, mcols: Optional[BiocFrame], in_place: bool = False) -> GenomicRanges: + def set_mcols(self, mcols: BiocFrame | None, in_place: bool = False) -> GenomicRanges: """Set new range metadata. Args: @@ -743,7 +748,7 @@ def mcols(self) -> BiocFrame: return self.get_mcols() @mcols.setter - def mcols(self, mcols: Optional[BiocFrame]): + def mcols(self, mcols: BiocFrame | None): """Alias for :py:meth:`~set_mcols` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -765,7 +770,7 @@ def get_seqinfo(self) -> SeqInfo: """ return self._seqinfo - def set_seqinfo(self, seqinfo: Optional[SeqInfo], in_place: bool = False) -> GenomicRanges: + def set_seqinfo(self, seqinfo: SeqInfo | None, in_place: bool = False) -> GenomicRanges: """Set new sequence information. Args: @@ -806,7 +811,7 @@ def seqinfo(self) -> np.ndarray: @seqinfo.setter def seqinfo( self, - seqinfo: Optional[SeqInfo], + seqinfo: SeqInfo | None, ): """Alias for :py:meth:`~set_seqinfo` with ``in_place = True``. @@ -877,7 +882,7 @@ def get_seqlengths(self) -> np.ndarray: ######>> Slicers <<###### ######################### - def get_subset(self, subset: Union[str, int, bool, Sequence]) -> GenomicRanges: + def get_subset(self, subset: str | int | bool | Sequence) -> GenomicRanges: """Subset ``GenomicRanges``, based on their indices or names. Args: @@ -910,13 +915,13 @@ def get_subset(self, subset: Union[str, int, bool, Sequence]) -> GenomicRanges: metadata=self._metadata, ) - def __getitem__(self, subset: Union[str, int, bool, Sequence]) -> GenomicRanges: + def __getitem__(self, subset: str | int | bool | Sequence) -> GenomicRanges: """Alias to :py:attr:`~get_subset`.""" return self.get_subset(subset) def set_subset( self, - args: Union[Sequence, int, str, bool, slice, range], + args: Sequence | int | str | bool | slice | range, value: GenomicRanges, in_place: bool = False, ) -> GenomicRanges: @@ -975,7 +980,7 @@ def set_subset( def __setitem__( self, - args: Union[Sequence, int, str, bool, slice, range], + args: Sequence | int | str | bool | slice | range, value: GenomicRanges, ) -> GenomicRanges: """Alias to :py:attr:`~set_subset`. @@ -1160,7 +1165,7 @@ def from_polars(cls, input) -> GenomicRanges: def flank( self, width: int, - start: Union[bool, np.ndarray, List[bool]] = True, + start: bool | np.ndarray | list[bool] = True, both: bool = False, ignore_strand: bool = False, in_place: bool = False, @@ -1240,8 +1245,8 @@ def flank( def resize( self, - width: Union[int, List[int], np.ndarray], - fix: Union[Literal["start", "end", "center"], List[Literal["start", "end", "center"]]] = "start", + width: int | list[int] | np.ndarray, + fix: Literal["start", "end", "center"] | list[Literal["start", "end", "center"]] = "start", ignore_strand: bool = False, in_place: bool = False, ) -> GenomicRanges: @@ -1301,7 +1306,7 @@ def resize( output._ranges = self._ranges.resize(width=width, fix=fix_arr) return output - def shift(self, shift: Union[int, List[int], np.ndarray] = 0, in_place: bool = False) -> GenomicRanges: + def shift(self, shift: int | list[int] | np.ndarray = 0, in_place: bool = False) -> GenomicRanges: """Shift all intervals. Args: @@ -1385,8 +1390,8 @@ def terminators(self, upstream: int = 2000, downstream: int = 200, in_place: boo def restrict( self, - start: Optional[Union[int, Dict[str, int], np.ndarray]] = None, - end: Optional[Union[int, Dict[str, int], np.ndarray]] = None, + start: int | dict[str, int] | np.ndarray | None = None, + end: int | dict[str, int] | np.ndarray | None = None, keep_all_ranges: bool = False, ) -> GenomicRanges: """Restrict ranges to a given start and end positions. @@ -1567,9 +1572,9 @@ def trim(self, in_place: bool = False) -> GenomicRanges: def narrow( self, - start: Optional[Union[int, List[int], np.ndarray]] = None, - width: Optional[Union[int, List[int], np.ndarray]] = None, - end: Optional[Union[int, List[int], np.ndarray]] = None, + start: int | list[int] | np.ndarray | None = None, + width: int | list[int] | np.ndarray | None = None, + end: int | list[int] | np.ndarray | None = None, in_place: bool = False, ) -> GenomicRanges: """Narrow genomic positions by provided ``start``, ``width`` and ``end`` parameters. @@ -1744,7 +1749,7 @@ def range(self, with_reverse_map: bool = False, ignore_strand: bool = False) -> def gaps( self, start: int = 1, - end: Optional[Union[int, Dict[str, int]]] = None, + end: int | dict[str, int] | None = None, ignore_strand: bool = False, ) -> GenomicRanges: """Identify complemented ranges for each distinct (seqname, strand) pair. @@ -1901,8 +1906,8 @@ def disjoint_bins(self, ignore_strand: bool = False) -> np.ndarray: return binned_results def coverage( - self, shift: int = 0, width: Optional[int] = None, weight: int = 1, ignore_strand: bool = True - ) -> Dict[str, np.ndarray]: + self, shift: int = 0, width: int | None = None, weight: int = 1, ignore_strand: bool = True + ) -> dict[str, np.ndarray]: """ Calculate coverage for each chromosome. For each position, this method counts the number of ranges that cover it. @@ -2153,7 +2158,7 @@ def extract_groups_by_seqnames(self): groups.append(idx) return groups - def _get_query_common_groups(self, query: GenomicRanges) -> Tuple[np.ndarray, np.ndarray]: + def _get_query_common_groups(self, query: GenomicRanges) -> tuple[np.ndarray, np.ndarray]: # smerged = merge_SeqInfo([self._seqinfo, query._seqinfo]) common_seqlevels = set(self._seqinfo._seqnames).intersection(query._seqinfo._seqnames) q_group_idx = [self._seqinfo._seqnames.index(i) for i in common_seqlevels] @@ -2417,7 +2422,7 @@ def nearest( ignore_strand: bool = False, num_threads: int = 1, adjacent_equals_overlap: bool = True, - ) -> Union[np.ndarray, BiocFrame]: + ) -> np.ndarray | BiocFrame: """Search nearest positions both upstream and downstream that overlap with each range in ``query``. Args: @@ -2507,7 +2512,7 @@ def precede( select: Literal["all", "first"] = "first", ignore_strand: bool = False, num_threads: int = 1, - ) -> Union[np.ndarray, BiocFrame]: + ) -> np.ndarray | BiocFrame: """Search nearest positions only downstream that overlap with each range in ``query``. Args: @@ -2587,7 +2592,7 @@ def follow( select: Literal["all", "last"] = "last", ignore_strand: bool = False, num_threads: int = 1, - ) -> Union[np.ndarray, BiocFrame]: + ) -> np.ndarray | BiocFrame: """Search nearest positions only upstream that overlap with each range in ``query``. Args: @@ -2658,7 +2663,7 @@ def follow( else: return BiocFrame({"query_hits": final_qhits, "self_hits": final_shits}) - def distance(self, query: Union[GenomicRanges, IRanges]) -> np.ndarray: + def distance(self, query: GenomicRanges | IRanges) -> np.ndarray: """Compute the pair-wise distance with intervals in query. Args: @@ -2725,7 +2730,7 @@ def match(self, query: GenomicRanges, ignore_strand: bool = False) -> np.ndarray return result - def _get_ranges_as_list(self) -> List[Tuple[int, int, int]]: + def _get_ranges_as_list(self) -> list[tuple[int, int, int]]: """Internal method to get ranges as a list of tuples. Returns: @@ -2738,15 +2743,13 @@ def _get_ranges_as_list(self) -> List[Tuple[int, int, int]]: strands[strands == 0] = 8 for i in range(len(self)): - ranges.append( - ( - self._seqnames[i], - strands[i], - self._ranges._start[i], - self._ranges.end[i], - i, - ) - ) + ranges.append(( + self._seqnames[i], + strands[i], + self._ranges._start[i], + self._ranges.end[i], + i, + )) return ranges @@ -2788,7 +2791,7 @@ def sort(self, decreasing: bool = False, in_place: bool = False) -> GenomicRange output = self._define_output(in_place) return output[list(order)] - def rank(self) -> List[int]: + def rank(self) -> list[int]: """Get rank of the ``GenomicRanges`` object. For each range identifies its position is a sorted order. @@ -2848,7 +2851,7 @@ def invert_strand(self, in_place: bool = False) -> GenomicRanges: ######>> window methods <<###### ################################ - def tile(self, n: Optional[int] = None, width: Optional[int] = None) -> List[GenomicRanges]: + def tile(self, n: int | None = None, width: int | None = None) -> list[GenomicRanges]: """Split each interval by ``n`` (number of sub intervals) or ``width`` (intervals with equal width). Note: Either ``n`` or ``width`` must be provided but not both. @@ -2893,7 +2896,7 @@ def tile(self, n: Optional[int] = None, width: Optional[int] = None) -> List[Gen return result - def sliding_windows(self, width: int, step: int = 1) -> List[GenomicRanges]: + def sliding_windows(self, width: int, step: int = 1) -> list[GenomicRanges]: """Slide along each range by ``width`` (intervals with equal ``width``) and ``step``. Also, checkout :py:func:`~genomicranges.io.tiling.tile_genome` for splitting @@ -2934,9 +2937,9 @@ def sliding_windows(self, width: int, step: int = 1) -> List[GenomicRanges]: @classmethod def tile_genome( cls, - seqlengths: Dict[str, int], - ntile: Optional[int] = None, - tilewidth: Optional[int] = None, + seqlengths: dict[str, int], + ntile: int | None = None, + tilewidth: int | None = None, cut_last_tile_in_chrom: bool = False, ) -> GenomicRanges: """Tile genome into approximately equal-sized regions. @@ -3125,7 +3128,7 @@ def binned_average( ######>> split <<###### ####################### - def split(self, groups: list) -> "CompressedGenomicRangesList": + def split(self, groups: list) -> CompressedGenomicRangesList: """Split the `GenomicRanges` object into a :py:class:`~genomicranges.grangeslist.CompressedGenomicRangesList`. Args: @@ -3175,7 +3178,7 @@ def empty(cls): def subtract( self, other: GenomicRanges, min_overlap: int = 1, ignore_strand: bool = False - ) -> "CompressedGenomicRangesList": + ) -> CompressedGenomicRangesList: """Subtract searches for features in ``x`` that overlap ``self`` by at least the number of base pairs given by ``min_overlap``. diff --git a/src/genomicranges/grangeslist.py b/src/genomicranges/grangeslist.py index d04b9fe8..2e6cda93 100644 --- a/src/genomicranges/grangeslist.py +++ b/src/genomicranges/grangeslist.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any +from collections.abc import Sequence import biocutils as ut import numpy as np @@ -120,10 +121,10 @@ def __init__( self, unlist_data: GenomicRanges, partitioning: Partitioning, - element_metadata: Optional[dict] = None, - metadata: Optional[Union[Dict[str, Any], ut.NamedList]] = None, - **kwargs, - ): + element_metadata: dict[str, Any] | None = None, + metadata: dict[str, Any] | ut.NamedList | None = None, + **kwargs: Any, + ) -> None: """Initialize a CompressedIRangesList. Args: @@ -152,9 +153,9 @@ def __init__( @classmethod def from_list( cls, - lst: List[GenomicRanges], - names: Optional[Union[ut.Names, Sequence[str]]] = None, - metadata: Optional[Union[Dict[str, Any], ut.NamedList]] = None, + lst: list[GenomicRanges], + names: ut.Names | Sequence[str] | None = None, + metadata: dict[str, Any] | ut.NamedList | None = None, ) -> CompressedGenomicRangesList: """Create a `CompressedIRangesList` from a regular list. @@ -336,7 +337,7 @@ def stack(self, index_column_name: str = "index") -> GenomicRanges: return output @classmethod - def empty(cls, n: int): + def empty(cls, n: int) -> CompressedGenomicRangesList: """Create an zero-length `CompressedGenomicRangesList` object. Args: @@ -356,9 +357,9 @@ def empty(cls, n: int): @splitAsCompressedList.register def _( data: GenomicRanges, - groups_or_partitions: Union[list, Partitioning], - names: Optional[Union[ut.Names, Sequence[str]]] = None, - metadata: Optional[dict] = None, + groups_or_partitions: list[Any] | Partitioning, + names: ut.Names | Sequence[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> CompressedGenomicRangesList: """Handle lists of IRanges objects.""" diff --git a/src/genomicranges/io/gtf.py b/src/genomicranges/io/gtf.py index 3fc05809..aaece1be 100644 --- a/src/genomicranges/io/gtf.py +++ b/src/genomicranges/io/gtf.py @@ -1,6 +1,11 @@ +from __future__ import annotations + import logging -from typing import Dict, List, Union +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + import pandas as pd + from ..GenomicRanges import GenomicRanges # Variation of https://github.com/epiviz/epivizfileserver/src/epivizfileserver/cli.py __author__ = "jkanche" @@ -8,7 +13,7 @@ __license__ = "MIT" -def _parse_all_attribute(row: str) -> Dict: +def _parse_all_attribute(row: dict[str, Any]) -> dict[str, Any]: """Extract all keys from the gtf/gff attribute string. Args: @@ -32,9 +37,9 @@ def _parse_all_attribute(row: str) -> Dict: def parse_gtf( path: str, compressed: bool, - skiprows: Union[int, List[int]] = None, + skiprows: int | list[int] | None = None, comment: str = "#", -): +) -> pd.DataFrame: """Read a GTF file as :py:class:`~pandas.DataFrame`. Args: @@ -59,43 +64,28 @@ def parse_gtf( from pandas import DataFrame, read_csv logging.info(f"Reading File - {path}") + + kwargs = { + "sep": "\t", + "names": [ + "seqnames", + "source", + "feature", + "starts", + "ends", + "score", + "strand", + "frame", + "group", + ], + "skiprows": skiprows, + "comment": comment, + } + if compressed: - df = read_csv( - path, - sep="\t", - names=[ - "seqnames", - "source", - "feature", - "starts", - "ends", - "score", - "strand", - "frame", - "group", - ], - compression="gzip", - skiprows=skiprows, - comment=comment, - ) - else: - df = read_csv( - path, - sep="\t", - names=[ - "seqnames", - "source", - "feature", - "starts", - "ends", - "score", - "strand", - "frame", - "group", - ], - skiprows=skiprows, - comment=comment, - ) + kwargs["compression"] = "gzip" + + df = read_csv(path, **kwargs) records = df.to_dict("records") rows = Parallel(n_jobs=-2)(delayed(_parse_all_attribute)(row) for row in records) @@ -107,9 +97,9 @@ def parse_gtf( def read_gtf( file: str, - skiprows: Union[int, List[int]] = None, + skiprows: int | list[int] | None = None, comment: str = "#", -) -> "GenomicRanges": +) -> GenomicRanges: """Read a GTF file as :py:class:`~genomicranges.GenomicRanges.GenomicRanges`. Args: diff --git a/src/genomicranges/io/ucsc.py b/src/genomicranges/io/ucsc.py index 7e03a48a..c01cd9a8 100644 --- a/src/genomicranges/io/ucsc.py +++ b/src/genomicranges/io/ucsc.py @@ -1,4 +1,9 @@ -from typing import Literal +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from ..GenomicRanges import GenomicRanges from .gtf import parse_gtf @@ -41,7 +46,7 @@ def access_gtf_ucsc( def read_ucsc( genome: str, type: Literal["refGene", "ensGene", "knownGene", "ncbiRefSeq"] = "refGene", -) -> "GenomicRanges": +) -> GenomicRanges: """Load a genome annotation from UCSC as :py:class:`~genomicranges.GenomicRanges.GenomicRanges`. Args: diff --git a/src/genomicranges/py.typed b/src/genomicranges/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/genomicranges/sequence_info.py b/src/genomicranges/sequence_info.py index abb23f8e..a4a5a880 100644 --- a/src/genomicranges/sequence_info.py +++ b/src/genomicranges/sequence_info.py @@ -1,4 +1,7 @@ -from typing import Dict, List, Optional, Sequence, Union +from __future__ import annotations + +from collections.abc import Sequence + from warnings import warn import biocutils as ut @@ -10,7 +13,7 @@ __license__ = "MIT" -def _validate_seqnames(seqnames): +def _validate_seqnames(seqnames: list[str]) -> None: if not ut.is_list_of_type(seqnames, str): raise ValueError("'seqnames' should be a list of strings.") @@ -19,7 +22,7 @@ def _validate_seqnames(seqnames): raise ValueError("'seqnames' should contain unique strings.") -def _validate_seqlengths(seqlengths, num_seqs): +def _validate_seqlengths(seqlengths: list[int | None], num_seqs: int) -> None: if not (isinstance(seqlengths, ut.IntegerList) or ut.is_list_of_type(seqlengths, int, ignore_none=True)): raise ValueError("'seqlengths' should be a list of integers.") @@ -31,7 +34,7 @@ def _validate_seqlengths(seqlengths, num_seqs): raise ValueError("all entries of 'seqlengths' should be non-negative.") -def _validate_is_circular(is_circular, num_seqs): +def _validate_is_circular(is_circular: list[bool | None], num_seqs: int) -> None: if not (isinstance(is_circular, ut.BooleanList) or ut.is_list_of_type(is_circular, bool, ignore_none=True)): raise ValueError("'is_circular' should be a list of booleans.") @@ -39,7 +42,7 @@ def _validate_is_circular(is_circular, num_seqs): raise ValueError("'seqnames' and 'is_circular' should have the same length.") -def _validate_genome(genome, num_seqs): +def _validate_genome(genome: list[str | None], num_seqs: int) -> None: if not ut.is_list_of_type(genome, str, ignore_none=True): raise ValueError("'genome' should be a list of strings.") @@ -50,7 +53,7 @@ def _validate_genome(genome, num_seqs): class SeqInfoIterator: """An iterator to a :py:class:`~SeqInfo` object.""" - def __init__(self, obj: "SeqInfo") -> None: + def __init__(self, obj: SeqInfo) -> None: """Initialize the iterator. Args: @@ -81,9 +84,9 @@ class SeqInfo: def __init__( self, seqnames: Sequence[str], - seqlengths: Optional[Union[int, Sequence[int], Dict[str, int]]] = None, - is_circular: Optional[Union[bool, Sequence[bool], Dict[str, bool]]] = None, - genome: Optional[Union[str, Sequence[str], Dict[str, str]]] = None, + seqlengths: int | Sequence[int] | dict[str, int] | None = None, + is_circular: bool | Sequence[bool] | dict[str, bool] | None = None, + genome: str | Sequence[str] | dict[str, str] | None = None, validate: bool = True, ) -> None: """ @@ -152,7 +155,7 @@ def __init__( _validate_is_circular(self._is_circular, num_seqs) _validate_genome(self._genome, num_seqs) - def _populate_reverse_seqnames_index(self): + def _populate_reverse_seqnames_index(self) -> None: if self._reverse_seqnames is None: revmap = {} for i, n in enumerate(self._seqnames): @@ -160,10 +163,10 @@ def _populate_reverse_seqnames_index(self): revmap[n] = i self._reverse_seqnames = revmap - def _wipe_reverse_seqnames_index(self): + def _wipe_reverse_seqnames_index(self) -> None: self._reverse_seqnames = None - def _flatten_incoming(self, values, expected) -> List: + def _flatten_incoming(self, values: Any, expected: Any) -> list: if values is None or isinstance(values, expected): return [values] * len(self) @@ -183,7 +186,7 @@ def _flatten_incoming(self, values, expected) -> List: return list(values) - def _define_output(self, in_place: bool = False) -> "SeqInfo": + def _define_output(self, in_place: bool = False) -> SeqInfo: if in_place is True: return self else: @@ -193,7 +196,7 @@ def _define_output(self, in_place: bool = False) -> "SeqInfo": ######>> Copying <<###### ######################### - def __deepcopy__(self, memo=None, _nil=[]): + def __deepcopy__(self, memo: dict | None = None, _nil: list = []) -> SeqInfo: """ Returns: A deep copy of the current ``SeqInfo``. @@ -214,7 +217,7 @@ def __deepcopy__(self, memo=None, _nil=[]): validate=False, ) - def __copy__(self): + def __copy__(self) -> SeqInfo: """ Returns: A shallow copy of the current ``SeqInfo``. @@ -228,7 +231,7 @@ def __copy__(self): validate=False, ) - def copy(self): + def copy(self) -> SeqInfo: """Alias for :py:meth:`~__copy__`.""" return self.__copy__() @@ -313,14 +316,14 @@ def __str__(self) -> str: ######>> seqnames <<###### ########################## - def get_seqnames(self) -> List[str]: + def get_seqnames(self) -> list[str]: """ Returns: List of all chromosome names. """ return self._seqnames - def set_seqnames(self, seqnames: Sequence[str], in_place: bool = False) -> "SeqInfo": + def set_seqnames(self, seqnames: Sequence[str], in_place: bool = False) -> SeqInfo: """ Args: seqnames: @@ -342,12 +345,12 @@ def set_seqnames(self, seqnames: Sequence[str], in_place: bool = False) -> "SeqI return output @property - def seqnames(self) -> List[str]: + def seqnames(self) -> list[str]: warn("'seqnames' is deprecated, use 'get_seqnames' instead", UserWarning) return self.get_seqnames() @seqnames.setter - def seqnames(self, seqnames: Sequence[str]): + def seqnames(self, seqnames: Sequence[str]) -> None: warn( "Setting property 'seqnames' is an in-place operation, use 'set_seqnames' instead", UserWarning, @@ -359,7 +362,7 @@ def seqnames(self, seqnames: Sequence[str]): ######>> seqlengths <<###### ############################ - def get_seqlengths(self) -> List[int]: + def get_seqlengths(self) -> list[int]: """ Returns: A list of integers is returned containing the lengths of all @@ -370,9 +373,9 @@ def get_seqlengths(self) -> List[int]: def set_seqlengths( self, - seqlengths: Optional[Union[int, Sequence[int], Dict[str, int]]], + seqlengths: int | Sequence[int] | dict[str, int] | None, in_place: bool = False, - ) -> "SeqInfo": + ) -> SeqInfo: """ Args: seqlengths: @@ -399,7 +402,7 @@ def set_seqlengths( return output @property - def seqlengths(self) -> List[int]: + def seqlengths(self) -> list[int]: warn( "'seqlengths' is deprecated, use 'get_seqlengths' instead", UserWarning, @@ -407,7 +410,7 @@ def seqlengths(self) -> List[int]: return self.get_seqlengths() @seqlengths.setter - def seqlengths(self, seqlengths: Optional[Union[int, Sequence[int], Dict[str, int]]]): + def seqlengths(self, seqlengths: int | Sequence[int] | dict[str, int] | None) -> None: warn( "Setting property 'seqlengths' is an in-place operation, use 'set_seqlengths' instead", UserWarning, @@ -419,7 +422,7 @@ def seqlengths(self, seqlengths: Optional[Union[int, Sequence[int], Dict[str, in ######>> is-circular <<###### ############################# - def get_is_circular(self) -> List[bool]: + def get_is_circular(self) -> list[bool]: """ Returns: A list of booleans is returned specifying whether each sequence @@ -429,9 +432,9 @@ def get_is_circular(self) -> List[bool]: def set_is_circular( self, - is_circular: Optional[Union[bool, Sequence[bool], Dict[str, bool]]], + is_circular: bool | Sequence[bool] | dict[str, bool] | None, in_place: bool = False, - ) -> "SeqInfo": + ) -> SeqInfo: """ Args: is_circular: @@ -459,7 +462,7 @@ def set_is_circular( return output @property - def is_circular(self) -> List[bool]: + def is_circular(self) -> list[bool]: warn( "'is_circular' is deprecated, use 'get_is_circular' instead", UserWarning, @@ -467,7 +470,7 @@ def is_circular(self) -> List[bool]: return self.get_is_circular() @is_circular.setter - def is_circular(self, is_circular: Optional[Union[bool, Sequence[bool], Dict[str, bool]]]): + def is_circular(self, is_circular: bool | Sequence[bool] | dict[str, bool] | None) -> None: warn( "Setting property 'is_circular' is an in-place operation, use 'set_is_circular' instead", UserWarning, @@ -479,7 +482,7 @@ def is_circular(self, is_circular: Optional[Union[bool, Sequence[bool], Dict[str ######>> genome <<###### ######################## - def get_genome(self) -> List[str]: + def get_genome(self) -> list[str]: """ Returns: A list of strings is returned containing the genome identity for @@ -489,9 +492,9 @@ def get_genome(self) -> List[str]: def set_genome( self, - genome: Optional[Union[str, Sequence[str], Dict[str, str]]], + genome: str | Sequence[str] | dict[str, str] | None, in_place: bool = False, - ) -> "SeqInfo": + ) -> SeqInfo: """ Args: genome: @@ -514,12 +517,12 @@ def set_genome( return output @property - def genome(self) -> List[str]: + def genome(self) -> list[str]: warn("'genome' is deprecated, use 'get_genome' instead", UserWarning) return self.get_genome() @genome.setter - def genome(self, genome: Optional[Union[bool, Sequence[bool], Dict[str, bool]]]): + def genome(self, genome: str | Sequence[str] | dict[str, str] | None) -> None: warn( "Setting property 'genome' is an in-place operation, use 'set_genome' instead", UserWarning, @@ -546,7 +549,7 @@ def __iter__(self) -> SeqInfoIterator: ######>> Slicers <<###### ######################### - def get_subset(self, subset: Union[str, int, bool, Sequence]) -> "SeqInfo": + def get_subset(self, subset: str | int | bool | Sequence) -> SeqInfo: """Subset ``SeqInfo``, based on their indices or seqnames. Args: @@ -576,12 +579,12 @@ def get_subset(self, subset: Union[str, int, bool, Sequence]) -> "SeqInfo": genome=ut.subset_sequence(self._genome, idx), ) - def __getitem__(self, subset: Union[str, int, bool, Sequence]) -> "SeqInfo": + def __getitem__(self, subset: str | int | bool | Sequence) -> SeqInfo: """Alias to :py:attr:`~get_subset`.""" return self.get_subset(subset) @classmethod - def empty(cls): + def empty(cls) -> SeqInfo: """Create an zero-length `SeqInfo` object. Returns: @@ -595,7 +598,7 @@ def _combine_SeqInfo(*x: SeqInfo) -> SeqInfo: return merge_SeqInfo(x) -def merge_SeqInfo(objects: List[SeqInfo]) -> SeqInfo: +def merge_SeqInfo(objects: list[SeqInfo]) -> SeqInfo: """Merge multiple :py:class:`~SeqInfo` objects, taking the union of all reference sequences. If the same reference sequence is present with the same details across ``objects``, only a single instance is present in the final object; if details are contradictory, they are replaced with None. diff --git a/src/genomicranges/utils.py b/src/genomicranges/utils.py index 1f4e1b30..0a164d83 100644 --- a/src/genomicranges/utils.py +++ b/src/genomicranges/utils.py @@ -1,5 +1,11 @@ +from __future__ import annotations + from itertools import groupby -from typing import List, Sequence, Union +from typing import TYPE_CHECKING, Any +from collections.abc import Sequence + +if TYPE_CHECKING: + from .GenomicRanges import GenomicRanges import biocutils as ut import numpy as np @@ -12,7 +18,7 @@ REV_STRAND_MAP = {"1": "+", "-1": "-", "0": "*"} -def sanitize_strand_vector(strand: Union[Sequence[str], Sequence[int], np.ndarray]) -> np.ndarray: +def sanitize_strand_vector(strand: Sequence[str] | Sequence[int] | np.ndarray) -> np.ndarray: """Create a numpy representation for ``strand``. Mapping: 1 for "+" (forward strand), 0 for "*" (any strand) and -1 for "-" (reverse strand). @@ -55,7 +61,16 @@ def sanitize_strand_vector(strand: Union[Sequence[str], Sequence[int], np.ndarra raise TypeError("'strand' must be either a numpy vector, a list of integers or strings representing strand.") -def _sanitize_vec(x: Sequence): +def _sanitize_vec(x: Sequence | np.ndarray) -> list: + """Sanitize a vector into a standard Python list. + + Args: + x: + Vector to sanitize. + + Returns: + Sanitized list. + """ if isinstance(x, np.ma.MaskedArray): x.filled(fill_value=None) return x.tolist() @@ -63,7 +78,19 @@ def _sanitize_vec(x: Sequence): return list(x) -def _sanitize_strand_search_ops(query_strand, subject_strand): +def _sanitize_strand_search_ops(query_strand: str, subject_strand: str) -> int | None: + """Sanitize strand search operations. + + Args: + query_strand: + Query strand. + + subject_strand: + Subject strand. + + Returns: + Sanitized strand value or None if invalid. + """ query_strand = REV_STRAND_MAP[query_strand] subject_strand = REV_STRAND_MAP[subject_strand] @@ -97,7 +124,7 @@ def _sanitize_strand_search_ops(query_strand, subject_strand): return STRAND_MAP[out] -def split_intervals(start: int, end: int, step: int) -> List: +def split_intervals(start: int, end: int, step: int) -> list[tuple[int, int]]: """Split an interval range into equal bins. Args: @@ -120,7 +147,7 @@ def split_intervals(start: int, end: int, step: int) -> List: return bins -def slide_intervals(start: int, end: int, width: int, step: int) -> List: +def slide_intervals(start: int, end: int, width: int, step: int) -> list[tuple[int, int]]: """Sliding intervals. Args: @@ -151,10 +178,19 @@ def slide_intervals(start: int, end: int, width: int, step: int) -> List: def group_by_indices(groups: list) -> dict: + """Group items by their indices. + + Args: + groups: + List of groups. + + Returns: + A dictionary mapping group names to their indices. + """ return {k: [x[0] for x in v] for k, v in groupby(sorted(enumerate(groups), key=lambda x: x[1]), lambda x: x[1])} -def compute_up_down(starts, ends, strands, upstream, downstream, site: str = "TSS"): +def compute_up_down(starts: np.ndarray, ends: np.ndarray, strands: np.ndarray, upstream: int | float | np.ndarray, downstream: int | float | np.ndarray, site: str = "TSS") -> tuple[np.ndarray, np.ndarray]: """Compute promoter or terminator regions for genomic ranges. Args: @@ -205,7 +241,19 @@ def compute_up_down(starts, ends, strands, upstream, downstream, site: str = "TS return new_starts, new_ends - new_starts + 1 -def extract_groups_from_granges(x, ignore_strand=False): +def extract_groups_from_granges(x: GenomicRanges, ignore_strand: bool = False) -> list[tuple[Any, np.ndarray]]: + """Extract groups from a GenomicRanges object based on seqnames and strand. + + Args: + x: + GenomicRanges object. + + ignore_strand: + Whether to ignore strand information. Defaults to False. + + Returns: + List of tuples containing the group name and matched indices. + """ if ignore_strand: groups = [] for idx, seq in enumerate(x._seqinfo._seqnames): @@ -228,7 +276,7 @@ def extract_groups_from_granges(x, ignore_strand=False): return groups -def wrapper_follow_precede(args): +def wrapper_follow_precede(args: tuple) -> tuple[np.ndarray, np.ndarray]: """Processes a single group for precede/follow operations. This function is designed to be called by a multiprocessing pool. """ diff --git a/tests/test_io_gtf.py b/tests/test_io_gtf.py new file mode 100644 index 00000000..e275d802 --- /dev/null +++ b/tests/test_io_gtf.py @@ -0,0 +1,59 @@ +import pandas as pd +from genomicranges.io.gtf import parse_gtf, read_gtf, _parse_all_attribute +import pytest +from unittest.mock import patch +import io +import gzip + +def test_parse_all_attribute(): + row = {"group": 'gene_id "ENSG00000223972.5"; transcript_id "ENST00000456328.2"; gene_type "transcribed_unprocessed_pseudogene";'} + res = _parse_all_attribute(row) + assert res["gene_id"] == "ENSG00000223972.5" + assert res["transcript_id"] == "ENST00000456328.2" + assert res["gene_type"] == "transcribed_unprocessed_pseudogene" + + +def test_parse_gtf(): + mock_df = pd.DataFrame({ + "seqnames": ["chr1", "chr1"], + "source": ["havana", "havana"], + "feature": ["gene", "transcript"], + "starts": [11869, 11869], + "ends": [14409, 14409], + "score": [".", "."], + "strand": ["+", "+"], + "frame": [".", "."], + "group": ['gene_id "ENSG0"; transcript_id "ENST0";', 'gene_id "ENSG0"; transcript_id "ENST0";'] + }) + + with patch("pandas.read_csv") as mock_read_csv: + mock_read_csv.return_value = mock_df + + df = parse_gtf("dummy.gtf", compressed=False) + + assert isinstance(df, pd.DataFrame) + assert len(df) == 2 + assert "gene_id" in df.columns + assert df["ends"].iloc[0] == 14408 # Because it subtracts 1 in parse_gtf + + +def test_read_gtf(): + mock_df = pd.DataFrame({ + "seqnames": ["chr1", "chr1"], + "source": ["havana", "havana"], + "feature": ["gene", "transcript"], + "starts": [11869, 11869], + "ends": [14409, 14409], + "score": [".", "."], + "strand": ["+", "+"], + "frame": [".", "."], + "group": ['gene_id "ENSG0"; transcript_id "ENST0";', 'gene_id "ENSG0"; transcript_id "ENST0";'] + }) + with patch("pandas.read_csv") as mock_read_csv: + mock_read_csv.return_value = mock_df + + gr = read_gtf("dummy.gtf") + + assert len(gr) == 2 + assert gr.get_seqnames()[0] == "chr1" + assert gr.get_mcols().shape[1] > 0 diff --git a/tests/test_io_ucsc.py b/tests/test_io_ucsc.py new file mode 100644 index 00000000..e8710900 --- /dev/null +++ b/tests/test_io_ucsc.py @@ -0,0 +1,36 @@ +import pytest +from unittest.mock import patch +import pandas as pd +from genomicranges.io.ucsc import access_gtf_ucsc, read_ucsc +from genomicranges.GenomicRanges import GenomicRanges + +def test_access_gtf_ucsc(): + url = access_gtf_ucsc("hg19", type="refGene") + assert url == "http://hgdownload.cse.ucsc.edu/goldenPath/hg19/bigZips/genes//hg19.refGene.gtf.gz" + + with pytest.raises(ValueError): + access_gtf_ucsc("hg19", type="invalidType") + + +@patch("genomicranges.io.ucsc.parse_gtf") +def test_read_ucsc(mock_parse_gtf): + # Mock the return of parse_gtf with a dummy dataframe + mock_df = pd.DataFrame({ + "seqnames": ["chr1"], + "starts": [100], + "ends": [200], + "strand": ["+"] + }) + mock_parse_gtf.return_value = mock_df + + gr = read_ucsc("hg19", type="refGene") + + assert isinstance(gr, GenomicRanges) + assert len(gr) == 1 + assert gr.get_seqnames()[0] == "chr1" + + # ensure it was called properly + mock_parse_gtf.assert_called_once_with( + "http://hgdownload.cse.ucsc.edu/goldenPath/hg19/bigZips/genes//hg19.refGene.gtf.gz", + compressed=True + ) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..de8c417d --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,57 @@ +import pytest +import numpy as np +from genomicranges.utils import sanitize_strand_vector, _sanitize_strand_search_ops, extract_groups_from_granges, _sanitize_vec +from genomicranges.GenomicRanges import GenomicRanges +from iranges import IRanges + +def test_sanitize_strand_vector(): + with pytest.raises(ValueError): + sanitize_strand_vector(None) + + with pytest.raises(ValueError): + sanitize_strand_vector(np.array([[1, 2], [3, 4]])) + + with pytest.raises(ValueError): + sanitize_strand_vector(np.array([2, 3])) + + with pytest.raises(ValueError): + sanitize_strand_vector(["+", "a"]) + + with pytest.raises(ValueError): + sanitize_strand_vector([1, 2, 3]) + + with pytest.raises(ValueError): + sanitize_strand_vector([1.2, 3.4]) + +def test_sanitize_vec(): + masked = np.ma.masked_array([1, 2, 3], mask=[0, 1, 0]) + res = _sanitize_vec(masked) + assert res == [1, None, 3] + +def test_sanitize_strand_search_ops(): + # query_strand: +, -, * + assert _sanitize_strand_search_ops("1", "1") == 1 # + + -> + + assert _sanitize_strand_search_ops("1", "-1") is None # + - -> None + assert _sanitize_strand_search_ops("1", "0") == 1 # + * -> + + + assert _sanitize_strand_search_ops("-1", "1") is None # - + -> None + assert _sanitize_strand_search_ops("-1", "-1") == -1 # - - -> - + assert _sanitize_strand_search_ops("-1", "0") == -1 # - * -> - + + assert _sanitize_strand_search_ops("0", "0") == 1 # * * -> + + assert _sanitize_strand_search_ops("0", "-1") == -1 # * - -> - + assert _sanitize_strand_search_ops("0", "1") is None + +def test_extract_groups_from_granges(): + gr = GenomicRanges(seqnames=["chr1", "chr2", "chr1"], ranges=IRanges([1, 2, 3], [4, 5, 6]), strand=["+", "-", "+"]) + + # ignore_strand=True + groups = extract_groups_from_granges(gr, ignore_strand=True) + assert len(groups) == 2 + assert groups[0][0] == "chr1" + assert (groups[0][1] == np.array([0, 2])).all() + + # ignore_strand=False + groups2 = extract_groups_from_granges(gr, ignore_strand=False) + assert len(groups2) == 2 + diff --git a/tox.ini b/tox.ini index fe005ada..ab663e9e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,72 +1,63 @@ -# Tox configuration file -# Read more under https://tox.readthedocs.io/ -# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! +# Tox configuration file using uv as the backend runner +# Read more under https://tox.wiki/ [tox] -minversion = 3.15 +minversion = 4.0 envlist = default -isolated_build = True - [testenv] description = Invoke pytest to run automated tests -setenv = - TOXINIDIR = {toxinidir} -passenv = - HOME -extras = - testing +extras = testing +deps = twine commands = pytest {posargs} +[testenv:typecheck] +deps = mypy +description = Run static type checking with mypy +commands = + mypy src/ + +[testenv:lint] +description = Perform static analysis and style checks +deps = ruff +skip_install = True +commands = + ruff check {posargs:.} + ruff format --check {posargs:.} [testenv:{build,clean}] description = - build: Build the package in isolation according to PEP517, see https://github.com/pypa/build - clean: Remove old distribution files and temporary build artifacts (./build and ./dist) -# NOTE: build is still experimental, please refer to the links for updates/issues -# https://setuptools.readthedocs.io/en/stable/build_meta.html#how-to-use-it -# https://github.com/pypa/pep517/issues/91 + build: Build the package + clean: Remove old distribution files +deps = build skip_install = True -changedir = {toxinidir} -deps = - build: build[virtualenv] commands = - clean: python -c 'from shutil import rmtree; rmtree("build", True); rmtree("dist", True)' - build: python -m build . -# By default `build` produces wheels, you can also explicitly use the flags `--sdist` and `--wheel` - + clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' + clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' + build: python -m build {posargs} [testenv:{docs,doctests,linkcheck}] description = docs: Invoke sphinx-build to build the docs doctests: Invoke sphinx-build to run doctests linkcheck: Check for broken links in the documentation +deps = + -r {toxinidir}/docs/requirements.txt setenv = DOCSDIR = {toxinidir}/docs BUILDDIR = {toxinidir}/docs/_build docs: BUILD = html doctests: BUILD = doctest linkcheck: BUILD = linkcheck -deps = - -r {toxinidir}/docs/requirements.txt - # ^ requirements.txt shared with Read The Docs commands = + sphinx-apidoc -f -o "{env:DOCSDIR}/api" src/ sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} - [testenv:publish] description = Publish the package you have been developing to a package index server. - By default, it uses testpypi. If you really want to publish your package - to be publicly accessible in PyPI, use the `-- --repository pypi` option. skip_install = True -changedir = {toxinidir} -passenv = - TWINE_USERNAME - TWINE_PASSWORD - TWINE_REPOSITORY deps = twine commands = - python -m twine check dist/* - python -m twine upload {posargs:--repository testpypi} dist/* + python -m twine upload {posargs:dist/*} From 794f448d79e6f3e780f974a5897e432b262e437b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:55:58 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/workflows/publish-pypi.yml | 2 +- docs/requirements.txt | 4 ++-- perf/genomicranges.ipynb | 1 - src/genomicranges/GenomicRanges.py | 21 ++++++++++----------- src/genomicranges/grangeslist.py | 6 +++--- src/genomicranges/io/gtf.py | 1 + src/genomicranges/sequence_info.py | 1 - src/genomicranges/utils.py | 19 +++++++++++-------- tests/test_io_gtf.py | 6 +++--- tests/test_io_ucsc.py | 6 +++--- tests/test_utils.py | 5 ++--- 11 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f0be5037..f0f6cfa3 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -71,7 +71,7 @@ jobs: with: name: python-package-distributions path: dist/ - + - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/docs/requirements.txt b/docs/requirements.txt index a1b9d2bd..c20cf60b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,9 +1,9 @@ +furo +myst-nb # Requirements file for ReadTheDocs, check .readthedocs.yml. # To build the module reference correctly, make sure every external package # under `install_requires` in `setup.cfg` is also listed here! # sphinx_rtd_theme myst-parser[linkify] sphinx>=3.2.1 -myst-nb -furo sphinx-autodoc-typehints diff --git a/perf/genomicranges.ipynb b/perf/genomicranges.ipynb index 4486a48f..211d7aeb 100644 --- a/perf/genomicranges.ipynb +++ b/perf/genomicranges.ipynb @@ -9,7 +9,6 @@ "source": [ "import biobear as bb\n", "\n", - "\n", "session = bb.new_session()\n", "\n", "bed = session.read_bed_file(\"consensus_peaks_bicnn.bed\", bb.BEDReadOptions(n_fields=4))" diff --git a/src/genomicranges/GenomicRanges.py b/src/genomicranges/GenomicRanges.py index 89b3ce1b..945ac3a2 100644 --- a/src/genomicranges/GenomicRanges.py +++ b/src/genomicranges/GenomicRanges.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, Literal from warnings import warn - if TYPE_CHECKING: from .grangeslist import CompressedGenomicRangesList @@ -561,9 +560,7 @@ def ranges(self, ranges: IRanges): ######>> strand <<###### ######################## - def get_strand( - self, as_type: Literal["numpy", "factor", "list"] = "numpy" - ) -> tuple[np.ndarray, dict] | list[str]: + def get_strand(self, as_type: Literal["numpy", "factor", "list"] = "numpy") -> tuple[np.ndarray, dict] | list[str]: """Access strand information. Args: @@ -2743,13 +2740,15 @@ def _get_ranges_as_list(self) -> list[tuple[int, int, int]]: strands[strands == 0] = 8 for i in range(len(self)): - ranges.append(( - self._seqnames[i], - strands[i], - self._ranges._start[i], - self._ranges.end[i], - i, - )) + ranges.append( + ( + self._seqnames[i], + strands[i], + self._ranges._start[i], + self._ranges.end[i], + i, + ) + ) return ranges diff --git a/src/genomicranges/grangeslist.py b/src/genomicranges/grangeslist.py index 2e6cda93..1f49a716 100644 --- a/src/genomicranges/grangeslist.py +++ b/src/genomicranges/grangeslist.py @@ -1,7 +1,7 @@ from __future__ import annotations -from typing import Any from collections.abc import Sequence +from typing import Any import biocutils as ut import numpy as np @@ -242,8 +242,8 @@ def __str__(self) -> str: output += f"partitioning: {ut.print_truncated_list(self._partitioning)}\n" - output += f"element_metadata({str(len(self._element_metadata))} rows): {ut.print_truncated_list(list(self._element_metadata.get_column_names()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" - output += f"metadata({str(len(self._metadata))}): {ut.print_truncated_list(list(self._metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" + output += f"element_metadata({len(self._element_metadata)!s} rows): {ut.print_truncated_list(list(self._element_metadata.get_column_names()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" + output += f"metadata({len(self._metadata)!s}): {ut.print_truncated_list(list(self._metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" return output diff --git a/src/genomicranges/io/gtf.py b/src/genomicranges/io/gtf.py index aaece1be..def13e05 100644 --- a/src/genomicranges/io/gtf.py +++ b/src/genomicranges/io/gtf.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: import pandas as pd + from ..GenomicRanges import GenomicRanges # Variation of https://github.com/epiviz/epivizfileserver/src/epivizfileserver/cli.py diff --git a/src/genomicranges/sequence_info.py b/src/genomicranges/sequence_info.py index a4a5a880..6024800a 100644 --- a/src/genomicranges/sequence_info.py +++ b/src/genomicranges/sequence_info.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Sequence - from warnings import warn import biocutils as ut diff --git a/src/genomicranges/utils.py b/src/genomicranges/utils.py index 0a164d83..49428807 100644 --- a/src/genomicranges/utils.py +++ b/src/genomicranges/utils.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Sequence from itertools import groupby from typing import TYPE_CHECKING, Any -from collections.abc import Sequence if TYPE_CHECKING: from .GenomicRanges import GenomicRanges @@ -106,16 +106,12 @@ def _sanitize_strand_search_ops(query_strand: str, subject_strand: str) -> int | elif query_strand == "-": if subject_strand == "+": out = None - elif subject_strand == "-": - out = "-" - elif subject_strand == "*": + elif subject_strand == "-" or subject_strand == "*": out = "-" elif query_strand == "*": if subject_strand == "*": out = "+" - elif subject_strand == "-": - out = "-" - elif subject_strand == "*": + elif subject_strand == "-" or subject_strand == "*": out = "-" if out is None: @@ -190,7 +186,14 @@ def group_by_indices(groups: list) -> dict: return {k: [x[0] for x in v] for k, v in groupby(sorted(enumerate(groups), key=lambda x: x[1]), lambda x: x[1])} -def compute_up_down(starts: np.ndarray, ends: np.ndarray, strands: np.ndarray, upstream: int | float | np.ndarray, downstream: int | float | np.ndarray, site: str = "TSS") -> tuple[np.ndarray, np.ndarray]: +def compute_up_down( + starts: np.ndarray, + ends: np.ndarray, + strands: np.ndarray, + upstream: float | np.ndarray, + downstream: float | np.ndarray, + site: str = "TSS", +) -> tuple[np.ndarray, np.ndarray]: """Compute promoter or terminator regions for genomic ranges. Args: diff --git a/tests/test_io_gtf.py b/tests/test_io_gtf.py index e275d802..cd1086f9 100644 --- a/tests/test_io_gtf.py +++ b/tests/test_io_gtf.py @@ -25,12 +25,12 @@ def test_parse_gtf(): "frame": [".", "."], "group": ['gene_id "ENSG0"; transcript_id "ENST0";', 'gene_id "ENSG0"; transcript_id "ENST0";'] }) - + with patch("pandas.read_csv") as mock_read_csv: mock_read_csv.return_value = mock_df df = parse_gtf("dummy.gtf", compressed=False) - + assert isinstance(df, pd.DataFrame) assert len(df) == 2 assert "gene_id" in df.columns @@ -53,7 +53,7 @@ def test_read_gtf(): mock_read_csv.return_value = mock_df gr = read_gtf("dummy.gtf") - + assert len(gr) == 2 assert gr.get_seqnames()[0] == "chr1" assert gr.get_mcols().shape[1] > 0 diff --git a/tests/test_io_ucsc.py b/tests/test_io_ucsc.py index e8710900..1463c094 100644 --- a/tests/test_io_ucsc.py +++ b/tests/test_io_ucsc.py @@ -24,13 +24,13 @@ def test_read_ucsc(mock_parse_gtf): mock_parse_gtf.return_value = mock_df gr = read_ucsc("hg19", type="refGene") - + assert isinstance(gr, GenomicRanges) assert len(gr) == 1 assert gr.get_seqnames()[0] == "chr1" - + # ensure it was called properly mock_parse_gtf.assert_called_once_with( - "http://hgdownload.cse.ucsc.edu/goldenPath/hg19/bigZips/genes//hg19.refGene.gtf.gz", + "http://hgdownload.cse.ucsc.edu/goldenPath/hg19/bigZips/genes//hg19.refGene.gtf.gz", compressed=True ) diff --git a/tests/test_utils.py b/tests/test_utils.py index de8c417d..ded8c770 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,7 +7,7 @@ def test_sanitize_strand_vector(): with pytest.raises(ValueError): sanitize_strand_vector(None) - + with pytest.raises(ValueError): sanitize_strand_vector(np.array([[1, 2], [3, 4]])) @@ -44,7 +44,7 @@ def test_sanitize_strand_search_ops(): def test_extract_groups_from_granges(): gr = GenomicRanges(seqnames=["chr1", "chr2", "chr1"], ranges=IRanges([1, 2, 3], [4, 5, 6]), strand=["+", "-", "+"]) - + # ignore_strand=True groups = extract_groups_from_granges(gr, ignore_strand=True) assert len(groups) == 2 @@ -54,4 +54,3 @@ def test_extract_groups_from_granges(): # ignore_strand=False groups2 = extract_groups_from_granges(gr, ignore_strand=False) assert len(groups2) == 2 - From bc6fd814e059f1956033a729c4179dffa0f56db0 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Thu, 3 Sep 2026 00:13:12 -0700 Subject: [PATCH 3/4] skip biobear for python 3.14+ since wheels aren't available --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c6822d7..3fb63e54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,14 +56,14 @@ Source = "https://github.com/BiocPy/genomicranges" [project.optional-dependencies] optional = [ - "biobear", + "biobear; python_version < '3.14'", "joblib", "matplotlib", "pandas", "polars", ] testing = [ - "biobear", + "biobear; python_version < '3.14'", "joblib", "matplotlib", "pandas", From b80e6396a2c149bf9f9a2942d48542caf70bf587 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Thu, 3 Sep 2026 00:19:34 -0700 Subject: [PATCH 4/4] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e6b68c..2334d380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.9.0 + +- Migrate package to hatch + ## Version 0.8.0 - 0.8.5 - Rename `GenomicRangesList` to `CompressedGenomicRangesList` and now extends compressed-lists.