pax_global_header00006660000000000000000000000064152026061320014507gustar00rootroot0000000000000052 comment=ed5b4091a2514399b114ed7deb5d221b224ab91c scverse-scverse-misc-ed5b409/000077500000000000000000000000001520260613200162005ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/.codecov.yaml000066400000000000000000000004411520260613200205630ustar00rootroot00000000000000# 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-ed5b409/.cruft.json000066400000000000000000000024431520260613200202770ustar00rootroot00000000000000{ "template": "https://github.com/scverse/cookiecutter-scverse", "commit": "ade99079c9508247bb14e9aa52af847a991675a5", "checkout": null, "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, "_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": "ade99079c9508247bb14e9aa52af847a991675a5" } }, "directory": null } scverse-scverse-misc-ed5b409/.editorconfig000066400000000000000000000003451520260613200206570ustar00rootroot00000000000000root = 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-ed5b409/.github/000077500000000000000000000000001520260613200175405ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/.github/ISSUE_TEMPLATE/000077500000000000000000000000001520260613200217235ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000055741520260613200246310ustar00rootroot00000000000000name: 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-ed5b409/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000003051520260613200237110ustar00rootroot00000000000000blank_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-ed5b409/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000006121520260613200256500ustar00rootroot00000000000000name: 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-ed5b409/.github/workflows/000077500000000000000000000000001520260613200215755ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/.github/workflows/build.yaml000066400000000000000000000010151520260613200235550ustar00rootroot00000000000000name: Check Build on: push: branches: [main] pull_request: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: filter: blob:none fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Build package run: uv build - name: Check package run: uvx twine check --strict dist/*.whl scverse-scverse-misc-ed5b409/.github/workflows/release.yaml000066400000000000000000000016701520260613200241050ustar00rootroot00000000000000name: Release on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+**" workflow_dispatch: # Use "trusted publishing", see https://docs.pypi.org/trusted-publishers/ 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: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v6 with: filter: blob:none fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 - 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@release/v1 scverse-scverse-misc-ed5b409/.github/workflows/test.yaml000066400000000000000000000067341520260613200234520ustar00rootroot00000000000000name: 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 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@v6 with: filter: blob:none fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v8.1.0 - 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 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@v6 with: filter: blob:none fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.env.python }} - name: create hatch environment run: uvx hatch env create ${{ matrix.env.name }} - 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/en/latest/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@v5 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@release/v1 with: jobs: ${{ toJSON(needs) }} scverse-scverse-misc-ed5b409/.gitignore000066400000000000000000000004151520260613200201700ustar00rootroot00000000000000# 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/ scverse-scverse-misc-ed5b409/.pre-commit-config.yaml000066400000000000000000000026441520260613200224670ustar00rootroot00000000000000fail_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: v2.4.15 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: v2.21.2 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.12 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: 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: v2.0.0 hooks: - id: mypy args: [] additional_dependencies: - pydantic-settings - pydocstring-rs - pytest - sphinx - sphinx-autodoc-typehints - sphinxcontrib-katex scverse-scverse-misc-ed5b409/.readthedocs.yaml000066400000000000000000000005571520260613200214360ustar00rootroot00000000000000# https://docs.readthedocs.io/en/stable/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-ed5b409/CHANGELOG.md000066400000000000000000000040561520260613200200160ustar00rootroot00000000000000# 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/en/1.1.0/ [semantic versioning]: https://semver.org/spec/v2.0.0.html ## [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.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-ed5b409/LICENSE000066400000000000000000000027551520260613200172160ustar00rootroot00000000000000BSD 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-ed5b409/README.md000066400000000000000000000033551520260613200174650ustar00rootroot00000000000000# 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://img.shields.io/readthedocs/scverse-misc 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-ed5b409/biome.jsonc000066400000000000000000000010231520260613200203250ustar00rootroot00000000000000{ "$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-ed5b409/docs/000077500000000000000000000000001520260613200171305ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/Makefile000066400000000000000000000011721520260613200205710ustar00rootroot00000000000000# 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-ed5b409/docs/_static/000077500000000000000000000000001520260613200205565ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_static/.gitkeep000066400000000000000000000000001520260613200221750ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_static/css/000077500000000000000000000000001520260613200213465ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_static/css/custom.css000066400000000000000000000002451520260613200233730ustar00rootroot00000000000000/* 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; } scverse-scverse-misc-ed5b409/docs/_templates/000077500000000000000000000000001520260613200212655ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_templates/.gitkeep000066400000000000000000000000001520260613200227040ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_templates/autosummary/000077500000000000000000000000001520260613200236535ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/_templates/autosummary/class.rst000066400000000000000000000017671520260613200255250ustar00rootroot00000000000000{{ fullname | escape | underline}} .. currentmodule:: {{ module }} .. add toctree option to make autodoc generate the pages .. autoclass:: {{ objname }} {% block attributes %} {% if attributes %} Attributes table ~~~~~~~~~~~~~~~~ .. autosummary:: {% for item in attributes %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} {% block methods %} {% if methods %} Methods table ~~~~~~~~~~~~~ .. autosummary:: {% for item in methods %} {%- if item != '__init__' %} ~{{ name }}.{{ item }} {%- endif -%} {%- endfor %} {% endif %} {% endblock %} {% block attributes_documentation %} {% if attributes %} Attributes ~~~~~~~~~~ {% for item in attributes %} .. autoattribute:: {{ [objname, item] | join(".") }} {%- endfor %} {% endif %} {% endblock %} {% block methods_documentation %} {% if methods %} Methods ~~~~~~~ {% for item in methods %} {%- if item != '__init__' %} .. automethod:: {{ [objname, item] | join(".") }} {%- endif -%} {%- endfor %} {% endif %} {% endblock %} scverse-scverse-misc-ed5b409/docs/api.md000066400000000000000000000012561520260613200202270ustar00rootroot00000000000000# API ```{eval-rst} .. currentmodule:: scverse_misc .. toctree:: ``` ## Extensions ```{eval-rst} .. autosummary:: :toctree: generated make_register_namespace_decorator ``` Types used by the former: ```{eval-rst} .. autosummary:: :toctree: generated ExtensionNamespace ``` ## Deprecations ```{eval-rst} .. autosummary:: :toctree: generated deprecated deprecated_arg Deprecation ``` ## Settings ```{eval-rst} .. toctree:: :hidden: api/settings +---------------------------+----------------------------------+ | :class:`Settings` () | Base class for package settings. | +---------------------------+----------------------------------+ ``` scverse-scverse-misc-ed5b409/docs/api/000077500000000000000000000000001520260613200177015ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/api/settings.rst000066400000000000000000000002401520260613200222670ustar00rootroot00000000000000scverse\_misc.Settings ====================== .. currentmodule:: scverse_misc .. autoclass:: Settings .. automethod:: override .. automethod:: reset scverse-scverse-misc-ed5b409/docs/changelog.md000066400000000000000000000000421520260613200213750ustar00rootroot00000000000000```{include} ../CHANGELOG.md ``` scverse-scverse-misc-ed5b409/docs/conf.py000066400000000000000000000104061520260613200204300ustar00rootroot00000000000000# 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/en/master/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")) # -- 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_design", "IPython.sphinxext.ipython_console_highlighting", "sphinxext.opengraph", *[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), "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", "igraph.Graph"), ] scverse-scverse-misc-ed5b409/docs/contributing.md000066400000000000000000000277011520260613200221700ustar00rootroot00000000000000# 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.readthedocs.io/en/latest/dev/index.html :::{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] ``` When using an IDE such as VS Code, you’ll have to point the editor at the paths to the virtual environments manually. The environment you typically want to use as your main development environment is the `hatch-test` environment with the latest Python version. 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.11-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. 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 ``` In case you are using VScode, now open the command palette (Ctrl+Shift+P) and search for `Python: Select Interpreter`. Choose `Enter Interpreter Path` and paste the path to the virtual environment from above. In this future, this may become easier through a hatch vscode extension. :::: ::::{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/ [uv]: https://docs.astral.sh/uv/ ## Code-style This package uses [pre-commit][] to enforce consistent code-styles. On every commit, pre-commit checks will either automatically fix issues with the code, or raise an error message. To enable pre-commit locally, simply run ```bash pre-commit install ``` in the root of the repository. Pre-commit 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 `pre-commit` 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 pre-commit locally first to understand its 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/ [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. Just point yours to one of the environments returned by ```bash hatch env create hatch-test # create test environments for all supported versions hatch env find hatch-test # list all possible test environment paths ``` 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/en/master/ [myst]: https://myst-parser.readthedocs.io/en/latest/intro.html [myst-nb]: https://myst-nb.readthedocs.io/en/latest/ [numpydoc-napoleon]: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html [google-python-style]: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings [numpydoc]: https://numpydoc.readthedocs.io/en/latest/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-ed5b409/docs/extensions/000077500000000000000000000000001520260613200213275ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/docs/extensions/typed_returns.py000066400000000000000000000022731520260613200246140ustar00rootroot00000000000000# 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-ed5b409/docs/index.md000066400000000000000000000001771520260613200205660ustar00rootroot00000000000000```{include} ../README.md ``` ```{toctree} :hidden: true :maxdepth: 1 api.md changelog.md contributing.md references.md ``` scverse-scverse-misc-ed5b409/docs/references.bib000066400000000000000000000020261520260613200217270ustar00rootroot00000000000000@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-ed5b409/docs/references.md000066400000000000000000000000541520260613200215720ustar00rootroot00000000000000# References ```{bibliography} :cited: ``` scverse-scverse-misc-ed5b409/pyproject.toml000066400000000000000000000114171520260613200211200ustar00rootroot00000000000000[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.settings = [ "pydantic-settings", "python-dotenv" ] optional-dependencies.sphinx = [ "pydocstring-rs>=0.1.13" ] # 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 = [ "pre-commit", "twine>=4.0.2", ] test = [ "coverage>=7.10", "numpy", "pytest", "scverse-misc[settings,sphinx]", "sphinx", "sphinx-autodoc-typehints" ] doc = [ "ipykernel", "ipython", "myst-nb>=1.1", "pandas", "scverse-misc[settings]", "sphinx>=8.1", "sphinx-autodoc-typehints", "sphinx-book-theme>=1", "sphinx-copybutton", "sphinx-design", "sphinxcontrib-bibtex>=1", "sphinxcontrib-katex", "sphinxext-opengraph", ] [tool.hatch] envs.default.installer = "uv" envs.default.dependency-groups = [ "dev" ] envs.docs.dependency-groups = [ "doc" ] envs.docs.scripts.build = "sphinx-build -M html docs docs/_build {args}" envs.docs.scripts.open = "python -m webbrowser -t docs/_build/html/index.html" envs.docs.scripts.clean = "git clean -fdX -- {args:docs}" envs.hatch-test.dependency-groups = [ "dev", "test" ] envs.hatch-test.matrix = [ # Test the lowest and highest supported Python versions with normal deps { deps = [ "stable" ], python = [ "3.12", "3.14" ] }, # Test the newest supported Python version also with pre-release deps { deps = [ "pre" ], python = [ "3.14" ] }, ] # 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 = [ { key = "UV_PRERELEASE", value = "allow", if = [ "pre" ] }, ] version.source = "vcs" [tool.ruff] line-length = 120 src = [ "src" ] extend-include = [ "*.ipynb" ] format.docstring-code-format = true lint.select = [ "B", # flake8-bugbear "BLE", # flake8-blind-except "C4", # flake8-comprehensions "D", # pydocstyle "E", # Error detected by Pycodestyle "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."*/__init__.py" = [ "F401" ] lint.per-file-ignores."docs/*" = [ "I" ] lint.per-file-ignores."tests/*" = [ "D" ] lint.pydocstyle.convention = "google" [tool.mypy] strict = true explicit_package_bases = true mypy_path = [ "$MYPY_CONFIG_FILE_DIR/stubs", "$MYPY_CONFIG_FILE_DIR/src" ] [tool.pytest] strict = true testpaths = [ "tests" ] addopts = [ "--import-mode=importlib", # allow using test files with same name ] [tool.coverage] run.omit = [ "**/test_*.py", ] run.patch = [ "subprocess" ] run.source = [ "scverse_misc" ] [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-ed5b409/src/000077500000000000000000000000001520260613200167675ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/src/scverse_misc/000077500000000000000000000000001520260613200214545ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/src/scverse_misc/__init__.py000066400000000000000000000006101520260613200235620ustar00rootroot00000000000000from contextlib import suppress 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"] with suppress(ImportError): from ._settings import Settings __all__.append("Settings") scverse-scverse-misc-ed5b409/src/scverse_misc/_deprecated.py000066400000000000000000000140441520260613200242700ustar00rootroot00000000000000from __future__ import annotations import inspect import sys from contextlib import suppress from functools import wraps from textwrap import indent from typing import TYPE_CHECKING, LiteralString from warnings import warn if sys.version_info >= (3, 13): from warnings import deprecated as _deprecated else: from typing_extensions import deprecated as _deprecated if TYPE_CHECKING: from collections.abc import Callable __all__ = ["deprecated", "deprecated_arg", "Deprecation"] 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. """ version_deprecated: LiteralString def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString: # type: ignore[misc] # typing.Intersection doesn’t exist yet 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 def _deprecated_at[F: Callable[..., object]]( msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1 ) -> Callable[[F], F]: """Decorator to indicate that a class, function, or overload is deprecated. Wraps :func:`warnings.deprecated` and additionally modifies the docstring to 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. Examples: >>> @deprecated(Deprecation("0.2", "Use bar() instead.")) ... def foo(baz): ... pass """ def decorate(func: F) -> F: kind = "function" if func.__name__ == func.__qualname__ else "method" warnmsg = f"The {kind} {func.__name__} is deprecated and will be removed in the future" doc = inspect.getdoc(func) docmsg = f".. version-deprecated:: {msg.version_deprecated}" if len(msg): docmsg += f"\n{indent(msg, 3 * ' ')}" warnmsg += f". {msg}" if msg.count("\n") == 0 else f":\n{indent(msg, 4 * ' ')}" else: warnmsg += "." if doc is None: doc = docmsg else: lines = doc.splitlines() body = "\n".join(lines[1:]) doc = f"{lines[0]}\n\n{docmsg}\n{body}" func.__doc__ = doc return _deprecated(warnmsg, category=category, stacklevel=stacklevel)(func) return decorate if TYPE_CHECKING: deprecated = _deprecated else: deprecated = _deprecated_at def deprecated_arg[**P, R]( arg: LiteralString, msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1 ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator to indicate that a function argument is deprecated. Emits a warning when the decorated function is called with the deprecated argument and addtionally modifies the docstring 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: The stack level of the warning. Examples: >>> @deprecated_arg("bar", Deprecation("0.2", "The functionality has moved to the baz() function.")) ... def foo(baz, bar=1): ... pass """ def decorate(func: Callable[P, R]) -> Callable[P, R]: warnmsg = f"The argument {arg} is deprecated and will be removed in the future." if len(msg): warnmsg += f" {msg}" if func.__doc__ is not None: with suppress(ImportError): func.__doc__ = _deprecate_arg_doc(func.__doc__, arg=arg, msg=msg) sig = inspect.signature(func) param = sig.parameters[arg] @wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: if ( param.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and arg in kwargs ): warn(warnmsg, category=category, stacklevel=stacklevel + 1) else: bound = sig.bind(*args, **kwargs) if arg in bound.arguments and bound.arguments[arg] != param.default: warn(warnmsg, category=category, stacklevel=stacklevel + 1) return func(*args, **kwargs) return wrapped return decorate def _deprecate_arg_doc(doc: str, *, arg: str, msg: Deprecation) -> str: from pydocstring import Docstring, Section, SectionKind, Style, emit_google, emit_numpy, parse docmsg = f".. version-deprecated:: {msg.version_deprecated}" if len(msg): docmsg += f"\n {msg}" parsed = parse(doc) if parsed.style is Style.PLAIN: return doc model = parsed.to_model() if found := next( ( (s, section, p, par) for s, section in enumerate(model.sections) if section.kind in {SectionKind.PARAMETERS, SectionKind.KEYWORD_PARAMETERS, SectionKind.OTHER_PARAMETERS} for p, par in enumerate(section.parameters) if arg in par.names ), None, ): s, section, p, par = found if par.description is not None: docmsg += f"\n\n{par.description}" par.description = docmsg params = list(section.parameters) params[p] = par sections = list(model.sections) sections[s] = Section(section.kind, parameters=params) model = Docstring( summary=model.summary, extended_summary=model.extended_summary, deprecation=model.deprecation, sections=sections, ) match parsed.style: case Style.GOOGLE: return emit_google(model) case Style.NUMPY: return emit_numpy(model) case _: # pragma: no cover raise AssertionError scverse-scverse-misc-ed5b409/src/scverse_misc/_extensions.py000066400000000000000000000264441520260613200243760ustar00rootroot00000000000000"""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, Literal, Protocol, get_type_hints, overload, runtime_checkable 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. """ 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: raise TypeError(f"Namespace initializer must accept a {cls.__name__} instance as the second parameter.") # Get the second parameter (expected to be `canonical_instance_name`) [_, param, *_] = params.values() if param.annotation is inspect.Parameter.empty: raise AttributeError( f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got empty annotation." ) 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: raise NameError( f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got '{param.name}'." ) from e type_ok = resolved_type is cls match (name_ok, type_ok): case (True, True): return # Signature is correct. case (False, True): raise TypeError( f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}." ) case (True, False): type_repr = getattr(resolved_type, "__name__", str(resolved_type)) raise TypeError( f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got {type_repr!r}." ) case _: type_repr = getattr(resolved_type, "__name__", str(resolved_type)) raise TypeError( 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}." ) 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: raise AttributeError(f"cannot override reserved attribute {name!r}") 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 ) def make_register_namespace_decorator[NameSpT: ExtensionNamespace]( cls: type, canonical_instance_name: str, decorator_name: str, docstring_style: Literal["google", "numpy", "scverse"] ) -> 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. The return decorator will have a docstring describing how to use it 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: The name under which the decorator is accessible in your package. This is used for the examples in the decorator docstring. docstring_style: Whether the docstring of the generated decorator should conform to `"numpy"` or `"google"` style. We also support variant of `"numpy"` called `"scverse"`, which does not duplicate type annotation in docstrings. """ # 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) decorator_arg_description = f"""Name under which the accessor should be registered. This will be the attribute name used to access your namespace's functionality on {cls.__name__} objects (e.g., `instance.name`). Cannot conflict with existing {cls.__name__} attributes. The list of reserved attributes includes everything outputted by `dir({cls.__name__})`.""" decorator_return_description = "A decorator that registers the decorated class as a custom namespace." decorator_notes = f"""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:`~{cls.__module__}.{cls.__name__}`. 2. The namespace will be initialized with the {cls.__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 {cls.__name__} attribute, an AttributeError is raised.""" decorator_examples = f""">>> @{decorator_name}("do_something") ... class DoSomething: ... def __init__(self, {canonical_instance_name}: {cls.__name__}): ... self._obj = {canonical_instance_name} ... ... def has_foo(self) -> bool: ... return hasattr(self._obj, "foo") >>> >>> # Create a {cls.__name__} object >>> obj = {cls.__name__}() >>> >>> # use the registered namespace >>> obj.do_something.has_foo() False""" decorator.__doc__ = f"""Decorator for registering custom functionality with a :class:`~{cls.__module__}.{cls.__name__}` object. This decorator allows you to extend {cls.__name__} objects with custom methods and properties organized under a namespace. The namespace becomes accessible as an attribute on {cls.__name__} instances, providing a clean way to you to add domain-specific functionality without modifying the {cls.__name__} class itself, or extending the class with additional methods as you see fit in your workflow. """ if docstring_style == "google": decorator.__doc__ += f""" Args: name: {_indent_string_lines(decorator_arg_description, 3, 1)} Returns: {_indent_string_lines(decorator_return_description, 2, 1)} Notes: {_indent_string_lines(decorator_notes, 2, 1)} Examples: {_indent_string_lines(decorator_examples, 2, 1)} """ else: decorator.__doc__ += f""" Parameters ---------- name {_indent_string_lines(decorator_arg_description, 2, 1)} Returns ------- {_indent_string_lines(decorator_return_description, 1, 1)} Notes ----- {_indent_string_lines(decorator_notes, 1, 1)} Examples -------- {_indent_string_lines(decorator_examples, 1, 1)} """ return decorator scverse-scverse-misc-ed5b409/src/scverse_misc/_settings.py000066400000000000000000000252741520260613200240370ustar00rootroot00000000000000from __future__ import annotations import inspect import sys import textwrap import warnings from collections.abc import Generator from contextlib import AbstractContextManager, contextmanager from types import FunctionType, GenericAlias from typing import Literal, LiteralString, Self import dotenv from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict from ._utils import copy_func 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__}" _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:`~{package}.{name}.override` (local) or set the attributes directly (global) i.e., `{package}.{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}`. """ 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` method should be added to the package documentation. 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", docstring_style="numpy"): ... 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() ''' @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 @staticmethod def _get_packagename(subcls: type[Settings]) -> str: package_name = subcls.__module__ dotidx = package_name.find(".") if dotidx > -1: package_name = package_name[:dotidx] return package_name def __init_subclass__(subcls, *, exported_object_name: str, docstring_style: Literal["google", "numpy", "scverse"]): if (config := subcls.__dict__.get("model_config")) is not None: if not config.get("validate_assignment", True): warnings.warn("`validate_assignment=False` is not supported, overriding.", RuntimeWarning, stacklevel=2) if not config.get("use_attribute_docstrings", True): warnings.warn( "`use_attribute_docstrings=False` is not supported, overriding.", RuntimeWarning, stacklevel=2 ) if config.get("env_file") is not None: warnings.warn( "Setting a custom env_file location is not supported, overriding.", RuntimeWarning, stacklevel=2 ) else: config = SettingsConfigDict() config["validate_assignment"] = True config["use_attribute_docstrings"] = True config["env_file"] = dotenv.find_dotenv() if not config.get("env_prefix"): config["env_prefix"] = f"{__class__._get_packagename(subcls)}_" # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177 subcls.model_config = config super().__init_subclass__() @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() @classmethod def __pydantic_init_subclass__( # type: ignore[override] subcls: type[Self], *, exported_object_name: str, docstring_style: Literal["google", "numpy", "scverse"] ) -> None: subcls.__doc__ = ( _docstring_template.format( package=__class__._get_packagename(subcls), # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177 name=exported_object_name, env_prefix=subcls.model_config["env_prefix"].upper(), ) + "\n\nThe following options are available:\n" ) override_doc = "Provides local override via keyword arguments as a context manager.\n\n" if docstring_style == "google": override_doc += "Args:\n" else: override_doc += "Parameters\n----------\n" for fname, field in subcls.model_fields.items(): subcls.__doc__ += f""" .. attribute:: {exported_object_name}.{fname} :type: {_type_str(subcls, field)}\n""" if field.default is not PydanticUndefined: subcls.__doc__ += f" :value: {field.default!r}\n" description = "" if field.description is not None: subcls.__doc__ += f"\n{textwrap.indent(field.description, ' ')}\n" description += field.description if docstring_style == "google": override_doc += ( f""" {fname} ({_type_str(subcls, field)}): {textwrap.indent(description, " ")}\n""" ) else: annot = "" if docstring_style == "scverse" else f" : {_type_str(subcls, field)}" override_doc += f"""\ {fname}{annot} {textwrap.indent(description, " ")}\n""" subcls.override = _copy_override( # type: ignore[method-assign,type-var] subcls, subcls.override, override_doc, return_annotation=AbstractContextManager[None] ) subcls.reset = _copy_reset(subcls, subcls.reset) # type: ignore[method-assign,type-var] class CustomRepr(str): def __repr__(self) -> str: return self def _copy_override[F: FunctionType](cls: type[Settings], func: F, doc: str, return_annotation: object) -> 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() ], ] overrides = Overrides( __doc__=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 args_t = Literal[tuple(cls.model_fields.keys())] # type: ignore[valid-type] parameters = [ inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY), inspect.Parameter("args", inspect.Parameter.VAR_POSITIONAL, annotation=args_t), ] return_annotation = AbstractContextManager[frozenset[args_t]] # type: ignore[valid-type] overrides = Overrides( __module__=cls.__module__, __qualname__=f"{cls.__qualname__}.{func.__name__}", __signature__=inspect.Signature(parameters, return_annotation=return_annotation), __annotations__={"args": args_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-ed5b409/src/scverse_misc/_utils.py000066400000000000000000000025461520260613200233340ustar00rootroot00000000000000import functools import inspect import sys from collections.abc import Callable, Mapping from functools import WRAPPER_ASSIGNMENTS from types import FunctionType from typing import ParamSpec, TypedDict, TypeVar, TypeVarTuple, Unpack, cast class _BaseOverrides(TypedDict, total=False): __module__: str __name__: str __qualname__: str __doc__: str __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) scverse-scverse-misc-ed5b409/stubs/000077500000000000000000000000001520260613200173405ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/000077500000000000000000000000001520260613200222325ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/__init__.pyi000066400000000000000000000001271520260613200245140ustar00rootroot00000000000000# mypy doesn’t understand namespace packages apparently from . import katex as katex scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/katex.pyi000066400000000000000000000003331520260613200240700ustar00rootroot00000000000000STARTUP_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-ed5b409/tests/000077500000000000000000000000001520260613200173425ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/tests/conftest.py000066400000000000000000000000001520260613200215270ustar00rootroot00000000000000scverse-scverse-misc-ed5b409/tests/test_deprecation_decorator.py000066400000000000000000000116101520260613200253110ustar00rootroot00000000000000import inspect import warnings from collections.abc import Callable from typing import Literal, cast, get_args import pytest from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined] from scverse_misc import Deprecation, deprecated, deprecated_arg @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) type DocstringStyles = Literal["no_docstring", "short", "long_numpystyle", "long_googlestyle"] @pytest.fixture(params=get_args(DocstringStyles.__value__)) def docstring_style(request: pytest.FixtureRequest) -> DocstringStyles: return cast(DocstringStyles, request.param) @pytest.fixture def docstring(docstring_style: DocstringStyles) -> str | None: match docstring_style: case "no_docstring": return None case "short": return "Test function" case "long_numpystyle": 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 """ case "long_googlestyle": 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 """ @pytest.fixture def func(msg: str | None, 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) def test_deprecation_decorator(deprecated_func: Callable[..., int], docstring: str | None, msg: str | None) -> None: with pytest.warns(FutureWarning, match="deprecated"): assert deprecated_func(1, 2) == 42 assert deprecated_func.__doc__ is not None lines = deprecated_func.__doc__.expandtabs().splitlines() offset = 0 if docstring is None else 2 if docstring is not None: lines_orig = docstring.expandtabs().splitlines() assert lines[0] == lines_orig[0] assert len(lines[1].strip()) == 0, "expected empty line following summary" 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 @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, docstring_style: DocstringStyles ) -> 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 parser: type[NumpyDocstring] | type[GoogleDocstring] | None = None if docstring_style == "long_numpystyle": parser = NumpyDocstring elif docstring_style == "long_googlestyle": parser = GoogleDocstring if parser is None: return lines = parser(inspect.getdoc(deprecated_func) or "").lines() for i, line in enumerate(lines): if line.startswith(prefix := f":param {arg}: "): prefixlen = len(prefix) if msg is not None: stripped = lines[i + 1].strip() assert stripped == ".. version-deprecated:: 2.718" assert lines[i + 2][prefixlen:] == f" {msg}" assert not lines[i + 3] assert lines[i + 4][:prefixlen] == " " * prefixlen else: assert line == f":param {arg}: .. version-deprecated:: 2.718" assert not lines[i + 1] assert lines[i + 2][:prefixlen] == " " * prefixlen scverse-scverse-misc-ed5b409/tests/test_extensions.py000066400000000000000000000161011520260613200231510ustar00rootroot00000000000000from __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", "register_dummy_namespace", docstring_style="google" ) @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): 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] 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-ed5b409/tests/test_settings.py000066400000000000000000000223751520260613200226240ustar00rootroot00000000000000from __future__ import annotations import inspect import sys from contextlib import nullcontext from pathlib import Path from typing import TYPE_CHECKING, Annotated, Literal, cast, get_args import pytest from pydantic import Field, ValidationError from pydantic.fields import FieldInfo from pydantic_settings import SettingsConfigDict from sphinx.application import Sphinx from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined] from scverse_misc import Settings if TYPE_CHECKING: # Static version of the class returned by the `settings_class` fixture class DummySettings(Settings, exported_object_name="settings"): field_bool: bool = False field_no_docstring: int = 42 field_int_range: int = 1 pytest_plugins = ["sphinx.testing.fixtures"] @pytest.fixture def docstring_style(request: pytest.FixtureRequest) -> Literal["google", "numpy", "scverse"]: return getattr(request, "param", "google") @pytest.fixture def settings_class(docstring_style: Literal["google", "numpy", "scverse"]) -> type[DummySettings]: class _DummySettings(Settings, exported_object_name="settings", docstring_style=docstring_style): 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.""" return cast("type[DummySettings]", _DummySettings) @pytest.fixture def settings(settings_class: type[DummySettings]) -> DummySettings: return settings_class() 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"), ): class WarnSettings(Settings, exported_object_name="settings", docstring_style="google"): model_config = SettingsConfigDict( validate_assignment=False, use_attribute_docstrings=False, env_file="mydotenv" ) 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, settings_class: type[DummySettings], v: int) -> None: """Test that the env var prefix is derived from the module name.""" monkeypatch.setenv("TESTS_FIELD_INT_RANGE", str(v)) settings = settings_class() assert settings.field_int_range == v def test_validate_assignment(settings: DummySettings) -> None: with pytest.raises(ValidationError): settings.field_bool = 2 # type: ignore[assignment] with pytest.raises(ValidationError): settings.field_int_range = -1 def test_override(settings: DummySettings) -> None: with settings.override(field_bool=True): assert settings.field_bool is True assert settings.field_bool is False def test_override_error(settings: DummySettings) -> None: with pytest.raises(ValidationError): with settings.override(field_int_range=3, field_no_docstring=1.1): pass assert settings.field_no_docstring == 42 assert settings.field_int_range == 1 @pytest.mark.parametrize("temp", [True, False], ids=["temporary", "permanent"]) def test_reset(settings: DummySettings, temp: bool) -> None: 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(settings: DummySettings) -> None: sig = inspect.signature(settings.reset) assert get_args(sig.parameters["args"].annotation) == ("field_bool", "field_no_docstring", "field_int_range") @pytest.mark.skipif(sys.version_info < (3, 14), reason="requires annotationlib") def test_reset_annotations(settings: DummySettings) -> None: from contextlib import AbstractContextManager import annotationlib assert annotationlib.get_annotations(settings.reset) == { "args": Literal["field_bool", "field_no_docstring", "field_int_range"], "return": AbstractContextManager[frozenset[Literal["field_bool", "field_no_docstring", "field_int_range"]]], } @pytest.mark.parametrize("docstring_style", ["google", "numpy", "scverse"], indirect=True) def test_docs(docstring_style: Literal["google", "numpy"], settings: DummySettings) -> None: parser = GoogleDocstring if docstring_style == "google" else NumpyDocstring lines = parser(inspect.getdoc(settings) or "").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 @pytest.mark.parametrize("docstring_style", ["google", "numpy", "scverse"], indirect=True) def test_override_docs(docstring_style: Literal["google", "numpy"], settings: DummySettings) -> None: parser = GoogleDocstring if docstring_style == "google" else NumpyDocstring lines = parser(inspect.getdoc(settings.override) or "").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 "" # 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 assert line == f":type {current_field_name}: {current_field.annotation.__name__}" @pytest.mark.parametrize( ("attr", "expected"), [ pytest.param("string", "str", id="builtin"), pytest.param("path", "pathlib.Path", id="3rd-party"), # same module as `S`, so no leading `tests.test_settings.` pytest.param("local", "test_annotation_format..Local", id="same-module"), ], ) def test_annotation_format(attr: str, expected: str) -> None: """Test that annotation references work correctly.""" class Local: ... class S(Settings, exported_object_name="s", docstring_style="google"): if attr == "string": string: str if attr == "path": path: Path if attr == "local": local: Local lines = (inspect.getdoc(S) or "").splitlines() lines = lines[lines.index(f".. attribute:: s.{attr}") + 1 :] assert lines == [f" :type: {expected}"] @pytest.fixture(scope="session", autouse=True) def _sphinx_config(sphinx_test_tempdir: Path) -> None: """Since we only need one, we use this instead of static roots like `@pytest.mark.sphinx('html', testroot="mybook")`.""" p = sphinx_test_tempdir / "root" / "conf.py" p.parent.mkdir(parents=True) p.write_text(""" extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_autodoc_typehints"] typehints_defaults = "braces" """) @pytest.mark.parametrize("docstring_style", ["scverse"]) @pytest.mark.parametrize("parent", ["class", "object"]) def test_sphinx_autodoc_typehints( subtests: pytest.Subtests, app: Sphinx, settings_class: type[DummySettings], settings: DummySettings, parent: Literal["class", "object"], ) -> None: import sphinx_autodoc_typehints from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined] obj = (settings if parent == "object" else settings_class).override lines = (inspect.getdoc(obj) or "").splitlines() lines = NumpyDocstring(lines, app.config, app, "method", "", obj).lines() with subtests.test("napoleon"): # test that napoleon can parse things correctly # especially the last parameter could fail to parse if there are not enough trailing newlines for name in settings_class.model_fields: assert f":param {name}:" in "\n".join(lines) sphinx_autodoc_typehints.process_docstring(app, "method", "", obj, options=None, lines=lines) with subtests.test("type"): # no need to test all parameters assert ( r":type field_bool: :sphinx_autodoc_typehints_type:`\:py\:class\:\`bool\`` (default: ````)" in lines ) with subtests.test("rtype"): assert ( r":rtype: :sphinx_autodoc_typehints_type:`\:py\:class\:\`\~contextlib.AbstractContextManager\`\\ \\\[\:py\:obj\:\`None\`\]`" in lines )