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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Optional Sphinx config:
```python
pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script
pyrepl_doctest_blocks = False # default; see Docstring conversion below
pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs
pyrepl_autodoc_packages = None # optional; wheel path or PyPI name for autodoc REPLs
```

### Docstring conversion
Expand All @@ -82,6 +82,7 @@ extensions = [
"sphinx_pyrepl_web",
]
pyrepl_doctest_blocks = "autodoc"
pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl"
```

| | `pyrepl_doctest_blocks` options |
Expand All @@ -91,10 +92,24 @@ pyrepl_doctest_blocks = "autodoc"
| `"all"` | Transform every doctest block found |


| | `pyrepl_autodoc_bootstrap` options |
|------------------|------------------------------------------------------------------------------|
| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:` only |
| `False` | Replay doctest input only; documented names are not pre-defined |
| | `pyrepl_autodoc_packages` options |
|-------------------------|------------------------------------------------------------------|
| unset / `None` / `""` | Replay doctest input only (no wheel install or auto-import) |
| wheel path or PyPI name | Install the package and import the documented object before replay (comma-separated) |

Autodoc integration assumes a single documented package. The wheel (or PyPI
name) is installed in the browser REPL and the documented name is imported
automatically so doctest examples can use unqualified names. Autodoc still
imports the package on the host at build time.

To build this project's docs locally:

```bash
pip install -e ".[docs]"
```

The `[docs]` extra installs doc build dependencies plus the `pyrepl_test_pkg`
fixture used in the examples.

### Local Pyodide wheels

Expand Down
Binary file modified docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl
Binary file not shown.
5 changes: 1 addition & 4 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
from datetime import date
import sys
from pathlib import Path

from sphinx_pyrepl_web import __version__

sys.path.insert(0, str(Path(__file__).parent / "_static"))

project = "sphinx-pyrepl-web"
version = __version__
author = "Christian López Barrón"
Expand All @@ -18,6 +14,7 @@
"sphinx_pyrepl_web",
]
pyrepl_doctest_blocks = "autodoc"
pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl"
html_static_path = ["_static"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]

Expand Down
27 changes: 18 additions & 9 deletions docs/example.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,31 +123,40 @@ Rendered result:
Autodoc
-------

The documented module's source is loaded in advance before replay, so
module members are available in the REPL namespace when the module lives
under the Sphinx source tree (silent ``:src:``).
When ``pyrepl_doctest_blocks = "autodoc"``, doctest examples in documented
APIs become interactive REPLs. Set ``pyrepl_autodoc_packages`` to install the
documented package from a Pyodide-compatible wheel (or PyPI name) and
automatically import the documented object before replay:

.. code-block:: python

# conf.py
html_static_path = ["_static"]
pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl"

Autodoc still imports the package on the host at build time (for example via
``pip install -e ".[docs]"`` in this repository).

Source module:

.. literalinclude:: _static/autodoc_demo.py
.. literalinclude:: ../../tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py
:language: python

RST content:

.. code-block:: rst

.. autofunction:: autodoc_demo.example_generator
.. autofunction:: pyrepl_test_pkg.demo.example_generator

Rendered result:

.. autofunction:: autodoc_demo.example_generator
.. autofunction:: pyrepl_test_pkg.demo.example_generator

Local Pyodide wheels
--------------------

Preload a Pyodide-compatible wheel from ``_static/wheels/`` via ``:packages:``.
Combine with ``:src:`` for optional post-install bootstrap and a doctest replay
body:
The same wheel can be referenced manually from ``.. py-repl::`` when you want
a standalone REPL without autodoc:

.. code-block:: rst

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ test = [
]
docs = [
"myst-parser",
"pyrepl_test_pkg @ file:./tests/fixtures/pyrepl_test_pkg",
]

[tool.pytest.ini_options]
Expand Down
59 changes: 59 additions & 0 deletions scripts/build_test_pkg_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Build pyrepl_test_pkg wheel and copy it into docs and test fixture paths."""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PKG_DIR = ROOT / "tests" / "fixtures" / "pyrepl_test_pkg"
WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl"
DEST_DIRS = (
ROOT / "tests" / "fixtures" / "wheels",
ROOT / "docs" / "_static" / "wheels",
)


def run(cmd: list[str], *, cwd: Path) -> None:
print(f"+ {' '.join(cmd)}")
subprocess.run(cmd, cwd=cwd, check=True)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--keep-dist",
action="store_true",
help="Leave the build dist/ directory in the fixture package tree",
)
args = parser.parse_args()

dist_dir = PKG_DIR / "dist"
if dist_dir.exists():
shutil.rmtree(dist_dir)

run([sys.executable, "-m", "pip", "wheel", ".", "-w", "dist"], cwd=PKG_DIR)

built = dist_dir / WHEEL_NAME
if not built.is_file():
wheels = sorted(dist_dir.glob("*.whl"))
if len(wheels) != 1:
sys.exit(f"expected one wheel in {dist_dir}, found: {wheels!r}")
built = wheels[0]

for dest_dir in DEST_DIRS:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / WHEEL_NAME
shutil.copy2(built, dest)
print(f"copied {built.name} -> {dest}")

if not args.keep_dist:
shutil.rmtree(dist_dir)


if __name__ == "__main__":
main()
137 changes: 66 additions & 71 deletions sphinx_pyrepl_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@

__version__ = "0.2.0"

import importlib
import inspect
import json
from doctest import DocTestParser
from pathlib import Path
import sys

from docutils import nodes
from docutils.parsers.rst import directives
Expand All @@ -20,6 +17,7 @@
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__)

Expand All @@ -28,7 +26,7 @@ def setup(app: Sphinx):
"""Setup the extension."""
app.add_config_value("pyrepl_js", "../pyrepl.js", "env")
app.add_config_value("pyrepl_doctest_blocks", False, "env")
app.add_config_value("pyrepl_autodoc_bootstrap", True, "env")
app.add_config_value("pyrepl_autodoc_packages", None, "env")
app.add_directive("py-repl", PyRepl)
app.connect("doctree-read", doctree_read)
app.connect("doctree-read", transform_doctest_blocks)
Expand Down Expand Up @@ -59,36 +57,57 @@ def register_autodoc_repl(
env,
docname: str,
replay_text: str,
) -> str:
"""Record a replay script in env metadata and return its replay-src path."""
) -> tuple[str, str]:
"""Record a replay script in env metadata and return (replay-src path, name)."""
replay_files = json.loads(
env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}")
)
counter = _next_replay_counter(replay_files)
replay_name = f"{docname.replace('/', '-')}-{counter}.py"
replay_files[replay_name] = replay_text
env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files)
return f"_static/pyrepl/{replay_name}"
return f"_static/pyrepl/{replay_name}", replay_name


def register_startup_file(env, docname: str, path: Path) -> str:
"""Track a startup script under srcdir for copying into HTML output."""
env.note_dependency(path)
rel_src = path.relative_to(Path(env.srcdir)).as_posix()
startup_files = json.loads(
env.metadata[docname].setdefault(STARTUP_FILES_KEY, "[]")
def register_autodoc_bootstrap(
env,
docname: str,
bootstrap_text: str,
replay_name: str,
) -> str:
"""Record a silent bootstrap script paired with a replay file."""
bootstrap_files = json.loads(
env.metadata[docname].setdefault(BOOTSTRAP_FILES_KEY, "{}")
)
abs_path = str(path.resolve())
if abs_path not in startup_files:
startup_files.append(abs_path)
env.metadata[docname][STARTUP_FILES_KEY] = json.dumps(startup_files)
return rel_src
bootstrap_name = replay_name.replace(".py", "-bootstrap.py")
bootstrap_files[bootstrap_name] = bootstrap_text
env.metadata[docname][BOOTSTRAP_FILES_KEY] = json.dumps(bootstrap_files)
return f"_static/pyrepl/{bootstrap_name}"


def autodoc_bootstrap_source(
module: str | None,
fullname: str | None,
objtype: str | None,
) -> str | None:
"""Return a silent import script for the documented autodoc object."""
if not module:
return None

if objtype == "module" or not fullname:
return f"import {module}\n"

if "." in fullname:
root = fullname.split(".", 1)[0]
return f"from {module} import {root}\n"

return f"from {module} import {fullname}\n"


def make_pyrepl_raw(
replay_src: str,
src: str | None = None,
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}"']
Expand All @@ -110,51 +129,9 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None:
return None


def _resolve_autodoc_bootstrap(
app: Sphinx, env, docname: str, desc: addnodes.desc
) -> tuple[str | None, str | None]:
"""Return (startup src path, packages) for autodoc REPLs.

Only modules whose source lives under the Sphinx source directory are
bootstrapped via silent ``:src:``. Installed or out-of-tree modules are
left without bootstrap; use explicit ``:packages:`` wheel paths instead.
"""
if not app.config.pyrepl_autodoc_bootstrap:
return None, None

sig = desc.next_node(addnodes.desc_signature)
if sig is None:
return None, None

module_name = sig.get("module")
fullname = sig.get("fullname")
if not module_name:
return None, None

target = f"{module_name}.{fullname}" if fullname else module_name
try:
mod = sys.modules.get(module_name)
if mod is None:
mod = importlib.import_module(module_name)
obj = mod
if fullname:
for part in fullname.split("."):
obj = getattr(obj, part)
mod_obj = inspect.getmodule(obj) or mod
source_path = Path(inspect.getfile(mod_obj)).resolve()
srcdir = Path(env.srcdir).resolve()
try:
source_path.relative_to(srcdir)
return register_startup_file(env, docname, source_path), None
except ValueError:
return None, None
except (AttributeError, ImportError, OSError, TypeError) as exc:
logger.error(
"Could not bootstrap autodoc REPL for %s: %s",
target,
exc,
)
return None, None
def _autodoc_packages(app: Sphinx) -> str | None:
"""Return configured package preload for autodoc doctest REPLs."""
return app.config.pyrepl_autodoc_packages or None


def _inside_autodoc_desc(node: nodes.Node) -> bool:
Expand All @@ -177,15 +154,27 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document):
source = doctest_to_replay_source(node.astext())
if not source.strip():
continue
bootstrap_src = None
packages = None
bootstrap_src = None
desc = _find_autodoc_desc(node)
if desc is not None:
bootstrap_src, packages = _resolve_autodoc_bootstrap(
app, env, docname, desc
)
replay_src = register_autodoc_repl(env, docname, source)
node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages))
packages = _autodoc_packages(app)
replay_src, replay_name = register_autodoc_repl(env, docname, source)
if desc is not None and packages:
sig = desc.next_node(addnodes.desc_signature)
if sig is not None:
bootstrap_text = autodoc_bootstrap_source(
sig.get("module"),
sig.get("fullname"),
desc.get("objtype"),
)
if bootstrap_text:
bootstrap_src = register_autodoc_bootstrap(
env, docname, bootstrap_text, replay_name
)
node.replace_self(
make_pyrepl_raw(replay_src, src=bootstrap_src, packages=packages)
)
replaced = True

if replaced:
Expand Down Expand Up @@ -258,7 +247,7 @@ 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)
replay_src, _ = register_autodoc_repl(env, env.docname, body_text)
attrs.append(f'replay-src="{replay_src}"')

self.env.metadata[self.env.docname]["pyrepl"] = True
Expand Down Expand Up @@ -303,6 +292,12 @@ def copy_asset_files(app, _):
for name, content in replay_files.items():
(replay_dest / name).write_text(content, encoding="utf-8")

raw_bootstrap = metadata.get(BOOTSTRAP_FILES_KEY)
if raw_bootstrap:
bootstrap_files = json.loads(raw_bootstrap)
for name, content in bootstrap_files.items():
(replay_dest / name).write_text(content, encoding="utf-8")

srcdir = Path(app.builder.srcdir)
copied = set()
for docname, metadata in app.env.metadata.items():
Expand Down
Loading
Loading