pax_global_header 0000666 0000000 0000000 00000000064 15202606132 0014507 g ustar 00root root 0000000 0000000 52 comment=ed5b4091a2514399b114ed7deb5d221b224ab91c
scverse-scverse-misc-ed5b409/ 0000775 0000000 0000000 00000000000 15202606132 0016200 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/.codecov.yaml 0000664 0000000 0000000 00000000441 15202606132 0020563 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-ed5b409/.cruft.json 0000664 0000000 0000000 00000002443 15202606132 0020277 0 ustar 00root root 0000000 0000000 {
"template": "https://github.com/scverse/cookiecutter-scverse",
"commit": "ade99079c9508247bb14e9aa52af847a991675a5",
"checkout": null,
"context": {
"cookiecutter": {
"project_name": "scverse-misc",
"package_name": "scverse_misc",
"project_description": "Miscellaneous utility code used by scverse packages",
"author_full_name": "Ilia Kats",
"author_email": "i.kats@dkfz.de",
"github_user": "scverse",
"github_repo": "scverse-misc",
"license": "BSD 3-Clause License",
"ide_integration": false,
"_copy_without_render": [
".github/workflows/build.yaml",
".github/workflows/test.yaml",
"docs/_templates/autosummary/**.rst"
],
"_exclude_on_template_update": [
"CHANGELOG.md",
"LICENSE",
"README.md",
"docs/api.md",
"docs/index.md",
"docs/notebooks/example.ipynb",
"docs/references.bib",
"docs/references.md",
"src/**",
"tests/**"
],
"_render_devdocs": false,
"_jinja2_env_vars": {
"lstrip_blocks": true,
"trim_blocks": true
},
"_template": "https://github.com/scverse/cookiecutter-scverse",
"_commit": "ade99079c9508247bb14e9aa52af847a991675a5"
}
},
"directory": null
}
scverse-scverse-misc-ed5b409/.editorconfig 0000664 0000000 0000000 00000000345 15202606132 0020657 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-ed5b409/.github/ 0000775 0000000 0000000 00000000000 15202606132 0017540 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15202606132 0021723 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/.github/ISSUE_TEMPLATE/bug_report.yml 0000664 0000000 0000000 00000005574 15202606132 0024631 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-ed5b409/.github/ISSUE_TEMPLATE/config.yml 0000664 0000000 0000000 00000000305 15202606132 0023711 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-ed5b409/.github/ISSUE_TEMPLATE/feature_request.yml 0000664 0000000 0000000 00000000612 15202606132 0025650 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-ed5b409/.github/workflows/ 0000775 0000000 0000000 00000000000 15202606132 0021575 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/.github/workflows/build.yaml 0000664 0000000 0000000 00000001015 15202606132 0023555 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
jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
filter: blob:none
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build package
run: uv build
- name: Check package
run: uvx twine check --strict dist/*.whl
scverse-scverse-misc-ed5b409/.github/workflows/release.yaml 0000664 0000000 0000000 00000001670 15202606132 0024105 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/
jobs:
release:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/scverse-misc/${{ steps.wheel.outputs.version }}/
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/checkout@v6
with:
filter: blob:none
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build package
run: uv build
- id: wheel
run: |
version="$(uvx wheel-filename dist/*.whl | jq -r .version)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
scverse-scverse-misc-ed5b409/.github/workflows/test.yaml 0000664 0000000 0000000 00000006734 15202606132 0023452 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
jobs:
# Get the test environment from hatch as defined in pyproject.toml.
# This ensures that the pyproject.toml is the single point of truth for test definitions and the same tests are
# run locally and on continuous integration.
# Check [[tool.hatch.envs.hatch-test.matrix]] in pyproject.toml and https://hatch.pypa.io/latest/environment/ for
# more details.
get-environments:
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.get-envs.outputs.envs }}
steps:
- uses: actions/checkout@v6
with:
filter: blob:none
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
- name: Get test environments
id: get-envs
run: |
ENVS_JSON=$(uvx hatch env show --json | jq -c 'to_entries
| map(
select(.key | startswith("hatch-test"))
| {
name: .key,
label: (if (.key | contains("pre")) then .key + " (PRE-RELEASE DEPENDENCIES)" else .key end),
python: .value.python
}
)')
echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT
# Run tests through hatch. Spawns a separate runner for each environment defined in the hatch matrix obtained above.
test:
needs: get-environments
permissions:
id-token: write # for codecov OIDC
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
env: ${{ fromJSON(needs.get-environments.outputs.envs) }}
name: ${{ matrix.env.label }}
runs-on: ${{ matrix.os }}
continue-on-error: ${{ contains(matrix.env.name, 'pre') }} # make "all-green" pass even if pre-release job fails
steps:
- uses: actions/checkout@v6
with:
filter: blob:none
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
python-version: ${{ matrix.env.python }}
- name: create hatch environment
run: uvx hatch env create ${{ matrix.env.name }}
- name: run tests using hatch
env:
MPLBACKEND: agg
PLATFORM: ${{ matrix.os }}
DISPLAY: :42
run: uvx hatch run ${{ matrix.env.name }}:run-cov -v --color=yes -n auto
- name: generate coverage report
run: |
# See https://coverage.readthedocs.io/en/latest/config.html#run-patch
test -f .coverage || uvx hatch run ${{ matrix.env.name }}:cov-combine
uvx hatch run ${{ matrix.env.name }}:cov-report # report visibly
uvx hatch run ${{ matrix.env.name }}:coverage xml # create report for upload
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
use_oidc: true
# Check that all tests defined above pass. This makes it easy to set a single "required" test in branch
# protection instead of having to update it frequently. See https://github.com/re-actors/alls-green#why.
check:
name: Tests pass in all hatch environments
if: always()
needs:
- get-environments
- test
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
scverse-scverse-misc-ed5b409/.gitignore 0000664 0000000 0000000 00000000415 15202606132 0020170 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/
scverse-scverse-misc-ed5b409/.pre-commit-config.yaml 0000664 0000000 0000000 00000002644 15202606132 0022467 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: v2.4.15
hooks:
- id: biome-format
exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually.
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.21.2
hooks:
- id: pyproject-fmt
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
hooks:
- id: ruff-check
types_or: [python, pyi, jupyter]
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
types_or: [python, pyi, jupyter]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: detect-private-key
- id: check-ast
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
- id: trailing-whitespace
- id: check-case-conflict
# Check that there are no merge conflicts (could be generated by template sync)
- id: check-merge-conflict
args: [--assume-in-merge]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.0.0
hooks:
- id: mypy
args: []
additional_dependencies:
- pydantic-settings
- pydocstring-rs
- pytest
- sphinx
- sphinx-autodoc-typehints
- sphinxcontrib-katex
scverse-scverse-misc-ed5b409/.readthedocs.yaml 0000664 0000000 0000000 00000000557 15202606132 0021436 0 ustar 00root root 0000000 0000000 # https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
build:
os: ubuntu-24.04
tools:
python: "3.14"
nodejs: latest
jobs:
create_environment:
- asdf plugin add uv
- asdf install uv latest
- asdf global uv latest
build:
html:
- uvx hatch run docs:build
- mv docs/_build $READTHEDOCS_OUTPUT
scverse-scverse-misc-ed5b409/CHANGELOG.md 0000664 0000000 0000000 00000004056 15202606132 0020016 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/en/1.1.0/
[semantic versioning]: https://semver.org/spec/v2.0.0.html
## [0.0.7]
### Added
- A `reset` method for `Settings` to reset settings to their default values.
## [0.0.6]
### Added
- A `deprecated_arg` decorator to deprecate function arguments.
## [0.0.5]
### Added
- The `docstring_style` used by scanpy, `"scverse"`, which looks like `"numpy"` but with no parameter types in the docstring.
### Changed
- The `Settings` class and the `make_register_namespace_decorator` function now require passing a `docstring_style` argument.
### Fixed
- The `Settings` docstrings longer have `:value: PydanticUndefined` for fields with no defaults.
- Remove the “default” text from `override` parameters so we don’t imply that `override` resets all settings the user isn’t overriding.
## [0.0.4]
### Added
- A `Settings` base class that packages can inherit from for their settings. This is based
on [Pydantic Settings](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/) and
provides validation for settings values as well as loading settings from environment variables and
`.env` files.
## [0.0.3]
### Added
- A `deprecated` decorator wrapping `warnings.deprecated` that additionally modifies the
docstring to include a deprecation notice.
## [0.0.2]
### Removed
- The Pandas utility functions
## [0.0.1]
- Initial release
[0.0.7]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.7
[0.0.6]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.6
[0.0.5]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.5
[0.0.4]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.4
[0.0.3]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.3
[0.0.2]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.2
[0.0.1]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.1
scverse-scverse-misc-ed5b409/LICENSE 0000664 0000000 0000000 00000002755 15202606132 0017216 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-ed5b409/README.md 0000664 0000000 0000000 00000003355 15202606132 0017465 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://img.shields.io/readthedocs/scverse-misc
Miscellaneous utility code used by scverse packages
## Getting started
Please refer to the [documentation][],
in particular, the [API documentation][].
## Installation
You need to have Python 3.11 or newer installed on your system.
If you don't have Python installed, we recommend installing [uv][].
There are several alternative options to install scverse-misc:
1. Install the latest release of `scverse-misc` from [PyPI][]:
```bash
pip install scverse-misc
```
2. Install the latest development version:
```bash
pip install git+https://github.com/scverse/scverse-misc.git@main
```
## Release notes
See the [changelog][].
## Contact
For questions and help requests, you can reach out in the [scverse discourse][].
If you found a bug, please use the [issue tracker][].
[uv]: https://github.com/astral-sh/uv
[scverse discourse]: https://discourse.scverse.org/
[issue tracker]: https://github.com/scverse/scverse-misc/issues
[tests]: https://github.com/scverse/scverse-misc/actions/workflows/test.yaml
[codecov]: https://codecov.io/gh/scverse/scverse-misc
[documentation]: https://scverse-misc.readthedocs.io
[changelog]: https://scverse-misc.readthedocs.io/page/changelog.html
[api documentation]: https://scverse-misc.readthedocs.io/page/api.html
[pypi]: https://pypi.org/project/scverse-misc
scverse-scverse-misc-ed5b409/biome.jsonc 0000664 0000000 0000000 00000001023 15202606132 0020325 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-ed5b409/docs/ 0000775 0000000 0000000 00000000000 15202606132 0017130 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/Makefile 0000664 0000000 0000000 00000001172 15202606132 0020571 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-ed5b409/docs/_static/ 0000775 0000000 0000000 00000000000 15202606132 0020556 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_static/.gitkeep 0000664 0000000 0000000 00000000000 15202606132 0022175 0 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_static/css/ 0000775 0000000 0000000 00000000000 15202606132 0021346 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_static/css/custom.css 0000664 0000000 0000000 00000000245 15202606132 0023373 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;
}
scverse-scverse-misc-ed5b409/docs/_templates/ 0000775 0000000 0000000 00000000000 15202606132 0021265 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_templates/.gitkeep 0000664 0000000 0000000 00000000000 15202606132 0022704 0 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_templates/autosummary/ 0000775 0000000 0000000 00000000000 15202606132 0023653 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/_templates/autosummary/class.rst 0000664 0000000 0000000 00000001767 15202606132 0025525 0 ustar 00root root 0000000 0000000 {{ fullname | escape | underline}}
.. currentmodule:: {{ module }}
.. add toctree option to make autodoc generate the pages
.. autoclass:: {{ objname }}
{% block attributes %}
{% if attributes %}
Attributes table
~~~~~~~~~~~~~~~~
.. autosummary::
{% for item in attributes %}
~{{ name }}.{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block methods %}
{% if methods %}
Methods table
~~~~~~~~~~~~~
.. autosummary::
{% for item in methods %}
{%- if item != '__init__' %}
~{{ name }}.{{ item }}
{%- endif -%}
{%- endfor %}
{% endif %}
{% endblock %}
{% block attributes_documentation %}
{% if attributes %}
Attributes
~~~~~~~~~~
{% for item in attributes %}
.. autoattribute:: {{ [objname, item] | join(".") }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block methods_documentation %}
{% if methods %}
Methods
~~~~~~~
{% for item in methods %}
{%- if item != '__init__' %}
.. automethod:: {{ [objname, item] | join(".") }}
{%- endif -%}
{%- endfor %}
{% endif %}
{% endblock %}
scverse-scverse-misc-ed5b409/docs/api.md 0000664 0000000 0000000 00000001256 15202606132 0020227 0 ustar 00root root 0000000 0000000 # API
```{eval-rst}
.. currentmodule:: scverse_misc
.. toctree::
```
## Extensions
```{eval-rst}
.. autosummary::
:toctree: generated
make_register_namespace_decorator
```
Types used by the former:
```{eval-rst}
.. autosummary::
:toctree: generated
ExtensionNamespace
```
## Deprecations
```{eval-rst}
.. autosummary::
:toctree: generated
deprecated
deprecated_arg
Deprecation
```
## Settings
```{eval-rst}
.. toctree::
:hidden:
api/settings
+---------------------------+----------------------------------+
| :class:`Settings` () | Base class for package settings. |
+---------------------------+----------------------------------+
```
scverse-scverse-misc-ed5b409/docs/api/ 0000775 0000000 0000000 00000000000 15202606132 0017701 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/api/settings.rst 0000664 0000000 0000000 00000000240 15202606132 0022267 0 ustar 00root root 0000000 0000000 scverse\_misc.Settings
======================
.. currentmodule:: scverse_misc
.. autoclass:: Settings
.. automethod:: override
.. automethod:: reset
scverse-scverse-misc-ed5b409/docs/changelog.md 0000664 0000000 0000000 00000000042 15202606132 0021375 0 ustar 00root root 0000000 0000000 ```{include} ../CHANGELOG.md
```
scverse-scverse-misc-ed5b409/docs/conf.py 0000664 0000000 0000000 00000010406 15202606132 0020430 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/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
import shutil
import sys
from datetime import datetime
from importlib.metadata import metadata
from pathlib import Path
from sphinxcontrib import katex
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE / "extensions"))
# -- Project information -----------------------------------------------------
# NOTE: If you installed your project in editable mode, this might be stale.
# If this is the case, reinstall it to refresh the metadata
info = metadata("scverse-misc")
project = info["Name"]
author = info["Author"]
copyright = f"{datetime.now():%Y}, {author}."
version = info["Version"]
urls = dict(pu.split(", ") for pu in info.get_all("Project-URL") or ())
repository_url = urls["Source"]
# The full version, including alpha/beta/rc tags
release = info["Version"]
bibtex_bibfiles = ["references.bib"]
templates_path = ["_templates"]
nitpicky = True # Warn about broken links
needs_sphinx = "4.0"
html_context = {
"display_github": True, # Integrate GitHub
"github_user": "scverse",
"github_repo": project,
"github_version": "main",
"conf_py_path": "/docs/",
}
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings.
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
"myst_nb",
"sphinx_copybutton",
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.autosummary",
"sphinx.ext.napoleon",
"sphinxcontrib.bibtex",
"sphinxcontrib.katex",
"sphinx_autodoc_typehints",
"sphinx_design",
"IPython.sphinxext.ipython_console_highlighting",
"sphinxext.opengraph",
*[p.stem for p in (HERE / "extensions").glob("*.py")],
]
autosummary_generate = True
autodoc_member_order = "groupwise"
default_role = "literal"
napoleon_google_docstring = True
napoleon_numpy_docstring = False
napoleon_include_init_with_doc = False
napoleon_use_rtype = True # having a separate entry generally helps readability
napoleon_use_param = True
myst_heading_anchors = 6 # create anchors for h1-h6
myst_enable_extensions = [
"amsmath",
"colon_fence",
"deflist",
"dollarmath",
"html_image",
"html_admonition",
]
myst_url_schemes = ("http", "https", "mailto")
nb_output_stderr = "remove"
nb_execution_mode = "off"
nb_merge_streams = True
typehints_defaults = "braces"
always_use_bars_union = True # use `|` instead of `Union` in types even when building with Python ≤3.14
source_suffix = {
".rst": "restructuredtext",
".ipynb": "myst-nb",
".myst": "myst-nb",
}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"scipy": ("https://docs.scipy.org/doc/scipy", None),
"pandas": ("https://pandas.pydata.org/docs/", None),
"scanpy": ("https://scanpy.readthedocs.io/en/stable/", None),
"pydantic": ("https://pydantic.dev/docs/validation/", None),
}
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_book_theme"
html_static_path = ["_static"]
html_css_files = ["css/custom.css"]
html_title = project
html_theme_options = {
"repository_url": repository_url,
"use_repository_button": True,
"path_to_docs": "docs/",
"navigation_with_keys": False,
}
pygments_style = "default"
katex_prerender = shutil.which(katex.NODEJS_BINARY) is not None
nitpick_ignore: list[tuple[str, str]] = [
# If building the documentation fails because of a missing link that is outside your control,
# you can add an exception to this list.
# ("py:class", "igraph.Graph"),
]
scverse-scverse-misc-ed5b409/docs/contributing.md 0000664 0000000 0000000 00000027701 15202606132 0022170 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.readthedocs.io/en/latest/dev/index.html
:::{tip} The *hatch* project manager
We highly recommend to familiarize yourself with [`hatch`][hatch].
Hatch is a Python project manager that
- manages virtual environments, separately for development, testing and building the documentation.
Separating the environments is useful to avoid dependency conflicts.
- allows to run tests locally in different environments (e.g. different python versions)
- allows to run tasks defined in `pyproject.toml`, e.g. to build documentation.
While the project is setup with `hatch` in mind,
it is still possible to use different tools to manage dependencies, such as `uv` or `pip`.
:::
[hatch]: https://hatch.pypa.io/latest/
## Installing dev dependencies
In addition to the packages needed to _use_ this package,
you need additional python packages to [run tests](#writing-tests) and [build the documentation](#docs-building).
:::::{tab-set}
::::{tab-item} Hatch
:sync: hatch
On the command line, you typically interact with hatch through its command line interface (CLI).
Running one of the following commands will automatically resolve the environments for testing and
building the documentation in the background:
```bash
hatch test # defined in the table [tool.hatch.envs.hatch-test] in pyproject.toml
hatch run docs:build # defined in the table [tool.hatch.envs.docs]
```
When using an IDE such as VS Code,
you’ll have to point the editor at the paths to the virtual environments manually.
The environment you typically want to use as your main development environment is the `hatch-test`
environment with the latest Python version.
To get a list of all environments for your projects, run
```bash
hatch env show -i
```
This will list “Standalone” environments and a table of “Matrix” environments like the following:
```
+------------+---------+--------------------------+----------+---------------------------------+-------------+
| Name | Type | Envs | Features | Dependencies | Scripts |
+------------+---------+--------------------------+----------+---------------------------------+-------------+
| hatch-test | virtual | hatch-test.py3.11-stable | dev | coverage-enable-subprocess==1.0 | cov-combine |
| | | hatch-test.py3.14-stable | test | coverage[toml]~=7.4 | cov-report |
| | | hatch-test.py3.14-pre | | pytest-mock~=3.12 | run |
| | | | | pytest-randomly~=3.15 | run-cov |
| | | | | pytest-rerunfailures~=14.0 | |
| | | | | pytest-xdist[psutil]~=3.5 | |
| | | | | pytest~=8.1 | |
+------------+---------+--------------------------+----------+---------------------------------+-------------+
```
From the `Envs` column, select the environment name you want to use for development.
In this example, it would be `hatch-test.py3.14-stable`.
Next, create the environment with
```bash
hatch env create hatch-test.py3.14-stable
```
Then, obtain the path to the environment using
```bash
hatch env find hatch-test.py3.14-stable
```
In case you are using VScode, now open the command palette (Ctrl+Shift+P) and search for `Python: Select Interpreter`.
Choose `Enter Interpreter Path` and paste the path to the virtual environment from above.
In this future, this may become easier through a hatch vscode extension.
::::
::::{tab-item} uv
:sync: uv
A popular choice for managing virtual environments is [uv][].
The main disadvantage compared to hatch is that it supports only a single environment per project at a time,
which requires you to mix the dependencies for running tests and building docs.
This can have undesired side-effects,
such as requiring to install a lower version of a library your project depends on,
only because an outdated sphinx plugin pins an older version.
To initialize a virtual environment in the `.venv` directory of your project, simply run
```bash
uv sync --all-extras
```
The `.venv` directory is typically automatically discovered by IDEs such as VS Code.
::::
::::{tab-item} Pip
:sync: pip
Pip is nowadays mostly superseded by environment manager such as [hatch][].
However, for the sake of completeness, and since it’s ubiquitously available,
we describe how you can manage environments manually using `pip`:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev,test,doc]"
```
The `.venv` directory is typically automatically discovered by IDEs such as VS Code.
::::
:::::
[hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/
[uv]: https://docs.astral.sh/uv/
## Code-style
This package uses [pre-commit][] to enforce consistent code-styles.
On every commit, pre-commit checks will either automatically fix issues with the code, or raise an error message.
To enable pre-commit locally, simply run
```bash
pre-commit install
```
in the root of the repository.
Pre-commit will automatically download all dependencies when it is run for the first time.
Alternatively, you can rely on the [pre-commit.ci][] service enabled on GitHub.
If you didn’t run `pre-commit` before pushing changes to GitHub it will automatically commit fixes to your pull request, or show an error message.
If pre-commit.ci added a commit on a branch you still have been working on locally, simply use
```bash
git pull --rebase
```
to integrate the changes into yours.
While the [pre-commit.ci][] is useful, we strongly encourage installing and running pre-commit locally first to understand its usage.
Finally, most editors have an _autoformat on save_ feature.
Consider enabling this option for [ruff][ruff-editors] and [biome][biome-editors].
[pre-commit]: https://pre-commit.com/
[pre-commit.ci]: https://pre-commit.ci/
[ruff-editors]: https://docs.astral.sh/ruff/integrations/
[biome-editors]: https://biomejs.dev/guides/integrate-in-editor/
(writing-tests)=
## Writing tests
This package uses [pytest][] for automated testing.
Please write {doc}`scanpy:dev/testing` for every function added to the package.
Most IDEs integrate with pytest and provide a GUI to run tests.
Just point yours to one of the environments returned by
```bash
hatch env create hatch-test # create test environments for all supported versions
hatch env find hatch-test # list all possible test environment paths
```
Alternatively, you can run all tests from the command line by executing
:::::{tab-set}
::::{tab-item} Hatch
:sync: hatch
```bash
hatch test # test with the highest supported Python version
# or
hatch test --all # test with all supported Python versions
```
::::
::::{tab-item} uv
:sync: uv
```bash
uv run pytest
```
::::
::::{tab-item} Pip
:sync: pip
```bash
source .venv/bin/activate
pytest
```
::::
:::::
in the root of the repository.
[pytest]: https://docs.pytest.org/
### Continuous integration
Continuous integration via GitHub actions will automatically run the tests on all pull requests and test
against the minimum and maximum supported Python version.
Additionally, there’s a CI job that tests against pre-releases of all dependencies (if there are any).
The purpose of this check is to detect incompatibilities of new package versions early on and
gives you time to fix the issue or reach out to the developers of the dependency before the package
is released to a wider audience.
The CI job is defined in `.github/workflows/test.yaml`,
however the single point of truth for CI jobs is the Hatch test matrix defined in `pyproject.toml`.
This means that local testing via hatch and remote testing on CI tests against the same python versions and uses the same environments.
## Publishing a release
### Updating the version number
Before making a release, you need to update the version number in the `changelog.md` file.
Please adhere to [Semantic Versioning][semver], in brief
> Given a version number MAJOR.MINOR.PATCH, increment the:
>
> 1. MAJOR version when you make incompatible API changes,
> 2. MINOR version when you add functionality in a backwards compatible manner, and
> 3. PATCH version when you make backwards compatible bug fixes.
>
> Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
Once you are done, commit your changes and tag the commit as `vX.X.X`.
Push the tag.
This will automatically create a git tag and trigger a Github workflow that creates a release on [PyPI][].
[semver]: https://semver.org/
[pypi]: https://pypi.org/
## Writing documentation
Please write documentation for new or changed features and use-cases.
This project uses [sphinx][] with the following features:
- The [myst][] extension allows to write documentation in markdown/Markedly Structured Text
- [Google-style docstrings][google-python-style] (through the [napoloen][numpydoc-napoleon] extension).
- Jupyter notebooks as tutorials through [myst-nb][] (See [Tutorials with myst-nb](#tutorials-with-myst-nb-and-jupyter-notebooks))
- [sphinx-autodoc-typehints][], to automatically reference annotated input and output types
- Citations (like {cite:p}`Virshup_2023`) can be included with [sphinxcontrib-bibtex](https://sphinxcontrib-bibtex.readthedocs.io/)
See scanpy’s {doc}`scanpy:dev/documentation` for more information on how to write your own.
[sphinx]: https://www.sphinx-doc.org/en/master/
[myst]: https://myst-parser.readthedocs.io/en/latest/intro.html
[myst-nb]: https://myst-nb.readthedocs.io/en/latest/
[numpydoc-napoleon]: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html
[google-python-style]: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings
[numpydoc]: https://numpydoc.readthedocs.io/en/latest/format.html
[sphinx-autodoc-typehints]: https://github.com/tox-dev/sphinx-autodoc-typehints
### Tutorials with myst-nb and jupyter notebooks
The documentation is set-up to render jupyter notebooks stored in the `docs/notebooks` directory using [myst-nb][].
Currently, only notebooks in `.ipynb` format are supported that will be included with both their input and output cells.
It is your responsibility to update and re-run the notebook whenever necessary.
If you are interested in automatically running notebooks as part of the continuous integration,
please check out [this feature request][issue-render-notebooks] in the `cookiecutter-scverse` repository.
[issue-render-notebooks]: https://github.com/scverse/cookiecutter-scverse/issues/40
#### Hints
- If you refer to objects from other packages, please add an entry to `intersphinx_mapping` in `docs/conf.py`.
Only if you do so can sphinx automatically create a link to the external documentation.
- If building the documentation fails because of a missing link that is outside your control,
you can add an entry to the `nitpick_ignore` list in `docs/conf.py`
(docs-building)=
### Building the docs locally
:::::{tab-set}
::::{tab-item} Hatch
:sync: hatch
```bash
hatch run docs:build
hatch run docs:open
```
::::
::::{tab-item} uv
:sync: uv
```bash
cd docs
uv run sphinx-build -M html . _build -W
(xdg-)open _build/html/index.html
```
::::
::::{tab-item} Pip
:sync: pip
```bash
source .venv/bin/activate
cd docs
sphinx-build -M html . _build -W
(xdg-)open _build/html/index.html
```
::::
:::::
scverse-scverse-misc-ed5b409/docs/extensions/ 0000775 0000000 0000000 00000000000 15202606132 0021327 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/docs/extensions/typed_returns.py 0000664 0000000 0000000 00000002273 15202606132 0024614 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-ed5b409/docs/index.md 0000664 0000000 0000000 00000000177 15202606132 0020566 0 ustar 00root root 0000000 0000000 ```{include} ../README.md
```
```{toctree}
:hidden: true
:maxdepth: 1
api.md
changelog.md
contributing.md
references.md
```
scverse-scverse-misc-ed5b409/docs/references.bib 0000664 0000000 0000000 00000002026 15202606132 0021727 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-ed5b409/docs/references.md 0000664 0000000 0000000 00000000054 15202606132 0021572 0 ustar 00root root 0000000 0000000 # References
```{bibliography}
:cited:
```
scverse-scverse-misc-ed5b409/pyproject.toml 0000664 0000000 0000000 00000011417 15202606132 0021120 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.settings = [ "pydantic-settings", "python-dotenv" ]
optional-dependencies.sphinx = [ "pydocstring-rs>=0.1.13" ]
# https://docs.pypi.org/project_metadata/#project-urls
urls.Documentation = "https://scverse-misc.readthedocs.io/"
urls.Homepage = "https://github.com/scverse/scverse-misc"
urls.Source = "https://github.com/scverse/scverse-misc"
[dependency-groups]
dev = [
"pre-commit",
"twine>=4.0.2",
]
test = [ "coverage>=7.10", "numpy", "pytest", "scverse-misc[settings,sphinx]", "sphinx", "sphinx-autodoc-typehints" ]
doc = [
"ipykernel",
"ipython",
"myst-nb>=1.1",
"pandas",
"scverse-misc[settings]",
"sphinx>=8.1",
"sphinx-autodoc-typehints",
"sphinx-book-theme>=1",
"sphinx-copybutton",
"sphinx-design",
"sphinxcontrib-bibtex>=1",
"sphinxcontrib-katex",
"sphinxext-opengraph",
]
[tool.hatch]
envs.default.installer = "uv"
envs.default.dependency-groups = [ "dev" ]
envs.docs.dependency-groups = [ "doc" ]
envs.docs.scripts.build = "sphinx-build -M html docs docs/_build {args}"
envs.docs.scripts.open = "python -m webbrowser -t docs/_build/html/index.html"
envs.docs.scripts.clean = "git clean -fdX -- {args:docs}"
envs.hatch-test.dependency-groups = [ "dev", "test" ]
envs.hatch-test.matrix = [
# Test the lowest and highest supported Python versions with normal deps
{ deps = [ "stable" ], python = [ "3.12", "3.14" ] },
# Test the newest supported Python version also with pre-release deps
{ deps = [ "pre" ], python = [ "3.14" ] },
]
# If the matrix variable `deps` is set to "pre",
# set the environment variable `UV_PRERELEASE` to "allow".
envs.hatch-test.overrides.matrix.deps.env-vars = [
{ key = "UV_PRERELEASE", value = "allow", if = [ "pre" ] },
]
version.source = "vcs"
[tool.ruff]
line-length = 120
src = [ "src" ]
extend-include = [ "*.ipynb" ]
format.docstring-code-format = true
lint.select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # Error detected by Pycodestyle
"F", # Errors detected by Pyflakes
"I", # isort
"RUF100", # Report unused noqa directives
"TID", # flake8-tidy-imports
"UP", # pyupgrade
"W", # Warning detected by Pycodestyle
]
lint.ignore = [
"B008", # Errors from function calls in argument defaults. These are fine when the result is immutable.
"C408", # `dict()` is sometimes nicer than `{}`
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
"D105", # __magic__ methods are often self-explanatory, allow missing docstrings
"D107", # Missing docstring in __init__
# Disable one in each pair of mutually incompatible rules
"D203", # We don’t want a blank line before a class docstring
"D213", # <> We want docstrings to start immediately after the opening triple quote
"D400", # first line should end with a period [Bug: doesn’t work with single-line docstrings]
"D401", # First line should be in imperative mood; try rephrasing
"E501", # line too long -> we accept long comment lines; formatter gets rid of long code lines
"E731", # Do not assign a lambda expression, use a def -> lambda expression assignments are convenient
"E741", # allow I, O, l as variable names -> I is the identity matrix
"TID252", # allow relative imports
]
lint.per-file-ignores."*/__init__.py" = [ "F401" ]
lint.per-file-ignores."docs/*" = [ "I" ]
lint.per-file-ignores."tests/*" = [ "D" ]
lint.pydocstyle.convention = "google"
[tool.mypy]
strict = true
explicit_package_bases = true
mypy_path = [ "$MYPY_CONFIG_FILE_DIR/stubs", "$MYPY_CONFIG_FILE_DIR/src" ]
[tool.pytest]
strict = true
testpaths = [ "tests" ]
addopts = [
"--import-mode=importlib", # allow using test files with same name
]
[tool.coverage]
run.omit = [
"**/test_*.py",
]
run.patch = [ "subprocess" ]
run.source = [ "scverse_misc" ]
[tool.cruft]
skip = [
".git",
"tests",
"src/**/__init__.py",
"src/**/basic.py",
"docs/api.md",
"docs/changelog.md",
"docs/references.bib",
"docs/references.md",
"docs/notebooks/example.ipynb",
]
scverse-scverse-misc-ed5b409/src/ 0000775 0000000 0000000 00000000000 15202606132 0016767 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/src/scverse_misc/ 0000775 0000000 0000000 00000000000 15202606132 0021454 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/src/scverse_misc/__init__.py 0000664 0000000 0000000 00000000610 15202606132 0023562 0 ustar 00root root 0000000 0000000 from contextlib import suppress
from ._deprecated import Deprecation, deprecated, deprecated_arg
from ._extensions import ExtensionNamespace, make_register_namespace_decorator
__all__ = ["ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "deprecated_arg", "Deprecation"]
with suppress(ImportError):
from ._settings import Settings
__all__.append("Settings")
scverse-scverse-misc-ed5b409/src/scverse_misc/_deprecated.py 0000664 0000000 0000000 00000014044 15202606132 0024270 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import inspect
import sys
from contextlib import suppress
from functools import wraps
from textwrap import indent
from typing import TYPE_CHECKING, LiteralString
from warnings import warn
if sys.version_info >= (3, 13):
from warnings import deprecated as _deprecated
else:
from typing_extensions import deprecated as _deprecated
if TYPE_CHECKING:
from collections.abc import Callable
__all__ = ["deprecated", "deprecated_arg", "Deprecation"]
class Deprecation(str):
"""Utility class storing information on deprecated functionality.
Args:
version_deprecated: The version of the package where the functionality was deprecated.
msg: The deprecation message.
"""
version_deprecated: LiteralString
def __new__(cls, version_deprecated: LiteralString, msg: LiteralString = "") -> LiteralString: # type: ignore[misc] # typing.Intersection doesn’t exist yet
if not msg:
msg = "" # be lenient here, people don’t want to see “None” or “False” here
obj = super().__new__(cls, msg)
obj.version_deprecated = version_deprecated
return obj
def _deprecated_at[F: Callable[..., object]](
msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1
) -> Callable[[F], F]:
"""Decorator to indicate that a class, function, or overload is deprecated.
Wraps :func:`warnings.deprecated` and additionally modifies the docstring to include a deprecation notice.
Args:
msg: The deprecation message.
category: The category of the warning that will be emitted at runtime.
stacklevel: The stack level of the warning.
Examples:
>>> @deprecated(Deprecation("0.2", "Use bar() instead."))
... def foo(baz):
... pass
"""
def decorate(func: F) -> F:
kind = "function" if func.__name__ == func.__qualname__ else "method"
warnmsg = f"The {kind} {func.__name__} is deprecated and will be removed in the future"
doc = inspect.getdoc(func)
docmsg = f".. version-deprecated:: {msg.version_deprecated}"
if len(msg):
docmsg += f"\n{indent(msg, 3 * ' ')}"
warnmsg += f". {msg}" if msg.count("\n") == 0 else f":\n{indent(msg, 4 * ' ')}"
else:
warnmsg += "."
if doc is None:
doc = docmsg
else:
lines = doc.splitlines()
body = "\n".join(lines[1:])
doc = f"{lines[0]}\n\n{docmsg}\n{body}"
func.__doc__ = doc
return _deprecated(warnmsg, category=category, stacklevel=stacklevel)(func)
return decorate
if TYPE_CHECKING:
deprecated = _deprecated
else:
deprecated = _deprecated_at
def deprecated_arg[**P, R](
arg: LiteralString, msg: Deprecation, *, category: type[Warning] = FutureWarning, stacklevel: int = 1
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator to indicate that a function argument is deprecated.
Emits a warning when the decorated function is called with the deprecated argument and addtionally modifies the
docstring to include a deprecation notice.
Args:
arg: The deprecated argument.
msg: The deprecation message.
category: The category of the warning that will be emitted at runtime.
stacklevel: The stack level of the warning.
Examples:
>>> @deprecated_arg("bar", Deprecation("0.2", "The functionality has moved to the baz() function."))
... def foo(baz, bar=1):
... pass
"""
def decorate(func: Callable[P, R]) -> Callable[P, R]:
warnmsg = f"The argument {arg} is deprecated and will be removed in the future."
if len(msg):
warnmsg += f" {msg}"
if func.__doc__ is not None:
with suppress(ImportError):
func.__doc__ = _deprecate_arg_doc(func.__doc__, arg=arg, msg=msg)
sig = inspect.signature(func)
param = sig.parameters[arg]
@wraps(func)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
if (
param.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
and arg in kwargs
):
warn(warnmsg, category=category, stacklevel=stacklevel + 1)
else:
bound = sig.bind(*args, **kwargs)
if arg in bound.arguments and bound.arguments[arg] != param.default:
warn(warnmsg, category=category, stacklevel=stacklevel + 1)
return func(*args, **kwargs)
return wrapped
return decorate
def _deprecate_arg_doc(doc: str, *, arg: str, msg: Deprecation) -> str:
from pydocstring import Docstring, Section, SectionKind, Style, emit_google, emit_numpy, parse
docmsg = f".. version-deprecated:: {msg.version_deprecated}"
if len(msg):
docmsg += f"\n {msg}"
parsed = parse(doc)
if parsed.style is Style.PLAIN:
return doc
model = parsed.to_model()
if found := next(
(
(s, section, p, par)
for s, section in enumerate(model.sections)
if section.kind in {SectionKind.PARAMETERS, SectionKind.KEYWORD_PARAMETERS, SectionKind.OTHER_PARAMETERS}
for p, par in enumerate(section.parameters)
if arg in par.names
),
None,
):
s, section, p, par = found
if par.description is not None:
docmsg += f"\n\n{par.description}"
par.description = docmsg
params = list(section.parameters)
params[p] = par
sections = list(model.sections)
sections[s] = Section(section.kind, parameters=params)
model = Docstring(
summary=model.summary,
extended_summary=model.extended_summary,
deprecation=model.deprecation,
sections=sections,
)
match parsed.style:
case Style.GOOGLE:
return emit_google(model)
case Style.NUMPY:
return emit_numpy(model)
case _: # pragma: no cover
raise AssertionError
scverse-scverse-misc-ed5b409/src/scverse_misc/_extensions.py 0000664 0000000 0000000 00000026444 15202606132 0024376 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, Literal, Protocol, get_type_hints, overload, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Callable, Set
__all__ = ["make_register_namespace_decorator", "ExtensionNamespace"]
@runtime_checkable
class ExtensionNamespace(Protocol):
"""Protocol for extension namespaces.
Enforces that the namespace initializer accepts a class with the proper `__init__` method.
Protocol's can't enforce that the `__init__` accepts the correct types. See
`_check_namespace_signature` for that. This is mainly useful for static type
checking with mypy and IDEs.
"""
def __init__(self, instance: object) -> None:
"""Used to enforce the correct signature for extension namespaces."""
class AccessorNameSpace[T, NameSpT: ExtensionNamespace]:
"""Establish property-like namespace object for user-defined functionality."""
def __init__(self, name: str, namespace: type[NameSpT]) -> None:
self._accessor = name
self._ns = namespace
@overload
def __get__(self, instance: None, cls: type[T]) -> type[NameSpT]: ...
@overload
def __get__(self, instance: T, cls: type[T]) -> NameSpT: ...
def __get__(self, instance: T | None, cls: type[T]) -> NameSpT | type[NameSpT]:
if instance is None:
return self._ns
ns_instance = self._ns(instance)
setattr(instance, self._accessor, ns_instance)
return ns_instance
def _check_namespace_signature(ns_class: type, cls: type, canonical_instance_name: str) -> None:
"""Validate the signature of a namespace class for extensions.
This function ensures that any class intended to be used as an extension namespace
has a properly formatted `__init__` method such that:
1. Accepts at least two parameters (self and the instance of the extended class)
2. Has `canonical_instance_name` as the name of the second parameter
3. Has the second parameter properly type-annotated as `ns_class` or any equivalent import alias
The function performs runtime validation of these requirements before a namespace
can be registered through the `register_namespace` decorator.
Args:
ns_class: The namespace class to validate.
cls: The class that is being extended.
canonical_instance_name: The name of the `ns_class` constructor argument.
Raises:
TypeError: If the `__init__` method has fewer than 2 parameters (missing the instance parameter).
AttributeError: If the second parameter of `__init__` lacks a type annotation.
TypeError: If the second parameter of `__init__` is not named `canonical_instance_name`.
TypeError: If the second parameter of `__init__` is not annotated as the `ns_class` class.
TypeError: If both the name and type annotation of the second parameter are incorrect.
"""
sig = inspect.signature(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236
params = sig.parameters
# Ensure there are at least two parameters (self and mdata)
if len(params) < 2:
raise TypeError(f"Namespace initializer must accept a {cls.__name__} instance as the second parameter.")
# Get the second parameter (expected to be `canonical_instance_name`)
[_, param, *_] = params.values()
if param.annotation is inspect.Parameter.empty:
raise AttributeError(
f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got empty annotation."
)
name_ok = param.name == canonical_instance_name
# Resolve the annotation using get_type_hints to handle forward references and aliases.
try:
type_hints = get_type_hints(ns_class.__init__) # type: ignore[misc] # https://github.com/python/mypy/issues/21236
resolved_type = type_hints.get(param.name, param.annotation)
except NameError as e:
raise NameError(
f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got '{param.name}'."
) from e
type_ok = resolved_type is cls
match (name_ok, type_ok):
case (True, True):
return # Signature is correct.
case (False, True):
raise TypeError(
f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}."
)
case (True, False):
type_repr = getattr(resolved_type, "__name__", str(resolved_type))
raise TypeError(
f"Namespace initializer's second parameter must be annotated as the {cls.__name__!r} class, got {type_repr!r}."
)
case _:
type_repr = getattr(resolved_type, "__name__", str(resolved_type))
raise TypeError(
f"Namespace initializer's second parameter must be named {canonical_instance_name!r}, got {param.name!r}. "
f"And must be annotated as {cls.__name__!r}, got {type_repr!r}."
)
def _create_namespace[NameSpT: ExtensionNamespace](
name: str, cls: type, reserved_namespaces: Set[str], canonical_instance_name: str
) -> Callable[[type[NameSpT]], type[NameSpT]]:
"""Register custom namespace against the underlying class."""
def namespace(ns_class: type[NameSpT]) -> type[NameSpT]:
_check_namespace_signature(ns_class, cls, canonical_instance_name) # Perform the runtime signature check
if name in reserved_namespaces:
raise AttributeError(f"cannot override reserved attribute {name!r}")
elif hasattr(cls, name):
warnings.warn(
f"Overriding existing custom namespace {name!r} (on {cls.__name__!r})", UserWarning, stacklevel=2
)
setattr(cls, name, AccessorNameSpace(name, ns_class))
return ns_class
return namespace
def _indent_string_lines(string: str, indentation_level: int, skip_lines: int = 0) -> str:
minspace = sys.maxsize
for line in islice(string.splitlines(), 1, None):
for i, char in enumerate(line):
if not char.isspace():
minspace = min(minspace, i)
break
if minspace == sys.maxsize: # single-line string
minspace = 0
return "\n".join(
" " * 4 * indentation_level + sline if i >= skip_lines else sline
for i, line in enumerate(string.splitlines())
if (sline := (line[minspace:] if i > 0 else line)) or True
)
def make_register_namespace_decorator[NameSpT: ExtensionNamespace](
cls: type, canonical_instance_name: str, decorator_name: str, docstring_style: Literal["google", "numpy", "scverse"]
) -> Callable[[str], Callable[[type[NameSpT]], type[NameSpT]]]:
"""Create a decorator for registering custom functionality with a class.
The decorator will allow your users to extend `cls` objects with custom methods and properties
organized under a namespace. The namespace becomes accessible as an attribute on `cls` instances,
providing a clean way for users to add domain-specific functionality without modifying the `cls`
class itself.
The return decorator will have a docstring describing how to use it along with examples.
Args:
cls: The class to be made extensible.
canonical_instance_name: The typical name of an instance of `cls`, e.g. `adata` for `AnnData`. This
is used for run-time checking of constructor signatures of the namespace classes.
decorator_name: The name under which the decorator is accessible in your package. This is used for
the examples in the decorator docstring.
docstring_style: Whether the docstring of the generated decorator should conform to `"numpy"` or
`"google"` style. We also support variant of `"numpy"` called `"scverse"`,
which does not duplicate type annotation in docstrings.
"""
# Reserved namespaces include accessors built into cls and all current attributes of cls
reserved_namespaces = set(dir(cls))
def decorator(name: str) -> Callable[[type[NameSpT]], type[NameSpT]]:
return _create_namespace(name, cls, reserved_namespaces, canonical_instance_name)
decorator_arg_description = f"""Name under which the accessor should be registered. This will be the attribute name
used to access your namespace's functionality on {cls.__name__} objects (e.g., `instance.name`).
Cannot conflict with existing {cls.__name__} attributes. The list of reserved attributes includes
everything outputted by `dir({cls.__name__})`."""
decorator_return_description = "A decorator that registers the decorated class as a custom namespace."
decorator_notes = f"""Implementation requirements:
1. The decorated class must have an `__init__` method that accepts exactly one parameter
(besides `self`) named `{canonical_instance_name}` and annotated with type :class:`~{cls.__module__}.{cls.__name__}`.
2. The namespace will be initialized with the {cls.__name__} object on first access and then
cached on the instance.
3. If the namespace name conflicts with an existing namespace, a warning is issued.
4. If the namespace name conflicts with a built-in {cls.__name__} attribute, an AttributeError is raised."""
decorator_examples = f""">>> @{decorator_name}("do_something")
... class DoSomething:
... def __init__(self, {canonical_instance_name}: {cls.__name__}):
... self._obj = {canonical_instance_name}
...
... def has_foo(self) -> bool:
... return hasattr(self._obj, "foo")
>>>
>>> # Create a {cls.__name__} object
>>> obj = {cls.__name__}()
>>>
>>> # use the registered namespace
>>> obj.do_something.has_foo()
False"""
decorator.__doc__ = f"""Decorator for registering custom functionality with a :class:`~{cls.__module__}.{cls.__name__}` object.
This decorator allows you to extend {cls.__name__} objects with custom methods and properties
organized under a namespace. The namespace becomes accessible as an attribute on {cls.__name__}
instances, providing a clean way to you to add domain-specific functionality without modifying
the {cls.__name__} class itself, or extending the class with additional methods as you see fit in your workflow.
"""
if docstring_style == "google":
decorator.__doc__ += f"""
Args:
name: {_indent_string_lines(decorator_arg_description, 3, 1)}
Returns:
{_indent_string_lines(decorator_return_description, 2, 1)}
Notes:
{_indent_string_lines(decorator_notes, 2, 1)}
Examples:
{_indent_string_lines(decorator_examples, 2, 1)}
"""
else:
decorator.__doc__ += f"""
Parameters
----------
name
{_indent_string_lines(decorator_arg_description, 2, 1)}
Returns
-------
{_indent_string_lines(decorator_return_description, 1, 1)}
Notes
-----
{_indent_string_lines(decorator_notes, 1, 1)}
Examples
--------
{_indent_string_lines(decorator_examples, 1, 1)}
"""
return decorator
scverse-scverse-misc-ed5b409/src/scverse_misc/_settings.py 0000664 0000000 0000000 00000025274 15202606132 0024037 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import inspect
import sys
import textwrap
import warnings
from collections.abc import Generator
from contextlib import AbstractContextManager, contextmanager
from types import FunctionType, GenericAlias
from typing import Literal, LiteralString, Self
import dotenv
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
from ._utils import copy_func
def _type_str(cls: type, field: FieldInfo) -> str:
if isinstance(field.annotation, GenericAlias) or not isinstance(field.annotation, type):
return str(field.annotation)
if field.annotation.__module__ in {"builtins", cls.__module__}:
return field.annotation.__qualname__
return f"{field.annotation.__module__}.{field.annotation.__qualname__}"
_docstring_template = """Allows users to customize settings for the `{package}` package.
Settings here will generally be for advanced use-cases and should be used with caution.
For setting an option use :func:`~{package}.{name}.override` (local) or set the attributes directly (global)
i.e., `{package}.{name}.my_setting = foo`. For assignment by environment variable, use the variable name in
all caps with `{env_prefix}` as the prefix before import of `{package}`.
"""
class Settings(BaseSettings):
'''Base class for package settings.
This class can be subclassed by individual packages to get package-specific settings handling.
Settings will be validated on assignment thanks to Pydantic. The class requires the arguments
`exported_object_name` and `docstring_style`, which will be used to construct a suitable
docstring (see the examples).
Both a settings instance and its `override` method should be added to the package documentation.
Thanks to Pydantic Settings, settings values will also be loaded from environment variables or `.env`
files. Environment variables must be prefixex with `$PACKAGE_NAME_` to take effect, where `$PACKAGE_NAME`
is the name of the package of the subclass. This can be overridden by passing `env_prefix=CUSTOMPREFIX`
as class argument.
Examples:
>>> from typing import Annotated
... from pydantic import Field
... from scverse_misc import Settings
...
...
... class MySettings(Settings, exported_object_name="settings", docstring_style="numpy"):
... eps: Annotated[float, Field(gt=0, lt=1)] = 1e-8
... """Small epsilon for numerical stability."""
...
... use_optional_feature: bool = False
... """Whether to use the optional feature."""
...
...
... settings = MySettings()
'''
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return init_settings, env_settings, dotenv_settings
@staticmethod
def _get_packagename(subcls: type[Settings]) -> str:
package_name = subcls.__module__
dotidx = package_name.find(".")
if dotidx > -1:
package_name = package_name[:dotidx]
return package_name
def __init_subclass__(subcls, *, exported_object_name: str, docstring_style: Literal["google", "numpy", "scverse"]):
if (config := subcls.__dict__.get("model_config")) is not None:
if not config.get("validate_assignment", True):
warnings.warn("`validate_assignment=False` is not supported, overriding.", RuntimeWarning, stacklevel=2)
if not config.get("use_attribute_docstrings", True):
warnings.warn(
"`use_attribute_docstrings=False` is not supported, overriding.", RuntimeWarning, stacklevel=2
)
if config.get("env_file") is not None:
warnings.warn(
"Setting a custom env_file location is not supported, overriding.", RuntimeWarning, stacklevel=2
)
else:
config = SettingsConfigDict()
config["validate_assignment"] = True
config["use_attribute_docstrings"] = True
config["env_file"] = dotenv.find_dotenv()
if not config.get("env_prefix"):
config["env_prefix"] = f"{__class__._get_packagename(subcls)}_" # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177
subcls.model_config = config
super().__init_subclass__()
@contextmanager
def override(self, **overrides: object) -> Generator[None]:
"""Context manager for local setting overrides.
Subclasses will get a version with a docstring detailing the available parameters.
"""
oldsettings = {argname: getattr(self, argname) for argname in overrides.keys()}
try:
for argname, argval in overrides.items():
setattr(self, argname, argval)
yield
finally:
for argname, argval in reversed(oldsettings.items()):
setattr(self, argname, argval)
def reset(self, *names: LiteralString) -> AbstractContextManager[frozenset[LiteralString]]:
"""Reset passed settings to their default values.
Can be used as a context manager to make the resets temporary.
On `__enter__`, the context manager returns the settings that have been changed.
"""
prev_values = {name: getattr(self, name) for name in names if name in self.model_fields_set}
# since we want to allow using this method imperatively,
# eagerly do the reset here instead of returning a context manager with a lazy `__enter__`.
for name in prev_values:
default = type(self).model_fields[name].get_default()
setattr(self, name, default)
self.model_fields_set.remove(name)
class Cm(AbstractContextManager[frozenset[str]]):
def __enter__(_self) -> frozenset[str]:
return frozenset(prev_values)
def __exit__(_self, *_: object) -> None:
for arg, value in prev_values.items():
setattr(self, arg, value)
return Cm()
@classmethod
def __pydantic_init_subclass__( # type: ignore[override]
subcls: type[Self], *, exported_object_name: str, docstring_style: Literal["google", "numpy", "scverse"]
) -> None:
subcls.__doc__ = (
_docstring_template.format(
package=__class__._get_packagename(subcls), # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177
name=exported_object_name,
env_prefix=subcls.model_config["env_prefix"].upper(),
)
+ "\n\nThe following options are available:\n"
)
override_doc = "Provides local override via keyword arguments as a context manager.\n\n"
if docstring_style == "google":
override_doc += "Args:\n"
else:
override_doc += "Parameters\n----------\n"
for fname, field in subcls.model_fields.items():
subcls.__doc__ += f"""
.. attribute:: {exported_object_name}.{fname}
:type: {_type_str(subcls, field)}\n"""
if field.default is not PydanticUndefined:
subcls.__doc__ += f" :value: {field.default!r}\n"
description = ""
if field.description is not None:
subcls.__doc__ += f"\n{textwrap.indent(field.description, ' ')}\n"
description += field.description
if docstring_style == "google":
override_doc += (
f""" {fname} ({_type_str(subcls, field)}): {textwrap.indent(description, " ")}\n"""
)
else:
annot = "" if docstring_style == "scverse" else f" : {_type_str(subcls, field)}"
override_doc += f"""\
{fname}{annot}
{textwrap.indent(description, " ")}\n"""
subcls.override = _copy_override( # type: ignore[method-assign,type-var]
subcls, subcls.override, override_doc, return_annotation=AbstractContextManager[None]
)
subcls.reset = _copy_reset(subcls, subcls.reset) # type: ignore[method-assign,type-var]
class CustomRepr(str):
def __repr__(self) -> str:
return self
def _copy_override[F: FunctionType](cls: type[Settings], func: F, doc: str, return_annotation: object) -> F:
from ._utils import Overrides
parameters = [
inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY),
*[
inspect.Parameter(
n, inspect.Parameter.KEYWORD_ONLY, default=CustomRepr(""), annotation=f.annotation
)
for n, f in cls.model_fields.items()
],
]
overrides = Overrides(
__doc__=doc,
__module__=cls.__module__,
__qualname__=f"{cls.__qualname__}.{func.__name__}",
__signature__=inspect.Signature(parameters, return_annotation=return_annotation),
__annotations__={
**{name: field.annotation for name, field in cls.model_fields.items()},
"return": return_annotation,
},
)
if sys.version_info >= (3, 14):
from annotationlib import Format
str_annotations = {n: _type_str(cls, f) for n, f in cls.model_fields.items()}
overrides["__annotate__"] = lambda fmt: (
overrides["__annotations__"] if fmt != Format.STRING else str_annotations
)
return copy_func(func, **overrides)
def _copy_reset[F: FunctionType](cls: type[Settings], func: F) -> F:
from ._utils import Overrides
args_t = Literal[tuple(cls.model_fields.keys())] # type: ignore[valid-type]
parameters = [
inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY),
inspect.Parameter("args", inspect.Parameter.VAR_POSITIONAL, annotation=args_t),
]
return_annotation = AbstractContextManager[frozenset[args_t]] # type: ignore[valid-type]
overrides = Overrides(
__module__=cls.__module__,
__qualname__=f"{cls.__qualname__}.{func.__name__}",
__signature__=inspect.Signature(parameters, return_annotation=return_annotation),
__annotations__={"args": args_t, "return": return_annotation},
)
if sys.version_info >= (3, 14):
from annotationlib import Format
str_annotations = {n: str(t) for n, t in overrides["__annotations__"].items()}
overrides["__annotate__"] = lambda fmt: (
overrides["__annotations__"] if fmt != Format.STRING else str_annotations
)
return copy_func(func, **overrides)
scverse-scverse-misc-ed5b409/src/scverse_misc/_utils.py 0000664 0000000 0000000 00000002546 15202606132 0023334 0 ustar 00root root 0000000 0000000 import functools
import inspect
import sys
from collections.abc import Callable, Mapping
from functools import WRAPPER_ASSIGNMENTS
from types import FunctionType
from typing import ParamSpec, TypedDict, TypeVar, TypeVarTuple, Unpack, cast
class _BaseOverrides(TypedDict, total=False):
__module__: str
__name__: str
__qualname__: str
__doc__: str
__signature__: inspect.Signature
__annotations__: Mapping[str, object]
__type_params__: tuple[TypeVar | TypeVarTuple | ParamSpec, ...]
if sys.version_info >= (3, 14):
from annotationlib import Format
class Overrides(_BaseOverrides, total=False):
__annotate__: Callable[[Format], Mapping[str, object]]
else:
class Overrides(_BaseOverrides, total=False):
pass
def copy_func[F: FunctionType](func: F, /, **overrides: Unpack[Overrides]) -> F:
kw = dict(kwdefaults=func.__kwdefaults__) if sys.version_info >= (3, 13) else {}
new = FunctionType(
func.__code__, func.__globals__, name=func.__name__, argdefs=func.__defaults__, closure=func.__closure__, **kw
)
for key, value in overrides.items():
setattr(new, key, value)
copy = set(WRAPPER_ASSIGNMENTS) - overrides.keys()
wrapper = functools.update_wrapper(new, func, assigned=copy)
del wrapper.__wrapped__ # otherwise sphinx will try to document that.
return cast("F", wrapper)
scverse-scverse-misc-ed5b409/stubs/ 0000775 0000000 0000000 00000000000 15202606132 0017340 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/ 0000775 0000000 0000000 00000000000 15202606132 0022232 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/__init__.pyi 0000664 0000000 0000000 00000000127 15202606132 0024514 0 ustar 00root root 0000000 0000000 # mypy doesn’t understand namespace packages apparently
from . import katex as katex
scverse-scverse-misc-ed5b409/stubs/sphinxcontrib/katex.pyi 0000664 0000000 0000000 00000000333 15202606132 0024070 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-ed5b409/tests/ 0000775 0000000 0000000 00000000000 15202606132 0017342 5 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/tests/conftest.py 0000664 0000000 0000000 00000000000 15202606132 0021527 0 ustar 00root root 0000000 0000000 scverse-scverse-misc-ed5b409/tests/test_deprecation_decorator.py 0000664 0000000 0000000 00000011610 15202606132 0025311 0 ustar 00root root 0000000 0000000 import inspect
import warnings
from collections.abc import Callable
from typing import Literal, cast, get_args
import pytest
from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined]
from scverse_misc import Deprecation, deprecated, deprecated_arg
@pytest.fixture(
params=[
pytest.param(None, id="no_message"),
pytest.param("Test message.", id="short_message"),
pytest.param("Test\nmessage.", id="long_message"),
]
)
def msg(request: pytest.FixtureRequest) -> str | None:
return cast(str | None, request.param)
type DocstringStyles = Literal["no_docstring", "short", "long_numpystyle", "long_googlestyle"]
@pytest.fixture(params=get_args(DocstringStyles.__value__))
def docstring_style(request: pytest.FixtureRequest) -> DocstringStyles:
return cast(DocstringStyles, request.param)
@pytest.fixture
def docstring(docstring_style: DocstringStyles) -> str | None:
match docstring_style:
case "no_docstring":
return None
case "short":
return "Test function"
case "long_numpystyle":
return """Test function
This is a test.
Parameters
----------
positional_only_no_default
foo
positional_only_default
bar
positional_or_keyword_default
baz
keyword_only_default
foobar
"""
case "long_googlestyle":
return """Test function
This is a test.
Args:
positional_only_no_default: foo
positional_only_default: bar lorem ipsum
test
positional_or_keyword_default: baz
keyword_only_default: foobar
"""
@pytest.fixture
def func(msg: str | None, docstring: str | None) -> Callable[..., int]:
def _func(
positional_only_no_default: int,
positional_only_default: int = 1337,
/,
positional_or_keyword_default: int = 42,
*,
keyword_only_default: float = 3.1415,
) -> int:
return 42
_func.__doc__ = docstring
return _func
@pytest.fixture
def deprecated_func(msg: str | None, func: Callable[..., int]) -> Callable[..., int]:
return deprecated(Deprecation("foo", msg or ""))(func)
def test_deprecation_decorator(deprecated_func: Callable[..., int], docstring: str | None, msg: str | None) -> None:
with pytest.warns(FutureWarning, match="deprecated"):
assert deprecated_func(1, 2) == 42
assert deprecated_func.__doc__ is not None
lines = deprecated_func.__doc__.expandtabs().splitlines()
offset = 0 if docstring is None else 2
if docstring is not None:
lines_orig = docstring.expandtabs().splitlines()
assert lines[0] == lines_orig[0]
assert len(lines[1].strip()) == 0, "expected empty line following summary"
assert lines[offset].startswith(".. version-deprecated")
if msg is None:
assert len(lines) == offset + 1 or not lines[offset + 1].startswith(" ")
else:
msg_lines = msg.splitlines()
msg_indented = [f" {line}" for line in msg_lines]
assert lines[offset + 1 : offset + 1 + len(msg_lines)] == msg_indented
@pytest.mark.parametrize(
"arg",
("positional_only_no_default", "positional_only_default", "positional_or_keyword_default", "keyword_only_default"),
)
def test_deprecated_arg_decorator(
func: Callable[..., int], msg: str | None, arg: str, docstring_style: DocstringStyles
) -> None:
deprecated_func = deprecated_arg(arg, Deprecation("2.718", msg or ""))(func)
with pytest.warns(FutureWarning, match=f"{arg} is deprecated"):
assert deprecated_func(1, 2, 3, keyword_only_default=4.0) == 42
if arg != "positional_only_no_default":
with warnings.catch_warnings():
warnings.simplefilter("error")
assert deprecated_func(1) == 42
parser: type[NumpyDocstring] | type[GoogleDocstring] | None = None
if docstring_style == "long_numpystyle":
parser = NumpyDocstring
elif docstring_style == "long_googlestyle":
parser = GoogleDocstring
if parser is None:
return
lines = parser(inspect.getdoc(deprecated_func) or "").lines()
for i, line in enumerate(lines):
if line.startswith(prefix := f":param {arg}: "):
prefixlen = len(prefix)
if msg is not None:
stripped = lines[i + 1].strip()
assert stripped == ".. version-deprecated:: 2.718"
assert lines[i + 2][prefixlen:] == f" {msg}"
assert not lines[i + 3]
assert lines[i + 4][:prefixlen] == " " * prefixlen
else:
assert line == f":param {arg}: .. version-deprecated:: 2.718"
assert not lines[i + 1]
assert lines[i + 2][:prefixlen] == " " * prefixlen
scverse-scverse-misc-ed5b409/tests/test_extensions.py 0000664 0000000 0000000 00000016101 15202606132 0023151 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", "register_dummy_namespace", docstring_style="google"
)
@pytest.fixture
def obj() -> DummyClass:
"""Create a basic object for testing."""
return DummyClass()
@pytest.fixture(autouse=True)
def _cleanup_dummy() -> Generator[None, None, None]:
"""Automatically cleanup dummy namespace after each test."""
original = getattr(DummyClass, "dummy", None)
yield
if original is not None:
DummyClass.dummy = original
elif hasattr(DummyClass, "dummy"):
delattr(DummyClass, "dummy")
@pytest.fixture
def dummy_namespace() -> type:
"""Create a basic dummy namespace class."""
@register_dummy_namespace("dummy")
class DummyNamespace:
def __init__(self, obj: DummyClass) -> None:
self._obj = obj
def greet(self) -> str:
return "hello"
return DummyNamespace
def test_accessor_namespace() -> None:
"""Test the behavior of the AccessorNameSpace descriptor.
This test verifies that:
- When accessed at the class level (i.e., without an instance), the descriptor
returns the namespace type.
- When accessed via an instance, the descriptor instantiates the namespace,
passing the instance to its constructor.
- The instantiated namespace is then cached on the instance such that subsequent
accesses of the same attribute return the cached namespace instance.
"""
# Define a dummy namespace class to be used via the descriptor.
class DummyNamespace:
def __init__(self, obj: Dummy):
self._obj = obj
def foo(self) -> str:
return "foo"
class Dummy:
dummy: DummyNamespace # just typing, runtime added below
descriptor: extensions.AccessorNameSpace[Dummy, DummyNamespace] = extensions.AccessorNameSpace(
"dummy", DummyNamespace
)
# When accessed on the class, it should return the namespace type.
ns_class = descriptor.__get__(None, Dummy)
assert ns_class is DummyNamespace
# When accessed via an instance, it should instantiate DummyNamespace.
dummy_obj = Dummy()
ns_instance = descriptor.__get__(dummy_obj, Dummy)
assert isinstance(ns_instance, DummyNamespace)
assert ns_instance._obj is dummy_obj
# __get__ should cache the namespace instance on the object.
# Subsequent access should return the same cached instance.
assert dummy_obj.dummy is ns_instance
def test_descriptor_instance_caching(dummy_namespace: type, obj: DummyClass) -> None:
"""Test that namespace instances are cached on individual DummyClass objects."""
# First access creates the instance
ns_instance = obj.dummy
# Subsequent accesses should return the same instance
assert obj.dummy is ns_instance
def test_register_namespace_basic(dummy_namespace: type, obj: DummyClass) -> None:
"""Test basic namespace registration and access."""
assert obj.dummy.greet() == "hello"
def test_register_namespace_override(dummy_namespace: type) -> None:
"""Test namespace registration and override behavior."""
assert hasattr(DummyClass, "dummy")
# Override should warn and update the namespace
with pytest.warns(UserWarning, match="Overriding existing custom namespace 'dummy'"):
@register_dummy_namespace("dummy")
class DummyNamespaceOverride:
def __init__(self, obj: DummyClass) -> None:
self._obj = obj
def greet(self) -> str:
return "world"
# Verify the override worked
obj = DummyClass()
assert obj.dummy.greet() == "world"
@pytest.mark.parametrize(
"attr",
[
"foo",
"bar",
"baz",
"foobar",
],
)
def test_register_existing_attributes(attr: str) -> None:
"""
Test that registering an accessor with a name that is a reserved attribute of DummyClass raises an attribute error.
"""
with pytest.raises(AttributeError, match=f"cannot override reserved attribute {attr!r}"):
@register_dummy_namespace(attr)
class DummyNamespace:
def __init__(self, obj: DummyClass) -> None:
self._obj = obj
def test_valid_signature() -> None:
"""Test that a namespace with valid signature is accepted."""
@register_dummy_namespace("valid")
class ValidNamespace:
def __init__(self, obj: DummyClass) -> None:
self.obj = obj
def test_missing_param() -> None:
"""Test that a namespace missing the second parameter is rejected."""
with pytest.raises(
TypeError, match=r"Namespace initializer must accept a DummyClass instance as the second parameter\."
):
@register_dummy_namespace("missing_param")
class MissingParamNamespace:
def __init__(self) -> None:
pass
def test_wrong_name() -> None:
"""Test that a namespace with wrong parameter name is rejected."""
with pytest.raises(
TypeError, match=r"Namespace initializer's second parameter must be named 'obj', got 'notobj'\."
):
@register_dummy_namespace("wrong_name")
class WrongNameNamespace:
def __init__(self, notobj: DummyClass) -> None:
self.notobj = notobj
def test_wrong_annotation() -> None:
"""Test that a namespace with wrong parameter annotation is rejected."""
with pytest.raises(
TypeError,
match=r"Namespace initializer's second parameter must be annotated as the 'DummyClass' class, got 'int'\.",
):
@register_dummy_namespace("wrong_annotation")
class WrongAnnotationNamespace:
def __init__(self, obj: int) -> None:
self.obj = obj
def test_missing_annotation() -> None:
"""Test that a namespace with missing parameter annotation is rejected."""
with pytest.raises(AttributeError):
@register_dummy_namespace("missing_annotation")
class MissingAnnotationNamespace:
def __init__(self, obj) -> None: # type: ignore[no-untyped-def]
self.obj = obj
def test_both_wrong() -> None:
"""Test that a namespace with both wrong name and annotation is rejected."""
with pytest.raises(
TypeError,
match=(
r"Namespace initializer's second parameter must be named 'obj', got 'info'\. "
r"And must be annotated as 'DummyClass', got 'str'\."
),
):
@register_dummy_namespace("both_wrong")
class BothWrongNamespace:
def __init__(self, info: str) -> None:
self.info = info
scverse-scverse-misc-ed5b409/tests/test_settings.py 0000664 0000000 0000000 00000022375 15202606132 0022624 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import inspect
import sys
from contextlib import nullcontext
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Literal, cast, get_args
import pytest
from pydantic import Field, ValidationError
from pydantic.fields import FieldInfo
from pydantic_settings import SettingsConfigDict
from sphinx.application import Sphinx
from sphinx.ext.napoleon import GoogleDocstring, NumpyDocstring # type: ignore[attr-defined]
from scverse_misc import Settings
if TYPE_CHECKING:
# Static version of the class returned by the `settings_class` fixture
class DummySettings(Settings, exported_object_name="settings"):
field_bool: bool = False
field_no_docstring: int = 42
field_int_range: int = 1
pytest_plugins = ["sphinx.testing.fixtures"]
@pytest.fixture
def docstring_style(request: pytest.FixtureRequest) -> Literal["google", "numpy", "scverse"]:
return getattr(request, "param", "google")
@pytest.fixture
def settings_class(docstring_style: Literal["google", "numpy", "scverse"]) -> type[DummySettings]:
class _DummySettings(Settings, exported_object_name="settings", docstring_style=docstring_style):
field_bool: bool = False
"""Boolean field."""
field_no_docstring: int = 42
field_int_range: Annotated[int, Field(ge=0, le=4)] = 1
"""Integer range field."""
return cast("type[DummySettings]", _DummySettings)
@pytest.fixture
def settings(settings_class: type[DummySettings]) -> DummySettings:
return settings_class()
def test_defaults_override() -> None:
with (
pytest.warns(RuntimeWarning, match="validate_assignment=False"),
pytest.warns(RuntimeWarning, match="use_attribute_docstrings=False"),
pytest.warns(RuntimeWarning, match="custom env_file location"),
):
class WarnSettings(Settings, exported_object_name="settings", docstring_style="google"):
model_config = SettingsConfigDict(
validate_assignment=False, use_attribute_docstrings=False, env_file="mydotenv"
)
field_bool: bool = False
settings = WarnSettings()
with pytest.raises(ValidationError):
settings.field_bool = 2 # type: ignore[assignment]
@pytest.mark.parametrize("v", [2, 4])
def test_env_vars(monkeypatch: pytest.MonkeyPatch, settings_class: type[DummySettings], v: int) -> None:
"""Test that the env var prefix is derived from the module name."""
monkeypatch.setenv("TESTS_FIELD_INT_RANGE", str(v))
settings = settings_class()
assert settings.field_int_range == v
def test_validate_assignment(settings: DummySettings) -> None:
with pytest.raises(ValidationError):
settings.field_bool = 2 # type: ignore[assignment]
with pytest.raises(ValidationError):
settings.field_int_range = -1
def test_override(settings: DummySettings) -> None:
with settings.override(field_bool=True):
assert settings.field_bool is True
assert settings.field_bool is False
def test_override_error(settings: DummySettings) -> None:
with pytest.raises(ValidationError):
with settings.override(field_int_range=3, field_no_docstring=1.1):
pass
assert settings.field_no_docstring == 42
assert settings.field_int_range == 1
@pytest.mark.parametrize("temp", [True, False], ids=["temporary", "permanent"])
def test_reset(settings: DummySettings, temp: bool) -> None:
default = settings.field_bool
settings.field_bool = not default
undo_reset = settings.reset("field_bool")
with undo_reset if temp else nullcontext():
assert settings.field_bool is default
assert settings.field_bool is (not default if temp else default)
def test_reset_signature(settings: DummySettings) -> None:
sig = inspect.signature(settings.reset)
assert get_args(sig.parameters["args"].annotation) == ("field_bool", "field_no_docstring", "field_int_range")
@pytest.mark.skipif(sys.version_info < (3, 14), reason="requires annotationlib")
def test_reset_annotations(settings: DummySettings) -> None:
from contextlib import AbstractContextManager
import annotationlib
assert annotationlib.get_annotations(settings.reset) == {
"args": Literal["field_bool", "field_no_docstring", "field_int_range"],
"return": AbstractContextManager[frozenset[Literal["field_bool", "field_no_docstring", "field_int_range"]]],
}
@pytest.mark.parametrize("docstring_style", ["google", "numpy", "scverse"], indirect=True)
def test_docs(docstring_style: Literal["google", "numpy"], settings: DummySettings) -> None:
parser = GoogleDocstring if docstring_style == "google" else NumpyDocstring
lines = parser(inspect.getdoc(settings) or "").lines()
assert lines[0].endswith("`tests` package.")
current_field: FieldInfo | None = None
field_iter = iter(type(settings).model_fields.items())
for line in lines:
if line.startswith(".. attribute::"):
current_field_name, current_field = next(field_iter)
assert line.endswith(current_field_name)
elif current_field is not None:
line = line.strip()
if line.startswith(":type:"):
assert current_field.annotation is not None
assert line.endswith(current_field.annotation.__name__)
elif line.startswith(":value:"):
assert line.endswith(repr(current_field.default))
elif len(line) > 0 and current_field.description is not None:
assert line == current_field.description
@pytest.mark.parametrize("docstring_style", ["google", "numpy", "scverse"], indirect=True)
def test_override_docs(docstring_style: Literal["google", "numpy"], settings: DummySettings) -> None:
parser = GoogleDocstring if docstring_style == "google" else NumpyDocstring
lines = parser(inspect.getdoc(settings.override) or "").lines()
current_field: FieldInfo | None = None
field_iter = iter(type(settings).model_fields.items())
for line in lines:
if line.startswith(":param"):
current_field_name, current_field = next(field_iter)
description = f" {current_field.description}" if current_field.description is not None else ""
# no default here, as the default is “leave this value alone”
assert line.startswith(f":param {current_field_name}:{description}")
elif current_field is not None and len(line) > 0:
assert current_field.annotation is not None
assert line == f":type {current_field_name}: {current_field.annotation.__name__}"
@pytest.mark.parametrize(
("attr", "expected"),
[
pytest.param("string", "str", id="builtin"),
pytest.param("path", "pathlib.Path", id="3rd-party"),
# same module as `S`, so no leading `tests.test_settings.`
pytest.param("local", "test_annotation_format..Local", id="same-module"),
],
)
def test_annotation_format(attr: str, expected: str) -> None:
"""Test that annotation references work correctly."""
class Local: ...
class S(Settings, exported_object_name="s", docstring_style="google"):
if attr == "string":
string: str
if attr == "path":
path: Path
if attr == "local":
local: Local
lines = (inspect.getdoc(S) or "").splitlines()
lines = lines[lines.index(f".. attribute:: s.{attr}") + 1 :]
assert lines == [f" :type: {expected}"]
@pytest.fixture(scope="session", autouse=True)
def _sphinx_config(sphinx_test_tempdir: Path) -> None:
"""Since we only need one, we use this instead of static roots like `@pytest.mark.sphinx('html', testroot="mybook")`."""
p = sphinx_test_tempdir / "root" / "conf.py"
p.parent.mkdir(parents=True)
p.write_text("""
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_autodoc_typehints"]
typehints_defaults = "braces"
""")
@pytest.mark.parametrize("docstring_style", ["scverse"])
@pytest.mark.parametrize("parent", ["class", "object"])
def test_sphinx_autodoc_typehints(
subtests: pytest.Subtests,
app: Sphinx,
settings_class: type[DummySettings],
settings: DummySettings,
parent: Literal["class", "object"],
) -> None:
import sphinx_autodoc_typehints
from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined]
obj = (settings if parent == "object" else settings_class).override
lines = (inspect.getdoc(obj) or "").splitlines()
lines = NumpyDocstring(lines, app.config, app, "method", "", obj).lines()
with subtests.test("napoleon"):
# test that napoleon can parse things correctly
# especially the last parameter could fail to parse if there are not enough trailing newlines
for name in settings_class.model_fields:
assert f":param {name}:" in "\n".join(lines)
sphinx_autodoc_typehints.process_docstring(app, "method", "", obj, options=None, lines=lines)
with subtests.test("type"): # no need to test all parameters
assert (
r":type field_bool: :sphinx_autodoc_typehints_type:`\:py\:class\:\`bool\`` (default: ````)"
in lines
)
with subtests.test("rtype"):
assert (
r":rtype: :sphinx_autodoc_typehints_type:`\:py\:class\:\`\~contextlib.AbstractContextManager\`\\ \\\[\:py\:obj\:\`None\`\]`"
in lines
)