Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 53 additions & 7 deletions sphinx_pyrepl_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,44 @@
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"
REPLAY_FILES_KEY = "pyrepl-replay-files"
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):
Expand Down Expand Up @@ -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"<py-repl {attr_str}></py-repl>\n", format="html")

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 (
Expand All @@ -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"):
Expand All @@ -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, "[]"
Expand All @@ -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 ""
Expand Down
98 changes: 98 additions & 0 deletions tests/test_asset_href.py
Original file line number Diff line number Diff line change
@@ -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"
)
77 changes: 77 additions & 0 deletions tests/test_nested_static_paths.py
Original file line number Diff line number Diff line change
@@ -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
Loading