pax_global_header00006660000000000000000000000064152507267320014523gustar00rootroot0000000000000052 comment=f5fb21e313d5588ce7e27fce72a48762f4764216 scverse-scverse-misc-f5fb21e/000077500000000000000000000000001525072673200162725ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/.codecov.yaml000066400000000000000000000004411525072673200206550ustar00rootroot00000000000000# Based on pydata/xarray codecov: require_ci_to_pass: no coverage: status: project: default: # Require 1% coverage, i.e., always succeed target: 1 patch: false changes: false comment: layout: diff, flags, files behavior: once require_base: no scverse-scverse-misc-f5fb21e/.cruft.json000066400000000000000000000027231525072673200203720ustar00rootroot00000000000000{ "template": "https://github.com/scverse/cookiecutter-scverse", "commit": "6518dfa1abde7379ea7255daf0ce09c23f2b4c94", "checkout": "v0.8.0", "context": { "cookiecutter": { "project_name": "scverse-misc", "package_name": "scverse_misc", "project_description": "Miscellaneous utility code used by scverse packages", "author_full_name": "Ilia Kats", "author_email": "i.kats@dkfz.de", "github_user": "scverse", "github_repo": "scverse-misc", "license": "BSD 3-Clause License", "ide_integration": false, "issue_categorization": "issue types", "_copy_without_render": [ ".github/workflows/build.yaml", ".github/workflows/test.yaml", "docs/_templates/autosummary/**.rst" ], "_exclude_on_template_update": [ "CHANGELOG.md", "LICENSE", "README.md", "docs/api.md", "docs/index.md", "docs/notebooks/example.ipynb", "docs/references.bib", "docs/references.md", "src/**", "tests/**" ], "_render_devdocs": false, "_jinja2_env_vars": { "lstrip_blocks": true, "trim_blocks": true }, "_template": "https://github.com/scverse/cookiecutter-scverse", "_commit": "4d6aa34fd5be7523ca3bd49fbd81a882dde01d1f" }, "_commit": "6518dfa1abde7379ea7255daf0ce09c23f2b4c94", "_template": "https://github.com/scverse/cookiecutter-scverse" }, "directory": null } scverse-scverse-misc-f5fb21e/.editorconfig000066400000000000000000000003451525072673200207510ustar00rootroot00000000000000root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [{*.{yml,yaml,toml},.cruft.json}] indent_size = 2 [Makefile] indent_style = tab scverse-scverse-misc-f5fb21e/.github/000077500000000000000000000000001525072673200176325ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/.github/ISSUE_TEMPLATE/000077500000000000000000000000001525072673200220155ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000055741525072673200247230ustar00rootroot00000000000000name: Bug report description: Report something that is broken or incorrect type: Bug body: - type: markdown attributes: value: | **Note**: Please read [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug. In brief: * Please provide exact steps how to reproduce the bug in a clean Python environment. * In case it's not clear what's causing this bug, please provide the data or the data generation procedure. * Replicate problems on public datasets or share data subsets when full sharing isn't possible. - type: textarea id: report attributes: label: Report description: A clear and concise description of what the bug is. validations: required: true - type: textarea id: versions attributes: label: Versions description: | Which version of packages. Please install `session-info2`, run the following command in a notebook, click the “Copy as Markdown” button, then paste the results into the text box below. ```python In[1]: import session_info2; session_info2.session_info(dependencies=True) ``` Alternatively, run this in a console: ```python >>> import session_info2; print(session_info2.session_info(dependencies=True)._repr_mimebundle_()["text/markdown"]) ``` render: python placeholder: | anndata 0.11.3 ---- ---- charset-normalizer 3.4.1 coverage 7.7.0 psutil 7.0.0 dask 2024.7.1 jaraco.context 5.3.0 numcodecs 0.15.1 jaraco.functools 4.0.1 Jinja2 3.1.6 sphinxcontrib-jsmath 1.0.1 sphinxcontrib-htmlhelp 2.1.0 toolz 1.0.0 session-info2 0.1.2 PyYAML 6.0.2 llvmlite 0.44.0 scipy 1.15.2 pandas 2.2.3 sphinxcontrib-devhelp 2.0.0 h5py 3.13.0 tblib 3.0.0 setuptools-scm 8.2.0 more-itertools 10.3.0 msgpack 1.1.0 sparse 0.15.5 wrapt 1.17.2 jaraco.collections 5.1.0 numba 0.61.0 pyarrow 19.0.1 pytz 2025.1 MarkupSafe 3.0.2 crc32c 2.7.1 sphinxcontrib-qthelp 2.0.0 sphinxcontrib-serializinghtml 2.0.0 zarr 2.18.4 asciitree 0.3.3 six 1.17.0 sphinxcontrib-applehelp 2.0.0 numpy 2.1.3 cloudpickle 3.1.1 sphinxcontrib-bibtex 2.6.3 natsort 8.4.0 jaraco.text 3.12.1 setuptools 76.1.0 Deprecated 1.2.18 packaging 24.2 python-dateutil 2.9.0.post0 ---- ---- Python 3.13.2 | packaged by conda-forge | (main, Feb 17 2025, 14:10:22) [GCC 13.3.0] OS Linux-6.11.0-109019-tuxedo-x86_64-with-glibc2.39 Updated 2025-03-18 15:47 scverse-scverse-misc-f5fb21e/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000003051525072673200240030ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Scverse Community Forum url: https://discourse.scverse.org/ about: If you have questions about “How to do X”, please ask them here. scverse-scverse-misc-f5fb21e/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000006121525072673200257420ustar00rootroot00000000000000name: Feature request description: Propose a new feature for scverse-misc type: Enhancement body: - type: textarea id: description attributes: label: Description of feature description: Please describe your suggestion for a new feature. It might help to describe a problem or use case, plus any alternatives that you have considered. validations: required: true scverse-scverse-misc-f5fb21e/.github/dependabot.yml000066400000000000000000000003261525072673200224630ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly cooldown: default-days: 7 groups: actions-deps: patterns: - "*" scverse-scverse-misc-f5fb21e/.github/workflows/000077500000000000000000000000001525072673200216675ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/.github/workflows/build.yaml000066400000000000000000000013261525072673200236540ustar00rootroot00000000000000name: Check Build on: push: branches: [main] pull_request: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: package: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: filter: blob:none fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Build package run: uv build - name: Check package run: uvx twine check --strict dist/*.whl scverse-scverse-misc-f5fb21e/.github/workflows/release.yaml000066400000000000000000000022661525072673200242010ustar00rootroot00000000000000name: Release on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+**" workflow_dispatch: # Use "trusted publishing", see https://docs.pypi.org/trusted-publishers/ permissions: {} jobs: release: name: Upload release to PyPI runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/project/scverse-misc/${{ steps.wheel.outputs.version }}/ permissions: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: filter: blob:none fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: false - name: Build package run: uv build - id: wheel run: | version="$(uvx wheel-filename dist/*.whl | jq -r .version)" echo "version=$version" >> "$GITHUB_OUTPUT" - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 scverse-scverse-misc-f5fb21e/.github/workflows/test.yaml000066400000000000000000000077261525072673200235460ustar00rootroot00000000000000name: Test on: push: branches: - main - "v[0-9]+.[0-9]+.x" pull_request: branches: - main - "v[0-9]+.[0-9]+.x" schedule: - cron: "0 5 1,15 * *" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: # Get the test environment from hatch as defined in pyproject.toml. # This ensures that the pyproject.toml is the single point of truth for test definitions and the same tests are # run locally and on continuous integration. # Check [[tool.hatch.envs.hatch-test.matrix]] in pyproject.toml and https://hatch.pypa.io/latest/environment/ for # more details. get-environments: runs-on: ubuntu-latest outputs: envs: ${{ steps.get-envs.outputs.envs }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: filter: blob:none fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Get test environments id: get-envs run: | ENVS_JSON=$(uvx hatch env show --json | jq -c 'to_entries | map( select(.key | startswith("hatch-test")) | { name: .key, label: (if (.key | contains("pre")) then .key + " (PRE-RELEASE DEPENDENCIES)" else .key end), python: .value.python } )') echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT # Run tests through hatch. Spawns a separate runner for each environment defined in the hatch matrix obtained above. test: needs: get-environments permissions: id-token: write # for codecov OIDC contents: read strategy: fail-fast: false matrix: os: [ubuntu-latest] env: ${{ fromJSON(needs.get-environments.outputs.envs) }} name: ${{ matrix.env.label }} runs-on: ${{ matrix.os }} continue-on-error: ${{ contains(matrix.env.name, 'pre') }} # make "all-green" pass even if pre-release job fails steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: filter: blob:none fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: ${{ matrix.env.python }} - name: create hatch environment run: uvx hatch env create ${{ matrix.env.name }} - name: list all all installed package versions run: uvx hatch run ${{ matrix.env.name }}:uv pip list - name: run tests using hatch env: MPLBACKEND: agg PLATFORM: ${{ matrix.os }} DISPLAY: :42 run: uvx hatch run ${{ matrix.env.name }}:run-cov -v --color=yes -n auto - name: generate coverage report run: | # See https://coverage.readthedocs.io/page/config.html#run-patch test -f .coverage || uvx hatch run ${{ matrix.env.name }}:cov-combine uvx hatch run ${{ matrix.env.name }}:cov-report # report visibly uvx hatch run ${{ matrix.env.name }}:coverage xml # create report for upload - name: Upload coverage uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true use_oidc: true # Check that all tests defined above pass. This makes it easy to set a single "required" test in branch # protection instead of having to update it frequently. See https://github.com/re-actors/alls-green#why. check: name: Tests pass in all hatch environments if: always() needs: - get-environments - test runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 with: jobs: ${{ toJSON(needs) }} scverse-scverse-misc-f5fb21e/.gitignore000066400000000000000000000004741525072673200202670ustar00rootroot00000000000000# Temp files .DS_Store *~ buck-out/ # IDEs /.idea/ /.vscode/ # Compiled files .venv/ __pycache__/ .*cache/ /src/scverse_misc/_version.py # Distribution / packaging /dist/ # Tests and coverage /data/ /node_modules/ /.coverage* # docs /docs/generated/ /docs/_build/ # lockfiles (library: not committed) /uv.lock scverse-scverse-misc-f5fb21e/.pre-commit-config.yaml000066400000000000000000000036411525072673200225570ustar00rootroot00000000000000fail_fast: false default_language_version: python: python3.14 default_stages: - pre-commit - pre-push minimum_pre_commit_version: 2.16.0 repos: - repo: https://github.com/biomejs/pre-commit rev: 14ca40ece1f3137d278ba53855521bfec129e1f5 # frozen: v2.5.12 hooks: - id: biome-format exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually. - repo: https://github.com/tox-dev/pyproject-fmt rev: 3b42b9776870badc9eaffdef9a4a0834b764d87c # frozen: v2.29.3 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: 321478e58f4938179c6b86e4ddfa923d1547a49b # frozen: v0.16.6 hooks: - id: ruff-check types_or: [python, pyi, jupyter] args: [--fix, --exit-non-zero-on-fix] - id: ruff-format types_or: [python, pyi, jupyter] - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: detect-private-key - id: check-ast - id: end-of-file-fixer - id: mixed-line-ending args: [--fix=lf] - id: trailing-whitespace - id: check-case-conflict # Check that there are no merge conflicts (could be generated by template sync) - id: check-merge-conflict args: [--assume-in-merge] - repo: https://github.com/pre-commit/mirrors-mypy rev: 7ff8d35ae36a7d2b968f2f90b4c723e292e594ee # frozen: v2.3.1 hooks: - id: mypy exclude: ^docs/sphinx_ext_examples/ args: [] additional_dependencies: - pydantic-settings - pydocstring-rs>=0.4.1 - pytest - sphinx - sphinxcontrib-katex - types-docutils - types-PyYAML - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: cef8b8350da46d8114c7e6b7272aebdccdf193ce # frozen: v1.30.0 hooks: - id: zizmor args: [--no-progress, --fix] scverse-scverse-misc-f5fb21e/.readthedocs.yaml000066400000000000000000000005521525072673200215230ustar00rootroot00000000000000# https://docs.readthedocs.io/page/config-file/v2.html version: 2 build: os: ubuntu-24.04 tools: python: "3.14" nodejs: latest jobs: create_environment: - asdf plugin add uv - asdf install uv latest - asdf global uv latest build: html: - uvx hatch run docs:build - mv docs/_build $READTHEDOCS_OUTPUT scverse-scverse-misc-f5fb21e/CHANGELOG.md000066400000000000000000000114311525072673200201030ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog][], and this project adheres to [Semantic Versioning][]. [keep a changelog]: https://keepachangelog.com/ [semantic versioning]: https://semver.org/spec/ ## [0.1.6] ### Fixed - `deprecated_arg` now indents the `version-deprecated` directive correctly for parameters without a description (previously it was rendered as a separate parameter). ## [0.1.5] ### Added - An `arg_alias` decorator that simplifies handling of function arguments taking on one of a fixed set of values, where each value may have multiple aliases (e.g. the `axis` argument of many scverse functions). ## [0.1.4] ### Added - The `datasets` module can now download files from fallback URLs if a download from the primary URL fails. ## [0.1.3] ### Fixed - The sphinx extension now handles module aliases correctly. ## [0.1.2] ### Added - The sphinx extension ships templates for `sphinx.ext.autosummary` that were previously part of `cookiecutter-scverse`. ## [0.1.1] ### Fixed - The sphinx extension no longer adds parameter types to `override` and `reset` docstrings if the users have `sphinx-autodoc-typehints` enabled or `autodoc_typehints = 'description' | 'both'` set. ## [0.1.0] ### Fixed - Allow additional methods on `Settings` subclasses. - Allow the Sphinx extension to go anywhere in the `conf.py` extension list. - `anndata` isn’t a core dependency anymore again. ## [0.0.9] ### Added - A Sphinx extension to take care of documentation. This moves docstring processing from import time to documentation building time. - A reusable `datasets` subpackage (behind the `datasets` extra): typed `DatasetEntry`/ `FileEntry` + `parse_registry` (YAML), a thin pooch-based `fetch` (SHA-256 verification, retries, archive processors), and a pluggable `type -> loader` registry (`register_loader`) so packages can share dataset-download infrastructure. Ships built-in `anndata` and `spatialdata` loaders (the latter behind the `spatialdata` extra); other types are consumer-registered. - `anndata` is now a core dependency. ### Changed - Docstrings are no longer generated or modified at import time. ### Fixed - Marking a setting as `Field(deprecated=...)` will show a deprecation notice in the documentaiton. ## [0.0.8] ### Fixed - The `Settings` class can now handle `.env` files containing unrelated environment variables. ## [0.0.7] ### Added - A `reset` method for `Settings` to reset settings to their default values. ## [0.0.6] ### Added - A `deprecated_arg` decorator to deprecate function arguments. ## [0.0.5] ### Added - The `docstring_style` used by scanpy, `"scverse"`, which looks like `"numpy"` but with no parameter types in the docstring. ### Changed - The `Settings` class and the `make_register_namespace_decorator` function now require passing a `docstring_style` argument. ### Fixed - The `Settings` docstrings longer have `:value: PydanticUndefined` for fields with no defaults. - Remove the “default” text from `override` parameters so we don’t imply that `override` resets all settings the user isn’t overriding. ## [0.0.4] ### Added - A `Settings` base class that packages can inherit from for their settings. This is based on [Pydantic Settings](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/) and provides validation for settings values as well as loading settings from environment variables and `.env` files. ## [0.0.3] ### Added - A `deprecated` decorator wrapping `warnings.deprecated` that additionally modifies the docstring to include a deprecation notice. ## [0.0.2] ### Removed - The Pandas utility functions ## [0.0.1] - Initial release [0.1.6]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.6 [0.1.5]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.5 [0.1.4]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.4 [0.1.3]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.3 [0.1.2]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.2 [0.1.1]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.1 [0.1.0]: https://github.com/scverse/scverse-misc/releases/tag/v0.1.0 [0.0.9]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.9 [0.0.8]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.8 [0.0.7]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.7 [0.0.6]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.6 [0.0.5]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.5 [0.0.4]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.4 [0.0.3]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.3 [0.0.2]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.2 [0.0.1]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.1 scverse-scverse-misc-f5fb21e/LICENSE000066400000000000000000000027551525072673200173100ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2026, Ilia Kats All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. scverse-scverse-misc-f5fb21e/README.md000066400000000000000000000033661525072673200175610ustar00rootroot00000000000000# scverse-misc [![PyPI][badge-pypi]][pypi] [![Tests][badge-tests]][tests] [![codecov][badge-codecov]][codecov] [![Documentation][badge-docs]][documentation] [badge-pypi]: https://img.shields.io/pypi/v/scverse-misc [badge-tests]: https://github.com/scverse/scverse-misc/actions/workflows/test.yaml/badge.svg [badge-codecov]: https://codecov.io/gh/scverse/scverse-misc/graph/badge.svg?token=EUH9BZZK7T [badge-docs]: https://app.readthedocs.org/projects/scverse-misc/badge/ Miscellaneous utility code used by scverse packages ## Getting started Please refer to the [documentation][], in particular, the [API documentation][]. ## Installation You need to have Python 3.11 or newer installed on your system. If you don't have Python installed, we recommend installing [uv][]. There are several alternative options to install scverse-misc: 1. Install the latest release of `scverse-misc` from [PyPI][]: ```bash pip install scverse-misc ``` 2. Install the latest development version: ```bash pip install git+https://github.com/scverse/scverse-misc.git@main ``` ## Release notes See the [changelog][]. ## Contact For questions and help requests, you can reach out in the [scverse discourse][]. If you found a bug, please use the [issue tracker][]. [uv]: https://github.com/astral-sh/uv [scverse discourse]: https://discourse.scverse.org/ [issue tracker]: https://github.com/scverse/scverse-misc/issues [tests]: https://github.com/scverse/scverse-misc/actions/workflows/test.yaml [codecov]: https://codecov.io/gh/scverse/scverse-misc [documentation]: https://scverse-misc.readthedocs.io [changelog]: https://scverse-misc.readthedocs.io/page/changelog.html [api documentation]: https://scverse-misc.readthedocs.io/page/api.html [pypi]: https://pypi.org/project/scverse-misc scverse-scverse-misc-f5fb21e/biome.jsonc000066400000000000000000000010231525072673200204170ustar00rootroot00000000000000{ "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "formatter": { "useEditorconfig": true }, "overrides": [ { "includes": ["./.vscode/*.json", "**/*.jsonc"], "json": { "formatter": { "trailingCommas": "all" }, "parser": { "allowComments": true, "allowTrailingCommas": true, }, }, }, ], } scverse-scverse-misc-f5fb21e/docs/000077500000000000000000000000001525072673200172225ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/Makefile000066400000000000000000000011721525072673200206630ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) scverse-scverse-misc-f5fb21e/docs/_static/000077500000000000000000000000001525072673200206505ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_static/.gitkeep000066400000000000000000000000001525072673200222670ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_static/css/000077500000000000000000000000001525072673200214405ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_static/css/custom.css000066400000000000000000000003411525072673200234620ustar00rootroot00000000000000/* Reduce the font size in data frames - See https://github.com/scverse/cookiecutter-scverse/issues/193 */ div.cell_output table.dataframe { font-size: 0.8em; } .sphinx-codeautolink-a { text-decoration: underline; } scverse-scverse-misc-f5fb21e/docs/_templates/000077500000000000000000000000001525072673200213575ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_templates/.gitkeep000066400000000000000000000000001525072673200227760ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_templates/autosummary/000077500000000000000000000000001525072673200237455ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/_templates/autosummary/class.rst000066400000000000000000000021231525072673200256020ustar00rootroot00000000000000{{ fullname | escape | underline}} .. currentmodule:: {{ module }} .. add toctree option to make autodoc generate the pages .. autoclass:: {{ objname }} {% block attributes %} {% for item in attributes %} {% if loop.length != 1 %} {% if loop.first %} Attributes table ~~~~~~~~~~~~~~~~ .. autosummary:: {% endif %} ~{{ name }}.{{ item }} {% endif %} {%- endfor %} {% endblock %} {% block methods %} {% for item in all_methods if item == '__call__' or not item.startswith('__') %} {% if loop.length != 1 %} {% if loop.first %} Methods table ~~~~~~~~~~~~~ .. autosummary:: {% endif %} ~{{ name }}.{{ item }} {% endif %} {%- endfor %} {% endblock %} {% block attributes_documentation %} {% for item in attributes %} {% if loop.first %} Attributes ~~~~~~~~~~ {% endif %} .. autoattribute:: {{ [objname, item] | join(".") }} {%- endfor %} {% endblock %} {% block methods_documentation %} {% for item in all_methods if item == '__call__' or not item.startswith('__') %} {% if loop.first %} Methods ~~~~~~~ {% endif %} .. automethod:: {{ [objname, item] | join(".") }} {%- endfor %} {% endblock %} scverse-scverse-misc-f5fb21e/docs/api.md000066400000000000000000000022531525072673200203170ustar00rootroot00000000000000# API ```{eval-rst} .. module:: scverse_misc .. toctree:: ``` (extensions)= ## Extensions ```{eval-rst} .. autosummary:: :toctree: generated make_register_namespace_decorator ``` Types used by the former: ```{eval-rst} .. autosummary:: :toctree: generated ExtensionNamespace ``` *Examples:* {ref}`example-extension-namespaces` (deprecations)= ## Deprecations ```{eval-rst} .. autosummary:: :toctree: generated deprecated deprecated_arg Deprecation ``` *Examples:* {ref}`example-deprecating-a-function`, {ref}`example-deprecating-a-function-argument`, {ref}`example-settings-class` (settings)= ## Settings ```{eval-rst} .. toctree:: :hidden: api/settings .. autosummary:: :signatures: short Settings ``` *Examples:* {ref}`example-settings-class` (arg_alias)= ## Argument value aliasing ```{eval-rst} .. autosummary:: :toctree: generated arg_alias ``` (datasets)= ## Datasets (`scverse_misc.datasets`) ```{eval-rst} .. automodule:: scverse_misc.datasets .. autosummary:: :toctree: generated DatasetEntry FileEntry parse_registry fetch register_loader available_loaders Loader DownloadCB ``` scverse-scverse-misc-f5fb21e/docs/api/000077500000000000000000000000001525072673200177735ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/api/settings.rst000066400000000000000000000002711525072673200223650ustar00rootroot00000000000000scverse\_misc.Settings ====================== .. currentmodule:: scverse_misc .. autoclass:: Settings .. rubric:: Methods .. automethod:: override .. automethod:: reset scverse-scverse-misc-f5fb21e/docs/changelog.md000066400000000000000000000000421525072673200214670ustar00rootroot00000000000000```{include} ../CHANGELOG.md ``` scverse-scverse-misc-f5fb21e/docs/conf.py000066400000000000000000000107211525072673200205220ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/page/usage/configuration.html # -- Path setup -------------------------------------------------------------- import shutil import sys from datetime import datetime from importlib.metadata import metadata from pathlib import Path from sphinxcontrib import katex HERE = Path(__file__).parent sys.path.insert(0, str(HERE / "extensions")) sys.path.insert(0, str(HERE / "sphinx_ext_examples")) # -- Project information ----------------------------------------------------- # NOTE: If you installed your project in editable mode, this might be stale. # If this is the case, reinstall it to refresh the metadata info = metadata("scverse-misc") project = info["Name"] author = info["Author"] copyright = f"{datetime.now():%Y}, {author}." version = info["Version"] urls = dict(pu.split(", ") for pu in info.get_all("Project-URL") or ()) repository_url = urls["Source"] # The full version, including alpha/beta/rc tags release = info["Version"] bibtex_bibfiles = ["references.bib"] templates_path = ["_templates"] nitpicky = True # Warn about broken links needs_sphinx = "4.0" html_context = { "display_github": True, # Integrate GitHub "github_user": "scverse", "github_repo": project, "github_version": "main", "conf_py_path": "/docs/", } # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. # They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "myst_nb", "sphinx_copybutton", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.napoleon", "sphinxcontrib.bibtex", "sphinxcontrib.katex", "sphinx_autodoc_typehints", "sphinx_codeautolink", "sphinx_design", "IPython.sphinxext.ipython_console_highlighting", "sphinxext.opengraph", "scverse_misc.sphinx_ext", *[p.stem for p in (HERE / "extensions").glob("*.py")], ] autosummary_generate = True autodoc_member_order = "groupwise" default_role = "literal" napoleon_google_docstring = True napoleon_numpy_docstring = False napoleon_include_init_with_doc = False napoleon_use_rtype = True # having a separate entry generally helps readability napoleon_use_param = True myst_heading_anchors = 6 # create anchors for h1-h6 myst_enable_extensions = [ "amsmath", "colon_fence", "deflist", "dollarmath", "html_image", "html_admonition", ] myst_url_schemes = ("http", "https", "mailto") nb_output_stderr = "remove" nb_execution_mode = "off" nb_merge_streams = True typehints_defaults = "braces" always_use_bars_union = True # use `|` instead of `Union` in types even when building with Python ≤3.14 source_suffix = { ".rst": "restructuredtext", ".ipynb": "myst-nb", ".myst": "myst-nb", } intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), "scipy": ("https://docs.scipy.org/doc/scipy", None), "pandas": ("https://pandas.pydata.org/docs/", None), "scanpy": ("https://scanpy.readthedocs.io/en/stable/", None), "pooch": ("https://www.fatiando.org/pooch/latest/", None), "pydantic": ("https://pydantic.dev/docs/validation/", None), } # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"] # -- 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 = "sphinx_book_theme" html_static_path = ["_static"] html_css_files = ["css/custom.css"] html_title = project html_theme_options = { "repository_url": repository_url, "use_repository_button": True, "path_to_docs": "docs/", "navigation_with_keys": False, } pygments_style = "default" katex_prerender = shutil.which(katex.NODEJS_BINARY) is not None nitpick_ignore: list[tuple[str, str]] = [ # If building the documentation fails because of a missing link that is outside your control, # you can add an exception to this list. ("py:class", "scverse_misc._deprecated.CallableWithDeprecatedArg"), ] scverse-scverse-misc-f5fb21e/docs/contributing.md000066400000000000000000000310331525072673200222530ustar00rootroot00000000000000# Contributing guide This document aims at summarizing the most important information for getting you started on contributing to this project. We assume that you are already familiar with git and with making pull requests on GitHub. For more extensive tutorials, that also cover the absolute basics, please refer to other resources such as the [pyopensci tutorials][], the [scientific Python tutorials][], or the [scanpy developer guide][]. [pyopensci tutorials]: https://www.pyopensci.org/learn.html [scientific Python tutorials]: https://learn.scientific-python.org/development/tutorials/ [scanpy developer guide]: https://scanpy.scverse.org/page/dev/ :::{tip} The *hatch* project manager We highly recommend to familiarize yourself with [`hatch`][hatch]. Hatch is a Python project manager that - manages virtual environments, separately for development, testing and building the documentation. Separating the environments is useful to avoid dependency conflicts. - allows to run tests locally in different environments (e.g. different python versions) - allows to run tasks defined in `pyproject.toml`, e.g. to build documentation. While the project is setup with `hatch` in mind, it is still possible to use different tools to manage dependencies, such as `uv` or `pip`. ::: [hatch]: https://hatch.pypa.io/latest/ ## Installing dev dependencies In addition to the packages needed to _use_ this package, you need additional python packages to [run tests](#writing-tests) and [build the documentation](#docs-building). :::::{tab-set} ::::{tab-item} Hatch :sync: hatch On the command line, you typically interact with hatch through its command line interface (CLI). Running one of the following commands will automatically resolve the environments for testing and building the documentation in the background: ```bash hatch test # defined in the table [tool.hatch.envs.hatch-test] in pyproject.toml hatch run docs:build # defined in the table [tool.hatch.envs.docs] ``` ### VS Code If you are using VS code, install the [hatch-code][] extension. Additionally, make sure that the `vscode-python-environments` extension is installed (should be by default) and `"python.useEnvironmentsExtension": true` is activated in your `settings.json`. Next, open the "Python Environment Managers" sidebar. You can do so by opening the command palette (Ctrl+Shift+P) and searching for `Python: Focus on Environment Managers View`. It will show a collapsible list where you can expand "Hatch" and activate an environment by clicking on the checkmark next to it. As the main development environment, we recommend to use `hatch-test` with the latest supported Python version. ### Other IDEs For other IDEs, you’ll have to point the editor at the paths to the virtual environments manually. To get a list of all environments for your projects, run ```bash hatch env show -i ``` This will list “Standalone” environments and a table of “Matrix” environments like the following: ``` +------------+---------+--------------------------+----------+---------------------------------+-------------+ | Name | Type | Envs | Features | Dependencies | Scripts | +------------+---------+--------------------------+----------+---------------------------------+-------------+ | hatch-test | virtual | hatch-test.py3.12-stable | dev | coverage-enable-subprocess==1.0 | cov-combine | | | | hatch-test.py3.14-stable | test | coverage[toml]~=7.4 | cov-report | | | | hatch-test.py3.14-pre | | pytest-mock~=3.12 | run | | | | | | pytest-randomly~=3.15 | run-cov | | | | | | pytest-rerunfailures~=14.0 | | | | | | | pytest-xdist[psutil]~=3.5 | | | | | | | pytest~=8.1 | | +------------+---------+--------------------------+----------+---------------------------------+-------------+ ``` From the `Envs` column, select the environment name you want to use for development. As the main development environment, we recommend to use `hatch-test` with the latest supported Python version. In this example, it would be `hatch-test.py3.14-stable`. Next, create the environment with ```bash hatch env create hatch-test.py3.14-stable ``` Then, obtain the path to the environment using ```bash hatch env find hatch-test.py3.14-stable ``` and manually point it to the python binary. :::: ::::{tab-item} uv :sync: uv A popular choice for managing virtual environments is [uv][]. The main disadvantage compared to hatch is that it supports only a single environment per project at a time, which requires you to mix the dependencies for running tests and building docs. This can have undesired side-effects, such as requiring to install a lower version of a library your project depends on, only because an outdated sphinx plugin pins an older version. To initialize a virtual environment in the `.venv` directory of your project, simply run ```bash uv sync --all-extras ``` The `.venv` directory is typically automatically discovered by IDEs such as VS Code. :::: ::::{tab-item} Pip :sync: pip Pip is nowadays mostly superseded by environment manager such as [hatch][]. However, for the sake of completeness, and since it’s ubiquitously available, we describe how you can manage environments manually using `pip`: ```bash python3 -m venv .venv source .venv/bin/activate pip install -e ".[dev,test,doc]" ``` The `.venv` directory is typically automatically discovered by IDEs such as VS Code. :::: ::::: [hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/ [hatch-code]: https://marketplace.visualstudio.com/items?itemName=PyPA.hatch [uv]: https://docs.astral.sh/uv/ ## Code-style This package uses [pre-commit][]-style hooks to enforce consistent code-styles. We recommend running them with [prek][], a fast, drop-in replacement for `pre-commit` that reads the same `.pre-commit-config.yaml`. On every commit, the checks will either automatically fix issues with the code, or raise an error message. To enable the checks locally, install [prek][] (e.g. with `uv tool install prek`) and run ```bash prek install ``` in the root of the repository. prek will automatically download all dependencies when it is run for the first time. Alternatively, you can rely on the [pre-commit.ci][] service enabled on GitHub. If you didn’t run the checks before pushing changes to GitHub it will automatically commit fixes to your pull request, or show an error message. If pre-commit.ci added a commit on a branch you still have been working on locally, simply use ```bash git pull --rebase ``` to integrate the changes into yours. While the [pre-commit.ci][] is useful, we strongly encourage installing and running the checks locally first to understand their usage. Finally, most editors have an _autoformat on save_ feature. Consider enabling this option for [ruff][ruff-editors] and [biome][biome-editors]. [pre-commit]: https://pre-commit.com/ [prek]: https://prek.j178.dev/ [pre-commit.ci]: https://pre-commit.ci/ [ruff-editors]: https://docs.astral.sh/ruff/integrations/ [biome-editors]: https://biomejs.dev/guides/integrate-in-editor/ (writing-tests)= ## Writing tests This package uses [pytest][] for automated testing. Please write {doc}`scanpy:dev/testing` for every function added to the package. Most IDEs integrate with pytest and provide a GUI to run tests. If you set up your virtual environments as described in [installing dev dependencies](#installing-dev-dependencies), test cases should be automatically discovered by your IDE. Alternatively, you can run all tests from the command line by executing :::::{tab-set} ::::{tab-item} Hatch :sync: hatch ```bash hatch test # test with the highest supported Python version # or hatch test --all # test with all supported Python versions ``` :::: ::::{tab-item} uv :sync: uv ```bash uv run pytest ``` :::: ::::{tab-item} Pip :sync: pip ```bash source .venv/bin/activate pytest ``` :::: ::::: in the root of the repository. [pytest]: https://docs.pytest.org/ ### Continuous integration Continuous integration via GitHub actions will automatically run the tests on all pull requests and test against the minimum and maximum supported Python version. Additionally, there’s a CI job that tests against pre-releases of all dependencies (if there are any). The purpose of this check is to detect incompatibilities of new package versions early on and gives you time to fix the issue or reach out to the developers of the dependency before the package is released to a wider audience. The CI job is defined in `.github/workflows/test.yaml`, however the single point of truth for CI jobs is the Hatch test matrix defined in `pyproject.toml`. This means that local testing via hatch and remote testing on CI tests against the same python versions and uses the same environments. ## Publishing a release ### Updating the version number Before making a release, you need to update the version number in the `changelog.md` file. Please adhere to [Semantic Versioning][semver], in brief > Given a version number MAJOR.MINOR.PATCH, increment the: > > 1. MAJOR version when you make incompatible API changes, > 2. MINOR version when you add functionality in a backwards compatible manner, and > 3. PATCH version when you make backwards compatible bug fixes. > > Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. Once you are done, commit your changes and tag the commit as `vX.X.X`. Push the tag. This will automatically create a git tag and trigger a Github workflow that creates a release on [PyPI][]. [semver]: https://semver.org/ [pypi]: https://pypi.org/ ## Writing documentation Please write documentation for new or changed features and use-cases. This project uses [sphinx][] with the following features: - The [myst][] extension allows to write documentation in markdown/Markedly Structured Text - [Google-style docstrings][google-python-style] (through the [napoloen][numpydoc-napoleon] extension). - Jupyter notebooks as tutorials through [myst-nb][] (See [Tutorials with myst-nb](#tutorials-with-myst-nb-and-jupyter-notebooks)) - [sphinx-autodoc-typehints][], to automatically reference annotated input and output types - Citations (like {cite:p}`Virshup_2023`) can be included with [sphinxcontrib-bibtex](https://sphinxcontrib-bibtex.readthedocs.io/) See scanpy’s {doc}`scanpy:dev/documentation` for more information on how to write your own. [sphinx]: https://www.sphinx-doc.org/ [myst]: https://myst-parser.readthedocs.io/page/intro.html [myst-nb]: https://myst-nb.readthedocs.io/ [numpydoc-napoleon]: https://www.sphinx-doc.org/page/usage/extensions/napoleon.html [google-python-style]: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings [numpydoc]: https://numpydoc.readthedocs.io/page/format.html [sphinx-autodoc-typehints]: https://github.com/tox-dev/sphinx-autodoc-typehints ### Tutorials with myst-nb and jupyter notebooks The documentation is set-up to render jupyter notebooks stored in the `docs/notebooks` directory using [myst-nb][]. Currently, only notebooks in `.ipynb` format are supported that will be included with both their input and output cells. It is your responsibility to update and re-run the notebook whenever necessary. If you are interested in automatically running notebooks as part of the continuous integration, please check out [this feature request][issue-render-notebooks] in the `cookiecutter-scverse` repository. [issue-render-notebooks]: https://github.com/scverse/cookiecutter-scverse/issues/40 #### Hints - If you refer to objects from other packages, please add an entry to `intersphinx_mapping` in `docs/conf.py`. Only if you do so can sphinx automatically create a link to the external documentation. - If building the documentation fails because of a missing link that is outside your control, you can add an entry to the `nitpick_ignore` list in `docs/conf.py` (docs-building)= ### Building the docs locally :::::{tab-set} ::::{tab-item} Hatch :sync: hatch ```bash hatch run docs:build hatch run docs:open ``` :::: ::::{tab-item} uv :sync: uv ```bash cd docs uv run sphinx-build -M html . _build -W (xdg-)open _build/html/index.html ``` :::: ::::{tab-item} Pip :sync: pip ```bash source .venv/bin/activate cd docs sphinx-build -M html . _build -W (xdg-)open _build/html/index.html ``` :::: ::::: scverse-scverse-misc-f5fb21e/docs/extensions/000077500000000000000000000000001525072673200214215ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/extensions/typed_returns.py000066400000000000000000000022731525072673200247060ustar00rootroot00000000000000# code from https://github.com/theislab/scanpy/blob/master/docs/extensions/typed_returns.py # with some minor adjustment from __future__ import annotations import re from collections.abc import Generator, Iterable from sphinx.application import Sphinx from sphinx.ext.napoleon.docstring import NumpyDocstring, GoogleDocstring from sphinx.util.typing import ExtensionMetadata def _process_return(lines: Iterable[str]) -> Generator[str, None, None]: for line in lines: if m := re.fullmatch(r"(?P\w+)\s+:\s+(?P[\w.]+)", line): yield f"-{m['param']} (:class:`~{m['type']}`)" else: yield line def _parse_returns_section(self: GoogleDocstring, section: str) -> list[str]: lines_raw = self._dedent(self._consume_to_next_section()) if lines_raw[0] == ":": del lines_raw[0] lines = self._format_block(":returns: ", list(_process_return(lines_raw))) if lines and lines[-1]: lines.append("") return lines def setup(app: Sphinx) -> ExtensionMetadata: """Set app.""" NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign] return ExtensionMetadata(parallel_read_safe=True) scverse-scverse-misc-f5fb21e/docs/index.md000066400000000000000000000002151525072673200206510ustar00rootroot00000000000000```{include} ../README.md ``` ```{toctree} :hidden: true :maxdepth: 1 api.md sphinx_ext.md changelog.md contributing.md references.md ``` scverse-scverse-misc-f5fb21e/docs/references.bib000066400000000000000000000020261525072673200220210ustar00rootroot00000000000000@article{Virshup_2023, doi = {10.1038/s41587-023-01733-8}, url = {https://doi.org/10.1038%2Fs41587-023-01733-8}, year = 2023, month = {apr}, publisher = {Springer Science and Business Media {LLC}}, author = {Isaac Virshup and Danila Bredikhin and Lukas Heumos and Giovanni Palla and Gregor Sturm and Adam Gayoso and Ilia Kats and Mikaela Koutrouli and Philipp Angerer and Volker Bergen and Pierre Boyeau and Maren Büttner and Gokcen Eraslan and David Fischer and Max Frank and Justin Hong and Michal Klein and Marius Lange and Romain Lopez and Mohammad Lotfollahi and Malte D. Luecken and Fidel Ramirez and Jeffrey Regier and Sergei Rybakov and Anna C. Schaar and Valeh Valiollah Pour Amiri and Philipp Weiler and Galen Xing and Bonnie Berger and Dana Pe'er and Aviv Regev and Sarah A. Teichmann and Francesca Finotello and F. Alexander Wolf and Nir Yosef and Oliver Stegle and Fabian J. Theis and}, title = {The scverse project provides a computational ecosystem for single-cell omics data analysis}, journal = {Nature Biotechnology} } scverse-scverse-misc-f5fb21e/docs/references.md000066400000000000000000000000541525072673200216640ustar00rootroot00000000000000# References ```{bibliography} :cited: ``` scverse-scverse-misc-f5fb21e/docs/sphinx_ext.md000066400000000000000000000040361525072673200217400ustar00rootroot00000000000000```{toctree} :hidden: true :maxdepth: 10 ``` # Sphinx extension This package provides a Sphinx extension to create or adjust documentation. For example, using the [deprecated decorator](#scverse_misc.deprecated) adds a deprecation notice to the function's description. To use the extension, add `"scverse_misc.sphinx_ext"` to your extensions array in `conf.py`. The extension requires `scverse_misc` to be installed with the `sphinx` extra. The extension also ships and enables several templates for `sphinx.ext.autosummary`. These were previously part of the [scverse cookiecutter template](https://github.com/scverse/cookiecutter-scverse). This keeps the cookiecutter template lean and ensures that scverse packages have a consistent documentation design, even if some packages are not updating the cookiecutter template. If you have custom autosummary templates in your package, they will still be used. Similarly, support for prose return sections in NumPy-style docstrings, previously part of cookiecutter-scverse, is now provided here. # Examples (example-deprecating-a-function)= ## Deprecating a function ### source ```{literalinclude} sphinx_ext_examples/deprecated_decorator.py :language: python ``` ### rendered ```{eval-rst} .. autofunction:: deprecated_decorator.foo ``` (example-deprecating-a-function-argument)= ## Deprecating a function argument ### source ```{literalinclude} sphinx_ext_examples/deprecated_arg.py :language: python ``` ### rendered ```{eval-rst} .. autofunction:: deprecated_arg.foo ``` (example-settings-class)= ## Settings class ### source ```{literalinclude} sphinx_ext_examples/package.py :language: python ``` ### rendered ```{eval-rst} .. autodata:: package.settings .. autofunction:: package.settings.override .. autofunction:: package.settings.reset ``` (example-extension-namespaces)= ## Extension namespaces ### source ```{literalinclude} sphinx_ext_examples/extension.py :language: python ``` ### rendered ```{eval-rst} .. autoclass:: extension.DummyClass .. autodecorator:: extension.register_namespace ``` scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/000077500000000000000000000000001525072673200233115ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/__init__.py000066400000000000000000000000001525072673200254100ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/deprecated_arg.py000066400000000000000000000004211525072673200266110ustar00rootroot00000000000000from scverse_misc import deprecated_arg, Deprecation @deprecated_arg("b", Deprecation("0.3.1", "Use function `bar()` instead.")) def foo(a: int, b: str) -> None: """Frobnicates its arguments. Args: a: The frobnicator. b: The frobnicatee. """ scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/deprecated_decorator.py000066400000000000000000000003671525072673200300330ustar00rootroot00000000000000from scverse_misc import deprecated, Deprecation @deprecated(Deprecation("0.3.1", "Use bar instead.")) def foo(a: int, b: str) -> None: """Frobnicates its arguments. Args: a: The frobnicator. b: The frobnicatee. """ scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/extension.py000066400000000000000000000002751525072673200257030ustar00rootroot00000000000000from scverse_misc import make_register_namespace_decorator class DummyClass: """Some class to extend.""" register_namespace = make_register_namespace_decorator(DummyClass, "dummy") scverse-scverse-misc-f5fb21e/docs/sphinx_ext_examples/package.py000066400000000000000000000007101525072673200252540ustar00rootroot00000000000000from typing import Annotated from scverse_misc import Settings, deprecated, Deprecation from pydantic import Field class _Settings(Settings): frobnicate: bool = False """Controls whether to frobnicate.""" eps: Annotated[ float, Field(gt=0, lt=1, deprecated=deprecated(Deprecation("0.4.2", "This functionality does not exist anymore."))), ] = 1e-8 """Small epsilon for numerical stability""" settings = _Settings() scverse-scverse-misc-f5fb21e/pyproject.toml000066400000000000000000000131371525072673200212130ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = [ "hatch-vcs", "hatchling" ] [project] name = "scverse-misc" description = "Miscellaneous utility code used by scverse packages" readme = "README.md" license = { file = "LICENSE" } maintainers = [ { name = "Ilia Kats", email = "i.kats@dkfz.de" }, ] authors = [ { name = "Ilia Kats" }, ] requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dynamic = [ "version" ] dependencies = [ # for debug logging (referenced from the issue template) "session-info2", "typing-extensions; python_version<'3.13'", ] optional-dependencies.datasets = [ "anndata", "pooch", "pyyaml", "tqdm" ] optional-dependencies.settings = [ "pydantic-settings", "python-dotenv" ] optional-dependencies.spatialdata = [ "spatialdata" ] optional-dependencies.sphinx = [ "jinja2", "pydocstring-rs>=0.4.1", "sphinx>=9" ] # https://docs.pypi.org/project_metadata/#project-urls urls.Documentation = "https://scverse-misc.readthedocs.io/" urls.Homepage = "https://github.com/scverse/scverse-misc" urls.Source = "https://github.com/scverse/scverse-misc" [dependency-groups] dev = [ "prek", "twine>=4.0.2", ] test = [ "coverage>=7.10", "pytest" ] doc = [ "ipykernel", "ipython", "myst-nb>=1.1", "pandas", "scverse-misc[datasets,settings,sphinx]", "sphinx>=8.1", "sphinx-autodoc-typehints", "sphinx-book-theme>=1", "sphinx-codeautolink", "sphinx-copybutton", "sphinx-design", "sphinxcontrib-bibtex>=1", "sphinxcontrib-katex", "sphinxext-opengraph", ] [tool.hatch] version.source = "vcs" envs.default.installer = "uv" envs.default.dependency-groups = [ "dev" ] envs.docs.scripts.build = "sphinx-build -M html docs docs/_build {args}" envs.docs.scripts.clean = "git clean -fdX -- {args:docs}" envs.docs.scripts.open = "python -m webbrowser -t docs/_build/html/index.html" envs.docs.dependency-groups = [ "doc" ] envs.hatch-test.matrix = [ # Test the lowest and highest supported Python versions with normal deps { deps = [ "stable" ], python = [ "3.12", "3.13", "3.14" ], feature-set = [ "full" ] }, # Test the newest supported Python version with different feature sets { deps = [ "stable" ], python = [ "3.14" ], feature-set = [ "min", "sphinx" ] }, # Test the newest supported Python version also with pre-release deps { deps = [ "pre" ], python = [ "3.14" ], feature-set = [ "full" ] }, ] # If the matrix variable `deps` is set to "pre", # set the environment variable `UV_PRERELEASE` to "allow". envs.hatch-test.overrides.matrix.deps.env-vars = [ { value = "allow", key = "UV_PRERELEASE", if = [ "pre" ] }, ] envs.hatch-test.overrides.matrix.feature-set.extra-dependencies = [ { value = "sphinx-autodoc-typehints", if = [ "sphinx", "full" ] }, ] envs.hatch-test.overrides.matrix.feature-set.features = [ # make sure the sphinx extension works without having all deps { value = "sphinx", if = [ "sphinx", "full" ] }, { value = "datasets", if = [ "full" ] }, { value = "spatialdata", if = [ "full" ] }, { value = "settings", if = [ "full" ] }, ] envs.hatch-test.dependency-groups = [ "dev", "test" ] [tool.ruff] line-length = 120 src = [ "src" ] extend-include = [ "*.ipynb" ] format.docstring-code-format = true lint.select = [ "ANN", # flake8-annotations "B", # flake8-bugbear "BLE", # flake8-blind-except "C4", # flake8-comprehensions "D", # pydocstyle "E", # Error detected by Pycodestyle "EM", # Error messages "F", # Errors detected by Pyflakes "I", # isort "RUF100", # Report unused noqa directives "TID", # flake8-tidy-imports "UP", # pyupgrade "W", # Warning detected by Pycodestyle ] lint.ignore = [ "B008", # Errors from function calls in argument defaults. These are fine when the result is immutable. "C408", # `dict()` is sometimes nicer than `{}` "D100", # Missing docstring in public module "D104", # Missing docstring in public package "D105", # __magic__ methods are often self-explanatory, allow missing docstrings "D107", # Missing docstring in __init__ # Disable one in each pair of mutually incompatible rules "D203", # We don’t want a blank line before a class docstring "D213", # <> We want docstrings to start immediately after the opening triple quote "D400", # first line should end with a period [Bug: doesn’t work with single-line docstrings] "D401", # First line should be in imperative mood; try rephrasing "E501", # line too long -> we accept long comment lines; formatter gets rid of long code lines "E731", # Do not assign a lambda expression, use a def -> lambda expression assignments are convenient "E741", # allow I, O, l as variable names -> I is the identity matrix "TID252", # allow relative imports ] lint.per-file-ignores."docs/*" = [ "I" ] lint.per-file-ignores."tests/*" = [ "D" ] lint.pydocstyle.convention = "google" [tool.mypy] mypy_path = [ "$MYPY_CONFIG_FILE_DIR/stubs", "$MYPY_CONFIG_FILE_DIR/src", "$MYPY_CONFIG_FILE_DIR/tests/_pkgs" ] explicit_package_bases = true strict = true [tool.pytest] addopts = [ "--import-mode=importlib", # allow using test files with same name ] filterwarnings = [ "error", ] strict = true testpaths = [ "tests" ] [tool.coverage] run.source = [ "scverse_misc" ] run.omit = [ "**/test_*.py", ] run.patch = [ "subprocess" ] [tool.cruft] skip = [ ".git", "tests", "src/**/__init__.py", "src/**/basic.py", "docs/api.md", "docs/changelog.md", "docs/references.bib", "docs/references.md", "docs/notebooks/example.ipynb", ] scverse-scverse-misc-f5fb21e/src/000077500000000000000000000000001525072673200170615ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/000077500000000000000000000000001525072673200215465ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/__init__.py000066400000000000000000000007401525072673200236600ustar00rootroot00000000000000from contextlib import suppress from ._arg_alias import arg_alias from ._deprecated import Deprecation, deprecated, deprecated_arg from ._extensions import ExtensionNamespace, make_register_namespace_decorator __all__ = [ "ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "deprecated_arg", "Deprecation", "arg_alias", ] with suppress(ImportError): from ._settings import Settings # noqa: F401 __all__.append("Settings") scverse-scverse-misc-f5fb21e/src/scverse_misc/_arg_alias.py000066400000000000000000000117431525072673200242070ustar00rootroot00000000000000import sys import typing from collections.abc import Callable from functools import partial, wraps from typing import Any, ForwardRef, Literal, Union, get_args, get_origin, get_type_hints if sys.version_info >= (3, 14): # annotations can be ForwardRef from inspect import signature as _signature from typing import evaluate_forward_ref from annotationlib import Format, get_annotations signature = partial(_signature, annotation_format=Format.FORWARDREF) else: # annotations are resolved or stringified from inspect import signature from typing_extensions import Format, evaluate_forward_ref, get_annotations _TYPING_NS = {"typing": typing, "Literal": Literal, "Union": Union} def _compute_aliases(func: Callable[..., Any], argname: str) -> tuple[dict[object, object], set[object]]: try: hint = get_type_hints(func)[argname] except NameError: hint = get_annotations(func, format=Format.FORWARDREF)[argname] if isinstance(hint, str): # stringified annotation hint = ForwardRef(hint) if isinstance(hint, ForwardRef): try: hint = evaluate_forward_ref(hint, owner=func) except NameError: # fall back to custom namespace for `if TYPE_CHECKING` hint = evaluate_forward_ref(hint, owner=func, locals=_TYPING_NS) if get_origin(hint) is Literal: sets = (hint,) elif get_origin(hint) is Union: sets = get_args(hint) else: msg = f"Type hint for argument {argname!r} must be 'Union' or 'Literal', found {hint!r}." raise TypeError(msg) replacements: dict[object, object] = {} values = set() for aliasset in sets: if get_origin(aliasset) is not Literal: msg = f"All type hints specifying aliases for argument {argname!r} must be 'Literal', found {aliasset!r}." raise TypeError(msg) value, *aliases = get_args(aliasset) if isinstance(value, ForwardRef): value = evaluate_forward_ref(value) values.add(value) replacements.update( (evaluate_forward_ref(alias) if isinstance(alias, ForwardRef) else alias, value) for alias in aliases ) return replacements, values def arg_alias[**P, R](argname: str) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator to specify aliases for function arguments that accept a fixed set of values. If a function argument accepts a fixed set of values that partitions into multiple equivalence classes (i.e. several values are aliased to the same behavior), this decorator converts each equivalence class into its chosen canonical representation before passing it on to the function. This eliminates the need for the function to perform validation and conversion itself, the function can be certain that it always gets the canonical representation. The rules are encoded in the type hint for the aliased argument. If there is only a single set of aliases, the type hint must be a :obj:`~typing.Literal` with the canonical representation as first argument followed by its aliases. If there are multiple sets of aliases, that is multiple semantically different values that the function accepts, the type hint must be a :obj:`~typing.Union` of :obj:`~typing.Literal` s, where each :obj:`~typing.Literal` follows the same rules as above: The canonical representation is the first argument followed by its aliases. Args: argname: The name of the argument to alias. Examples: >>> @axis_arg("axis") ... def foo(x: int, axis: Literal[0, "obs"]): ... return axis ... ... ... assert foo(42, 0) == foo(42, "obs") == 0 >>> @axis_arg("axis") ... def foo(x: float, axis: Literal[0, "obs"] | Literal[1, "var", "vars"]): ... return axis ... ... ... assert foo(42, 0) == foo(42, "obs") == 1 ... assert foo(42, 1) == foo(42, "var") == foo(42, "vars") == 1 """ def wrapper(func: Callable[P, R]) -> Callable[P, R]: try: # try evaluating type hints eagerly for early usage errors rv = _compute_aliases(func, argname) except NameError: rv = None sig = signature(func) @wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: nonlocal rv if rv is None: rv = _compute_aliases(func, argname) replacements, values = rv bound = sig.bind(*args, **kwargs) bound.apply_defaults() argval = bound.arguments[argname] try: bound.arguments[argname] = replacements[argval] except KeyError: if argval not in values: msg = f"Argument {argname!r} must be one of {tuple(values) + tuple(replacements.keys())}, got {argval!r}." raise ValueError(msg) from None return func(*bound.args, **bound.kwargs) return wrapped return wrapper scverse-scverse-misc-f5fb21e/src/scverse_misc/_deprecated/000077500000000000000000000000001525072673200240055ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/_deprecated/__init__.py000066400000000000000000000102361525072673200261200ustar00rootroot00000000000000from __future__ import annotations import inspect from functools import wraps from typing import TYPE_CHECKING, LiteralString, Protocol, cast from warnings import warn from .._utils import caller_skip_prefixes, warn_outside from ..constants import ATTR_DEPRECATED_ARG if TYPE_CHECKING: from collections.abc import Callable __all__ = ["deprecated", "deprecated_arg", "Deprecation"] if TYPE_CHECKING: from .decorator import _deprecated as deprecated else: from .decorator import deprecated class Deprecation(str): """Utility class storing information on deprecated functionality. Args: version_deprecated: The version of the package where the functionality was deprecated. msg: The deprecation message. .. seealso:: :ref:`example-deprecating-a-function`, :ref:`example-deprecating-a-function-argument`, :ref:`example-settings-class` """ version_deprecated: LiteralString _docmsg: str | None = None def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> Deprecation: if not msg: msg = "" # be lenient here, people don’t want to see “None” or “False” here obj = super().__new__(cls, msg) obj.version_deprecated = version_deprecated return obj class CallableWithDeprecatedArg[**P, R](Protocol): __scverse_misc_deprecated_arg__: list[deprecated_arg] def __call__(*args: P.args, **kwargs: P.kwargs) -> R: ... class deprecated_arg: """Decorator to indicate that a function argument is deprecated. Emits a warning when the decorated function is called with the deprecated argument. If the `scverse_misc` Sphinx extension is enabled, the documentation will be modified to include a deprecation notice. Args: arg: The deprecated argument. msg: The deprecation message. category: The category of the warning that will be emitted at runtime. stacklevel: Override the stack level of the warning. By default it’s the first frame outside your package and any wrappers around it, which is right however many decorators are stacked on the function. Examples: >>> @deprecated_arg("bar", Deprecation("0.2", "The functionality has moved to the baz() function.")) ... def foo(baz, bar=1): ... pass .. seealso:: :ref:`example-deprecating-a-function-argument` """ def __init__( self, arg: LiteralString, msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int | None = None, ) -> None: self.arg = arg self.msg = msg self.category = category self.stacklevel = stacklevel self.skip_file_prefixes = caller_skip_prefixes(stacklevel=2) def __call__[**P, R](self, func: Callable[P, R]) -> CallableWithDeprecatedArg[P, R]: warnmsg = f"The argument {self.arg} is deprecated and will be removed in the future." if len(self.msg): warnmsg += f" {self.msg}" sig = inspect.signature(func) param = sig.parameters[self.arg] def emit() -> None: if self.stacklevel is not None: warn(warnmsg, category=self.category, stacklevel=self.stacklevel + 2) else: # the walk eats this frame and `wrapped` by itself warn_outside(warnmsg, self.category, self.skip_file_prefixes) @wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: __tracebackhide__ = True if ( param.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and self.arg in kwargs ): emit() else: bound = sig.bind(*args, **kwargs) if self.arg in bound.arguments and bound.arguments[self.arg] != param.default: emit() return func(*args, **kwargs) args = getattr(func, ATTR_DEPRECATED_ARG, []) args.append(self) setattr(func, ATTR_DEPRECATED_ARG, args) setattr(wrapped, ATTR_DEPRECATED_ARG, args) return cast(CallableWithDeprecatedArg[P, R], wrapped) scverse-scverse-misc-f5fb21e/src/scverse_misc/_deprecated/decorator.py000066400000000000000000000045351525072673200263500ustar00rootroot00000000000000from __future__ import annotations import sys from textwrap import indent from typing import TYPE_CHECKING, LiteralString, cast if sys.version_info >= (3, 13): from warnings import deprecated as _deprecated else: from typing_extensions import deprecated as _deprecated if TYPE_CHECKING: from types import FunctionType from scverse_misc import Deprecation __all__ = ["deprecated", "_deprecated"] class deprecated(_deprecated): """Decorator to indicate that a class, function, or overload is deprecated. Wraps :func:`warnings.deprecated`. If the scverse_misc Sphinx extension is enabled, the function's documentation will include a deprecation notice. Args: msg: The deprecation message. category: The category of the warning that will be emitted at runtime. stacklevel: The stack level of the warning. Unlike :class:`~scverse_misc.deprecated_arg`, this can’t be worked out automatically: :func:`warnings.deprecated` emits the warning itself with a fixed level, so any decorator stacked on top of this one has to be counted here by hand. See https://github.com/python/cpython/issues/156929. Examples: >>> @deprecated(Deprecation("0.2", "Use bar() instead.")) ... def foo(baz): ... pass .. seealso:: :ref:`example-deprecating-a-function`, :ref:`example-settings-class` """ message: Deprecation def __init__(self, msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1) -> None: super().__init__(cast("LiteralString", msg), category=category, stacklevel=stacklevel) def __call__[F](self, func: F) -> F: from . import Deprecation if TYPE_CHECKING: assert isinstance(func, FunctionType) kind = "class" if isinstance(func, type) else "function" if func.__name__ == func.__qualname__ else "method" warnmsg = f"The {kind} {func.__name__} is deprecated and will be removed in the future" if len(self.message): warnmsg += f". {self.message}" if self.message.count("\n") == 0 else f":\n{indent(self.message, 4 * ' ')}" else: warnmsg += "." newmsg = Deprecation(self.message.version_deprecated, warnmsg) newmsg._docmsg = str(self.message) self.message = newmsg return super().__call__(func) scverse-scverse-misc-f5fb21e/src/scverse_misc/_extensions.py000066400000000000000000000216131525072673200244610ustar00rootroot00000000000000"""System to add extension attributes to classes. Based off of the extension framework in Polars: https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py """ from __future__ import annotations import inspect import sys import warnings from itertools import islice from typing import TYPE_CHECKING, NamedTuple, Protocol, get_type_hints, overload, runtime_checkable from .constants import ATTR_NAMESPACE if TYPE_CHECKING: from collections.abc import Callable, Set __all__ = ["make_register_namespace_decorator", "ExtensionNamespace"] @runtime_checkable class ExtensionNamespace(Protocol): """Protocol for extension namespaces. Enforces that the namespace initializer accepts a class with the proper `__init__` method. Protocol's can't enforce that the `__init__` accepts the correct types. See `_check_namespace_signature` for that. This is mainly useful for static type checking with mypy and IDEs. .. seealso:: :ref:`example-extension-namespaces` """ def __init__(self, instance: object) -> None: """Used to enforce the correct signature for extension namespaces.""" class AccessorNameSpace[T, NameSpT: ExtensionNamespace]: """Establish property-like namespace object for user-defined functionality.""" def __init__(self, name: str, namespace: type[NameSpT]) -> None: self._accessor = name self._ns = namespace @overload def __get__(self, instance: None, cls: type[T]) -> type[NameSpT]: ... @overload def __get__(self, instance: T, cls: type[T]) -> NameSpT: ... def __get__(self, instance: T | None, cls: type[T]) -> NameSpT | type[NameSpT]: if instance is None: return self._ns ns_instance = self._ns(instance) setattr(instance, self._accessor, ns_instance) return ns_instance def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_name: str) -> None: """Validate the signature of a namespace class for extensions. This function ensures that any class intended to be used as an extension namespace has a properly formatted `__init__` method such that: 1. Accepts at least two parameters (self and the instance of the extended class) 2. Has `canonical_instance_name` as the name of the second parameter 3. Has the second parameter properly type-annotated as `ns_class` or any equivalent import alias The function performs runtime validation of these requirements before a namespace can be registered through the `register_namespace` decorator. Args: ns_class: The namespace class to validate. cls: The class that is being extended. canonical_instance_name: The name of the `ns_class` constructor argument. Raises: TypeError: If the `__init__` method has fewer than 2 parameters (missing the instance parameter). AttributeError: If the second parameter of `__init__` lacks a type annotation. TypeError: If the second parameter of `__init__` is not named `canonical_instance_name`. TypeError: If the second parameter of `__init__` is not annotated as the `ns_class` class. TypeError: If both the name and type annotation of the second parameter are incorrect. """ sig = inspect.signature(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236 params = sig.parameters # Ensure there are at least two parameters (self and mdata) if len(params) < 2: msg = f"Namespace initializer must accept a {cls.__name__} instance as the second parameter." raise TypeError(msg) # Get the second parameter (expected to be `canonical_instance_name`) [_, param, *_] = params.values() if param.annotation is inspect.Parameter.empty: msg = f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got empty annotation." raise AttributeError(msg) name_ok = param.name == canonical_instance_name # Resolve the annotation using get_type_hints to handle forward references and aliases. try: type_hints = get_type_hints(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236 resolved_type = type_hints.get(param.name, param.annotation) except NameError as e: msg = f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}." raise NameError(msg) from e type_ok = resolved_type is cls match (name_ok, type_ok): case (True, True): return # Signature is correct. case (False, True): msg = f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}." raise TypeError(msg) case (True, False): type_repr = getattr(resolved_type, "__name__", str(resolved_type)) msg = f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got {type_repr!r}." raise TypeError(msg) case _: type_repr = getattr(resolved_type, "__name__", str(resolved_type)) msg = ( f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}. " f"And must be annotated as {cls.__name__!r}, got {type_repr!r}." ) raise TypeError(msg) def _create_namespace[NameSpT: ExtensionNamespace]( name: str, cls: type, reserved_namespaces: Set[str], canonical_instance_name: str ) -> Callable[[type[NameSpT]], type[NameSpT]]: """Register custom namespace against the underlying class.""" def namespace(ns_class: type[NameSpT]) -> type[NameSpT]: _check_namespace_signature(ns_class, cls, canonical_instance_name) # Perform the runtime signature check if name in reserved_namespaces: msg = f"cannot override reserved attribute {name!r}" raise AttributeError(msg) elif hasattr(cls, name): warnings.warn( f"Overriding existing custom namespace {name!r} (on {cls.__name__!r})", UserWarning, stacklevel=2 ) setattr(cls, name, AccessorNameSpace(name, ns_class)) return ns_class return namespace def _indent_string_lines(string: str, indentation_level: int, skip_lines: int = 0) -> str: minspace = sys.maxsize for line in islice(string.splitlines(), 1, None): for i, char in enumerate(line): if not char.isspace(): minspace = min(minspace, i) break if minspace == sys.maxsize: # single-line string minspace = 0 return "\n".join( " " * 4 * indentation_level + sline if i >= skip_lines else sline for i, line in enumerate(string.splitlines()) if (sline := (line[minspace:] if i > 0 else line)) or True ) class _NSInfo(NamedTuple): name: str """Canonical instance name.""" cls: type """Namespace Class.""" def make_register_namespace_decorator[NameSpT: ExtensionNamespace]( cls: type, canonical_instance_name: str, decorator_name: str | None = None, docstring_style: str | None = None ) -> Callable[[str], Callable[[type[NameSpT]], type[NameSpT]]]: """Create a decorator for registering custom functionality with a class. The decorator will allow your users to extend `cls` objects with custom methods and properties organized under a namespace. The namespace becomes accessible as an attribute on `cls` instances, providing a clean way for users to add domain-specific functionality without modifying the `cls` class itself. If the `scverse_misc` Sphinx extension is enabled, the returned decorator will be documented along with examples. Args: cls: The class to be made extensible. canonical_instance_name: The typical name of an instance of `cls`, e.g. `adata` for `AnnData`. This is used for run-time checking of constructor signatures of the namespace classes. decorator_name: Deprecated and unused. docstring_style: Deprecated and unused. .. seealso:: :ref:`example-extension-namespaces` """ if decorator_name is not None: warnings.warn( "The decorator_name argument is deprecated and will be removed in the future.", category=DeprecationWarning, stacklevel=2, ) if docstring_style is not None: warnings.warn( "The docstring_style argument is deprecated and will be removed in the future.", category=DeprecationWarning, stacklevel=2, ) # Reserved namespaces include accessors built into cls and all current attributes of cls reserved_namespaces = set(dir(cls)) def decorator(name: str) -> Callable[[type[NameSpT]], type[NameSpT]]: return _create_namespace(name, cls, reserved_namespaces, canonical_instance_name) setattr(decorator, ATTR_NAMESPACE, _NSInfo(canonical_instance_name, cls)) return decorator scverse-scverse-misc-f5fb21e/src/scverse_misc/_settings.py000066400000000000000000000231431525072673200241220ustar00rootroot00000000000000from __future__ import annotations import inspect import sys from collections.abc import Generator from contextlib import AbstractContextManager, contextmanager from types import FunctionType from typing import Final, Literal, LiteralString, Self import dotenv from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from ._utils import copy_func, get_packagename, package_prefixes, type_str, warn_outside #: `__init_subclass__` runs under `ModelMetaclass.__new__` → `ABCMeta.__new__`, so no fixed #: `stacklevel` reaches the `class` statement. Unlike for the deprecation decorators, the caller’s #: own package must *not* be skipped: it’s the package author who writes these arguments. _WRAPPERS: Final = tuple(p for m in (__package__, "pydantic") if (ps := package_prefixes(m)) for p in ps) class Settings(BaseSettings): '''Base class for package settings. This class can be subclassed by individual packages to get package-specific settings handling. Settings will be validated on assignment thanks to Pydantic. The class requires the arguments `exported_object_name` and `docstring_style`, which will be used to construct a suitable docstring (see the examples). Both a settings instance and its `override` and `reset` methods should be added to the package documentation. Note that the documentation is generated by the `scverse_misc` Sphinx extension, which must be enabled. Thanks to Pydantic Settings, settings values will also be loaded from environment variables or `.env` files. Environment variables must be prefixex with `$PACKAGE_NAME_` to take effect, where `$PACKAGE_NAME` is the name of the package of the subclass. This can be overridden by passing `env_prefix=CUSTOMPREFIX` as class argument. Examples: >>> from typing import Annotated ... from pydantic import Field ... from scverse_misc import Settings ... ... ... class MySettings(Settings, exported_object_name="settings"): ... eps: Annotated[float, Field(gt=0, lt=1)] = 1e-8 ... """Small epsilon for numerical stability.""" ... ... use_optional_feature: bool = False ... """Whether to use the optional feature.""" ... ... ... settings = MySettings() .. seealso:: :ref:`example-settings-class` ''' @classmethod def settings_customise_sources( cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: return init_settings, env_settings, dotenv_settings def __init_subclass__( subcls, *, exported_object_name: str | None = None, docstring_style: Literal["google", "numpy", "scverse"] | None = None, ) -> None: if exported_object_name is not None: warn_outside( "The exported_object_name class argument is deprecated and will be removed in the future.", DeprecationWarning, _WRAPPERS, ) if docstring_style is not None: warn_outside( "The docstring_style class argument is deprecated and will be removed in the future.", DeprecationWarning, _WRAPPERS, ) env_file = dotenv.find_dotenv(usecwd=True) dotenv_filtering: Final = "only_existing" if (config := subcls.__dict__.get("model_config")) is not None: if not config.get("validate_assignment", True): warn_outside("`validate_assignment=False` is not supported, overriding.", RuntimeWarning, _WRAPPERS) if not config.get("use_attribute_docstrings", True): warn_outside( "`use_attribute_docstrings=False` is not supported, overriding.", RuntimeWarning, _WRAPPERS, ) if config.get("env_file") not in (None, "", env_file): warn_outside( f"Setting a custom env_file location is not supported, overriding {config['env_file']=}.", RuntimeWarning, _WRAPPERS, ) if config.get("dotenv_filtering") not in (None, dotenv_filtering): warn_outside( f"Setting a custom dotenv_filtering scheme is not supported, overriding {config['dotenv_filtering']=}.", RuntimeWarning, _WRAPPERS, ) else: config = SettingsConfigDict() config["validate_assignment"] = True config["use_attribute_docstrings"] = True config["env_file"] = env_file config["dotenv_filtering"] = dotenv_filtering if not config.get("env_prefix"): config["env_prefix"] = f"{get_packagename(subcls)}_" subcls.model_config = config super().__init_subclass__() @classmethod def __pydantic_init_subclass__( # type: ignore[override] subcls: type[Self], *, exported_object_name: str | None = None, docstring_style: Literal["google", "numpy", "scverse"] | None = None, ) -> None: subcls.override = _copy_override( # type: ignore[method-assign,type-var] subcls, subcls.override ) subcls.reset = _copy_reset(subcls, subcls.reset) # type: ignore[method-assign,type-var] @contextmanager def override(self, **overrides: object) -> Generator[None]: """Context manager for local setting overrides. Subclasses will get a version with a docstring detailing the available parameters. """ oldsettings = {argname: getattr(self, argname) for argname in overrides.keys()} try: for argname, argval in overrides.items(): setattr(self, argname, argval) yield finally: for argname, argval in reversed(oldsettings.items()): setattr(self, argname, argval) def reset(self, *names: LiteralString) -> AbstractContextManager[frozenset[LiteralString]]: """Reset passed settings to their default values. Can be used as a context manager to make the resets temporary. On `__enter__`, the context manager returns the settings that have been changed. """ prev_values = {name: getattr(self, name) for name in names if name in self.model_fields_set} # since we want to allow using this method imperatively, # eagerly do the reset here instead of returning a context manager with a lazy `__enter__`. for name in prev_values: default = type(self).model_fields[name].get_default() setattr(self, name, default) self.model_fields_set.remove(name) class Cm(AbstractContextManager[frozenset[str]]): def __enter__(_self) -> frozenset[str]: return frozenset(prev_values) def __exit__(_self, *_: object) -> None: for arg, value in prev_values.items(): setattr(self, arg, value) return Cm() class CustomRepr(str): def __repr__(self) -> str: return self def _copy_override[F: FunctionType](cls: type[Settings], func: F) -> F: from ._utils import Overrides parameters = [ inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY), *[ inspect.Parameter( n, inspect.Parameter.KEYWORD_ONLY, default=CustomRepr(""), annotation=f.annotation ) for n, f in cls.model_fields.items() ], ] return_annotation = AbstractContextManager[None] overrides = Overrides( __doc__=func.__doc__, __module__=cls.__module__, __qualname__=f"{cls.__qualname__}.{func.__name__}", __signature__=inspect.Signature(parameters, return_annotation=return_annotation), __annotations__={ **{name: field.annotation for name, field in cls.model_fields.items()}, "return": return_annotation, }, ) if sys.version_info >= (3, 14): from annotationlib import Format str_annotations = {n: type_str(cls, f) for n, f in cls.model_fields.items()} overrides["__annotate__"] = lambda fmt: ( overrides["__annotations__"] if fmt != Format.STRING else str_annotations ) return copy_func(func, **overrides) def _copy_reset[F: FunctionType](cls: type[Settings], func: F) -> F: from ._utils import Overrides names_t = Literal[tuple(cls.model_fields.keys())] # type: ignore[valid-type] return_annotation = AbstractContextManager[frozenset[names_t]] # type: ignore[valid-type] signature = inspect.signature(func) signature = signature.replace( parameters=[p.replace(annotation=names_t) if p.name == "names" else p for p in signature.parameters.values()], return_annotation=return_annotation, ) overrides = Overrides( __module__=cls.__module__, __qualname__=f"{cls.__qualname__}.{func.__name__}", __signature__=signature, __annotations__={"names": names_t, "return": return_annotation}, ) if sys.version_info >= (3, 14): from annotationlib import Format str_annotations = {n: str(t) for n, t in overrides["__annotations__"].items()} overrides["__annotate__"] = lambda fmt: ( overrides["__annotations__"] if fmt != Format.STRING else str_annotations ) return copy_func(func, **overrides) scverse-scverse-misc-f5fb21e/src/scverse_misc/_utils.py000066400000000000000000000104251525072673200234210ustar00rootroot00000000000000from __future__ import annotations import functools import inspect import sys import warnings from collections.abc import Callable, Mapping from functools import WRAPPER_ASSIGNMENTS from types import FunctionType, GenericAlias from typing import TYPE_CHECKING, ParamSpec, TypedDict, TypeVar, TypeVarTuple, Unpack, cast if TYPE_CHECKING: from types import FrameType from pydantic.fields import FieldInfo class _BaseOverrides(TypedDict, total=False): __module__: str __name__: str __qualname__: str __doc__: str | None __signature__: inspect.Signature __annotations__: Mapping[str, object] __type_params__: tuple[TypeVar | TypeVarTuple | ParamSpec, ...] if sys.version_info >= (3, 14): from annotationlib import Format class Overrides(_BaseOverrides, total=False): __annotate__: Callable[[Format], Mapping[str, object]] else: class Overrides(_BaseOverrides, total=False): pass def copy_func[F: FunctionType](func: F, /, **overrides: Unpack[Overrides]) -> F: kw = dict(kwdefaults=func.__kwdefaults__) if sys.version_info >= (3, 13) else {} new = FunctionType( func.__code__, func.__globals__, name=func.__name__, argdefs=func.__defaults__, closure=func.__closure__, **kw ) for key, value in overrides.items(): setattr(new, key, value) copy = set(WRAPPER_ASSIGNMENTS) - overrides.keys() wrapper = functools.update_wrapper(new, func, assigned=copy) del wrapper.__wrapped__ # otherwise sphinx will try to document that. return cast("F", wrapper) def package_prefixes(mod_name: str | None) -> list[str]: """Root directory of `mod_name`’s package, its own file if it isn’t in one, `None` if unimported.""" if mod_name is None: return [] if ( (root := sys.modules.get(mod_name.partition(".")[0])) is not None and root.__spec__ is not None and (smsls := root.__spec__.submodule_search_locations) is not None ): return smsls if (file := getattr(sys.modules.get(mod_name), "__file__", None)) is not None: return [file] return [] def caller_skip_prefixes(*, stacklevel: int = 1) -> tuple[str, ...]: """Prefixes covering the package of the frame at `stacklevel`, plus our own. `stacklevel=1` means the frame calling this function, as in :func:`warnings.warn`. """ caller = sys._getframe(stacklevel).f_globals.get("__name__", "") return tuple(p for mod in {caller, __package__} if (ps := package_prefixes(mod)) for p in ps) def _is_wrapper_frame(frame: FrameType, prefixes: tuple[str, ...]) -> bool: if frame.f_code.co_filename.startswith(prefixes): return True # `singledispatch`, `contextmanager`, … – by module name, so frozen `abc` is covered # and `site-packages` (below the stdlib dir) isn’t. if frame.f_globals.get("__name__", "").partition(".")[0] in sys.stdlib_module_names: return True return bool(frame.f_locals.get("__tracebackhide__", frame.f_globals.get("__tracebackhide__"))) def warn_outside( message: str, category: type[Warning] = UserWarning, prefixes: tuple[str, ...] = (), *, stacklevel: int = 1 ) -> None: """:func:`warnings.warn`, blaming the first frame that isn’t a wrapper. Walks out of `prefixes`, stdlib frames and frames marked `__tracebackhide__`. `stacklevel=1` means the caller, as in :func:`warnings.warn`. """ # `sys._getframe(i)` counts from 0 = here, `warn(stacklevel=s)` from 1 = here. frame: FrameType | None = sys._getframe(stacklevel) level = stacklevel + 1 while frame is not None and _is_wrapper_frame(frame, prefixes): frame, level = frame.f_back, level + 1 warnings.warn(message, category, stacklevel=level) def get_packagename(cls: type | str) -> str: package_name = cls.__module__ if not isinstance(cls, str) else cls dotidx = package_name.find(".") if dotidx > -1: package_name = package_name[:dotidx] return package_name def type_str(cls: type, field: FieldInfo) -> str: if isinstance(field.annotation, GenericAlias) or not isinstance(field.annotation, type): return str(field.annotation) if field.annotation.__module__ in {"builtins", cls.__module__}: return field.annotation.__qualname__ return f"{field.annotation.__module__}.{field.annotation.__qualname__}" scverse-scverse-misc-f5fb21e/src/scverse_misc/constants.py000066400000000000000000000004051525072673200241330ustar00rootroot00000000000000from typing import Final __all__ = ["ATTR_NAMESPACE", "ATTR_DEPRECATED", "ATTR_DEPRECATED_ARG"] ATTR_NAMESPACE: Final = "__scverse_misc_namespace_info__" ATTR_DEPRECATED: Final = "__deprecated__" ATTR_DEPRECATED_ARG: Final = "__scverse_misc_deprecated_arg__" scverse-scverse-misc-f5fb21e/src/scverse_misc/datasets/000077500000000000000000000000001525072673200233565ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/datasets/__init__.py000066400000000000000000000015161525072673200254720ustar00rootroot00000000000000"""Reusable, declarative dataset download for scverse packages. Parse a YAML registry into typed :class:`DatasetEntry` objects, then download and load one with :func:`fetch`. Dataset ``type`` strings are dispatched against a pluggable loader registry (:func:`register_loader`); ``anndata`` and ``spatialdata`` loaders ship built in. Requires the ``datasets`` extra (``pip install scverse-misc[datasets]``); the built-in ``spatialdata`` loader additionally needs the ``spatialdata`` extra. """ from __future__ import annotations from ._fetcher import DownloadCB, Loader, available_loaders, fetch, register_loader from ._registry import DatasetEntry, FileEntry, parse_registry __all__ = [ "FileEntry", "DatasetEntry", "parse_registry", "fetch", "register_loader", "available_loaders", "Loader", "DownloadCB", ] scverse-scverse-misc-f5fb21e/src/scverse_misc/datasets/_fetcher.py000066400000000000000000000140361525072673200255130ustar00rootroot00000000000000"""Download + load a dataset: a thin ``fetch`` over pooch + a pluggable ``type -> loader`` registry. A loader is a callable ``(entry, target_dir, download, **kwargs) -> object`` where ``download`` is ``(FileEntry, dest=None, processor=None) -> path`` (pooch under the hood: hashing, caching, retries, and archive processors). ``anndata`` and ``spatialdata`` loaders ship built in. """ from __future__ import annotations import warnings from collections.abc import Callable from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, cast, overload if TYPE_CHECKING: from pooch.typing import Processor from ._registry import DatasetEntry, FileEntry if TYPE_CHECKING: # sphinx tries to import the above TYPE_CHECKING block from anndata import AnnData from spatialdata import SpatialData __all__ = ["register_loader", "available_loaders", "fetch", "Loader", "DownloadCB"] class Loader[T](Protocol): """Function that can be annotated by :func:`register_loader`.""" def __call__(self, entry: DatasetEntry, target: Path, download: DownloadCB, /, **kwargs: object) -> T: """Call `download` (see :class:`DownloadCB`) and load ``entry``. Args: entry: File to download. target: Loaded when it exists, otherwise it will be created. download: Called when `target` doesn’t exist. kwargs: Passed to `download`. """ class DownloadCB(Protocol): """Callback passed as `download` to a :class:`Loader`.""" def __call__(self, file: FileEntry, /, *, dest: Path | None = None, processor: Processor | None = None) -> str: """Download ``file`` if necessary. Args: file: File to download. dest: Optional target directory, defaults to :func:`fetch`’s `cache_dir / entry.type`. processor: Optional archive processor. """ _LOADERS: dict[str, Loader[object]] = {} @overload def register_loader[T](type_name: str) -> Callable[[Loader[T]], Loader[T]]: ... @overload def register_loader[T](type_name: str, loader: Loader[T]) -> Loader[T]: ... def register_loader[T](type_name: str, loader: Loader[T] | None = None) -> Callable[[Loader[T]], Loader[T]] | Loader[T]: """Register a :class:`Loader` for a dataset ``type`` (decorator or direct call).""" def deco(fn: Loader[T]) -> Loader[T]: _LOADERS[type_name] = fn return fn return deco if loader is None else deco(loader) def available_loaders() -> list[str]: """Return the names of all registered loader types.""" return sorted(_LOADERS) def fetch[T]( entry: DatasetEntry, cache_dir: str | Path, *, base_url: str | None = None, retries: int = 3, **kwargs: object ) -> T: # type: ignore[type-var] """Download (if needed) and load ``entry``, dispatching to the loader registered for ``entry.type``. Files are cached under ``cache_dir / entry.type``. Args: entry: File to download. cache_dir: Directory to download to. base_url: The base URL. retries: Number of attempts before failure. **kwargs: Passed to the loader. Returns: The Python object created by loading the ``entry`` with the loader registered for ``entry.type``. """ target = Path(cache_dir) / entry.type def download(file: FileEntry, /, dest: Path | None = None, processor: Processor | None = None) -> str: import pooch out = dest or target out.mkdir(parents=True, exist_ok=True) pup = pooch.create( path=str(out), base_url="", registry={file.name: f"sha256:{file.sha256}" if file.sha256 else None}, urls={file.name: file.resolve_url(base_url)}, retry_if_failed=retries, ) exceptions = [] try: return pup.fetch(file.name, processor=processor, progressbar=True) except (OSError, ValueError) as e: if not file.fallback_urls: raise warnings.warn( f"Primary download for {file.name} failed with {e!r}, retrying with fallback URLs.", stacklevel=3 ) exceptions.append(e) for fallback in file.fallback_urls: try: return download(replace(file, url=fallback, fallback_urls=None), dest=dest, processor=processor) except (OSError, ValueError) as e: exceptions.append(e) msg = f"Could not download {file.name}" raise ExceptionGroup(msg, exceptions) from None if entry.type not in _LOADERS: msg = f"No loader registered for type {entry.type!r}. Available: {available_loaders()}" raise KeyError(msg) return cast("Loader[T]", _LOADERS[entry.type])(entry, target, download, **kwargs) @register_loader("anndata") def _load_anndata(entry: DatasetEntry, target: Path, download: DownloadCB, /, **kwargs: object) -> AnnData: """Built-in loader: download a single ``.h5ad`` and read it with :func:`anndata.read_h5ad`.""" import anndata return anndata.read_h5ad(download(entry.file(suffix=".h5ad")), **cast("dict[str, Any]", kwargs)) @register_loader("spatialdata") def _load_spatialdata(entry: DatasetEntry, target: Path, download: DownloadCB, /, **kwargs: object) -> SpatialData: """Built-in loader: download a ``.zip``, unzip it (via pooch) and read the single ``.zarr`` store inside. Extracts into a per-dataset directory so the ``.zarr`` can be found by glob (its name need not match the registry key) without colliding with other spatialdata datasets cached under the same ``target``. Needs the ``spatialdata`` extra. """ import pooch import spatialdata as sd dest = target / entry.name download(entry.file(suffix=".zip"), dest=dest, processor=pooch.Unzip(extract_dir=".")) zarrs = sorted(dest.glob("*.zarr")) if len(zarrs) != 1: msg = f"Expected exactly one .zarr extracted under {dest}, found {len(zarrs)}: {zarrs}." raise RuntimeError(msg) return sd.read_zarr(zarrs[0], **cast("dict[str, Any]", kwargs)) scverse-scverse-misc-f5fb21e/src/scverse_misc/datasets/_registry.py000066400000000000000000000117021525072673200257400ustar00rootroot00000000000000"""Typed dataset entries + a YAML parser. Plain data — no registry/fetcher machinery.""" from __future__ import annotations import warnings from dataclasses import dataclass, field, fields from typing import TYPE_CHECKING, Any import yaml if TYPE_CHECKING: from collections.abc import Mapping from os import PathLike __all__ = ["FileEntry", "DatasetEntry", "parse_registry"] @dataclass(frozen=True, slots=True) class FileEntry: """A single downloadable file belonging to a dataset.""" name: str """File name as it should appear on disk (e.g. ``"cells.zip"``).""" url: str | None = None """Full download URL (e.g. a Zenodo file URL). Takes precedence over ``s3_key``.""" s3_key: str | None = None """Key relative to the registry's ``base_url``. Used when ``url`` is unset.""" sha256: str | None = None """Expected SHA-256 hash. If set, downloads are verified against it.""" fallback_urls: list[str] | None = None """List of fallback download URLs to use in case the primary URL or the S3 bucket fail.""" def resolve_url(self, base_url: str | None = None) -> str: """Resolve the download URL: the explicit ``url`` if set, else ``base_url/s3_key``.""" if self.url: return self.url if base_url and self.s3_key: return f"{base_url.rstrip('/')}/{self.s3_key}" msg = f"FileEntry {self.name!r} has neither `url` nor `s3_key` (with a registry `base_url`)." raise ValueError(msg) @dataclass(frozen=True, slots=True) class DatasetEntry: """A named dataset made up of one or more files.""" name: str """File name on disk.""" type: str """Entry type, e.g. `"anndata`".""" files: tuple[FileEntry, ...] """The files for this dataset.""" metadata: Mapping[str, Any] = field(default_factory=dict) """Everything in the YAML other than ``type`` and ``files``.""" def file(self, *, name: str | None = None, suffix: str | None = None) -> FileEntry: """Return the file matching ``name`` (exact) or ``suffix`` (endswith). Raises unless exactly one matches.""" if name is not None: matches = [f for f in self.files if f.name == name] crit = f"name={name!r}" elif suffix is not None: matches = [f for f in self.files if f.name.endswith(suffix)] crit = f"suffix={suffix!r}" else: msg = "Pass exactly one of `name` or `suffix`." raise ValueError(msg) if len(matches) != 1: msg = f"Expected exactly one file with {crit} in {self.name!r}, found {len(matches)}." raise ValueError(msg) return matches[0] _FILE_FIELDS = frozenset(f.name for f in fields(FileEntry)) def _file_entry(fd: Mapping[str, Any], dataset: str) -> FileEntry: """Build a :class:`FileEntry`, warning on (and dropping) keys it doesn't recognise. Unknown keys are tolerated so per-file extras (e.g. ``description``) don't crash the parse, but a warning surfaces likely typos. """ if unknown := fd.keys() - _FILE_FIELDS: warnings.warn(f"Ignoring unknown file keys {sorted(unknown)} in dataset {dataset!r}.", stacklevel=3) return FileEntry(**{k: v for k, v in fd.items() if k in _FILE_FIELDS}) def parse_registry(path: PathLike[str] | str) -> tuple[str | None, dict[str, DatasetEntry]]: """Parse a YAML registry into ``(base_url, {name: DatasetEntry})``. The YAML has a top-level ``base_url`` (or ``s3_base_url``) and a ``datasets`` mapping of ``name -> {type, files: [{name, url?/s3_key?, sha256?, fallback_urls?}], ...}``. Any keys other than ``type`` and ``files`` are collected into the entry's ``metadata``. Examples: .. code-block:: yaml base_url: https://example.com/data datasets: example1: type: anndata files: - name: example1.h5ad s3_key: ABCDEFGH.h5ad sha256: 86126c1a3c163ea20abb14c1a9711aaff34e6c492ef6dd86298bbaf18cc3f5f3 fallback_urls: - https://example.org/fallbackdata/ABCDEFGH.h5ad - https://example.net/data/ABCDEFGH.h5ad example2: type: anndata files: - name: example2.h5ad url: https://example.com/otherdata/DEFGHI.h5ad sha256: e7b358fef2b6da115b7ee3ab4c7fc55fd80e210bba427902e4afc9118992747c """ with open(path) as f: config = yaml.safe_load(f) or {} base_url = config.get("base_url") or config.get("s3_base_url") datasets = { name: DatasetEntry( name=name, type=row["type"], files=tuple(_file_entry(fd, name) for fd in row.get("files", [])), metadata={k: v for k, v in row.items() if k not in ("type", "files")}, ) for name, row in (config.get("datasets") or {}).items() } return base_url, datasets scverse-scverse-misc-f5fb21e/src/scverse_misc/py.typed000066400000000000000000000000001525072673200232330ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/000077500000000000000000000000001525072673200237375ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/__init__.py000066400000000000000000000375461525072673200260670ustar00rootroot00000000000000from __future__ import annotations import re import sys import textwrap import traceback import warnings from importlib.metadata import version from pathlib import Path from types import MethodType from typing import TYPE_CHECKING, Any, Generic, Literal, cast, get_origin if sys.version_info >= (3, 13): from warnings import deprecated as deprecated else: from typing_extensions import deprecated as deprecated from jinja2.defaults import DEFAULT_FILTERS # type: ignore[attr-defined] from pydocstring import Document, Parsed, Style, TextBlock, emit_google, emit_numpy, parse from pydocstring.model import Block, Docstring, Parameter, Return, Section, SectionKind from sphinx.ext.autodoc import Options as AutodocOptions from sphinx.ext.autodoc import import_object from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined] from sphinx.util import logging from .._deprecated import Deprecation, deprecated_arg from .._extensions import _NSInfo from .._utils import get_packagename, type_str from ..constants import ATTR_DEPRECATED, ATTR_DEPRECATED_ARG, ATTR_NAMESPACE try: from pydantic_core import PydanticUndefined from .._settings import Settings except ImportError: if not TYPE_CHECKING: Settings = type("Settings", (), {}) if TYPE_CHECKING: from collections.abc import Generator, Iterable from sphinx.application import Sphinx from sphinx.ext.autodoc import _AutodocObjType # type: ignore[attr-defined] from sphinx.util.typing import ExtensionMetadata __all__ = ["setup"] _logger = logging.getLogger(__package__) def setup(app: Sphinx) -> ExtensionMetadata: # noqa: D103 app.setup_extension("sphinx.ext.autodoc") # To go first, we use a lower number than napoleon (which uses the default, 500) app.connect("autodoc-process-docstring", _process_docstring, priority=100) app.connect("autodoc-process-bases", _skip_private_bases) DEFAULT_FILTERS["member_type"] = _member_type app.config.templates_path = list(app.config.templates_path) + [str(Path(__file__).parent / "templates")] NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[assignment] return {"version": version("scverse-misc"), "parallel_read_safe": True} def _process_return(lines: Iterable[str]) -> Generator[str, None, None]: for line in lines: if m := re.fullmatch(r"(?P\w+)\s+:\s+(?P[\w.]+)", line): yield f"-{m['param']} (:class:`~{m['type']}`)" else: yield line def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]: """Add support for prose return sections in Numpy-style docstrings.""" lines_raw = self._dedent(self._consume_to_next_section()) if lines_raw[0] == ":": del lines_raw[0] lines = self._format_block(":returns: ", list(_process_return(lines_raw))) if lines and lines[-1]: lines.append("") return lines def _skip_private_bases(app: Sphinx, name: str, obj: type, _unused: Any, bases: list[type]) -> None: # noqa: ANN401 bases[:] = [b for b in bases if b is not object and get_origin(b) is not Generic and not b.__name__.startswith("_")] def _member_type(obj_path: str) -> Literal["method", "property", "attribute"]: """Determine object member type. E.g.: `.. auto{{ fullname | member_type }}::` """ # https://jinja.palletsprojects.com/en/stable/api/#custom-filters cls_path, cls_name, member_name = obj_path.rsplit(".", 2) try: cls = import_object(cls_path, [cls_name], "class")[-1] except ImportError: _logger.error( f"Failed to import {cls_name} from {cls_path}; the following exception was rased: {traceback.format_exc()}" ) return "attribute" member = getattr(cls, member_name, None) match member: case property(): return "property" case _ if callable(member): return "method" case _: return "attribute" def _process_docstring( app: Sphinx, objtype: _AutodocObjType, name: str, obj: object, options: AutodocOptions, lines: list[str] ) -> None: match objtype: case "function" | "decorator" if hasattr(obj, ATTR_NAMESPACE): assert isinstance(getattr(obj, ATTR_NAMESPACE), _NSInfo) _process_namespace_decorator(app, name, getattr(obj, ATTR_NAMESPACE), lines) case "method" | "function" if isinstance(obj, MethodType) and isinstance(obj.__self__, Settings): _process_settings_method(app, obj, lines) case "method" | "function" | "property" | "class" | "exception": if isinstance(obj, property): obj = obj.fget if hasattr(obj, ATTR_DEPRECATED) and isinstance(msg := getattr(obj, ATTR_DEPRECATED, None), Deprecation): _process_deprecated_function(app, msg, lines) if (args := getattr(obj, ATTR_DEPRECATED_ARG, None)) is not None: _process_deprecated_args(app, args, lines) case "data" if isinstance(obj, Settings): _process_settings_object(obj, name, lines) def _emit_docstring( app: Sphinx, model: Docstring, lines: list[str], ) -> None: """Emit a docstring compatible with the user settings (i.e. renderable with the chosen napoleon settings).""" if getattr(app.config, "napoleon_google_docstring", True): doc = emit_google(model) elif getattr(app.config, "napoleon_numpy_docstring", True): doc = emit_numpy(model) else: warnings.warn( "Neither Google-style nor Numpy-style docstrings are enabled. Skipping docstring generation.", stacklevel=2 ) return lines[:] = doc.strip("\n").splitlines() def _process_deprecated_function(app: Sphinx, msg: Deprecation, lines: list[str]) -> None: parsed = parse("\n".join(lines)) doc = Document(parsed) edits = parsed.edit() notice = f".. version-deprecated:: {msg.version_deprecated}" if len(msg): notice += f"\n{textwrap.indent(msg._docmsg or '', 3 * ' ')}" if doc.extended_summary is not None and not doc.extended_summary.is_missing(): notice += f"\n\n{doc.extended_summary.text}" edits.replace(doc.extended_summary.range, notice) elif doc.summary is not None: edits.insert(doc.summary.range.end, f"\n\n{notice}") else: edits.insert(doc.range.start, notice) lines[:] = edits.apply().splitlines() def _starts_own_line(parsed: Parsed, elem: TextBlock) -> bool: return parsed.line_col(elem.range.start).col == len(parsed.line_indent(elem.range.start)) def _process_deprecated_args(app: Sphinx, deprecations: list[deprecated_arg], lines: list[str]) -> None: parsed = parse("\n".join(lines)) if parsed.style is Style.PLAIN: return doc = Document(parsed) edits = parsed.edit() for par, deprecation in ( (par, deprecation) for section in doc.sections if section.kind in {SectionKind.PARAMETERS, SectionKind.KEYWORD_PARAMETERS, SectionKind.OTHER_PARAMETERS} for par in section.entries for deprecation in deprecations if deprecation.arg in (name.text for name in par.names) ): desc = par.description # https://github.com/ryumasai/pydocstring/pull/140#issuecomment-4966943642 if desc is not None and _starts_own_line(parsed, desc): indentation = parsed.line_indent(desc.range.start) elif desc is not None and len(desc.lines) > 1: indentation = parsed.line_indent(desc.lines[1].range.start) else: indentation = parsed.line_indent(par.range.start) + " " docmsg = f".. version-deprecated:: {deprecation.msg.version_deprecated}" if len(deprecation.msg): docmsg += f"\n{textwrap.indent(deprecation.msg, ' ')}" if desc is None: # the insertion point is at the end of the name line, so indent every line edits.insert(par.range.end, f"\n{textwrap.indent(docmsg, indentation)}") else: # the insertion point is already indented, so only indent continuation lines docmsg = f"\n{indentation}".join(docmsg.splitlines()) edits.replace(desc.range, f"{docmsg}\n\n{indentation}{desc.text}") lines[:] = edits.apply().splitlines() _settings_docstring_template = """Allows users to customize settings for the `{package}` package. Settings here will generally be for advanced use-cases and should be used with caution. For setting an option use :func:`~{name}.override` (local) or set the attributes directly (global) e.g., `{name}.my_setting = foo`. For assignment by environment variable, use the variable name in all caps with `{env_prefix}` as the prefix before import of `{package}`. The following options are available: """ def _get_objname(name: str) -> str: dotidx = name.rfind(".") if dotidx > -1: name = name[dotidx + 1 :] return name def _process_settings_object(settings: Settings, name: str, lines: list[str]) -> None: package = get_packagename(name) doc = _settings_docstring_template.format( package=package, name=name, env_prefix=settings.model_config["env_prefix"].upper(), ).splitlines() objname = _get_objname(name) for fname, field in settings.__class__.model_fields.items(): doc.append(f".. attribute:: {objname}.{fname}") doc.append(f" :type: {type_str(type(settings), field)}") if field.default is not PydanticUndefined: doc.append(f" :value: {field.default!r}") deprecation_version = "???" deprecation_msg = None match field.deprecated: case deprecated(): deprecation_msg = field.deprecated.message if isinstance(field.deprecated.message, Deprecation): deprecation_version = field.deprecated.message.version_deprecated case str(): deprecation_msg = field.deprecated case True: deprecation_msg = "" if deprecation_msg is not None: doc.append("") doc.append(f" .. version-deprecated:: {deprecation_version}") doc.append(textwrap.indent(deprecation_msg, 6 * " ")) if field.description is not None: doc.append("") doc.append(f"{textwrap.indent(field.description, ' ')}") lines[:] = doc def _process_settings_method(app: Sphinx, method: MethodType, lines: list[str]) -> None: sphinx_adds_type = getattr(app.config, "autodoc_typehints", None) in {"description", "both"} ext_adds_type = "sphinx_autodoc_typehints" in app.extensions add_annot = not sphinx_adds_type and not ext_adds_type match method.__name__: case "override": _process_settings_method_override(app, method, lines, add_annot=add_annot) case "reset": _process_settings_method_reset(app, method, lines, add_annot=add_annot) def _process_settings_method_override(app: Sphinx, method: MethodType, lines: list[str], *, add_annot: bool) -> None: settings = cast("Settings", method.__self__) params: list[Block] = [ Block.Parameter( Parameter( [fname], type_annotation=type_str(type(settings), field) if add_annot else None, description=field.description, ) ) for fname, field in type(settings).model_fields.items() ] model = Docstring( summary="Provides local override via keyword arguments as a context manager.", sections=[Section(SectionKind.PARAMETERS, blocks=params)], ) _emit_docstring(app, model, lines) def _process_settings_method_reset(app: Sphinx, method: MethodType, lines: list[str], *, add_annot: bool) -> None: settings = cast("Settings", method.__self__) annot = f"typing.Literal[{', '.join(settings.model_fields.keys())}]" if add_annot else None desc = "Names of settings to reset." names_param = Parameter(["names"], type_annotation=annot, description=desc, is_optional=True) model = parse("\n".join(lines)).to_model() model.sections = [Section(SectionKind.PARAMETERS, blocks=[Block.Parameter(names_param)])] _emit_docstring(app, model, lines) _namespace_decorator_summary_template = ( "Decorator for registering custom functionality with a :class:`~{qualname}` object." ) _namespace_decorator_extended_summary_template = """This decorator allows you to extend {name} objects with custom methods and properties organized under a namespace. The namespace becomes accessible as an attribute on {name} instances, providing a clean way to you to add domain-specific functionality without modifying the {name} class itself, or extending the class with additional methods as you see fit in your workflow.""" _namespace_decorator_argument_description_template = """Name under which the accessor should be registered. This will be the attribute name used to access your namespace's functionality on {name} objects (e.g., `instance.name`). Cannot conflict with existing {name} attributes. The list of reserved attributes includes everything outputted by `dir({name})`.""" _namespace_decorator_notes_template = """Implementation requirements: 1. The decorated class must have an `__init__` method that accepts exactly one parameter (besides `self`) named `{canonical_instance_name}` and annotated with type :class:`~{qualname}`. 2. The namespace will be initialized with the {name} object on first access and then cached on the instance. 3. If the namespace name conflicts with an existing namespace, a warning is issued. 4. If the namespace name conflicts with a built-in {name} attribute, an AttributeError is raised.", """ _namespace_decorator_examples_template = """>>> @{decorator_name}("do_something") ... class DoSomething: ... def __init__(self, {canonical_instance_name}: {name}): ... self._obj = {canonical_instance_name} ... ... def has_foo(self) -> bool: ... return hasattr(self._obj, "foo") >>> >>> # Create a {name} object >>> obj = {name}() >>> >>> # use the registered namespace >>> obj.do_something.has_foo() False """ def _process_namespace_decorator(app: Sphinx, name: str, info: _NSInfo, lines: list[str]) -> None: qualname = f"{info.cls.__module__}.{info.cls.__name__}" model = Docstring( summary=_namespace_decorator_summary_template.format(qualname=qualname), extended_summary=_namespace_decorator_extended_summary_template.format(name=info.cls.__name__), sections=[ Section( SectionKind.PARAMETERS, blocks=[ Block.Parameter( Parameter( names=["name"], description=_namespace_decorator_argument_description_template.format( name=info.cls.__name__ ), ) ) ], ), Section( SectionKind.RETURNS, blocks=[ Block.Return( Return(description="A decorator that registers the decorated class as a custom namespace.") ) ], ), Section( SectionKind.NOTES, blocks=[ Block.Paragraph( _namespace_decorator_notes_template.format( qualname=qualname, name=info.cls.__name__, canonical_instance_name=info.name ) ) ], ), Section( SectionKind.EXAMPLES, blocks=[ Block.Paragraph( _namespace_decorator_examples_template.format( decorator_name=_get_objname(name), name=info.cls.__name__, canonical_instance_name=info.name ) ) ], ), ], ) _emit_docstring(app, model, lines) scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/templates/000077500000000000000000000000001525072673200257355ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/templates/autosummary/000077500000000000000000000000001525072673200303235ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/templates/autosummary/class.rst000066400000000000000000000020431525072673200321610ustar00rootroot00000000000000{{ fullname | escape | underline }} .. currentmodule:: {{ module }} .. add toctree option to make autodoc generate the pages .. autoclass:: {{ fullname }} {% set methods = methods | select("ne", "__init__") | list %} {% block attributes %} {% for item in attributes %} {% if loop.first %} Attributes table ~~~~~~~~~~~~~~~~ .. autosummary:: {% endif %} ~{{ item }} {%- endfor %} {% endblock %} {% block methods %} {% for item in methods %} {% if loop.first %} Methods table ~~~~~~~~~~~~~ .. autosummary:: {% endif %} ~{{ item }} {% endfor %} {% endblock %} {% block attributes_documentation %} {% for item in attributes %} {% if loop.first %} Attributes ~~~~~~~~~~ {% endif %} .. auto{{ [fullname, item] | join(".") | member_type }}:: {{ [fullname, item] | join(".") }} {%- endfor %} {% endblock %} {% block methods_documentation %} {% for item in methods %} {% if loop.first %} Methods ~~~~~~~ {% endif %} .. automethod:: {{ [fullname, item] | join(".") }} {%- endfor %} {% endblock %} scverse-scverse-misc-f5fb21e/src/scverse_misc/sphinx_ext/templates/autosummary/module.rst000066400000000000000000000021761525072673200323500ustar00rootroot00000000000000{{ fullname | escape | underline}} .. automodule:: {{ fullname }} {% block attributes %} {% if attributes %} .. rubric:: {{ _('Module Attributes') }} .. autosummary:: {% for item in attributes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block functions %} {% if functions %} .. rubric:: {{ _('Functions') }} .. autosummary:: :toctree: {% for item in functions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block classes %} {% if classes %} .. rubric:: {{ _('Classes') }} .. autosummary:: :toctree: {% for item in classes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block exceptions %} {% if exceptions %} .. rubric:: {{ _('Exceptions') }} .. autosummary:: :toctree: {% for item in exceptions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block modules %} {% if modules %} .. rubric:: Modules .. autosummary:: :toctree: {% for item in modules %} {{ item }} {%- endfor %} {% endif %} {% endblock %} scverse-scverse-misc-f5fb21e/stubs/000077500000000000000000000000001525072673200174325ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/stubs/anndata.pyi000066400000000000000000000002211525072673200215560ustar00rootroot00000000000000import os from typing import Any class AnnData: ... def read_h5ad(path: str | os.PathLike[str], **kwargs: Any) -> AnnData: ... # noqa: ANN401 scverse-scverse-misc-f5fb21e/stubs/pooch/000077500000000000000000000000001525072673200205425ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/stubs/pooch/__init__.pyi000066400000000000000000000020711525072673200230240ustar00rootroot00000000000000from .typing import Downloader, PathInputType, PathType, Processor def create( path: PathInputType, base_url: str, version: str | None = None, version_dev: str = "master", env: str | None = None, registry: dict[str, str | None] | None = None, urls: dict[str, str] | None = None, retry_if_failed: int = 0, allow_updates: bool | str = True, ) -> Pooch: ... class Pooch: def __init__( self, path: PathType, base_url: str, registry: dict[str, str | None] | None = None, urls: dict[str, str] | None = None, retry_if_failed: int = 0, allow_updates: bool = True, ) -> None: ... def fetch( self, fname: str, processor: Processor | None = None, downloader: Downloader | None = None, progressbar: bool = False, ) -> str: ... class Unzip: def __init__(self, extract_dir: str | None = None) -> None: ... def __call__(self, fname: str, action: str | None, pooch: Pooch | None) -> object: ... _u: Processor = Unzip() # type assertion scverse-scverse-misc-f5fb21e/stubs/pooch/typing.pyi000066400000000000000000000010601525072673200225740ustar00rootroot00000000000000import os from collections.abc import Callable from typing import Literal, Protocol from . import Pooch type Action = Literal["download", "fetch", "update"] type PathType = str | os.PathLike[str] type PathInputType = PathType | list[PathType] | tuple[PathType, ...] type Processor = Callable[[str, Action, Pooch | None], object] class Downloader(Protocol): def __call__( # noqa: E704 self, fname: str, action: PathType | None, pooch: Pooch | None, *, check_only: bool | None = None, ) -> object: ... scverse-scverse-misc-f5fb21e/stubs/spatialdata.pyi000066400000000000000000000002311525072673200224400ustar00rootroot00000000000000import os from typing import Any class SpatialData: ... def read_zarr(path: str | os.PathLike[str], **kwargs: Any) -> SpatialData: ... # noqa: ANN401 scverse-scverse-misc-f5fb21e/stubs/sphinxcontrib/000077500000000000000000000000001525072673200223245ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/stubs/sphinxcontrib/__init__.pyi000066400000000000000000000001271525072673200246060ustar00rootroot00000000000000# mypy doesn’t understand namespace packages apparently from . import katex as katex scverse-scverse-misc-f5fb21e/stubs/sphinxcontrib/katex.pyi000066400000000000000000000003331525072673200241620ustar00rootroot00000000000000STARTUP_TIMEOUT: float """How long to wait for the render server to start in seconds.""" RENDER_TIMEOUT: float """Timeout per rendering request in seconds.""" NODEJS_BINARY: str """nodejs binary to run javascript.""" scverse-scverse-misc-f5fb21e/tests/000077500000000000000000000000001525072673200174345ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/_pkgs/000077500000000000000000000000001525072673200205375ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/_pkgs/depkg/000077500000000000000000000000001525072673200216315ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/_pkgs/depkg/__init__.py000066400000000000000000000034361525072673200237500ustar00rootroot00000000000000"""Stand-in for a package that deprecates things. Must be a different package from its caller.""" from __future__ import annotations from abc import ABC from functools import singledispatch, wraps from typing import TYPE_CHECKING from scverse_misc import Deprecation, deprecated, deprecated_arg if TYPE_CHECKING: from collections.abc import Callable def hidden_decorator[F: Callable[..., object]](func: F) -> F: """A wrapper frame that opts out via `__tracebackhide__`, like `legacy_api_wrap`.""" @wraps(func) def wrapper(*args: object, **kwargs: object) -> object: __tracebackhide__ = True return func(*args, **kwargs) return wrapper # type: ignore[return-value] def plain_decorator[F: Callable[..., object]](func: F) -> F: """A wrapper frame in the deprecating package itself, marking nothing.""" @wraps(func) def wrapper(*args: object, **kwargs: object) -> object: return func(*args, **kwargs) return wrapper # type: ignore[return-value] @hidden_decorator @plain_decorator @deprecated(Deprecation("0.2", "Use bar() instead.")) def dep_func() -> None: pass @hidden_decorator @plain_decorator @deprecated_arg("bar", Deprecation("0.2", "Use baz instead.")) def dep_arg_func(*, bar: int = 1, baz: int = 2) -> None: pass @singledispatch # stdlib wrapper frame @deprecated_arg("bar", Deprecation("0.2")) def dep_dispatch(_x: object, *, bar: int = 1) -> None: pass @deprecated_arg("bar", Deprecation("0.2"), stacklevel=1) def dep_arg_explicit_stacklevel(*, bar: int = 1) -> None: pass @deprecated(Deprecation("0.2")) class DepClass: def __init__(self, x: int) -> None: self.x = x @deprecated(Deprecation("0.2")) class DepABC(ABC): # Python-level metaclass => extra frames on subclassing # noqa: B024 pass scverse-scverse-misc-f5fb21e/tests/conftest.py000066400000000000000000000036121525072673200216350ustar00rootroot00000000000000from __future__ import annotations import sys from collections.abc import Callable from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Literal, cast import pytest # `depkg` stands in for a package that deprecates things. It lives outside `tests/` because # warnings are blamed on the first frame outside the deprecating package – were it inside, # the test files calling it would be skipped too. sys.path.insert(0, str(Path(__file__).parent / "_pkgs")) if TYPE_CHECKING: from sphinx.ext.napoleon.docstring import GoogleDocstring, NumpyDocstring from sphinx.testing.util import SphinxTestApp if find_spec("sphinx"): pytest_plugins = ["sphinx.testing.fixtures"] @pytest.fixture(scope="session", params=["google", "numpy"]) def docstring_style(request: pytest.FixtureRequest) -> Literal["google", "numpy"]: return cast(Literal["google", "numpy"], request.param) @pytest.fixture(scope="session") def parser(docstring_style: Literal["google", "numpy"]) -> type[GoogleDocstring | NumpyDocstring]: from sphinx.ext.napoleon.docstring import GoogleDocstring, NumpyDocstring return GoogleDocstring if docstring_style == "google" else NumpyDocstring @pytest.fixture def app( tmp_path: Path, make_app: Callable[..., SphinxTestApp], docstring_style: Literal["google", "numpy"] ) -> SphinxTestApp: """Since we need the same set everywhere, we use this instead of static roots like `@pytest.mark.sphinx('html', testroot="mybook")`.""" (tmp_path / "conf.py").write_text("") app = make_app( srcdir=tmp_path, confoverrides=dict( extensions=["sphinx.ext.autodoc", "scverse_misc.sphinx_ext", "sphinx.ext.napoleon"], typehints_defaults="braces", napoleon_google_docstring=docstring_style == "google", napoleon_numpy_docstring=docstring_style == "numpy", ), ) return app scverse-scverse-misc-f5fb21e/tests/deprecation_decorator/000077500000000000000000000000001525072673200237735ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/deprecation_decorator/conftest.py000066400000000000000000000021361525072673200261740ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Callable from typing import cast import pytest from scverse_misc import Deprecation, deprecated @pytest.fixture( params=[ pytest.param(None, id="no_message"), pytest.param("Test message.", id="short_message"), pytest.param("Test\nmessage.", id="long_message"), ] ) def msg(request: pytest.FixtureRequest) -> str | None: return cast(str | None, request.param) @pytest.fixture(scope="session") def docstring() -> str | None: return None # gets overridden in `test_sphinx.py` @pytest.fixture def func(docstring: str | None) -> Callable[..., int]: def _func( positional_only_no_default: int, positional_only_default: int = 1337, /, positional_or_keyword_default: int = 42, *, keyword_only_default: float = 3.1415, ) -> int: return 42 _func.__doc__ = docstring return _func @pytest.fixture def deprecated_func(msg: str | None, func: Callable[..., int]) -> Callable[..., int]: return deprecated(Deprecation("foo", msg or ""))(func) scverse-scverse-misc-f5fb21e/tests/deprecation_decorator/test_runtime.py000066400000000000000000000020271525072673200270700ustar00rootroot00000000000000from __future__ import annotations from textwrap import indent from typing import TYPE_CHECKING import pytest from scverse_misc import Deprecation, deprecated_arg if TYPE_CHECKING: from collections.abc import Callable def test_deprecation_decorator(deprecated_func: Callable[..., int], msg: str | None) -> None: msg = indent(msg, " ") if isinstance(msg, str) and "\n" in msg else (msg or "") with pytest.warns(FutureWarning, match=rf"(?s)is deprecated.*{msg}"): assert deprecated_func(1, 2, 3, keyword_only_default=4.0) == 42 @pytest.mark.parametrize( "arg", ("positional_only_no_default", "positional_only_default", "positional_or_keyword_default", "keyword_only_default"), ) def test_deprecated_arg_decorator(func: Callable[..., int], msg: str | None, arg: str) -> None: deprecated_func = deprecated_arg(arg, Deprecation("2.718", msg or ""))(func) with pytest.warns(FutureWarning, match=rf"{arg} is deprecated.*{msg or ''}"): assert deprecated_func(1, 2, 3, keyword_only_default=4.0) == 42 scverse-scverse-misc-f5fb21e/tests/deprecation_decorator/test_sphinx.py000066400000000000000000000162641525072673200267260ustar00rootroot00000000000000from __future__ import annotations import inspect import textwrap import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Literal import pytest pytest.importorskip("scverse_misc.sphinx_ext") from docutils import nodes from sphinx import addnodes from scverse_misc import Deprecation, deprecated_arg, sphinx_ext from scverse_misc.constants import ATTR_DEPRECATED, ATTR_DEPRECATED_ARG if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined] from sphinx.testing.util import SphinxTestApp @pytest.fixture( scope="session", params=[ "no_docstring", "short", "long_googlestyle", "long_numpystyle", "no_param_desc_googlestyle", "no_param_desc_numpystyle", ], ) def docstring(request: pytest.FixtureRequest, docstring_style: Literal["google", "numpy"]) -> str | None: match request.param: case "no_docstring": return None case "short": return "Test function" case "no_param_desc_numpystyle": if docstring_style == "google": pytest.skip("only google docstring parser enabled") return """Test function Parameters ---------- positional_only_no_default positional_only_default positional_or_keyword_default keyword_only_default foobar """ case "no_param_desc_googlestyle": if docstring_style == "numpy": pytest.skip("only numpy docstring parser enabled") return """Test function Args: positional_only_no_default: positional_only_default: positional_or_keyword_default: keyword_only_default: foobar """ case "long_numpystyle": if docstring_style == "google": pytest.skip("only google docstring parser enabled") return """Test function This is a test. Parameters ---------- positional_only_no_default foo positional_only_default bar positional_or_keyword_default baz keyword_only_default foobar Returns ------- This is a prose returns section. :attr:`~module.ClassName.attr1` First attribute :attr:`~module.ClassName.attr2` Second attribute """ case "long_googlestyle": if docstring_style == "numpy": pytest.skip("only numpy docstring parser enabled") return """Test function This is a test. Args: positional_only_no_default: foo positional_only_default: bar lorem ipsum test positional_or_keyword_default: baz keyword_only_default: foobar """ case typ: pytest.fail(f"Unknown docstring style {typ}") def test_deprecation_decorator( app: Sphinx, deprecated_func: Callable[..., int], docstring: str | None, msg: str | None ) -> None: lines = (inspect.getdoc(deprecated_func) or "").splitlines() sphinx_ext._process_deprecated_function(app, getattr(deprecated_func, ATTR_DEPRECATED), lines) offset = 0 if docstring is None else 2 if docstring is not None: lines_orig = inspect.cleandoc(docstring).expandtabs().splitlines() assert lines[0] == lines_orig[0] assert len(lines[1].strip()) == 0, "expected empty line following summary" try: orig_returns_offset = lines_orig.index("Returns") except ValueError: pass else: returns_offset = lines.index("Returns") for roffset in range(max(len(lines_orig) - orig_returns_offset, len(lines) - returns_offset)): assert lines[returns_offset + roffset] == lines_orig[orig_returns_offset + roffset] assert lines[offset].startswith(".. version-deprecated") if msg is None: assert len(lines) == offset + 1 or not lines[offset + 1].startswith(" ") else: msg_lines = msg.splitlines() msg_indented = [f" {line}" for line in msg_lines] assert lines[offset + 1 : offset + 1 + len(msg_lines)] == msg_indented def _words(node: nodes.Element) -> list[str]: """Text of a node, ignoring whitespace and the separator before a parameter’s description.""" return [word for word in node.astext().split() if word != "–"] def _render_params( app: SphinxTestApp, parser: type[GoogleDocstring | NumpyDocstring], **docstrings: list[str] ) -> dict[str, dict[str, nodes.list_item]]: """Build each docstring as a `py:function` body, returning the rendered parameter items by name.""" src = "Test\n====\n" for name, lines in docstrings.items(): body = textwrap.indent("\n".join(parser(lines).lines()), " ") src += f"\n.. py:function:: {name}()\n\n{body}\n" (app.srcdir / "index.rst").write_text(src) app.build() doctree = app.env.get_doctree("index") assert not list(doctree.findall(nodes.system_message)), "invalid rST" return { next(desc.findall(addnodes.desc_name)).astext(): { next(item.findall(addnodes.literal_strong)).astext(): item for item in desc.findall(nodes.list_item) } for desc in doctree.findall(addnodes.desc) } @pytest.mark.parametrize( "arg", ("positional_only_no_default", "positional_only_default", "positional_or_keyword_default", "keyword_only_default"), ) def test_deprecated_arg_decorator( app: SphinxTestApp, parser: type[GoogleDocstring | NumpyDocstring], func: Callable[..., int], msg: str | None, arg: str, ) -> None: deprecated_func = deprecated_arg(arg, Deprecation("2.718", msg or ""))(func) with pytest.warns(FutureWarning, match=f"{arg} is deprecated"): assert deprecated_func(1, 2, 3, keyword_only_default=4.0) == 42 if arg != "positional_only_no_default": with warnings.catch_warnings(): warnings.simplefilter("error") assert deprecated_func(1) == 42 if "\n" not in (func.__doc__ or ""): return lines = (inspect.getdoc(deprecated_func) or "").splitlines() sphinx_ext._process_deprecated_args(app, getattr(deprecated_func, ATTR_DEPRECATED_ARG), lines) rendered = _render_params(app, parser, before=(inspect.getdoc(func) or "").splitlines(), after=lines) # an insufficiently indented directive would show up as an extra parameter assert rendered["after"].keys() == rendered["before"].keys() notice = "Deprecated since version 2.718." if msg is None else f"Deprecated since version 2.718: {msg}" for name, item in rendered["after"].items(): notices = list(item.findall(addnodes.versionmodified)) assert [n.astext().split() for n in notices] == ([notice.split()] if name == arg else []) for n in notices: # the rest of the parameter must be untouched n.parent.remove(n) assert _words(item) == _words(rendered["before"][name]) scverse-scverse-misc-f5fb21e/tests/deprecation_decorator/test_stacklevel.py000066400000000000000000000053031525072673200275420ustar00rootroot00000000000000"""The warning must be blamed on the caller, however many decorators are stacked in between.""" from __future__ import annotations import inspect import sys import pytest from depkg import DepABC, DepClass, dep_arg_explicit_stacklevel, dep_arg_func, dep_dispatch, dep_func # Calls go directly in the test body: a `lambda` in a fixture or `parametrize` would be # the caller, so blaming *it* would be correct and the test would prove nothing. @pytest.mark.xfail(reason="`warnings.deprecated` warns itself, at a fixed level: python/cpython#156929") def test_deprecated_blames_caller() -> None: with pytest.warns(FutureWarning, match="is deprecated") as record: expected = sys._getframe().f_lineno + 1 dep_func() assert (record[0].filename, record[0].lineno) == (__file__, expected) def test_deprecated_arg_blames_caller() -> None: with pytest.warns(FutureWarning, match="bar is deprecated") as record: expected = sys._getframe().f_lineno + 1 dep_arg_func(bar=3) assert (record[0].filename, record[0].lineno) == (__file__, expected) def test_stdlib_wrapper_blames_caller() -> None: with pytest.warns(FutureWarning, match="bar is deprecated") as record: expected = sys._getframe().f_lineno + 1 dep_dispatch(1, bar=2) assert (record[0].filename, record[0].lineno) == (__file__, expected) def test_explicit_stacklevel_still_honored() -> None: with pytest.warns(FutureWarning) as record: expected = sys._getframe().f_lineno + 1 dep_arg_explicit_stacklevel(bar=2) assert (record[0].filename, record[0].lineno) == (__file__, expected) def test_deprecated_class_stays_a_class() -> None: with pytest.warns(FutureWarning, match="The class DepClass is deprecated"): obj = DepClass(1) assert isinstance(obj, DepClass) and obj.x == 1 def test_deprecated_class_instantiation_blames_caller() -> None: with pytest.warns(FutureWarning) as record: expected = sys._getframe().f_lineno + 1 DepClass(1) assert (record[0].filename, record[0].lineno) == (__file__, expected) def test_deprecated_class_subclassing_blames_caller() -> None: with pytest.warns(FutureWarning) as record: class Sub(DepClass): ... expected = inspect.getsourcelines(Sub)[1] assert (record[0].filename, record[0].lineno) == (__file__, expected) @pytest.mark.xfail(reason="`warnings.deprecated` warns itself, at a fixed level: python/cpython#156929") def test_deprecated_class_with_metaclass_subclassing_blames_caller() -> None: with pytest.warns(FutureWarning) as record: class Sub(DepABC): ... expected = inspect.getsourcelines(Sub)[1] assert (record[0].filename, record[0].lineno) == (__file__, expected) scverse-scverse-misc-f5fb21e/tests/extension/000077500000000000000000000000001525072673200214505ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/extension/test_namespace.py000066400000000000000000000160351525072673200250220ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Protocol import pytest from scverse_misc import _extensions as extensions from scverse_misc import make_register_namespace_decorator if TYPE_CHECKING: from collections.abc import Generator class Greeter(Protocol): def __init__(self, obj: DummyClass) -> None: ... def greet(self) -> str: ... class DummyClass: foo: list[object] = [] bar = None @property def baz(self) -> None: ... def foobar(self) -> None: ... dummy: Greeter # available when using `dummy_namespace` fixture register_dummy_namespace = make_register_namespace_decorator(DummyClass, "obj") @pytest.fixture def obj() -> DummyClass: """Create a basic object for testing.""" return DummyClass() @pytest.fixture(autouse=True) def _cleanup_dummy() -> Generator[None, None, None]: """Automatically cleanup dummy namespace after each test.""" original = getattr(DummyClass, "dummy", None) yield if original is not None: DummyClass.dummy = original elif hasattr(DummyClass, "dummy"): delattr(DummyClass, "dummy") @pytest.fixture def dummy_namespace() -> type: """Create a basic dummy namespace class.""" @register_dummy_namespace("dummy") class DummyNamespace: def __init__(self, obj: DummyClass) -> None: self._obj = obj def greet(self) -> str: return "hello" return DummyNamespace def test_accessor_namespace() -> None: """Test the behavior of the AccessorNameSpace descriptor. This test verifies that: - When accessed at the class level (i.e., without an instance), the descriptor returns the namespace type. - When accessed via an instance, the descriptor instantiates the namespace, passing the instance to its constructor. - The instantiated namespace is then cached on the instance such that subsequent accesses of the same attribute return the cached namespace instance. """ # Define a dummy namespace class to be used via the descriptor. class DummyNamespace: def __init__(self, obj: Dummy) -> None: self._obj = obj def foo(self) -> str: return "foo" class Dummy: dummy: DummyNamespace # just typing, runtime added below descriptor: extensions.AccessorNameSpace[Dummy, DummyNamespace] = extensions.AccessorNameSpace( "dummy", DummyNamespace ) # When accessed on the class, it should return the namespace type. ns_class = descriptor.__get__(None, Dummy) assert ns_class is DummyNamespace # When accessed via an instance, it should instantiate DummyNamespace. dummy_obj = Dummy() ns_instance = descriptor.__get__(dummy_obj, Dummy) assert isinstance(ns_instance, DummyNamespace) assert ns_instance._obj is dummy_obj # __get__ should cache the namespace instance on the object. # Subsequent access should return the same cached instance. assert dummy_obj.dummy is ns_instance def test_descriptor_instance_caching(dummy_namespace: type, obj: DummyClass) -> None: """Test that namespace instances are cached on individual DummyClass objects.""" # First access creates the instance ns_instance = obj.dummy # Subsequent accesses should return the same instance assert obj.dummy is ns_instance def test_register_namespace_basic(dummy_namespace: type, obj: DummyClass) -> None: """Test basic namespace registration and access.""" assert obj.dummy.greet() == "hello" def test_register_namespace_override(dummy_namespace: type) -> None: """Test namespace registration and override behavior.""" assert hasattr(DummyClass, "dummy") # Override should warn and update the namespace with pytest.warns(UserWarning, match="Overriding existing custom namespace 'dummy'"): @register_dummy_namespace("dummy") class DummyNamespaceOverride: def __init__(self, obj: DummyClass) -> None: self._obj = obj def greet(self) -> str: return "world" # Verify the override worked obj = DummyClass() assert obj.dummy.greet() == "world" @pytest.mark.parametrize( "attr", [ "foo", "bar", "baz", "foobar", ], ) def test_register_existing_attributes(attr: str) -> None: """ Test that registering an accessor with a name that is a reserved attribute of DummyClass raises an attribute error. """ with pytest.raises(AttributeError, match=f"cannot override reserved attribute {attr!r}"): @register_dummy_namespace(attr) class DummyNamespace: def __init__(self, obj: DummyClass) -> None: self._obj = obj def test_valid_signature() -> None: """Test that a namespace with valid signature is accepted.""" @register_dummy_namespace("valid") class ValidNamespace: def __init__(self, obj: DummyClass) -> None: self.obj = obj def test_missing_param() -> None: """Test that a namespace missing the second parameter is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer must accept a DummyClass instance as the second parameter\." ): @register_dummy_namespace("missing_param") class MissingParamNamespace: def __init__(self) -> None: pass def test_wrong_name() -> None: """Test that a namespace with wrong parameter name is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer's second parameter must be named 'obj', got 'notobj'\." ): @register_dummy_namespace("wrong_name") class WrongNameNamespace: def __init__(self, notobj: DummyClass) -> None: self.notobj = notobj def test_wrong_annotation() -> None: """Test that a namespace with wrong parameter annotation is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer's second parameter must be annotated as the 'DummyClass' class, got 'int'\.", ): @register_dummy_namespace("wrong_annotation") class WrongAnnotationNamespace: def __init__(self, obj: int) -> None: self.obj = obj def test_missing_annotation() -> None: """Test that a namespace with missing parameter annotation is rejected.""" with pytest.raises(AttributeError): @register_dummy_namespace("missing_annotation") class MissingAnnotationNamespace: def __init__(self, obj) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 self.obj = obj def test_both_wrong() -> None: """Test that a namespace with both wrong name and annotation is rejected.""" with pytest.raises( TypeError, match=( r"Namespace initializer's second parameter must be named 'obj', got 'info'\. " r"And must be annotated as 'DummyClass', got 'str'\." ), ): @register_dummy_namespace("both_wrong") class BothWrongNamespace: def __init__(self, info: str) -> None: self.info = info scverse-scverse-misc-f5fb21e/tests/extension/test_sphinx.py000066400000000000000000000020321525072673200243670ustar00rootroot00000000000000from __future__ import annotations import inspect from typing import TYPE_CHECKING import pytest pytest.importorskip("scverse_misc.sphinx_ext") from sphinx.ext.autodoc import Options as AutodocOptions from scverse_misc import make_register_namespace_decorator from scverse_misc.sphinx_ext import _process_docstring if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.ext.napoleon.docstring import GoogleDocstring, NumpyDocstring class DummyClass: ... reg_ns = make_register_namespace_decorator(DummyClass, "dummy") def test_sphinx(app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring]) -> None: lines = (inspect.getdoc(DummyClass) or "").splitlines() _process_docstring(app, "decorator", "tests.reg_ns", reg_ns, AutodocOptions(), lines) lines = parser(lines).lines() assert ( lines[0] == "Decorator for registering custom functionality with a :class:`~tests.extension.test_sphinx.DummyClass` object." ) assert "... def __init__(self, dummy: DummyClass):" in lines scverse-scverse-misc-f5fb21e/tests/settings/000077500000000000000000000000001525072673200212745ustar00rootroot00000000000000scverse-scverse-misc-f5fb21e/tests/settings/conftest.py000066400000000000000000000000751525072673200234750ustar00rootroot00000000000000import pytest pytest.importorskip("scverse_misc._settings") scverse-scverse-misc-f5fb21e/tests/settings/test_runtime.py000066400000000000000000000103441525072673200243720ustar00rootroot00000000000000from __future__ import annotations import inspect import sys from contextlib import chdir, nullcontext from pathlib import Path from typing import Annotated, Literal, get_args, get_origin import pytest from pydantic import Field, ValidationError from pydantic_settings import SettingsConfigDict from scverse_misc import Deprecation, Settings, deprecated class DummySettings(Settings): field_bool: bool = False field_int: int = 42 field_int_range: Annotated[int, Field(ge=0, le=4)] = 1 field_deprecated: Annotated[ float, Field(deprecated=deprecated(Deprecation("0.42", "This setting does not do anything."))) ] = 3.14 def test_defaults_override() -> None: with ( pytest.warns(RuntimeWarning, match="validate_assignment=False"), pytest.warns(RuntimeWarning, match="use_attribute_docstrings=False"), pytest.warns(RuntimeWarning, match="custom env_file location"), pytest.warns(RuntimeWarning, match="dotenv_filtering scheme"), ): class WarnSettings(Settings): model_config = SettingsConfigDict( validate_assignment=False, use_attribute_docstrings=False, env_file="mydotenv", dotenv_filtering="match_prefix", ) field_bool: bool = False settings = WarnSettings() with pytest.raises(ValidationError): settings.field_bool = 2 # type: ignore[assignment] @pytest.mark.parametrize("v", [2, 4]) def test_env_vars(monkeypatch: pytest.MonkeyPatch, v: int) -> None: """Test that the env var prefix is derived from the module name.""" monkeypatch.setenv("TESTS_FIELD_INT_RANGE", str(v)) settings = DummySettings() assert settings.field_int_range == v @pytest.mark.parametrize("v", [2, 4]) def test_dotenv(v: int, tmp_path: Path) -> None: env = f"""\ SOMEVAR=true TESTS_FIELD_INT_RANGE={v} TESTS_DOES_NOT_EXIST=42 """ with open(tmp_path / ".env", "w") as dotenv: dotenv.write(env) with chdir(tmp_path): settings = type("S", (DummySettings,), {})() assert settings.field_int_range == v def test_validate_assignment() -> None: settings = DummySettings() with pytest.raises(ValidationError): settings.field_bool = 2 # type: ignore[assignment] with pytest.raises(ValidationError): settings.field_int_range = -1 def test_override() -> None: settings = DummySettings() with settings.override(field_bool=True): assert settings.field_bool is True assert settings.field_bool is False def test_override_error() -> None: settings = DummySettings() with pytest.raises(ValidationError): with settings.override(field_int_range=3, field_int=1.1): pass assert settings.field_int == 42 assert settings.field_int_range == 1 @pytest.mark.parametrize("temp", [True, False], ids=["temporary", "permanent"]) def test_reset(temp: bool) -> None: settings = DummySettings() default = settings.field_bool settings.field_bool = not default undo_reset = settings.reset("field_bool") with undo_reset if temp else nullcontext(): assert settings.field_bool is default assert settings.field_bool is (not default if temp else default) def test_reset_signature() -> None: settings = DummySettings() sig = inspect.signature(settings.reset) names_param = sig.parameters["names"] assert get_origin(names_param.annotation) is Literal assert get_args(names_param.annotation) == ("field_bool", "field_int", "field_int_range", "field_deprecated") @pytest.mark.skipif(sys.version_info < (3, 14), reason="requires annotationlib") def test_reset_annotations() -> None: from contextlib import AbstractContextManager import annotationlib settings = DummySettings() assert annotationlib.get_annotations(settings.reset) == { "names": Literal["field_bool", "field_int", "field_int_range", "field_deprecated"], "return": AbstractContextManager[ frozenset[Literal["field_bool", "field_int", "field_int_range", "field_deprecated"]] ], } def test_deprecation() -> None: settings = DummySettings() with pytest.warns(DeprecationWarning, match="does not do anything"): settings.field_deprecated # noqa: B018 scverse-scverse-misc-f5fb21e/tests/settings/test_sphinx.py000066400000000000000000000151471525072673200242260ustar00rootroot00000000000000from __future__ import annotations import inspect import sys from typing import Annotated, Literal if sys.version_info >= (3, 13): from warnings import deprecated as stdlib_deprecated else: from typing_extensions import deprecated as stdlib_deprecated import pytest pytest.importorskip("scverse_misc.sphinx_ext") from pydantic import Field from pydantic.fields import FieldInfo from sphinx.application import Sphinx from sphinx.ext.autodoc import Options as AutodocOptions from sphinx.ext.napoleon.docstring import GoogleDocstring, NumpyDocstring from scverse_misc import Deprecation, Settings, deprecated from scverse_misc.sphinx_ext import _process_docstring class DummySettings(Settings): field_bool: bool = False """Boolean field.""" field_no_docstring: int = 42 field_int_range: Annotated[int, Field(ge=0, le=4)] = 1 """Integer range field.""" def test_body(app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring]) -> None: settings = DummySettings() lines = (inspect.getdoc(settings) or "").splitlines() _process_docstring(app, "data", "tests.settings", settings, AutodocOptions(), lines) lines = parser(lines).lines() assert lines[0].endswith("`tests` package.") current_field: FieldInfo | None = None field_iter = iter(type(settings).model_fields.items()) for line in lines: if line.startswith(".. attribute::"): current_field_name, current_field = next(field_iter) assert line.endswith(current_field_name) elif current_field is not None: line = line.strip() if line.startswith(":type:"): assert current_field.annotation is not None assert line.endswith(current_field.annotation.__name__) elif line.startswith(":value:"): assert line.endswith(repr(current_field.default)) elif len(line) > 0 and current_field.description is not None: assert line == current_field.description assert not list(field_iter) def test_override(subtests: pytest.Subtests, app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring]) -> None: settings = DummySettings() lines = (inspect.getdoc(settings.override) or "").splitlines() _process_docstring(app, "method", "tests.settings.override", settings.override, AutodocOptions(), lines) lines = parser(lines).lines() current_field: FieldInfo | None = None field_iter = iter(type(settings).model_fields.items()) for line in lines: if line.startswith(":param"): current_field_name, current_field = next(field_iter) description = f" {current_field.description}" if current_field.description is not None else "" with subtests.test(current_field_name): # no default here, as the default is “leave this value alone” assert line.startswith(f":param {current_field_name}:{description}") elif current_field is not None and len(line) > 0: assert current_field.annotation is not None with subtests.test(current_field_name, what="type"): assert line == f":type {current_field_name}: {current_field.annotation.__name__}" assert not list(field_iter) @pytest.mark.parametrize("adds_types", ["s_a_t", "autodoc", None]) def test_override_add_types( app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring], adds_types: Literal["s_a_t", "autodoc", None] ) -> None: """Test that `:type :` isn’t added (by us!) when `sphinx_autodoc_typehints` is enabled.""" if adds_types == "s_a_t": app.setup_extension("sphinx_autodoc_typehints") elif adds_types == "autodoc": app.setup_extension("sphinx.ext.autodoc") app.config.autodoc_typehints = "description" settings = DummySettings() lines = (inspect.getdoc(settings.override) or "").splitlines() _process_docstring(app, "method", "tests.settings.override", settings.override, AutodocOptions(), lines) lines = parser(lines).lines() assert ":param field_bool: Boolean field." in lines assert (":type" in "\n".join(lines)) == (adds_types is None) @pytest.mark.parametrize( ("attr", "expected"), [ pytest.param("string", "str", id="builtin"), pytest.param("pattern", "re.Pattern[str]", id="import"), # same module as `S`, so no leading `tests.test_settings.` pytest.param("local", "test_annotation_format..Local", id="same-module"), ], ) def test_annotation_format( app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring], attr: str, expected: str ) -> None: """Test that annotation references work correctly.""" import re class Local: ... class S(Settings): if attr == "string": string: str = "abc" if attr == "pattern": pattern: re.Pattern[str] = re.compile("abc") if attr == "local": local: Local = Local() settings = S() lines = (inspect.getdoc(settings) or "").splitlines() _process_docstring(app, "data", "tests.settings", settings, AutodocOptions(), lines) lines = parser(lines).lines() lines = lines[lines.index(f".. attribute:: settings.{attr}") + 1 :] assert lines[0] == f" :type: {expected}" @pytest.mark.parametrize( ("deprecation", "expected_version", "expected_msg"), ( ( deprecated(Deprecation("0.4.2", "This setting does not do anything.")), "0.4.2", "This setting does not do anything.", ), ( stdlib_deprecated(Deprecation("0.4.2", "This setting does not do anything")), "0.4.2", "This setting does not do anything", ), (stdlib_deprecated("This setting does not do anything"), "???", "This setting does not do anything"), ("This setting does not do anything", "???", "This setting does not do anything"), (True, "???", ""), ), ) def test_deprecation( app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring], deprecation: deprecated | str | bool, expected_version: str, expected_msg: str, ) -> None: class S(Settings): field_deprecated: Annotated[float, Field(deprecated=deprecation)] = 3.14 """A deprecated setting.""" settings = S() lines = (inspect.getdoc(settings) or "").splitlines() _process_docstring(app, "data", "tests.settings", settings, AutodocOptions(), lines) lines = parser(lines).lines() lines = lines[lines.index(".. attribute:: settings.field_deprecated") + 4 :] assert lines[0] == f" .. version-deprecated:: {expected_version}" assert lines[1] == (f" {expected_msg}" if len(expected_msg) > 0 else "") scverse-scverse-misc-f5fb21e/tests/settings/test_stacklevel.py000066400000000000000000000010301525072673200250340ustar00rootroot00000000000000"""`__init_subclass__` runs under two metaclass frames, so no fixed `stacklevel` reaches the `class` statement.""" from __future__ import annotations import inspect import pytest from scverse_misc import Settings def test_subclass_warning_blames_class_statement() -> None: with pytest.warns(DeprecationWarning) as warns: class MySettings(Settings, exported_object_name="settings"): ... expected = inspect.getsourcelines(MySettings)[1] assert [(w.filename, w.lineno) for w in warns] == [(__file__, expected)] scverse-scverse-misc-f5fb21e/tests/test_arg_alias.py000066400000000000000000000132531525072673200227730ustar00rootroot00000000000000import sys from enum import Enum from inspect import getsource from textwrap import dedent from types import ModuleType from typing import TYPE_CHECKING, Literal import pytest from scverse_misc import arg_alias @pytest.mark.parametrize("stringify", [True, False], ids=["stringify", "no_stringify"]) def test_arg_alias(stringify: bool) -> None: @arg_alias("axis_union") @arg_alias("axis") def func( x: float, axis: Literal[0, "obs", "samples"], y: float = 2, axis_union: Literal[0, "obs"] | Literal[1, "var", "features"] = "var", ) -> tuple[int, int]: assert axis == 0 assert axis_union in (0, 1) return axis, axis_union if stringify: ns: dict[str, object] = {} exec(f"from __future__ import annotations\n{dedent(getsource(func))}", globals(), ns) if not TYPE_CHECKING: # shhh func = ns["func"] assert isinstance(func.__annotations__["return"], str) == stringify assert func(42, 0) == (0, 1) assert func(42, "obs") == (0, 1) assert func(42, "samples", 3) == (0, 1) assert func(42, 0, axis_union="obs") == (0, 0) assert func(42, 0, axis_union="features") == (0, 1) assert func(42, 0, axis_union=0) == (0, 0) with pytest.raises(ValueError, match="must be one of "): func(42, "obs", axis_union="vars") # type: ignore[arg-type] class Klass: @arg_alias("axis_str") @arg_alias("axis") def __init__( self, other: "Klass | None" = None, axis: Literal[0, "obs"] | Literal[1, "var"] = 0, axis_str: "Literal[0, 'obs'] | Literal[1, 'var']" = 0, ) -> None: self.axis = axis self.axis_str = axis_str def test_arg_alias_inner_method() -> None: obj = Klass(axis="var", axis_str="var") assert obj.axis == 1 assert obj.axis_str == 1 obj = Klass(axis="obs", axis_str="obs") assert obj.axis == 0 assert obj.axis_str == 0 def test_arg_alias_inner_class_method() -> None: class InnerKlass: @arg_alias("axis_str") @arg_alias("axis") def __init__( self, other: "InnerKlass | None" = None, axis: Literal[0, "obs"] | Literal[1, "var"] = 0, axis_str: "Literal[0, 'obs'] | Literal[1, 'var']" = 0, ) -> None: self.axis = axis self.axis_str = axis_str obj = InnerKlass(axis="var", axis_str="var") assert obj.axis == 1 assert obj.axis_str == 1 obj = InnerKlass(axis="obs", axis_str="obs") assert obj.axis == 0 assert obj.axis_str == 0 def test_arg_alias_inner_class_return_method() -> None: def make_class() -> type: class InnerKlass: @arg_alias("axis_str") @arg_alias("axis") def __init__( self, other: "InnerKlass | None" = None, axis: Literal[0, "obs"] | Literal[1, "var"] = 0, axis_str: "Literal[0, 'obs'] | Literal[1, 'var']" = 0, ) -> None: self.axis = axis self.axis_str = axis_str return InnerKlass klass = make_class() obj = klass(axis="var", axis_str="var") assert obj.axis == 1 assert obj.axis_str == 1 obj = klass(axis="obs", axis_str="obs") assert obj.axis == 0 assert obj.axis_str == 0 # unrealistic example, real code would use a StrEnum and `color = Color(color)` for this use case class Color(Enum): RED = 1 GREEN = 2 HINT = "Literal[Color.RED, 'red'] | Literal[Color.GREEN, 'green']" HINT_TC = HINT.replace("Literal", "typing.Literal") # as if `typing` was imported under `TYPE_CHECKING` lazy_annots = pytest.mark.skipif(sys.version_info < (3, 14), reason="unresolvable annotations need PEP 649") @pytest.mark.parametrize( "src", [ # the `NeverDefined` sibling makes `get_type_hints` fail forever, not just before `Color` exists pytest.param(f'def obj(color: "{HINT}", other: "NeverDefined" = None): return color', id="str"), pytest.param(f"def obj(color: {HINT}): return color", id="forwardref_in_literal", marks=lazy_annots), pytest.param("def obj(color: ColorHint): return color", id="forwardref_whole_hint", marks=lazy_annots), pytest.param( f"def obj(color: {HINT}, other: NeverDefined = None): return color", id="unresolvable_sibling", marks=lazy_annots, ), pytest.param( f'class Holder:\n color: "{HINT_TC}"\n def __init__(self, color): self.color = color\n' "obj = lambda color: Holder(color).color", id="class", ), ], ) def test_arg_alias_deferred(src: str, monkeypatch: pytest.MonkeyPatch) -> None: """The annotation is unresolvable at decoration time, only at call time.""" mod = ModuleType("deferred_mod") ns = mod.__dict__ ns.update(arg_alias=arg_alias, Literal=Literal) monkeypatch.setitem(sys.modules, mod.__name__, mod) # a class resolves its hints via sys.modules exec(f"@arg_alias('color')\n{src}", ns) # only now the hint becomes resolvable ns.update(Color=Color, ColorHint=Literal[Color.RED, "red"] | Literal[Color.GREEN, "green"]) assert ns["obj"]("red") is Color.RED assert ns["obj"](Color.RED) is Color.RED assert ns["obj"]("green") is Color.GREEN with pytest.raises(ValueError, match="must be one of "): ns["obj"]("blue") def test_arg_alias_raises() -> None: with pytest.raises(TypeError, match="must be 'Union' or 'Literal'"): @arg_alias("axis") def func(axis: int) -> None: pass with pytest.raises(TypeError, match="must be 'Literal'"): @arg_alias("axis") def func(axis: Literal[0, "obs"] | int) -> None: pass scverse-scverse-misc-f5fb21e/tests/test_datasets.py000066400000000000000000000171001525072673200226540ustar00rootroot00000000000000from __future__ import annotations import sys import types from pathlib import Path from typing import TYPE_CHECKING import pytest try: import pooch from scverse_misc.datasets import ( DatasetEntry, FileEntry, _fetcher, available_loaders, fetch, parse_registry, register_loader, ) except ImportError: pytest.skip("missing dependencies", allow_module_level=True) if TYPE_CHECKING: from scverse_misc.datasets import DownloadCB _YAML = """\ base_url: https://example.invalid/data/ datasets: toy: type: dummy shape: [10, 3] files: - name: toy.h5ad s3_key: toy.h5ad sha256: abc123 fallback_urls: - https://fallback.invalid/data/toy.h5ad - https://fallback2.invalid/data/toy.h5ad remote: type: dummy files: - name: remote.zip url: https://zenodo.org/records/1/files/remote.zip """ @pytest.fixture def registry(tmp_path: Path) -> dict[str, DatasetEntry]: p = tmp_path / "datasets.yaml" p.write_text(_YAML) base_url, datasets = parse_registry(p) assert base_url == "https://example.invalid/data/" return datasets def test_parse_registry(registry: dict[str, DatasetEntry]) -> None: assert set(registry) == {"toy", "remote"} toy = registry["toy"] assert toy.type == "dummy" assert toy.metadata["shape"] == [10, 3] # non-type/files keys land in metadata assert toy.file(suffix=".h5ad").sha256 == "abc123" def test_parse_registry_warns_on_extra_file_keys(tmp_path: Path) -> None: p = tmp_path / "datasets.yaml" p.write_text( "datasets:\n" " d:\n" " type: dummy\n" " files:\n" " - name: x.h5ad\n" " url: https://z/x.h5ad\n" " description: an unknown-to-FileEntry key\n" ) # unknown keys are dropped (not fatal) but warned about so typos surface with pytest.warns(UserWarning, match="unknown file keys.*description"): _, datasets = parse_registry(p) assert datasets["d"].file(name="x.h5ad").url == "https://z/x.h5ad" def test_resolve_url() -> None: # explicit url takes precedence over s3_key assert FileEntry(name="x.zip", url="https://z/x.zip", s3_key="x.zip").resolve_url("https://b/") == "https://z/x.zip" # s3_key resolves against base_url assert FileEntry(name="x", s3_key="k").resolve_url("https://b") == "https://b/k" # neither resolvable -> error with pytest.raises(ValueError, match="neither"): FileEntry(name="x", s3_key="k").resolve_url(None) def test_file_selection_is_unambiguous(registry: dict[str, DatasetEntry]) -> None: assert registry["toy"].file(name="toy.h5ad").s3_key == "toy.h5ad" # exact name match with pytest.raises(ValueError, match="exactly one"): registry["toy"].file(name="nope.h5ad") with pytest.raises(ValueError, match="exactly one"): registry["toy"].file(suffix=".missing") with pytest.raises(ValueError, match="exactly one of"): registry["toy"].file() def test_builtin_loaders_are_shipped() -> None: assert {"anndata", "spatialdata"} <= set(available_loaders()) def test_register_and_dispatch(registry: dict[str, DatasetEntry], tmp_path: Path) -> None: seen: dict[str, object] = {} @register_loader("dummy") def _load(entry: DatasetEntry, target: Path, download: DownloadCB, /, **kw: object) -> str: seen.update(kw) return entry.name try: # dummy loader does no download, so no network / pooch needed assert fetch(registry["toy"], tmp_path, base_url="https://b", foo=1) == "toy" assert seen == {"foo": 1} finally: _fetcher._LOADERS.pop("dummy", None) def test_unknown_loader(registry: dict[str, DatasetEntry], tmp_path: Path) -> None: # "toy" is type "dummy" but no dummy loader registered here with pytest.raises(KeyError, match="No loader registered"): fetch(registry["toy"], tmp_path) def test_download_drives_pooch( registry: dict[str, DatasetEntry], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The `download` closure wires FileEntry -> pooch.create/fetch without touching the network.""" calls: dict[str, object] = {} class FakePup: def fetch(self, name: str, *, processor: object, progressbar: bool) -> str: calls["fetched"] = name return f"/cache/{name}" def fake_create(**kw: object) -> FakePup: calls.update(kw) return FakePup() monkeypatch.setattr(pooch, "create", fake_create) @register_loader("dummy") def _load(entry: DatasetEntry, target: Path, download: DownloadCB, /, **kw: object) -> str: return download(entry.file(suffix=".h5ad")) try: assert fetch(registry["toy"], tmp_path, base_url="https://b") == "/cache/toy.h5ad" finally: _fetcher._LOADERS.pop("dummy", None) assert calls["urls"] == {"toy.h5ad": "https://b/toy.h5ad"} assert calls["registry"] == {"toy.h5ad": "sha256:abc123"} assert calls["fetched"] == "toy.h5ad" def test_fallback_urls(registry: dict[str, DatasetEntry], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: tried = 0 def fake_fetch(self: pooch.Pooch, name: str, *args: object, **kwargs: object) -> str: nonlocal tried if tried < 2: tried += 1 raise OSError() else: return name monkeypatch.setattr(pooch.Pooch, "fetch", fake_fetch) @register_loader("dummy") def _load(entry: DatasetEntry, target: Path, download: DownloadCB, /, **kw: object) -> str: return download(entry.files[0]) with pytest.warns(UserWarning, match="retrying with fallback URLs"): try: assert fetch(registry["toy"], tmp_path, base_url="https://test.invalid") == registry["toy"].files[0].name finally: _fetcher._LOADERS.pop("dummy", None) # old anndata versions use the old arguments @pytest.mark.filterwarnings( r"ignore:The (decorator_name|docstring_style|exported_object_name)( class)? argument is deprecated:DeprecationWarning" ) def test_load_anndata_reads_h5ad(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: import anndata monkeypatch.setattr(anndata, "read_h5ad", lambda path, **kw: ("adata", path, kw)) entry = DatasetEntry(name="toy", type="anndata", files=(FileEntry(name="toy.h5ad", url="https://z/toy.h5ad"),)) result: object = _fetcher._load_anndata(entry, tmp_path, lambda f, **kw: "/cache/toy.h5ad", backed="r") assert result == ("adata", "/cache/toy.h5ad", {"backed": "r"}) def test_load_spatialdata_reads_zarr(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fake_sd = types.ModuleType("spatialdata") fake_sd.read_zarr = lambda path, **kw: ("sdata", path) # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "spatialdata", fake_sd) entry = DatasetEntry( name="cells", type="spatialdata", files=(FileEntry(name="cells.zip", url="https://z/cells.zip"),) ) # download extracted nothing -> loud failure (0 zarrs found) with pytest.raises(RuntimeError, match="Expected exactly one"): _fetcher._load_spatialdata(entry, tmp_path, lambda f, **kw: str(kw["dest"])) # the extracted .zarr need not be named after the registry key; glob finds the single one def extract(file: FileEntry, **kw: object) -> str: dest = kw["dest"] assert isinstance(dest, Path) (dest / "whatever.zarr").mkdir(parents=True) return str(dest) result: object = _fetcher._load_spatialdata(entry, tmp_path, extract) assert result == ("sdata", tmp_path / "cells" / "whatever.zarr") scverse-scverse-misc-f5fb21e/tests/test_sphinx.py000066400000000000000000000021461525072673200223610ustar00rootroot00000000000000import sys from functools import cache import pytest pytest.importorskip("scverse_misc.sphinx_ext") from scverse_misc.sphinx_ext import _member_type class DummyCls: attr: float def __init__(self) -> None: self.attr = 3.14 def func(self) -> int: return 42 @property def prop(self) -> int: return 1337 @staticmethod def static() -> float: return 2.71 @classmethod def klass(cls) -> float: return 1.61 @cache # noqa: B019 def cached(self) -> float: return 4.13 alias = sys.modules[__name__] @pytest.mark.parametrize( ["attrname", "attrtype"], ( ("attr", "attribute"), ("func", "method"), ("prop", "property"), ("static", "method"), ("klass", "method"), ("cached", "method"), ), ) def test_member_type(attrname: str, attrtype: str) -> None: obj_path = f"{__name__}.DummyCls.{{}}" alias_path = f"{__name__}.alias.DummyCls.{{}}" assert _member_type(obj_path.format(attrname)) == attrtype assert _member_type(alias_path.format(attrname)) == attrtype