diff --git a/.coveragerc b/.coveragerc index d1c3683..7235976 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,8 @@ # .coveragerc to control coverage.py [run] branch = True -source = spatialexperiment -# 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 0000000..12eea0d --- /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 0000000..e940068 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,16 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [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 1760f1d..3c15153 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,55 +1,91 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -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.11 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.12 + + - name: Install tox + run: python -m pip install tox - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox + - name: Test + run: tox -e default - - name: Test with tox - run: | - tox + - name: Build Project + run: tox -e build + + - 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 - - name: Build Project and Publish - run: | - python -m tox -e clean,build + publish-pypi: + name: Publish to PyPI + needs: build-and-test + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/spatialexperiment + 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/ - # This uses the trusted publisher workflow so no token is required. - - name: Publish to PyPI + - 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 603ce97..313183f 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 e9e1e9b..99af268 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,59 @@ MANIFEST .venv*/ .conda*/ .python-version + +# 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 61fbd30..af3eb03 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,16 +17,46 @@ repos: - id: mixed-line-ending args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows +# - repo: https://github.com/PyCQA/docformatter +# rev: master +# hooks: +# - id: docformatter +# additional_dependencies: [tomli] +# args: [--in-place, --wrap-descriptions=120, --wrap-summaries=120] +# # --config, ./pyproject.toml + +# - repo: https://github.com/psf/black +# rev: 24.8.0 +# hooks: +# - id: black +# language_version: python3 + - 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: +# - repo: https://github.com/asottile/blacken-docs +# rev: v1.13.0 +# hooks: +# - id: blacken-docs +# additional_dependencies: [black] + ## Check for misspells in documentation files: # - repo: https://github.com/codespell-project/codespell # 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 a2bcab3..0000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,27 +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 - -build: - os: ubuntu-22.04 - tools: - python: "3.11" - -python: - install: - - requirements: docs/requirements.txt - - {path: ., method: pip} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc2537..870d836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.2.0 + +- Migrate package to hatch. + ## Version 0.1.0 - Migrating changes from SCE, replace `validate` with `_validate`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b18a7f..02b4546 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 `SpatialExperiment` 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 `SpatialExperiment`, 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 `SpatialExperiment` docs by making them more readable and coherent, or -by adding missing information and correcting mistakes. - -`SpatialExperiment` 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 `SpatialExperiment`'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 SpatialExperiment python=3 six virtualenv pytest pytest-cov -conda activate SpatialExperiment -``` - -### 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/SpatialExperiment.git - cd SpatialExperiment - ``` - -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 - ``` - - `SpatialExperiment` 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 -`SpatialExperiment`: - -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//SpatialExperiment -[issue tracker]: https://github.com//SpatialExperiment/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 60bdc64..bd529db 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -105,7 +105,7 @@ master_doc = "index" # General information about the project. -project = "SpatialExperiment" +project = "spatialexperiment" copyright = "2025, keviny2" # The version info for the project you're documenting, acts as replacement for @@ -117,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 spatialexperiment import __version__ as version -except ImportError: - version = "" + from importlib.metadata import version as get_version + version = get_version("spatialexperiment") +except Exception: + version = "unknown" if not version or version.lower() == "unknown": version = os.getenv("READTHEDOCS_VERSION", "unknown") # automatically set by RTD @@ -177,8 +178,17 @@ # 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/spatialexperiment", + "source_branch": "main", + "source_directory": "docs/", } # Add any paths that contain custom themes here, relative to this directory. @@ -247,7 +257,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = "SpatialExperiment-doc" +htmlhelp_basename = "spatialexperiment-doc" # -- Options for LaTeX output ------------------------------------------------ @@ -264,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", "SpatialExperiment Documentation", "keviny2", "manual") + ("index", "user_guide.tex", "spatialexperiment Documentation", "keviny2", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -298,7 +308,6 @@ "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), "genomicranges": ("https://biocpy.github.io/GenomicRanges", None), "summarizedexperiment": ("https://biocpy.github.io/SummarizedExperiment", None), diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..4790c60 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,179 @@ +--- +file_format: mystnb +python_version: 3.14 +--- + +# Represent Spatially Resolved transcriptomics data + +The `spatialexperiment` package provides the `SpatialExperiment` (SPE) container class, extending `SingleCellExperiment` (SCE) to support spatially resolved transcriptomics (SRT) and spatial-omics datasets. + +It adds dedicated slots for: +1. **Spatial Coordinates**: Location of spots or cells in the spatial grid/coordinate space. +2. **Image Data**: Associated tissue images and metadata, such as scaling factors. + +:::{important} +Like other BiocPy containers, the design adheres to the Bioconductor standard where **rows** correspond to features (e.g., genes) and **columns** represent cells/spots. +::: + +## Installation + +To get started, install the package from [PyPI](https://pypi.org/project/spatialexperiment/): + +```bash +pip install spatialexperiment +``` + +## Construction + +The `SpatialExperiment` constructor extends `SingleCellExperiment` with two main arguments: +* `spatial_coords`: A `BiocFrame` or `np.ndarray` of spatial coordinates. +* `img_data`: A `BiocFrame` containing image metadata (`sample_id`, `image_id`, `data` representing `VirtualSpatialImage`, and `scale_factor`). + +In addition, the `column_data` must include a `sample_id` column to map columns (spots) to their corresponding images. + +Let's generate mock SRT data: + +```python +import numpy as np +from biocframe import BiocFrame +from spatialexperiment import SpatialExperiment, construct_spatial_image_class + +nrows = 100 # Features +ncols = 50 # Spots/cells + +# Mock counts assay +counts = np.random.rand(nrows, ncols) + +# Feature metadata +row_data = BiocFrame({ + "gene_ids": [f"gene_{i}" for i in range(nrows)] +}) + +# Spot/cell metadata +col_data = BiocFrame({ + "cell_id": [f"spot_{i}" for i in range(ncols)], + "sample_id": ["sample_1"] * 25 + ["sample_2"] * 25 +}) + +# Spatial coordinates +spatial_coords = BiocFrame({ + "x": np.random.uniform(0.0, 100.0, size=ncols), + "y": np.random.uniform(0.0, 100.0, size=ncols) +}) + +# Image metadata +img_data = BiocFrame({ + "sample_id": ["sample_1", "sample_2"], + "image_id": ["lowres", "lowres"], + "data": [ + construct_spatial_image_class("tests/images/sample_image1.jpg"), + construct_spatial_image_class("tests/images/sample_image3.jpg"), + ], + "scale_factor": [0.5, 0.5] +}) +``` + +Now let's construct the `SpatialExperiment` object: + +```python +spe = SpatialExperiment( + assays={"counts": counts}, + row_data=row_data, + column_data=col_data, + spatial_coords=spatial_coords, + img_data=img_data +) + +print(spe) +``` + +## Spatial Coordinates accessors + +Spatial coordinates can be retrieved or modified using functional methods or properties. + +### Retrieve Spatial Coordinates + +```python +# Get spatial coordinates (returns a BiocFrame) +coords = spe.spatial_coords +print(coords.head(5)) +``` + +### Retrieve Coordinate Names + +```python +names = spe.spatial_coords_names +print("Coordinate dimensions:", names) +``` + +### Modify Spatial Coordinates + +Use the functional `set_spatial_coordinates` method to assign new spatial coordinates: + +```python +new_coords = BiocFrame({ + "x_new": np.random.uniform(10.0, 50.0, size=ncols), + "y_new": np.random.uniform(10.0, 50.0, size=ncols) +}) + +spe = spe.set_spatial_coordinates(new_coords) +print(spe.spatial_coords_names) +``` + +--- + +## Image Data Methods + +The `img_data` slot contains image metadata, allowing lookup of specific images or scale factors. + +### Accessing Image Data + +```python +print(spe.img_data) +``` + +### Fetching Specific Images + +The `get_img` method lets you fetch specific images by `sample_id` and/or `image_id`: + +```python +img = spe.get_img(sample_id="sample_1", image_id="lowres") +print(type(img)) +``` + +--- + +## Slicing / Subsetting + +`SpatialExperiment` objects can be sliced along either rows (features) or columns (spots/cells). Slicing will automatically subset the spatial coordinates and filter relevant images from `img_data`. + +### Slice by Row + +```python +# Slice first 10 genes +spe_genes = spe[0:10, :] +print(spe_genes.shape) +``` + +### Slice by Column + +```python +# Slice first 5 spots +spe_spots = spe[:, 0:5] +print(spe_spots.shape) +print(spe_spots.spatial_coords.shape) +``` + +--- + +## Combining Experiments + +You can combine multiple `SpatialExperiment` objects along their columns (spots/cells) using `combine_columns` or `relaxed_combine_columns`. Spatial coordinates and image metadata will be combined accordingly. + +```python +import biocutils as ut + +# Combine columns from the same experiment (with warning due to duplicate sample_ids) +combined = ut.combine_columns(spe, spe) +print(combined.shape) +``` diff --git a/pyproject.toml b/pyproject.toml index 086f90c..a4d131e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,122 @@ +[project] +name = "spatialexperiment" +dynamic = [ + "version", +] +description = "Container class for storing data from spatial-omics experiments" +readme = "README.md" +authors = [ + { name = "Jayaram Kancherla", email = "jayaram.kancherla@gmail.com" }, +] +requires-python = ">=3.9" +keywords = [ + "bioinformatics", + "spatial-omics", + "spatial-transcriptomics", + "single-cell", + "bioconductor", + "singlecellexperiment", +] +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.7.2", + "biocutils>=0.3.3", + "singlecellexperiment>=0.6.0", + "pillow>=11.0", + "requests", + "scipy~=1.13", + "rasterio", +] + + +[project.license] +file = "LICENSE.txt" + + +[project.urls] +Homepage = "https://github.com/BiocPy/spatialexperiment" +Documentation = "https://biocpy.github.io/spatialexperiment/" +Source = "https://github.com/BiocPy/spatialexperiment" +"Bug Tracker" = "https://github.com/BiocPy/spatialexperiment/issues" + +[project.optional-dependencies] +optional = [ + "anndata", + "delayedarray", + "pandas", +] +testing = [ + "anndata", + "delayedarray", + "pandas", + "pytest", + "pytest-cov", +] + [build-system] -# AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! -requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5"] -build-backend = "setuptools.build_meta" +requires = [ + "hatchling", + "hatch-vcs", +] +build-backend = "hatchling.build" -[tool.setuptools_scm] -# For smarter version schemes and other configuration options, -# check out 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] +strict = 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 866a1e0..0000000 --- a/setup.cfg +++ /dev/null @@ -1,137 +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 = SpatialExperiment -description = Container class for storing data from spatial-omics experiments -author = keviny2 -author_email = kevinyang10@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/spatialexperiment -# Add here related links, for example: -project_urls = - Documentation = https://biocpy.github.io/SpatialExperiment/ - Source = https://github.com/biocpy/spatialexperiment -# 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.2 - biocutils>=0.3.3 - singlecellexperiment>=0.6.0 - pillow>=11.0 - requests - scipy~=1.13 - rasterio - - -[options.packages.find] -where = src -exclude = - tests - -[options.extras_require] -# Add here additional requirements for extra features, to install with: -# `pip install SpatialExperiment[PDF]` like: -# PDF = ReportLab; RXP -optional = - pandas - anndata - delayedarray - -# Add here test requirements (semicolon/line-separated) -testing = - setuptools - pytest - pytest-cov - %(optional)s - -[options.entry_points] -# Add here console scripts like: -# console_scripts = -# script_name = spatialexperiment.module:function -# For example: -# console_scripts = -# fibonacci = spatialexperiment.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 spatialexperiment --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 = 88 -extend_ignore = E203, W503 -# ^ Black-compatible -# E203 and W503 have edge cases handled by black -exclude = - .tox - build - dist - .eggs - docs/conf.py - -[pyscaffold] -# PyScaffold's parameters when the project was created. -# This will be used when updating. Do not change! -version = 4.6 -package = spatialexperiment -extensions = - markdown diff --git a/setup.py b/setup.py deleted file mode 100644 index cbb86cc..0000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Setup file for SpatialExperiment. -Use setup.cfg to configure your project. - -This file was generated with PyScaffold 4.6. -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/spatialexperiment/__init__.py b/src/spatialexperiment/__init__.py index 8fd41e7..8dd20a4 100644 --- a/src/spatialexperiment/__init__.py +++ b/src/spatialexperiment/__init__.py @@ -26,11 +26,11 @@ ) __all__ = [ - "read_tenx_visium", - "SpatialExperiment", "LoadedSpatialImage", "RemoteSpatialImage", + "SpatialExperiment", "StoredSpatialImage", "VirtualSpatialImage", "construct_spatial_image_class", + "read_tenx_visium", ] diff --git a/src/spatialexperiment/_combineutils.py b/src/spatialexperiment/_combineutils.py index 0ff2805..51763a3 100644 --- a/src/spatialexperiment/_combineutils.py +++ b/src/spatialexperiment/_combineutils.py @@ -1,15 +1,14 @@ from __future__ import annotations -from typing import List, Tuple -from warnings import warn -from copy import deepcopy import itertools +from copy import deepcopy +from warnings import warn -from biocframe import BiocFrame import biocutils as ut +from biocframe import BiocFrame -def _append_indices_to_samples(bframes: List[BiocFrame]) -> List[BiocFrame]: +def _append_indices_to_samples(bframes: list[BiocFrame]) -> list[BiocFrame]: """Append indices to sample IDs for a list of `BiocFrames`. For each `BiocFrame`, appends an index to all sample IDs to ensure uniqueness @@ -29,7 +28,7 @@ def _append_indices_to_samples(bframes: List[BiocFrame]) -> List[BiocFrame]: return modified_bframes -def merge_spatial_frames(x: List["SpatialExperiment"], relaxed: bool = False) -> Tuple[BiocFrame, BiocFrame]: +def merge_spatial_frames(x: list[SpatialExperiment], relaxed: bool = False) -> tuple[BiocFrame, BiocFrame]: """Merge column data and image data from multiple ``SpatialExperiment`` objects. If duplicate sample IDs exist across objects, appends indices to make them unique. @@ -70,7 +69,7 @@ def merge_spatial_frames(x: List["SpatialExperiment"], relaxed: bool = False) -> return _new_cols, _new_img_data -def merge_spatial_coordinates(spatial_coords: List[BiocFrame], relaxed: bool = False) -> BiocFrame: +def merge_spatial_coordinates(spatial_coords: list[BiocFrame], relaxed: bool = False) -> BiocFrame: """Merge spatial coordinates from multiple frames. Args: diff --git a/src/spatialexperiment/_imgutils.py b/src/spatialexperiment/_imgutils.py index 6d47b0c..279c7bd 100644 --- a/src/spatialexperiment/_imgutils.py +++ b/src/spatialexperiment/_imgutils.py @@ -1,12 +1,12 @@ -from typing import Union, List - import os from io import BytesIO from pathlib import Path from urllib.parse import urlparse + import numpy as np -from PIL import Image from biocframe import BiocFrame +from PIL import Image + from .spatialimage import construct_spatial_image_class __author__ = "keviny2" @@ -43,7 +43,7 @@ def read_image(input_image): def construct_img_data( - img: Union[str, os.PathLike], scale_factor: str, sample_id: str, image_id: str, load: bool = True + img: str | os.PathLike, scale_factor: str, sample_id: str, image_id: str, load: bool = True ) -> BiocFrame: """ Construct an image data dataframe. @@ -77,9 +77,9 @@ def construct_img_data( def get_img_idx( img_data: BiocFrame, - sample_id: Union[str, bool, None] = None, - image_id: Union[str, bool, None] = None, -) -> List[int]: + sample_id: str | bool | None = None, + image_id: str | bool | None = None, +) -> list[int]: """ Retrieve the row index/indices of image(s) with matching 'sample_id' and 'image_id' from the 'img_data'. diff --git a/src/spatialexperiment/_initutils.py b/src/spatialexperiment/_initutils.py index 2f794af..35c97d8 100644 --- a/src/spatialexperiment/_initutils.py +++ b/src/spatialexperiment/_initutils.py @@ -1,19 +1,19 @@ from copy import deepcopy -from typing import List, Tuple from biocframe import BiocFrame from PIL import Image -from .spatialimage import construct_spatial_image_class from summarizedexperiment._frameutils import _sanitize_frame +from .spatialimage import construct_spatial_image_class + __author__ = "keviny2" __copyright__ = "keviny2" __license__ = "MIT" def construct_spatial_coords_from_names( - spatial_coords_names: List[str], column_data: BiocFrame -) -> Tuple[BiocFrame, BiocFrame]: + spatial_coords_names: list[str], column_data: BiocFrame +) -> tuple[BiocFrame, BiocFrame]: """Construct the `spatial_coords` dataframe from names. Args: @@ -54,8 +54,8 @@ def construct_spatial_coords_from_names( def construct_img_data( sample_id: str, image_id: str, - image_sources: List[str], - scale_factors: List[float], + image_sources: list[str], + scale_factors: list[float], load_image: bool = False, ) -> BiocFrame: """Construct the image data for a `SpatialExperiment`. diff --git a/src/spatialexperiment/_validators.py b/src/spatialexperiment/_validators.py index b2b884d..25533b8 100644 --- a/src/spatialexperiment/_validators.py +++ b/src/spatialexperiment/_validators.py @@ -1,7 +1,7 @@ import warnings -from biocframe import BiocFrame import biocutils as ut +from biocframe import BiocFrame __author__ = "keviny2" __copyright__ = "keviny2" diff --git a/src/spatialexperiment/io/tenx_visium.py b/src/spatialexperiment/io/tenx_visium.py index 43c3855..f19ca98 100644 --- a/src/spatialexperiment/io/tenx_visium.py +++ b/src/spatialexperiment/io/tenx_visium.py @@ -1,17 +1,17 @@ """Creates a ``SpatialExperiment`` from the Space Ranger output directories for 10x Genomics Visium spatial gene expression data""" -from typing import List, Union, Optional -from warnings import warn +import json import os import re -import json +from warnings import warn -from biocframe import BiocFrame import biocutils as ut +from biocframe import BiocFrame from singlecellexperiment import read_tenx_mtx -from ..spatialexperiment import SpatialExperiment + from .._imgutils import construct_img_data from .._initutils import construct_spatial_coords_from_names +from ..spatialexperiment import SpatialExperiment def read_tissue_positions(tissue_positions_path) -> "pd.DataFrame": @@ -46,8 +46,8 @@ def read_tissue_positions(tissue_positions_path) -> "pd.DataFrame": def read_img_data( path: str = ".", - sample_ids: Optional[List[str]] = None, - image_sources: Optional[List[str]] = None, + sample_ids: list[str] | None = None, + image_sources: list[str] | None = None, scale_factors: str = None, load: bool = True, ) -> BiocFrame: @@ -118,11 +118,11 @@ def read_img_data( def read_tenx_visium( - samples: List[Union[str, os.PathLike]], - sample_ids: Optional[List[str]] = None, + samples: list[str | os.PathLike], + sample_ids: list[str] | None = None, type: str = "HDF5", data: str = "filtered", - images: List[str] = "lowres", + images: list[str] = "lowres", load: bool = True, ): """Create a ``SpatialExperiment`` from the Space Ranger output directories for 10x Genomics Visium spatial gene expression data. diff --git a/src/spatialexperiment/py.typed b/src/spatialexperiment/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/spatialexperiment/spatialexperiment.py b/src/spatialexperiment/spatialexperiment.py index 5c805fa..b52fe5d 100644 --- a/src/spatialexperiment/spatialexperiment.py +++ b/src/spatialexperiment/spatialexperiment.py @@ -1,7 +1,8 @@ from __future__ import annotations +from collections.abc import Sequence from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any from urllib.parse import urlparse from warnings import warn @@ -56,21 +57,21 @@ class SpatialExperiment(SingleCellExperiment): def __init__( self, - assays: Dict[str, Any] = None, - row_ranges: Optional[GRangesOrGRangesList] = None, - row_data: Optional[BiocFrame] = None, - column_data: Optional[BiocFrame] = None, - row_names: Optional[List[str]] = None, - column_names: Optional[List[str]] = None, - metadata: Optional[Union[Dict[str, Any], ut.NamedList]] = None, - reduced_dims: Optional[Dict[str, Any]] = None, - main_experiment_name: Optional[str] = None, - alternative_experiments: Optional[Dict[str, Any]] = None, + assays: dict[str, Any] = None, + row_ranges: GRangesOrGRangesList | None = None, + row_data: BiocFrame | None = None, + column_data: BiocFrame | None = None, + row_names: list[str] | None = None, + column_names: list[str] | None = None, + metadata: dict[str, Any] | ut.NamedList | None = None, + reduced_dims: dict[str, Any] | None = None, + main_experiment_name: str | None = None, + alternative_experiments: dict[str, Any] | None = None, alternative_experiment_check_dim_names: bool = True, - row_pairs: Optional[Any] = None, - column_pairs: Optional[Any] = None, - spatial_coords: Optional[Union[BiocFrame, np.ndarray]] = None, - img_data: Optional[BiocFrame] = None, + row_pairs: Any | None = None, + column_pairs: Any | None = None, + spatial_coords: BiocFrame | np.ndarray | None = None, + img_data: BiocFrame | None = None, _validate: bool = True, **kwargs, ) -> None: @@ -376,7 +377,7 @@ def __str__(self) -> str: output += f"row_pairs({len(self.row_pair_names)}): {ut.print_truncated_list(self.row_pair_names)}\n" output += f"column_pairs({len(self.column_pair_names)}): {ut.print_truncated_list(self.column_pair_names)}\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"metadata({len(self.metadata)!s}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" output += f"spatial_coords columns({len(self.spatial_coords_names)}): {ut.print_truncated_list(self.spatial_coords_names)}\n" output += f"img_data columns({len(self._img_data.column_names)}): {ut.print_truncated_list(self._img_data.column_names)}" @@ -387,7 +388,7 @@ def __str__(self) -> str: #####>> spatial_coords <<##### ############################## - def get_spatial_coordinates(self) -> Union[BiocFrame, np.ndarray]: + def get_spatial_coordinates(self) -> BiocFrame | np.ndarray: """Access spatial coordinates. Returns: @@ -401,7 +402,7 @@ def get_spatial_coords(self) -> BiocFrame: def set_spatial_coordinates( self, - spatial_coords: Optional[Union[BiocFrame, np.ndarray]], + spatial_coords: BiocFrame | np.ndarray | None, in_place: bool = False, ) -> SpatialExperiment: """Set new spatial coordinates. @@ -436,7 +437,7 @@ def set_spatial_coordinates( def set_spatial_coords( self, - spatial_coords: Optional[Union[BiocFrame, np.ndarray]], + spatial_coords: BiocFrame | np.ndarray | None, in_place: bool = False, ) -> SpatialExperiment: """Alias for :py:meth:`~set_spatial_coordinates`.""" @@ -448,7 +449,7 @@ def spatial_coords(self) -> BiocFrame: return self.get_spatial_coordinates() @spatial_coords.setter - def spatial_coords(self, spatial_coords: Optional[Union[BiocFrame, np.ndarray]]): + def spatial_coords(self, spatial_coords: BiocFrame | np.ndarray | None): """Alias for :py:meth:`~set_spatial_coordinates`.""" warn( "Setting property 'spatial_coords' is an in-place operation, use 'set_spatial_coordinates' instead.", @@ -462,7 +463,7 @@ def spatial_coordinates(self) -> BiocFrame: return self.get_spatial_coordinates() @spatial_coordinates.setter - def spatial_coordinates(self, spatial_coords: Optional[Union[BiocFrame, np.ndarray]]): + def spatial_coordinates(self, spatial_coords: BiocFrame | np.ndarray | None): """Alias for :py:meth:`~set_spatial_coordinates`.""" warn( "Setting property 'spatial_coords' is an in-place operation, use 'set_spatial_coordinates' instead.", @@ -474,7 +475,7 @@ def spatial_coordinates(self, spatial_coords: Optional[Union[BiocFrame, np.ndarr ##>> spatial_coords_names <<## ############################## - def get_spatial_coordinates_names(self) -> List[str]: + def get_spatial_coordinates_names(self) -> list[str]: """Access spatial coordinates names. Returns: @@ -485,12 +486,12 @@ def get_spatial_coordinates_names(self) -> List[str]: return self._spatial_coords.columns.as_list() - def get_spatial_coords_names(self) -> List[str]: + def get_spatial_coords_names(self) -> list[str]: """Alias for :py:meth:`~get_spatial_coordinate_names`.""" return self.get_spatial_coordinate_names() def set_spatial_coordinates_names( - self, spatial_coords_names: List[str], in_place: bool = False + self, spatial_coords_names: list[str], in_place: bool = False ) -> SpatialExperiment: """Set new spatial coordinates names. @@ -518,17 +519,17 @@ def set_spatial_coordinates_names( output._spatial_coords = new_spatial_coords return output - def set_spatial_coords_names(self, spatial_coords_names: List[str], in_place: bool = False) -> SpatialExperiment: + def set_spatial_coords_names(self, spatial_coords_names: list[str], in_place: bool = False) -> SpatialExperiment: """Alias for :py:meth:`~set_spatial_coordinates_names`.""" return self.set_spatial_coordinates_names(spatial_coords_names=spatial_coords_names, in_place=in_place) @property - def spatial_coords_names(self) -> List[str]: + def spatial_coords_names(self) -> list[str]: """Alias for :py:meth:`~get_spatial_coordinates_names`.""" return self.get_spatial_coordinates_names() @spatial_coords_names.setter - def spatial_coords_names(self, spatial_coords_names: List[str]): + def spatial_coords_names(self, spatial_coords_names: list[str]): """Alias for :py:meth:`~set_spatial_coordinates_names`.""" warn( "Setting property 'spatial_coords_names' is an in-place operation, use 'set_spatial_coordinates_names' instead.", @@ -537,12 +538,12 @@ def spatial_coords_names(self, spatial_coords_names: List[str]): self.set_spatial_coordinates_names(spatial_coords_names=spatial_coords_names, in_place=True) @property - def spatial_coordinates_names(self) -> List[str]: + def spatial_coordinates_names(self) -> list[str]: """Alias for :py:meth:`~get_spatial_coordinates_names`.""" return self.get_spatial_coordinates_names() @spatial_coordinates_names.setter - def spatial_coordinates_names(self, spatial_coords_names: List[str]): + def spatial_coordinates_names(self, spatial_coords_names: list[str]): """Alias for :py:meth:`~set_spatial_coordinates_names`.""" warn( "Setting property 'spatial_coords_names' is an in-place operation, use 'set_spatial_coordinates_names' instead.", @@ -566,7 +567,7 @@ def get_img_data(self) -> BiocFrame: """Alias for :py:meth:`~get_image_data`.""" return self.get_image_data() - def set_image_data(self, img_data: Optional[BiocFrame], in_place: bool = False) -> SpatialExperiment: + def set_image_data(self, img_data: BiocFrame | None, in_place: bool = False) -> SpatialExperiment: """Set new image data. Args: @@ -633,9 +634,9 @@ def image_data(self, img_data: BiocFrame): def get_scale_factors( self, - sample_id: Union[str, bool, None] = None, - image_id: Union[str, bool, None] = None, - ) -> List[float]: + sample_id: str | bool | None = None, + image_id: str | bool | None = None, + ) -> list[float]: """Return scale factor(s) of image(s) based on the provided sample and image ids. See :py:meth:`~get_img` for more details on the behavior for various combinations of `sample_id` and `image_id` values. @@ -667,7 +668,7 @@ def get_scale_factors( def set_column_data( self, - cols: Optional[BiocFrame], + cols: BiocFrame | None, replace_column_names: bool = False, in_place: bool = False, ) -> SpatialExperiment: @@ -712,8 +713,8 @@ def set_column_data( def get_slice( self, - rows: Optional[Union[str, int, bool, Sequence]], - columns: Optional[Union[str, int, bool, Sequence]], + rows: str | int | bool | Sequence | None, + columns: str | int | bool | Sequence | None, ) -> SpatialExperiment: """Alias for :py:attr:`~__getitem__`.""" @@ -756,9 +757,9 @@ def get_slice( def get_img( self, - sample_id: Union[str, bool, None] = None, - image_id: Union[str, bool, None] = None, - ) -> Union[VirtualSpatialImage, List[VirtualSpatialImage]]: + sample_id: str | bool | None = None, + image_id: str | bool | None = None, + ) -> VirtualSpatialImage | list[VirtualSpatialImage]: """Retrieve spatial images based on the provided sample and image ids. Args: @@ -818,10 +819,10 @@ def get_img( def add_img( self, - image_source: Union[Image.Image, np.ndarray, str, Path], + image_source: Image.Image | np.ndarray | str | Path, scale_factor: float, - sample_id: Union[str, bool, None], - image_id: Union[str, bool, None], + sample_id: str | bool | None, + image_id: str | bool | None, load: bool = True, in_place: bool = False, ) -> SpatialExperiment: @@ -886,7 +887,7 @@ def add_img( return output def remove_img( - self, sample_id: Union[str, bool, None] = None, image_id: Union[str, bool, None] = None, in_place: bool = False + self, sample_id: str | bool | None = None, image_id: str | bool | None = None, in_place: bool = False ) -> SpatialExperiment: """Remove an image entry. @@ -930,10 +931,10 @@ def remove_img( def img_source( self, - sample_id: Union[str, bool, None] = None, - image_id: Union[str, bool, None] = None, + sample_id: str | bool | None = None, + image_id: str | bool | None = None, path=False, - ) -> Union[str, Path, None, List[Union[str, Path]]]: + ) -> str | Path | None | list[str | Path]: """Retrieve the source(s) for images stored in the SpatialExperiment object. Args: @@ -972,7 +973,7 @@ def img_source( return img_sources - def img_raster(self, sample_id=None, image_id=None) -> Union[Image.Image, List[Image.Image], None]: + def img_raster(self, sample_id=None, image_id=None) -> Image.Image | list[Image.Image] | None: """Retrieve and load (if necessary) the images stored in the SpatialExperiment object. Args: @@ -1025,7 +1026,7 @@ def to_spatial_experiment(): def to_anndata( self, include_alternative_experiments: bool = False - ) -> Tuple["anndata.AnnData", Dict[str, "anndata.AnnData"]]: + ) -> tuple[anndata.AnnData, dict[str, anndata.AnnData]]: """Transform :py:class:`~SpatialExperiment`-like into a :py:class:`~anndata.AnnData` representation. This method converts the main experiment data, spatial coordinates, @@ -1128,7 +1129,7 @@ def combine_columns(*x: SpatialExperiment) -> SpatialExperiment: _new_rdim = merge_generic(x, by="row", attr="reduced_dims") except Exception as e: warn( - f"Cannot combine 'reduced_dimensions' across experiments, {str(e)}", + f"Cannot combine 'reduced_dimensions' across experiments, {e!s}", UserWarning, ) @@ -1137,7 +1138,7 @@ def combine_columns(*x: SpatialExperiment) -> SpatialExperiment: _new_alt_expt = merge_generic(x, by="column", attr="alternative_experiments") except Exception as e: warn( - f"Cannot combine 'alternative_experiments' across experiments, {str(e)}", + f"Cannot combine 'alternative_experiments' across experiments, {e!s}", UserWarning, ) @@ -1197,7 +1198,7 @@ def relaxed_combine_columns( _new_rdim = relaxed_merge_numpy_generic(x, by="row", attr="reduced_dims") except Exception as e: warn( - f"Cannot combine 'reduced_dimensions' across experiments, {str(e)}", + f"Cannot combine 'reduced_dimensions' across experiments, {e!s}", UserWarning, ) @@ -1206,7 +1207,7 @@ def relaxed_combine_columns( _new_alt_expt = relaxed_merge_generic(x, by="column", attr="alternative_experiments") except Exception as e: warn( - f"Cannot combine 'alternative_experiments' across experiments, {str(e)}", + f"Cannot combine 'alternative_experiments' across experiments, {e!s}", UserWarning, ) diff --git a/src/spatialexperiment/spatialimage.py b/src/spatialexperiment/spatialimage.py index a4a3f0b..758fd3c 100644 --- a/src/spatialexperiment/spatialimage.py +++ b/src/spatialexperiment/spatialimage.py @@ -3,7 +3,6 @@ from abc import abstractmethod from functools import lru_cache from pathlib import Path -from typing import Optional, Tuple, Union from urllib.parse import urlparse from warnings import warn @@ -22,7 +21,7 @@ class VirtualSpatialImage(ut.BiocObject): """Base class for spatial images.""" - def __init__(self, metadata: Optional[dict] = None): + def __init__(self, metadata: dict | None = None): super().__init__(metadata=metadata) ######################### @@ -53,13 +52,13 @@ def affine(self, scale_factor: float = 1.0) -> Affine: """ return Affine.scale(scale_factor, scale_factor) - def get_dimensions(self) -> Tuple[int, int]: + def get_dimensions(self) -> tuple[int, int]: """Get image dimensions (width, height) in pixels.""" img = self.img_raster() return img.size @property - def dimensions(self) -> Tuple[int, int]: + def dimensions(self) -> tuple[int, int]: """Alias for :py:meth:`~get_dimensions`.""" return self.get_dimensions() @@ -68,7 +67,7 @@ def dimensions(self) -> Tuple[int, int]: ############################ @abstractmethod - def img_source(self, as_path: bool = False) -> Union[str, Path, None]: + def img_source(self, as_path: bool = False) -> str | Path | None: """Get the source of the image. Args: @@ -77,12 +76,10 @@ def img_source(self, as_path: bool = False) -> Union[str, Path, None]: Returns: Source path/URL of the image, or None if loaded in memory. """ - pass @abstractmethod def img_raster(self) -> Image.Image: """Get the image as a PIL Image object.""" - pass def to_numpy(self, **kwargs) -> np.ndarray: """Convert the image raster to a NumPy array. @@ -132,7 +129,7 @@ def mirror_img(self, axis: str = "h") -> "LoadedSpatialImage": ) -def _sanitize_loaded_image(image: Union[Image.Image, np.ndarray]) -> Image.Image: +def _sanitize_loaded_image(image: Image.Image | np.ndarray) -> Image.Image: if isinstance(image, np.ndarray): # trying to infer mode for multi-channel arrays if not RGBA/RGB if image.ndim == 3: @@ -159,7 +156,7 @@ def _sanitize_loaded_image(image: Union[Image.Image, np.ndarray]) -> Image.Image class LoadedSpatialImage(VirtualSpatialImage): """Class for images loaded into memory.""" - def __init__(self, image: Union[Image.Image, np.ndarray], metadata: Optional[dict] = None): + def __init__(self, image: Image.Image | np.ndarray, metadata: dict | None = None): """Initialize the object. Args: @@ -260,7 +257,7 @@ def __str__(self) -> str: """ output = f"class: {type(self).__name__}\n" output += f"image: ({self._image})\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"metadata({len(self.metadata)!s}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" return output @@ -272,7 +269,7 @@ def get_image(self) -> Image.Image: """Get the PIL Image object.""" return self._image - def set_image(self, image: Union[Image.Image, np.ndarray], in_place: bool = False) -> "LoadedSpatialImage": + def set_image(self, image: Image.Image | np.ndarray, in_place: bool = False) -> "LoadedSpatialImage": """Set new image. Args: @@ -295,7 +292,7 @@ def image(self) -> Image.Image: return self.get_image() @image.setter - def image(self, image: Union[Image.Image, np.ndarray]): + def image(self, image: Image.Image | np.ndarray): """Alias for :py:attr:`~set_image` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -308,7 +305,7 @@ def image(self, image: Union[Image.Image, np.ndarray]): def img_source(self, as_path: bool = False) -> None: """Get the source of the loaded image (always None for in-memory).""" - return None + return ############################ ######>> img utils <<####### @@ -319,7 +316,7 @@ def img_raster(self) -> Image.Image: return self._image -def _sanitize_path(path: Union[str, Path]) -> Path: +def _sanitize_path(path: str | Path) -> Path: _path = Path(path).resolve() if not _path.exists(): raise FileNotFoundError(f"Image file not found: {path}") @@ -330,7 +327,7 @@ def _sanitize_path(path: Union[str, Path]) -> Path: class StoredSpatialImage(VirtualSpatialImage): """Class for images stored on local filesystem.""" - def __init__(self, path: Union[str, Path], metadata: Optional[dict] = None): + def __init__(self, path: str | Path, metadata: dict | None = None): """Initialize the object. Args: @@ -414,8 +411,8 @@ def __str__(self) -> str: A pretty-printed string containing the contents of this object. """ output = f"class: {type(self).__name__}\n" - output += f"path: ({str(self._path)})\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"path: ({self._path!s})\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 @@ -427,7 +424,7 @@ def get_path(self) -> Path: """Get the path to the image file.""" return self._path - def set_path(self, path: Union[str, Path], in_place: bool = False) -> "StoredSpatialImage": + def set_path(self, path: str | Path, in_place: bool = False) -> "StoredSpatialImage": """Update the path to the image file. Args: @@ -455,7 +452,7 @@ def path(self) -> Path: return self.get_path() @path.setter - def path(self, path: Union[str, Path]): + def path(self, path: str | Path): """Alias for :py:attr:`~set_path` with ``in_place = True``. As this mutates the original object, a warning is raised. @@ -499,7 +496,7 @@ def _validate_url(url: str): class RemoteSpatialImage(VirtualSpatialImage): """Class for remotely hosted images.""" - def __init__(self, url: str, metadata: Optional[dict] = None, validate: bool = True): + def __init__(self, url: str, metadata: dict | None = None, validate: bool = True): """Initialize the object. Args: @@ -592,7 +589,7 @@ def __str__(self) -> str: """ output = f"class: {type(self).__name__}\n" output += f"url: ({self._url})\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"metadata({len(self.metadata)!s}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" return output @@ -676,7 +673,7 @@ def _get_cached_path(self) -> Path: # If download fails, remove incomplete cache file and re-raise if cache_path.exists(): cache_path.unlink(missing_ok=True) - raise IOError(f"Failed to download image from {self._url}: {e}.") from e + raise OSError(f"Failed to download image from {self._url}: {e}.") from e except ValueError as e: raise ValueError(f"Invalid URL for download {self._url}: {e}.") from e return cache_path @@ -713,9 +710,9 @@ def img_source(self, as_path: bool = False) -> str: def construct_spatial_image_class( - x: Union[str, Path, Image.Image, np.ndarray, VirtualSpatialImage], - metadata: Optional[dict] = None, - is_url: Optional[bool] = None, + x: str | Path | Image.Image | np.ndarray | VirtualSpatialImage, + metadata: dict | None = None, + is_url: bool | None = None, ) -> VirtualSpatialImage: """Factory function to create appropriate SpatialImage object. diff --git a/tests/test_read_tenx_visium.py b/tests/test_read_tenx_visium.py index 9560205..ab0e24a 100644 --- a/tests/test_read_tenx_visium.py +++ b/tests/test_read_tenx_visium.py @@ -54,7 +54,7 @@ def test_read_tenx_visium(samples, sample_ids): assert np.array_equal( pd.crosstab(spe.column_data["sample_id"], spe.column_data["in_tissue"]).values, pd.crosstab( - tissue_positions["sample_id"], tissue_positions["in_tissue"] + tissue_positions["sample_id"].values, tissue_positions["in_tissue"].values ).values, ) diff --git a/tox.ini b/tox.ini index 69f8159..ab663e9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,93 +1,63 @@ -# Tox configuration file +# Tox configuration file using uv as the backend runner # Read more under https://tox.wiki/ -# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! [tox] -minversion = 3.24 +minversion = 4.0 envlist = default -isolated_build = True - [testenv] description = Invoke pytest to run automated tests -setenv = - TOXINIDIR = {toxinidir} -passenv = - HOME - SETUPTOOLS_* -extras = - testing +extras = testing +deps = twine commands = pytest {posargs} +[testenv:typecheck] +deps = mypy +description = Run static type checking with mypy +commands = + mypy src/ -# # To run `tox -e lint` you need to make sure you have a -# # `.pre-commit-config.yaml` file. See https://pre-commit.com -# [testenv:lint] -# description = Perform static analysis and style checks -# skip_install = True -# deps = pre-commit -# passenv = -# HOMEPATH -# PROGRAMDATA -# SETUPTOOLS_* -# commands = -# pre-commit run --all-files {posargs:--show-diff-on-failure} - +[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) -# https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it + build: Build the package + clean: Remove old distribution files +deps = build skip_install = True -changedir = {toxinidir} -deps = - build: build[virtualenv] -passenv = - SETUPTOOLS_* commands = 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} -# By default, both `sdist` and `wheel` are built. If your sdist is too big or you don't want -# to make it available, consider running: `tox -e build -- --wheel` - [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 -passenv = - SETUPTOOLS_* +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 = - # See: https://twine.readthedocs.io/en/latest/ - TWINE_USERNAME - TWINE_PASSWORD - TWINE_REPOSITORY - TWINE_REPOSITORY_URL deps = twine commands = - python -m twine check dist/* - python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* + python -m twine upload {posargs:dist/*}