diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f46b112..95f1d1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: dependencies run: | pip install --upgrade pip wheel - pip install .[test,typehints] coverage-rich 'anyconfig[toml] >=0.14' + pip install .[test,typehints,myst] coverage-rich 'anyconfig[toml] >=0.14' - name: tests run: coverage run -m pytest --verbose --color=yes - name: show coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5d02b5..798ccac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,4 @@ repos: - pytest - types-docutils - legacy-api-wrap + - myst-parser diff --git a/pyproject.toml b/pyproject.toml index b5c568c..de33ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ dev = ['pre-commit'] test = [ 'pytest', + 'pytest-mock', 'coverage', 'legacy-api-wrap', 'defusedxml', # sphinx[test] would also pull in cython @@ -38,6 +39,7 @@ doc = [ ] typehints = ['sphinx-autodoc-typehints>=1.15.2'] theme = ['sphinx-book-theme>=1.1.0'] +myst = ['myst-parser'] [project.entry-points.'sphinx.html_themes'] scanpydoc = 'scanpydoc.theme' @@ -67,6 +69,7 @@ ignore = [ 'D103', # Test functions don’t need docstrings 'S101', # Pytest tests use `assert` 'RUF018', # Assignment expressions in assert are fine here + 'PLR0913', # Tests should be able to use as many fixtures as they want ] [tool.ruff.lint.flake8-type-checking] strict = true @@ -99,7 +102,7 @@ features = ['doc'] build = 'sphinx-build -M html docs docs/_build' [tool.hatch.envs.hatch-test] -features = ['test', 'typehints'] +features = ['test', 'typehints', 'myst'] [tool.pytest.ini_options] addopts = [ diff --git a/src/scanpydoc/__init__.py b/src/scanpydoc/__init__.py index 67550e3..f1cb5c1 100644 --- a/src/scanpydoc/__init__.py +++ b/src/scanpydoc/__init__.py @@ -46,4 +46,5 @@ def setup(app: Sphinx) -> dict[str, Any]: app.setup_extension("scanpydoc.elegant_typehints") app.setup_extension("scanpydoc.rtd_github_links") app.setup_extension("scanpydoc.theme") + app.setup_extension("scanpydoc.release_notes") return metadata diff --git a/src/scanpydoc/release_notes.py b/src/scanpydoc/release_notes.py new file mode 100644 index 0000000..74672bb --- /dev/null +++ b/src/scanpydoc/release_notes.py @@ -0,0 +1,187 @@ +"""A release notes directive. + +Given a list of version files matching :attr:`FULL_VERSION_RE`, +render them using the following (where ``.`` is the directory they are in): + +.. code:: restructuredtext + + .. release-notes:: . + +With e.g. the files `1.2.0.md`, `1.2.1.md`, `1.3.0.rst`, and `1.3.2.rst`, +this will render like the following: + +.. code:: restructuredtext + + _v1.3: + + Version 1.3 + =========== + + .. include:: 1.3.2.rst + .. include:: 1.3.0.rst + + _v1.2: + + Version 1.2 + =========== + + .. include:: 1.2.1.md + .. include:: 1.2.0.md +""" + +from __future__ import annotations + +import re +import itertools +from typing import TYPE_CHECKING +from pathlib import Path +from dataclasses import dataclass + +from docutils import nodes +from packaging.version import Version +from sphinx.util.parsing import nested_parse_to_nodes +from sphinx.util.docutils import SphinxDirective + +from . import metadata, _setup_sig + + +if TYPE_CHECKING: + from typing import Any, ClassVar + from collections.abc import Iterable, Sequence + + from sphinx.application import Sphinx + from myst_parser.mdit_to_docutils.base import DocutilsRenderer + + +FULL_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:\..*)?$") +"""Regex matching a full version number including patch part, maybe with more after.""" + + +@dataclass +class _Backend: + dir: Path + instance: SphinxDirective + + def run(self) -> Sequence[nodes.Node]: + versions = sorted( + ( + (Version(f.stem), f) + for f in self.dir.iterdir() + if FULL_VERSION_RE.match(f.stem) + ), + reverse=True, # descending + ) + version_groups = itertools.groupby( + versions, key=lambda vf: (vf[0].major, vf[0].minor) + ) + return [ + node + for (major, minor), versions in version_groups + for node in self.render_version_group(major, minor, versions) + ] + + def render_version_group( + self, + major: int, + minor: int, + versions: Iterable[tuple[Version, Path]] = (), + ) -> tuple[nodes.target, nodes.section]: + target = nodes.target( + ids=[f"v{major}-{minor}"], + names=[f"v{major}.{minor}"], + ) + section = nodes.section( + "", + nodes.title("", f"Version {major}.{minor}"), + ids=[], + names=[f"version {major}.{minor}"], + ) + + self.instance.state.document.note_implicit_target(section) + self.instance.state.document.note_explicit_target(target) + + for _, p in versions: + section += self.render_include(p) + return target, section + + def render_include(self, path: Path) -> Sequence[nodes.Node]: + return nested_parse_to_nodes( + self.instance.state, + path.read_text(), + source=str(path), + offset=self.instance.content_offset, + ) + + +# TODO(flying-sheep): Remove once MyST-Parser bug is fixed +# https://github.com/executablebooks/MyST-Parser/issues/967 +class _BackendMyst(_Backend): + def run(self) -> Sequence[nodes.Node]: + super().run() + return [] + + def render_version_group( + self, major: int, minor: int, versions: Iterable[tuple[Version, Path]] = () + ) -> tuple[nodes.target, nodes.section]: + target, section = super().render_version_group(major, minor) + # append target and section to parent + self._myst_renderer.current_node.append(target) + self._myst_renderer.update_section_level_state(section, 2) + # append children to section + with self._myst_renderer.current_node_context(section): + for _, p in versions: + self.render_include(p) + return target, section # ignored, just to not change the types + + def render_include(self, path: Path) -> Sequence[nodes.Node]: + from myst_parser.mocking import MockIncludeDirective + from docutils.parsers.rst.directives.misc import Include + + srcfile, lineno = self.instance.get_source_info() + parent_dir = Path(srcfile).parent + + d = MockIncludeDirective( + renderer=self._myst_renderer, + name=type(self).__name__, + klass=Include, # type: ignore[arg-type] # wrong type hint + arguments=[str(path.relative_to(parent_dir))], + options={}, + body=[], + lineno=lineno, + ) + return d.run() + + @property + def _myst_renderer(self) -> DocutilsRenderer: + rv: DocutilsRenderer = self.instance.state._renderer # type: ignore[attr-defined] # noqa: SLF001 + return rv + + +class ReleaseNotes(SphinxDirective): + """Directive rendering release notes, grouping them by minor versions.""" + + required_arguments: ClassVar = 1 + + def run(self) -> Sequence[nodes.Node]: + """Read the release notes and render them.""" + dir_ = Path(self.arguments[0]) + # resolve relative dir + if not dir_.is_absolute(): + src_file = Path(self.get_source_info()[0]) + if not src_file.is_file(): + msg = f"Cannot find relative path to: {src_file}" + raise self.error(msg) + dir_ = src_file.parent / self.arguments[0] + if not dir_.is_dir(): + msg = f"Not a directory: {dir_}" + raise self.error(msg) + + cls = _BackendMyst if hasattr(self.state, "_renderer") else _Backend + return cls(dir_, self).run() + + +@_setup_sig +def setup(app: Sphinx) -> dict[str, Any]: + """Add the `release-notes` directive.""" + app.add_directive("release-notes", ReleaseNotes) + return metadata diff --git a/tests/conftest.py b/tests/conftest.py index 0ccf24a..94beeb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,9 +25,9 @@ def make_app_setup( make_app: Callable[..., Sphinx], tmp_path: Path ) -> Callable[..., Sphinx]: - def make_app_setup(**conf: Any) -> Sphinx: # noqa: ANN401 + def make_app_setup(builder: str = "html", /, **conf: Any) -> Sphinx: # noqa: ANN401 (tmp_path / "conf.py").write_text("") - return make_app(srcdir=tmp_path, confoverrides=conf) + return make_app(buildername=builder, srcdir=tmp_path, confoverrides=conf) return make_app_setup diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..453534b --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,159 @@ +"""Test release_notes subextension.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING +from textwrap import dedent + +import pytest +from sphinx.errors import SphinxWarning +from docutils.utils import new_document +from docutils.languages import get_language +from docutils.parsers.rst import directives + + +if TYPE_CHECKING: + from typing import TypeAlias + from pathlib import Path + from collections.abc import Mapping, Callable + + from pytest_mock import MockerFixture + from sphinx.application import Sphinx + + Tree: TypeAlias = Mapping[str | Path, "Tree | str"] + + +def mkfiles(root: Path, tree: Tree = MappingProxyType({})) -> None: + root.mkdir(parents=True, exist_ok=True) + for path, sub in tree.items(): + if isinstance(sub, str): + (root / path).write_text(sub) + else: + mkfiles(root / path, sub) + + +@pytest.fixture(params=["rst", "myst"]) +def app( + request: pytest.FixtureRequest, make_app_setup: Callable[..., Sphinx] +) -> Sphinx: + return make_app_setup( + "pseudoxml", + extensions=[ + *(["myst_parser"] if request.param == "myst" else []), + "scanpydoc.release_notes", + ], + exclude_patterns=["[!i]*.md"], + ) + + +@pytest.fixture +def index_filename(app: Sphinx) -> str: + return "index.md" if "myst_parser" in app.extensions else "index.rst" + + +@pytest.fixture +def index_template(app: Sphinx) -> str: + return ( + "```{{release-notes}} {}\n```" + if "myst_parser" in app.extensions + else ".. release-notes:: {}" + ) + + +@pytest.fixture +def files(app: Sphinx, index_filename: str, index_template: str) -> Tree: + files: Tree + if "myst_parser" in app.extensions: + files = { + index_filename: index_template.format("."), + "1.2.0.md": "### 1.2.0", + "1.2.1.md": "### 1.2.1", + "1.3.0.md": "### 1.3.0", + "1.3.2.md": "### 1.3.2", + } + else: + files = { + index_filename: index_template.format("."), + "1.2.0.rst": "1.2.0\n=====", + "1.2.1.rst": "1.2.1\n=====", + "1.3.0.rst": "1.3.0\n=====", + "1.3.2.rst": "1.3.2\n=====", + } + return files + + +expected = """\ + +
+ + Version 1.3 + <section ids="id1" names="1.3.2"> + <title> + 1.3.2 + <section ids="id2" names="1.3.0"> + <title> + 1.3.0 +<target refid="v1-2"> +<section ids="version-1-2 v1-2" names="version\\ 1.2 v1.2"> + <title> + Version 1.2 + <section ids="id3" names="1.2.1"> + <title> + 1.2.1 + <section ids="id4" names="1.2.0"> + <title> + 1.2.0 +""" + + +def test_release_notes(tmp_path: Path, app: Sphinx, files: Tree) -> None: + mkfiles(tmp_path, files) + app.build() + index_out = (tmp_path / "_build/pseudoxml/index.pseudoxml").read_text() + assert ( + "\n".join(l[4:] for l in dedent(index_out).splitlines()[1:]) == expected.strip() + ) + + +@pytest.mark.parametrize( + ("root", "files", "pattern"), + [ + pytest.param( + "doesnt-exist.txt", {}, r"Not a directory:.*doesnt-exist.txt", id="nothing" + ), + pytest.param( + "file.txt", {"file.txt": "cont"}, r"Not a directory:.*file.txt", id="file" + ), + ], +) +def test_error_wrong_file( + tmp_path: Path, + app: Sphinx, + index_filename: str, + index_template: str, + root: str, + files: Tree, + pattern: str, +) -> None: + mkfiles(tmp_path, {index_filename: index_template.format(root), **files}) + app.warningiserror = True + with pytest.raises(SphinxWarning, match=pattern): + app.build() + + +def test_error_no_src( + mocker: MockerFixture, + tmp_path: Path, + app: Sphinx, + files: Tree, +) -> None: + if "myst_parser" not in app.extensions: + pytest.skip("rst parser doesn’t need this") + app.warningiserror = True + rn, _ = directives.directive("release-notes", get_language("en"), new_document("")) + mocker.patch.object(rn, "get_source_info", return_value=("<string>", 0)) + + mkfiles(tmp_path, files) + with pytest.raises(SphinxWarning, match=r"Cannot find relative path to: <string>"): + app.build()