scverse-doc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """The scverse Sphinx theme and shared documentation configuration.
2
+
3
+ A package’s `conf.py` needs few lines:
4
+
5
+ .. code:: python
6
+
7
+ extensions = ["scverse_doc"]
8
+
9
+ source_repository = "https://github.com/scverse/pertpy"
10
+
11
+ This extension sets up the subextensions,
12
+ each of which also works on its own:
13
+
14
+ :mod:`scverse_doc.autodoc`
15
+ The layout of the API pages.
16
+ :mod:`scverse_doc.config`
17
+ The extension stack and the shared defaults.
18
+ :mod:`scverse_doc.napoleon`
19
+ Prose return sections in NumPy-style docstrings.
20
+ :mod:`scverse_doc.registry`
21
+ The package registry, usable as an :func:`~scverse_doc.registry.intersphinx` mapping.
22
+ :mod:`scverse_doc.source`
23
+ The repository links: the header icon, “edit this page”, and ``[source]``.
24
+ :mod:`scverse_doc.theme`
25
+ The theme, its chrome, and the per-package accent.
26
+
27
+ Anything set in `conf.py` wins.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import TYPE_CHECKING
33
+
34
+ from sphinx.util.typing import ExtensionMetadata
35
+
36
+ from . import autodoc, config, napoleon, registry, source, theme
37
+ from .config import _is_set_by_user
38
+
39
+ if TYPE_CHECKING:
40
+ from sphinx.application import Sphinx
41
+ from sphinx.config import Config
42
+
43
+ __all__ = ["autodoc", "config", "napoleon", "registry", "source", "theme", "setup"]
44
+
45
+
46
+ def _default_theme(app: Sphinx, config: Config) -> None:
47
+ """Select the theme, since the umbrella extension means it too, unless `conf.py` picked another."""
48
+ if not _is_set_by_user(config, "html_theme"):
49
+ config.html_theme = "scverse"
50
+
51
+
52
+ def setup(app: Sphinx) -> ExtensionMetadata:
53
+ """Set up the registry and the subextensions."""
54
+ for extension in set(__all__) - {"setup"}:
55
+ app.setup_extension(f"scverse_doc.{extension}")
56
+ app.connect("config-inited", _default_theme)
57
+ return ExtensionMetadata(parallel_read_safe=True)
@@ -0,0 +1,12 @@
1
+ {
2
+ "default": "#262fb5",
3
+ "anndata": "#e5864b",
4
+ "mudata": "#4ab274",
5
+ "muon": "#6cf1a1",
6
+ "pertpy": "#da347f",
7
+ "scanpy": "#de367b",
8
+ "scirpy": "#da347f",
9
+ "scvi-tools": "#fbb822",
10
+ "spatialdata": "#40a9ff",
11
+ "squidpy": "#969dea"
12
+ }
scverse_doc/_color.py ADDED
@@ -0,0 +1,145 @@
1
+ """Colour maths for deriving accessible accent shades.
2
+
3
+ Package accents are chosen for brand recognition, not for contrast.
4
+ Several of the accents scverse already uses (``#6cf1a1`` for muon, ``#fbb822`` for scvi-tools)
5
+ fail WCAG AA as text colours on white, and others fail on a dark background.
6
+ So the raw accent is only ever used for decorative surfaces,
7
+ and text/link colours are *derived* from it here by moving lightness until the contrast target is met,
8
+ keeping hue and saturation intact so the result still reads as the package’s colour.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import colorsys
14
+ import re
15
+ from typing import NamedTuple
16
+
17
+ __all__ = ["contrast_ratio", "derive_readable", "parse_hex", "relative_luminance"]
18
+
19
+ #: Minimum contrast ratio for normal-size body text under WCAG 2.1 AA.
20
+ WCAG_AA_NORMAL = 4.5
21
+
22
+ _HEX_RE = re.compile(r"\A#?(?P<digits>[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\Z")
23
+
24
+
25
+ class RGB(NamedTuple):
26
+ """An sRGB colour with channels in ``[0, 1]``."""
27
+
28
+ r: float
29
+ g: float
30
+ b: float
31
+
32
+ def to_hex(self) -> str:
33
+ """Render as a lowercase ``#rrggbb`` string."""
34
+ return "#" + "".join(f"{round(c * 255):02x}" for c in self)
35
+
36
+
37
+ def parse_hex(color: str) -> RGB:
38
+ """Parse a CSS hex colour.
39
+
40
+ Parameters
41
+ ----------
42
+ color
43
+ A ``#rgb`` or ``#rrggbb`` string, with or without the leading ``#``.
44
+
45
+ Returns
46
+ -------
47
+ The colour, with channels normalised to ``[0, 1]``.
48
+
49
+ Raises
50
+ ------
51
+ ValueError
52
+ If `color` is not a three- or six-digit hex colour.
53
+ """
54
+ if (m := _HEX_RE.match(color.strip())) is None:
55
+ msg = f"Not a hex colour: {color!r}"
56
+ raise ValueError(msg)
57
+ digits = m["digits"]
58
+ if len(digits) == 3:
59
+ digits = "".join(d * 2 for d in digits)
60
+ return RGB(*(c / 255 for c in bytes.fromhex(digits)))
61
+
62
+
63
+ def _to_linear(channel: float) -> float:
64
+ """Undo the sRGB transfer function for a single channel."""
65
+ return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
66
+
67
+
68
+ def relative_luminance(color: RGB | str) -> float:
69
+ """Compute the WCAG relative luminance of a colour.
70
+
71
+ Parameters
72
+ ----------
73
+ color
74
+ An :class:`RGB` colour or a hex string.
75
+
76
+ Returns
77
+ -------
78
+ Relative luminance in ``[0, 1]``, where 0 is black and 1 is white.
79
+ """
80
+ r, g, b = parse_hex(color) if isinstance(color, str) else color
81
+ return 0.2126 * _to_linear(r) + 0.7152 * _to_linear(g) + 0.0722 * _to_linear(b)
82
+
83
+
84
+ def contrast_ratio(fg: RGB | str, bg: RGB | str) -> float:
85
+ """Compute the WCAG contrast ratio between two colours.
86
+
87
+ Parameters
88
+ ----------
89
+ fg
90
+ Foreground colour.
91
+ bg
92
+ Background colour.
93
+
94
+ Returns
95
+ -------
96
+ A ratio between 1 (identical) and 21 (black on white).
97
+ """
98
+ lum_a, lum_b = relative_luminance(fg), relative_luminance(bg)
99
+ lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b)
100
+ return (lighter + 0.05) / (darker + 0.05)
101
+
102
+
103
+ def derive_readable(accent: str, background: str, *, target: float = WCAG_AA_NORMAL) -> str:
104
+ """Derive a text-safe variant of `accent` that meets `target` contrast against `background`.
105
+
106
+ Hue and saturation are preserved and only lightness is moved,
107
+ so the result still reads as the same colour.
108
+ Lightness moves away from the background:
109
+ darker on a light background, lighter on a dark one.
110
+
111
+ Parameters
112
+ ----------
113
+ accent
114
+ The brand accent to adjust, as a hex string.
115
+ background
116
+ The background the derived colour will sit on, as a hex string.
117
+ target
118
+ The contrast ratio to reach.
119
+
120
+ Returns
121
+ -------
122
+ A hex colour meeting `target` where that is achievable by lightness alone,
123
+ otherwise the closest achievable colour, which is black or white.
124
+
125
+ Examples
126
+ --------
127
+ >>> derive_readable("#fbb822", "#ffffff") # scvi-tools yellow, unreadable as-is
128
+ '#9d6d03'
129
+ """
130
+ if contrast_ratio(accent, background) >= target:
131
+ return parse_hex(accent).to_hex()
132
+
133
+ hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(accent))
134
+ darken = relative_luminance(background) > 0.5
135
+ step = -1 / 512 if darken else 1 / 512
136
+
137
+ # Candidates are checked after rounding to 8-bit hex:
138
+ # a float lightness that clears the target can fall back under it once quantized,
139
+ # which is how #e5864b and #fbb822 ended up one hundredth short of AA.
140
+ while 0.0 <= (lightness := lightness + step) <= 1.0:
141
+ candidate = parse_hex(RGB(*colorsys.hls_to_rgb(hue, lightness, saturation)).to_hex())
142
+ if contrast_ratio(candidate, background) >= target:
143
+ return candidate.to_hex()
144
+
145
+ return "#000000" if darken else "#ffffff"
@@ -0,0 +1,70 @@
1
+ """The layout of the API pages.
2
+
3
+ Ships :mod:`~sphinx.ext.autosummary` templates for modules and classes.
4
+ A class page gets tables of its attributes and methods, then their documentation,
5
+ with each attribute documented as a property, method or plain attribute as appropriate.
6
+ Templates of the same name in your own `templates_path` still win.
7
+
8
+ Also hides ``object``, :class:`~typing.Generic` and private classes from the bases
9
+ that ``:show-inheritance:`` lists.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import traceback
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Generic, Literal, get_origin
17
+
18
+ from jinja2.defaults import DEFAULT_FILTERS # type: ignore[attr-defined]
19
+ from sphinx.ext.autodoc import import_object
20
+ from sphinx.util import logging
21
+ from sphinx.util.typing import ExtensionMetadata
22
+
23
+ if TYPE_CHECKING:
24
+ from typing import Any
25
+
26
+ from sphinx.application import Sphinx
27
+ from sphinx.config import Config
28
+
29
+ __all__ = ["setup"]
30
+
31
+ _logger = logging.getLogger(__name__)
32
+
33
+ TEMPLATES = Path(__file__).parent / "templates"
34
+
35
+
36
+ def _member_type(obj_path: str) -> Literal["method", "property", "attribute"]:
37
+ """Determine what kind of member `obj_path` is, for ``.. auto{{ fullname | member_type }}::``."""
38
+ cls_path, cls_name, member_name = obj_path.rsplit(".", 2)
39
+ try:
40
+ cls = import_object(cls_path, [cls_name], "class")[-1]
41
+ except ImportError:
42
+ _logger.error(f"Failed to import {cls_name} from {cls_path}: {traceback.format_exc()}")
43
+ return "attribute"
44
+ match getattr(cls, member_name, None):
45
+ case property():
46
+ return "property"
47
+ case member if callable(member):
48
+ return "method"
49
+ case _:
50
+ return "attribute"
51
+
52
+
53
+ def _skip_private_bases(app: Sphinx, name: str, obj: type, options: Any, bases: list[type]) -> None:
54
+ bases[:] = [b for b in bases if b is not object and get_origin(b) is not Generic and not b.__name__.startswith("_")]
55
+
56
+
57
+ def _add_templates(app: Sphinx, config: Config) -> None:
58
+ # Appended, so the package’s own templates win.
59
+ config.templates_path = [*config.templates_path, str(TEMPLATES)]
60
+
61
+
62
+ def setup(app: Sphinx) -> ExtensionMetadata:
63
+ """Register the templates, their filter, and the bases hook."""
64
+ app.setup_extension("sphinx.ext.autodoc")
65
+ app.setup_extension("sphinx.ext.autosummary")
66
+ # TODO: autosummary builds its own jinja environment with no hook for filters, so this is global.
67
+ DEFAULT_FILTERS["member_type"] = _member_type
68
+ app.connect("config-inited", _add_templates)
69
+ app.connect("autodoc-process-bases", _skip_private_bases)
70
+ return ExtensionMetadata(parallel_read_safe=True)
@@ -0,0 +1,54 @@
1
+ {{ fullname | escape | underline }}
2
+
3
+ .. currentmodule:: {{ module }}
4
+
5
+ .. add toctree option to make autodoc generate the pages
6
+
7
+ .. autoclass:: {{ fullname }}
8
+
9
+ {% set methods = methods | select("ne", "__init__") | list %}
10
+
11
+ {% block attributes %}
12
+ {% for item in attributes %}
13
+ {% if loop.first %}
14
+ Attributes table
15
+ ~~~~~~~~~~~~~~~~
16
+
17
+ .. autosummary::
18
+ {% endif %}
19
+ ~{{ item }}
20
+ {%- endfor %}
21
+ {% endblock %}
22
+
23
+ {% block methods %}
24
+ {% for item in methods %}
25
+ {% if loop.first %}
26
+ Methods table
27
+ ~~~~~~~~~~~~~
28
+
29
+ .. autosummary::
30
+ {% endif %}
31
+ ~{{ item }}
32
+ {% endfor %}
33
+ {% endblock %}
34
+
35
+ {% block attributes_documentation %}
36
+ {% for item in attributes %}
37
+ {% if loop.first %}
38
+ Attributes
39
+ ~~~~~~~~~~
40
+
41
+ {% endif %}
42
+ .. auto{{ [fullname, item] | join(".") | member_type }}:: {{ [fullname, item] | join(".") }}
43
+ {%- endfor %}
44
+ {% endblock %}
45
+
46
+ {% block methods_documentation %}
47
+ {% for item in methods %}
48
+ {% if loop.first %}
49
+ Methods
50
+ ~~~~~~~
51
+ {% endif %}
52
+ .. automethod:: {{ [fullname, item] | join(".") }}
53
+ {%- endfor %}
54
+ {% endblock %}
@@ -0,0 +1,62 @@
1
+ {{ fullname | escape | underline}}
2
+
3
+ .. automodule:: {{ fullname }}
4
+
5
+ {% block attributes %}
6
+ {% if attributes %}
7
+ .. rubric:: {{ _('Module Attributes') }}
8
+
9
+ .. autosummary::
10
+ {% for item in attributes %}
11
+ {{ item }}
12
+ {%- endfor %}
13
+ {% endif %}
14
+ {% endblock %}
15
+
16
+ {% block functions %}
17
+ {% if functions %}
18
+ .. rubric:: {{ _('Functions') }}
19
+
20
+ .. autosummary::
21
+ :toctree:
22
+ {% for item in functions %}
23
+ {{ item }}
24
+ {%- endfor %}
25
+ {% endif %}
26
+ {% endblock %}
27
+
28
+ {% block classes %}
29
+ {% if classes %}
30
+ .. rubric:: {{ _('Classes') }}
31
+
32
+ .. autosummary::
33
+ :toctree:
34
+ {% for item in classes %}
35
+ {{ item }}
36
+ {%- endfor %}
37
+ {% endif %}
38
+ {% endblock %}
39
+
40
+ {% block exceptions %}
41
+ {% if exceptions %}
42
+ .. rubric:: {{ _('Exceptions') }}
43
+
44
+ .. autosummary::
45
+ :toctree:
46
+ {% for item in exceptions %}
47
+ {{ item }}
48
+ {%- endfor %}
49
+ {% endif %}
50
+ {% endblock %}
51
+
52
+ {% block modules %}
53
+ {% if modules %}
54
+ .. rubric:: Modules
55
+
56
+ .. autosummary::
57
+ :toctree:
58
+ {% for item in modules %}
59
+ {{ item }}
60
+ {%- endfor %}
61
+ {% endif %}
62
+ {% endblock %}
scverse_doc/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """The extension stack and the shared defaults.
2
+
3
+ Sets up the extensions in :data:`EXTENSIONS`, so `conf.py` lists only ``scverse_doc``,
4
+ and fills in :data:`DEFAULTS`. Anything set in `conf.py` always wins.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from sphinx.util.typing import ExtensionMetadata
12
+
13
+ if TYPE_CHECKING:
14
+ from sphinx.application import Sphinx
15
+ from sphinx.config import Config
16
+
17
+ __all__ = ["DEFAULTS", "EXTENSIONS", "MYST_ENABLE_EXTENSIONS", "setup"]
18
+
19
+ #: Extensions set up alongside this one,
20
+ #: so packages list only ``scverse_doc``.
21
+ EXTENSIONS = (
22
+ "myst_nb",
23
+ "sphinx.ext.autodoc",
24
+ "sphinx.ext.autosummary",
25
+ "sphinx.ext.intersphinx",
26
+ "sphinx.ext.napoleon",
27
+ "sphinx_copybutton",
28
+ "sphinx_design",
29
+ "sphinxext.opengraph",
30
+ )
31
+
32
+ #: The MyST syntax extensions, i.e. the ``myst_enable_extensions`` entry of :data:`~scverse_doc.config.DEFAULTS`.
33
+ MYST_ENABLE_EXTENSIONS = (
34
+ "amsmath",
35
+ "colon_fence",
36
+ "deflist",
37
+ "dollarmath",
38
+ "html_image",
39
+ "html_admonition",
40
+ )
41
+
42
+ #: Sphinx config values applied unless `conf.py` sets them.
43
+ DEFAULTS: dict[str, Any] = {
44
+ "autosummary_generate": True,
45
+ "autodoc_member_order": "groupwise",
46
+ "napoleon_google_docstring": False,
47
+ "napoleon_numpy_docstring": True,
48
+ "napoleon_include_init_with_doc": False,
49
+ "napoleon_use_param": True,
50
+ "napoleon_use_rtype": True,
51
+ "default_role": "literal",
52
+ "myst_enable_extensions": list(MYST_ENABLE_EXTENSIONS),
53
+ "myst_heading_anchors": 6,
54
+ "source_suffix": {".rst": "restructuredtext", ".md": "myst-nb", ".ipynb": "myst-nb"},
55
+ # Off: tutorials needing a GPU or a large download must not gate a docs build.
56
+ "nb_execution_mode": "off",
57
+ "nb_output_stderr": "remove",
58
+ "nb_merge_streams": True,
59
+ # One unreachable host must not stall the build.
60
+ "intersphinx_timeout": 10,
61
+ "exclude_patterns": ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"],
62
+ "nitpicky": True,
63
+ }
64
+
65
+
66
+ def _is_set_by_user(config: Config, name: str) -> bool:
67
+ """Report whether `conf.py` or a ``-D`` override set `name`."""
68
+ return name in config._raw_config or name in config._overrides
69
+
70
+
71
+ def _apply_defaults(config: Config) -> None:
72
+ """Fill in the values `conf.py` did not set."""
73
+ for name, value in DEFAULTS.items():
74
+ if _is_set_by_user(config, name):
75
+ continue
76
+ setattr(config, name, value)
77
+
78
+
79
+ def _configure(app: Sphinx, config: Config) -> None:
80
+ _apply_defaults(config)
81
+ if not _is_set_by_user(config, "html_title"):
82
+ config.html_title = config.project
83
+
84
+
85
+ def setup(app: Sphinx) -> ExtensionMetadata:
86
+ """Set up the extension stack and apply the defaults."""
87
+ for extension in EXTENSIONS:
88
+ app.setup_extension(extension)
89
+ app.connect("config-inited", _configure)
90
+ return ExtensionMetadata(parallel_read_safe=True)
@@ -0,0 +1,54 @@
1
+ """Prose return sections in NumPy-style docstrings.
2
+
3
+ :mod:`sphinx.ext.napoleon` reads a NumPy-style ``Returns`` section as a list of ``name : type`` entries.
4
+ With this extension, it can be prose instead:
5
+
6
+ .. code:: rst
7
+
8
+ Returns
9
+ -------
10
+ The fitted model, or `None` if `copy` is false.
11
+
12
+ Lines that do look like ``name : type`` become an item linking to `type`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import TYPE_CHECKING
19
+
20
+ from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined]
21
+ from sphinx.util.typing import ExtensionMetadata
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable, Iterator
25
+
26
+ from sphinx.application import Sphinx
27
+
28
+ __all__ = ["setup"]
29
+
30
+
31
+ def _process_return(lines: Iterable[str]) -> Iterator[str]:
32
+ for line in lines:
33
+ if m := re.fullmatch(r"(?P<param>\w+)\s+:\s+(?P<type>[\w.]+)", line):
34
+ yield f"-{m['param']} (:class:`~{m['type']}`)"
35
+ else:
36
+ yield line
37
+
38
+
39
+ def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]:
40
+ lines_raw = self._dedent(self._consume_to_next_section())
41
+ if lines_raw and lines_raw[0] == ":":
42
+ del lines_raw[0]
43
+ lines = self._format_block(":returns: ", list(_process_return(lines_raw)))
44
+ if lines and lines[-1]:
45
+ lines.append("")
46
+ return lines
47
+
48
+
49
+ def setup(app: Sphinx) -> ExtensionMetadata:
50
+ """Set up napoleon and make it read ``Returns`` sections as prose."""
51
+ app.setup_extension("sphinx.ext.napoleon")
52
+ # TODO: patches private napoleon API; replace once napoleon has a hook for this.
53
+ NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign,assignment]
54
+ return ExtensionMetadata(parallel_read_safe=True)
scverse_doc/py.typed ADDED
File without changes