Skip to content

Commit a3907ed

Browse files
authored
Add release-notes directive (#170)
1 parent e9406f0 commit a3907ed

File tree

7 files changed

+355
-4
lines changed

7 files changed

+355
-4
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
- name: dependencies
3636
run: |
3737
pip install --upgrade pip wheel
38-
pip install .[test,typehints] coverage-rich 'anyconfig[toml] >=0.14'
38+
pip install .[test,typehints,myst] coverage-rich 'anyconfig[toml] >=0.14'
3939
- name: tests
4040
run: coverage run -m pytest --verbose --color=yes
4141
- name: show coverage

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ repos:
2525
- pytest
2626
- types-docutils
2727
- legacy-api-wrap
28+
- myst-parser

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
dev = ['pre-commit']
2828
test = [
2929
'pytest',
30+
'pytest-mock',
3031
'coverage',
3132
'legacy-api-wrap',
3233
'defusedxml', # sphinx[test] would also pull in cython
@@ -38,6 +39,7 @@ doc = [
3839
]
3940
typehints = ['sphinx-autodoc-typehints>=1.15.2']
4041
theme = ['sphinx-book-theme>=1.1.0']
42+
myst = ['myst-parser']
4143

4244
[project.entry-points.'sphinx.html_themes']
4345
scanpydoc = 'scanpydoc.theme'
@@ -67,6 +69,7 @@ ignore = [
6769
'D103', # Test functions don’t need docstrings
6870
'S101', # Pytest tests use `assert`
6971
'RUF018', # Assignment expressions in assert are fine here
72+
'PLR0913', # Tests should be able to use as many fixtures as they want
7073
]
7174
[tool.ruff.lint.flake8-type-checking]
7275
strict = true
@@ -99,7 +102,7 @@ features = ['doc']
99102
build = 'sphinx-build -M html docs docs/_build'
100103

101104
[tool.hatch.envs.hatch-test]
102-
features = ['test', 'typehints']
105+
features = ['test', 'typehints', 'myst']
103106

104107
[tool.pytest.ini_options]
105108
addopts = [

src/scanpydoc/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ def setup(app: Sphinx) -> dict[str, Any]:
4646
app.setup_extension("scanpydoc.elegant_typehints")
4747
app.setup_extension("scanpydoc.rtd_github_links")
4848
app.setup_extension("scanpydoc.theme")
49+
app.setup_extension("scanpydoc.release_notes")
4950
return metadata

src/scanpydoc/release_notes.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""A release notes directive.
2+
3+
Given a list of version files matching :attr:`FULL_VERSION_RE`,
4+
render them using the following (where ``.`` is the directory they are in):
5+
6+
.. code:: restructuredtext
7+
8+
.. release-notes:: .
9+
10+
With e.g. the files `1.2.0.md`, `1.2.1.md`, `1.3.0.rst`, and `1.3.2.rst`,
11+
this will render like the following:
12+
13+
.. code:: restructuredtext
14+
15+
_v1.3:
16+
17+
Version 1.3
18+
===========
19+
20+
.. include:: 1.3.2.rst
21+
.. include:: 1.3.0.rst
22+
23+
_v1.2:
24+
25+
Version 1.2
26+
===========
27+
28+
.. include:: 1.2.1.md
29+
.. include:: 1.2.0.md
30+
"""
31+
32+
from __future__ import annotations
33+
34+
import re
35+
import itertools
36+
from typing import TYPE_CHECKING
37+
from pathlib import Path
38+
from dataclasses import dataclass
39+
40+
from docutils import nodes
41+
from packaging.version import Version
42+
from sphinx.util.parsing import nested_parse_to_nodes
43+
from sphinx.util.docutils import SphinxDirective
44+
45+
from . import metadata, _setup_sig
46+
47+
48+
if TYPE_CHECKING:
49+
from typing import Any, ClassVar
50+
from collections.abc import Iterable, Sequence
51+
52+
from sphinx.application import Sphinx
53+
from myst_parser.mdit_to_docutils.base import DocutilsRenderer
54+
55+
56+
FULL_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:\..*)?$")
57+
"""Regex matching a full version number including patch part, maybe with more after."""
58+
59+
60+
@dataclass
61+
class _Backend:
62+
dir: Path
63+
instance: SphinxDirective
64+
65+
def run(self) -> Sequence[nodes.Node]:
66+
versions = sorted(
67+
(
68+
(Version(f.stem), f)
69+
for f in self.dir.iterdir()
70+
if FULL_VERSION_RE.match(f.stem)
71+
),
72+
reverse=True, # descending
73+
)
74+
version_groups = itertools.groupby(
75+
versions, key=lambda vf: (vf[0].major, vf[0].minor)
76+
)
77+
return [
78+
node
79+
for (major, minor), versions in version_groups
80+
for node in self.render_version_group(major, minor, versions)
81+
]
82+
83+
def render_version_group(
84+
self,
85+
major: int,
86+
minor: int,
87+
versions: Iterable[tuple[Version, Path]] = (),
88+
) -> tuple[nodes.target, nodes.section]:
89+
target = nodes.target(
90+
ids=[f"v{major}-{minor}"],
91+
names=[f"v{major}.{minor}"],
92+
)
93+
section = nodes.section(
94+
"",
95+
nodes.title("", f"Version {major}.{minor}"),
96+
ids=[],
97+
names=[f"version {major}.{minor}"],
98+
)
99+
100+
self.instance.state.document.note_implicit_target(section)
101+
self.instance.state.document.note_explicit_target(target)
102+
103+
for _, p in versions:
104+
section += self.render_include(p)
105+
return target, section
106+
107+
def render_include(self, path: Path) -> Sequence[nodes.Node]:
108+
return nested_parse_to_nodes(
109+
self.instance.state,
110+
path.read_text(),
111+
source=str(path),
112+
offset=self.instance.content_offset,
113+
)
114+
115+
116+
# TODO(flying-sheep): Remove once MyST-Parser bug is fixed
117+
# https://github.com/executablebooks/MyST-Parser/issues/967
118+
class _BackendMyst(_Backend):
119+
def run(self) -> Sequence[nodes.Node]:
120+
super().run()
121+
return []
122+
123+
def render_version_group(
124+
self, major: int, minor: int, versions: Iterable[tuple[Version, Path]] = ()
125+
) -> tuple[nodes.target, nodes.section]:
126+
target, section = super().render_version_group(major, minor)
127+
# append target and section to parent
128+
self._myst_renderer.current_node.append(target)
129+
self._myst_renderer.update_section_level_state(section, 2)
130+
# append children to section
131+
with self._myst_renderer.current_node_context(section):
132+
for _, p in versions:
133+
self.render_include(p)
134+
return target, section # ignored, just to not change the types
135+
136+
def render_include(self, path: Path) -> Sequence[nodes.Node]:
137+
from myst_parser.mocking import MockIncludeDirective
138+
from docutils.parsers.rst.directives.misc import Include
139+
140+
srcfile, lineno = self.instance.get_source_info()
141+
parent_dir = Path(srcfile).parent
142+
143+
d = MockIncludeDirective(
144+
renderer=self._myst_renderer,
145+
name=type(self).__name__,
146+
klass=Include, # type: ignore[arg-type] # wrong type hint
147+
arguments=[str(path.relative_to(parent_dir))],
148+
options={},
149+
body=[],
150+
lineno=lineno,
151+
)
152+
return d.run()
153+
154+
@property
155+
def _myst_renderer(self) -> DocutilsRenderer:
156+
rv: DocutilsRenderer = self.instance.state._renderer # type: ignore[attr-defined] # noqa: SLF001
157+
return rv
158+
159+
160+
class ReleaseNotes(SphinxDirective):
161+
"""Directive rendering release notes, grouping them by minor versions."""
162+
163+
required_arguments: ClassVar = 1
164+
165+
def run(self) -> Sequence[nodes.Node]:
166+
"""Read the release notes and render them."""
167+
dir_ = Path(self.arguments[0])
168+
# resolve relative dir
169+
if not dir_.is_absolute():
170+
src_file = Path(self.get_source_info()[0])
171+
if not src_file.is_file():
172+
msg = f"Cannot find relative path to: {src_file}"
173+
raise self.error(msg)
174+
dir_ = src_file.parent / self.arguments[0]
175+
if not dir_.is_dir():
176+
msg = f"Not a directory: {dir_}"
177+
raise self.error(msg)
178+
179+
cls = _BackendMyst if hasattr(self.state, "_renderer") else _Backend
180+
return cls(dir_, self).run()
181+
182+
183+
@_setup_sig
184+
def setup(app: Sphinx) -> dict[str, Any]:
185+
"""Add the `release-notes` directive."""
186+
app.add_directive("release-notes", ReleaseNotes)
187+
return metadata

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
def make_app_setup(
2626
make_app: Callable[..., Sphinx], tmp_path: Path
2727
) -> Callable[..., Sphinx]:
28-
def make_app_setup(**conf: Any) -> Sphinx: # noqa: ANN401
28+
def make_app_setup(builder: str = "html", /, **conf: Any) -> Sphinx: # noqa: ANN401
2929
(tmp_path / "conf.py").write_text("")
30-
return make_app(srcdir=tmp_path, confoverrides=conf)
30+
return make_app(buildername=builder, srcdir=tmp_path, confoverrides=conf)
3131

3232
return make_app_setup
3333

0 commit comments

Comments
 (0)