diff --git a/README.md b/README.md index c9353e7..4c6d69c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. +File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. + Optional Sphinx config: ```python diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 881a4d7..098f020 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -10,9 +10,11 @@ from docutils.parsers.rst import directives from sphinx import addnodes from sphinx.application import Sphinx +from sphinx.builders import Builder from sphinx.util import logging from sphinx.util.docutils import SphinxDirective from sphinx.util.fileutil import copy_asset_file +from sphinx.util.osutil import relative_uri PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" @@ -20,6 +22,32 @@ BOOTSTRAP_FILES_KEY = "pyrepl-bootstrap-files" _DOCTEST_PARSER = DocTestParser() logger = logging.getLogger(__name__) +_ABSOLUTE_PATH_PREFIXES = ("/", "http://", "https://", "emfs:") + + +def _is_file_like_path(path: str) -> bool: + """Return True if *path* should be rewritten as a page-relative asset URL.""" + if path.startswith(_ABSOLUTE_PATH_PREFIXES): + return False + if " @ " in path: + return False + return "/" in path or path.endswith((".whl", ".py")) + + +def _asset_href(builder: Builder, docname: str, path: str) -> str: + """Rewrite a file path for the HTML page that will emit it.""" + if not _is_file_like_path(path): + return path + if builder.format != "html": + return path + return relative_uri(builder.get_target_uri(docname), path) + + +def _asset_href_packages(builder: Builder, docname: str, packages: str) -> str: + """Rewrite comma-separated package entries that refer to local files.""" + return ", ".join( + _asset_href(builder, docname, part.strip()) for part in packages.split(",") + ) def setup(app: Sphinx): @@ -105,16 +133,24 @@ def autodoc_bootstrap_source( def make_pyrepl_raw( + builder: Builder, + docname: str, replay_src: str, packages: str | None = None, src: str | None = None, ) -> nodes.raw: """Build a raw HTML node for an autodoc doctest replay widget.""" - attrs = ["no-header", "no-banner", f'replay-src="{replay_src}"'] + attrs = [ + "no-header", + "no-banner", + f'replay-src="{_asset_href(builder, docname, replay_src)}"', + ] if packages: - attrs.insert(0, f'packages="{packages}"') + attrs.insert( + 0, f'packages="{_asset_href_packages(builder, docname, packages)}"' + ) if src: - attrs.insert(0, f'src="{src}"') + attrs.insert(0, f'src="{_asset_href(builder, docname, src)}"') attr_str = " ".join(attrs) return nodes.raw("", f"\n", format="html") @@ -173,7 +209,9 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): env, docname, bootstrap_text, replay_name ) node.replace_self( - make_pyrepl_raw(replay_src, src=bootstrap_src, packages=packages) + make_pyrepl_raw( + app.builder, docname, replay_src, src=bootstrap_src, packages=packages + ) ) replaced = True @@ -201,6 +239,8 @@ class PyRepl(SphinxDirective): def run(self): env = self.env + builder = env._app.builder + docname = env.docname attrs: list[str] = [] for option, attr in ( @@ -210,6 +250,8 @@ def run(self): ): if option in self.options: value = self.options[option] + if option == "packages": + value = _asset_href_packages(builder, docname, value) attrs.append(f'{attr}="{value}"') for flag in ("no-header", "no-buttons", "readonly", "no-banner"): @@ -228,7 +270,11 @@ def run(self): except OSError as exc: raise self.error(f"Could not read file: {exc}") from exc self.env.note_dependency(path) - rel_src = path.relative_to(Path(self.env.srcdir)).as_posix() + rel_src = _asset_href( + builder, + docname, + path.relative_to(Path(self.env.srcdir)).as_posix(), + ) startup_files = json.loads( self.env.metadata[self.env.docname].setdefault( STARTUP_FILES_KEY, "[]" @@ -247,8 +293,8 @@ def run(self): if has_body: body_text = doctest_to_replay_source(list(self.content)) - replay_src, _ = register_autodoc_repl(env, env.docname, body_text) - attrs.append(f'replay-src="{replay_src}"') + replay_src, _ = register_autodoc_repl(env, docname, body_text) + attrs.append(f'replay-src="{_asset_href(builder, docname, replay_src)}"') self.env.metadata[self.env.docname]["pyrepl"] = True attr_str = (" " + " ".join(attrs)) if attrs else "" diff --git a/tests/test_asset_href.py b/tests/test_asset_href.py new file mode 100644 index 0000000..fca2fb0 --- /dev/null +++ b/tests/test_asset_href.py @@ -0,0 +1,98 @@ +import shutil +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from sphinx.application import Sphinx + +from sphinx_pyrepl_web import _asset_href, _asset_href_packages + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + ) + app.build() + return app + + +@pytest.fixture +def html_builder(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + (srcdir / "conf.py").write_text("", encoding="utf-8") + (srcdir / "index.rst").write_text("Test\n====\n", encoding="utf-8") + (srcdir / "api").mkdir() + (srcdir / "api" / "module.rst").write_text("API\n===\n", encoding="utf-8") + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + app = _build_sphinx(srcdir, outdir, doctreedir) + return app.builder + + +def test_asset_href_rewrites_static_path_on_nested_page(html_builder): + assert ( + _asset_href(html_builder, "api/module", "_static/wheels/foo.whl") + == "../_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_root_page_static_path_unchanged(html_builder): + assert ( + _asset_href(html_builder, "index", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_root_absolute_path_unchanged(html_builder): + assert ( + _asset_href(html_builder, "api/module", "/_static/wheels/foo.whl") + == "/_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_https_url_unchanged(html_builder): + url = "https://cdn.example/w.whl" + assert _asset_href(html_builder, "api/module", url) == url + + +def test_asset_href_leaves_pypi_name_unchanged(html_builder): + assert _asset_href(html_builder, "api/module", "numpy") == "numpy" + + +def test_asset_href_rewrites_src_relative_to_source_root(html_builder): + assert _asset_href(html_builder, "api/module", "demo.py") == "../demo.py" + + +def test_asset_href_leaves_micropip_spec_unchanged(html_builder): + spec = "mypkg @ https://example.com/wheels/mypkg.whl" + assert _asset_href(html_builder, "api/module", spec) == spec + + +def test_asset_href_packages_rewrites_only_file_like_entries(html_builder): + packages = "numpy, _static/wheels/foo.whl" + assert _asset_href_packages(html_builder, "api/module", packages) == ( + "numpy, ../_static/wheels/foo.whl" + ) + + +def test_asset_href_skips_normalization_for_non_html_builder(): + builder = MagicMock() + builder.format = "" + assert ( + _asset_href(builder, "api/module", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) diff --git a/tests/test_nested_static_paths.py b/tests/test_nested_static_paths.py new file mode 100644 index 0000000..2acc8cc --- /dev/null +++ b/tests/test_nested_static_paths.py @@ -0,0 +1,77 @@ +import shutil +import sys +from pathlib import Path + +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" + +sys.path.insert(0, str(ROOT)) + + +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + ) + app.build() + return app + + +def test_nested_page_emits_page_relative_static_paths(tmp_path): + srcdir = tmp_path / "docs" + wheels_dir = srcdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + (srcdir / "api").mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + """ +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +html_static_path = ["_static"] +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") + (srcdir / "api" / "index.rst").write_text( + f""" +API +=== + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: + + >>> 1 + 1 +""", + encoding="utf-8", + ) + + app = _build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "api" / "index.html").read_text(encoding="utf-8") + assert 'packages="../_static/wheels/' in html + assert 'packages="api/_static/' not in html + assert 'packages="/_static/' not in html + assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html + + root_html = (outdir / "index.html").read_text(encoding="utf-8") + assert "py-repl" not in root_html + + replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") + assert replay_files is not None