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