From 0d21a9cb32a15b060fd303089948d1cde9b0f347 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 22:16:57 +0000 Subject: [PATCH 01/14] Add autodoc doctest_block REPL integration Transform doctest blocks inside autodoc API entries into interactive py-repl widgets using stdlib doctest.DocTestParser for source extraction. - Add pyrepl_doctest_blocks config (default: autodoc) - Refactor register_replay_script() shared by directive and transform - Remove unused :strip-prompts: option - Add tests and docs with example_generator demo Co-authored-by: chrizzftd --- README.md | 46 ++++++++- docs/_static/autodoc_demo.py | 15 +++ docs/conf.py | 6 ++ docs/example.rst | 22 ++++ sphinx_pyrepl_web/__init__.py | 81 ++++++++++++--- tests/test_autodoc_doctest.py | 155 ++++++++++++++++++++++++++++ tests/test_strip_doctest_prompts.py | 23 ++++- 7 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 docs/_static/autodoc_demo.py create mode 100644 tests/test_autodoc_doctest.py diff --git a/README.md b/README.md index 9196b8c..3782e89 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attri | `:src:` | Path to a Python startup script | ✅ | | `:replay:` | Replay `:src:` with interactive prompts instead of silent load | ✅ | | `:silent:` | Keep `:src:` silent even when combined with a directive body | ❌ | -| `:strip-prompts:` | Strip ``>>>`` / ``...`` prefixes from directive body | ❌ | | `:no-header:` | Hide the header bar | ✅ | | `:no-buttons:` | Hide copy/clear buttons | ✅ | | `:readonly:` | Disable input | ✅ | @@ -68,8 +67,53 @@ Optional Sphinx config: ```python pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script +pyrepl_doctest_blocks = "autodoc" # default; see Autodoc integration below ``` +### Autodoc integration + +When used with `sphinx.ext.autodoc` (and optionally `sphinx.ext.napoleon` for Google/NumPy-style docstrings), doctest examples in documented API entries are automatically converted into interactive REPLs. + +```python +# conf.py +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_pyrepl_web", +] +pyrepl_doctest_blocks = "autodoc" # default +``` + +Write doctest examples in your docstrings as usual: + +```python +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Examples: + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + yield from range(n) +``` + +Document the function with autodoc — no manual `.. py-repl::` directive needed: + +```rst +.. autofunction:: my_module.example_generator +``` + +The static doctest block is replaced with an interactive REPL at build time. Expected output lines (e.g. `[0, 1, 2, 3]`) are stripped; only executable input is replayed. + +| `pyrepl_doctest_blocks` | Behavior | +|---|---| +| `False` | Disable autodoc transform; only explicit `.. py-repl::` directives | +| `"autodoc"` (default) | Transform doctests inside autodoc API entries only | +| `"all"` | Transform every doctest block on every page | + +**Note:** the browser REPL runs in Pyodide and cannot import your local package unless you bootstrap it (e.g. with a global `:src:` setup script). Autodoc REPL examples work best for stdlib snippets or self-contained code. Auto-bootstrap is planned for a future release. + ## Updating pyrepl-web Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run: diff --git a/docs/_static/autodoc_demo.py b/docs/_static/autodoc_demo.py new file mode 100644 index 0000000..8701778 --- /dev/null +++ b/docs/_static/autodoc_demo.py @@ -0,0 +1,15 @@ +"""Demo module for autodoc doctest REPL integration.""" + + +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Examples: + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + yield from range(n) diff --git a/docs/conf.py b/docs/conf.py index ff8fa8a..2bb8173 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,11 @@ 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" @@ -9,6 +13,8 @@ extensions = [ "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", "sphinx_pyrepl_web", ] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/docs/example.rst b/docs/example.rst index d254db6..12399a7 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -113,3 +113,25 @@ The replay script: .. literalinclude:: _static/replay_demo.py :language: python + +Autodoc integration +------------------- + +When ``sphinx.ext.autodoc`` is enabled, doctest examples in API docstrings are +automatically converted into interactive REPLs (``pyrepl_doctest_blocks = "autodoc"``, +the default). Expected output lines are stripped; only executable input is replayed. + +Source module: + +.. literalinclude:: _static/autodoc_demo.py + :language: python + +RST page (autodoc only — no manual ``.. py-repl::`` needed): + +.. code-block:: rst + + .. autofunction:: autodoc_demo.example_generator + +Rendered result: + +.. autofunction:: autodoc_demo.example_generator diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 24ea698..5f0c030 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -3,10 +3,12 @@ __version__ = "0.1.1" import json +from doctest import DocTestParser from pathlib import Path from docutils import nodes from docutils.parsers.rst import directives +from sphinx import addnodes from sphinx.application import Sphinx from sphinx.util.docutils import SphinxDirective from sphinx.util.fileutil import copy_asset_file @@ -14,18 +16,31 @@ PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" REPLAY_FILES_KEY = "pyrepl-replay-files" +_DOCTEST_PARSER = DocTestParser() def setup(app: Sphinx): """Setup the extension.""" app.add_config_value("pyrepl_js", "../pyrepl.js", "env") + app.add_config_value("pyrepl_doctest_blocks", "autodoc", "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) + app.connect("doctree-resolved", transform_doctest_blocks) app.connect("html-page-context", add_html_context) app.connect("env-updated", copy_asset_files) return {"version": __version__, "parallel_read_safe": True} +def extract_doctest_source(text: str) -> str: + """Extract executable Python source from doctest-formatted text.""" + lines: list[str] = [] + for example in _DOCTEST_PARSER.get_examples(text): + lines.extend(example.source.splitlines()) + if not lines: + return "" + return "\n".join(lines) + "\n" + + def strip_doctest_prompts(lines: list[str]) -> list[str]: """Remove leading ``>>> `` / ``... `` prompts from doctest-style lines. @@ -51,6 +66,59 @@ def strip_doctest_prompts(lines: list[str]) -> list[str]: return result +def register_replay_script(env, docname: str, body_text: str) -> str: + """Record a replay script in env metadata and return its replay-src path.""" + replay_files = json.loads( + env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}") + ) + counter = len(replay_files) + 1 + script_name = f"{docname.replace('/', '-')}-{counter}.py" + replay_files[script_name] = body_text + env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files) + return f"_static/pyrepl/{script_name}" + + +def make_pyrepl_raw(replay_src: str) -> nodes.raw: + """Build a raw HTML node for an autodoc doctest replay widget.""" + return nodes.raw( + "", + f'\n', + format="html", + ) + + +def _inside_autodoc_desc(node: nodes.Node) -> bool: + """Return True if *node* is nested inside an autodoc desc entry.""" + current = node.parent + while current is not None: + if isinstance(current, addnodes.desc): + return True + current = current.parent + return False + + +def transform_doctest_blocks(app: Sphinx, doctree: nodes.document, docname: str): + """Replace doctest blocks with interactive py-repl widgets.""" + scope = app.config.pyrepl_doctest_blocks + if not scope: + return + + env = app.env + replaced = False + for node in doctree.findall(nodes.doctest_block): + if scope == "autodoc" and not _inside_autodoc_desc(node): + continue + source = extract_doctest_source(node.astext()) + if not source.strip(): + continue + replay_src = register_replay_script(env, docname, source) + node.replace_self(make_pyrepl_raw(replay_src)) + replaced = True + + if replaced: + env.metadata[docname]["pyrepl"] = True + + class PyRepl(SphinxDirective): """Embed a pyrepl-web ```` element.""" @@ -66,7 +134,6 @@ class PyRepl(SphinxDirective): "no-banner": directives.flag, "replay": directives.flag, "silent": directives.flag, - "strip-prompts": directives.flag, } def run(self): @@ -118,17 +185,7 @@ def run(self): if has_body: body_lines = strip_doctest_prompts(list(self.content)) body_text = "\n".join(body_lines) + "\n" - - replay_files = json.loads( - self.env.metadata[self.env.docname].setdefault(REPLAY_FILES_KEY, "{}") - ) - counter = len(replay_files) + 1 - script_name = f"{env.docname.replace('/', '-')}-{counter}.py" - replay_files[script_name] = body_text - self.env.metadata[self.env.docname][REPLAY_FILES_KEY] = json.dumps( - replay_files - ) - replay_src = f"_static/pyrepl/{script_name}" + replay_src = register_replay_script(env, env.docname, body_text) attrs.append(f'replay-src="{replay_src}"') self.env.metadata[self.env.docname]["pyrepl"] = True diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py new file mode 100644 index 0000000..e0e91c8 --- /dev/null +++ b/tests/test_autodoc_doctest.py @@ -0,0 +1,155 @@ +import json +import sys +from pathlib import Path + +import pytest +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +EXAMPLE_GENERATOR_SOURCE = ''' +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Examples: + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + yield from range(n) +'''.strip() + "\n" + + +@pytest.fixture +def autodoc_project(tmp_path): + mod_dir = tmp_path / "pkg" + mod_dir.mkdir() + (mod_dir / "demo.py").write_text(EXAMPLE_GENERATOR_SOURCE, encoding="utf-8") + + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + f""" +import sys +sys.path.insert(0, {str(mod_dir)!r}) +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_pyrepl_web", +] +master_doc = "index" +pyrepl_js = "pyrepl.js" +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + """ +API +=== + +.. autofunction:: demo.example_generator + +Tutorial +======== + +Plain doctest (outside autodoc): + +>>> x = 1 +>>> x + 1 +2 +""".strip() + + "\n", + encoding="utf-8", + ) + return srcdir, outdir, doctreedir, mod_dir + + +def _build_sphinx(srcdir, outdir, doctreedir, **kwargs): + 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, + **kwargs, + ) + app.build() + return app + + +def test_autodoc_doctest_becomes_pyrepl(autodoc_project): + srcdir, outdir, doctreedir, _ = autodoc_project + app = _build_sphinx(srcdir, outdir, doctreedir) + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 1 + script_name = next(iter(replay_files)) + script = replay_files[script_name] + assert script == "print([i for i in example_generator(4)])\n" + assert "[0, 1, 2, 3]" not in script + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'replay-src="_static/pyrepl/{script_name}"' in html + assert "no-header" in html + assert "no-banner" in html + + +def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): + srcdir, outdir, doctreedir, _ = autodoc_project + app = _build_sphinx(srcdir, outdir, doctreedir) + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 1 + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert html.count("replay-src=") == 1 + + +def test_all_scope_transforms_plain_rst_doctest(autodoc_project): + srcdir, outdir, doctreedir, _ = autodoc_project + conf = (srcdir / "conf.py").read_text(encoding="utf-8") + (srcdir / "conf.py").write_text( + conf + '\npyrepl_doctest_blocks = "all"\n', encoding="utf-8" + ) + app = _build_sphinx(srcdir, outdir, doctreedir) + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 2 + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert html.count("replay-src=") == 2 + + +def test_disabled_scope_leaves_doctest_static(autodoc_project): + srcdir, outdir, doctreedir, _ = autodoc_project + conf = (srcdir / "conf.py").read_text(encoding="utf-8") + (srcdir / "conf.py").write_text( + conf + "\npyrepl_doctest_blocks = False\n", encoding="utf-8" + ) + app = _build_sphinx(srcdir, outdir, doctreedir) + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert replay_files == {} + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert "replay-src=" not in html diff --git a/tests/test_strip_doctest_prompts.py b/tests/test_strip_doctest_prompts.py index 088fef0..17c55e2 100644 --- a/tests/test_strip_doctest_prompts.py +++ b/tests/test_strip_doctest_prompts.py @@ -1,4 +1,25 @@ -from sphinx_pyrepl_web import strip_doctest_prompts +from sphinx_pyrepl_web import extract_doctest_source, strip_doctest_prompts + + +def test_extract_doctest_source_strips_expected_output(): + block = ">>> add(1, 2)\n3\n>>> add(0, 0)\n0" + assert extract_doctest_source(block) == "add(1, 2)\nadd(0, 0)\n" + + +def test_extract_doctest_source_example_generator(): + block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" + assert extract_doctest_source(block) == ( + "print([i for i in example_generator(4)])\n" + ) + + +def test_extract_doctest_source_multiline_class(): + block = ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n" + assert extract_doctest_source(block) == "class Foo:\n x = 1\nFoo()\n" + + +def test_extract_doctest_source_empty(): + assert extract_doctest_source("not a doctest") == "" def test_issue_7_multiline_class_with_bare_terminator(): From 6ef66b1fb9db2bce5f568749027c5089f58a999f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 22:30:36 +0000 Subject: [PATCH 02/14] Fix autodoc replay scripts not written to output Move doctest_block transform from doctree-resolved to doctree-read. env-updated (which writes replay scripts) runs after read but before write, so metadata registered during doctree-resolved was too late. Add regression test for include:: pattern used on RTD docs. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 5 +- tests/test_autodoc_doctest.py | 3 ++ tests/test_autodoc_include.py | 97 +++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 tests/test_autodoc_include.py diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 5f0c030..010d9ac 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -25,7 +25,7 @@ def setup(app: Sphinx): app.add_config_value("pyrepl_doctest_blocks", "autodoc", "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) - app.connect("doctree-resolved", transform_doctest_blocks) + app.connect("doctree-read", transform_doctest_blocks) app.connect("html-page-context", add_html_context) app.connect("env-updated", copy_asset_files) return {"version": __version__, "parallel_read_safe": True} @@ -97,13 +97,14 @@ def _inside_autodoc_desc(node: nodes.Node) -> bool: return False -def transform_doctest_blocks(app: Sphinx, doctree: nodes.document, docname: str): +def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): """Replace doctest blocks with interactive py-repl widgets.""" scope = app.config.pyrepl_doctest_blocks if not scope: return env = app.env + docname = env.docname replaced = False for node in doctree.findall(nodes.doctest_block): if scope == "autodoc" and not _inside_autodoc_desc(node): diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index e0e91c8..96bd5dd 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -107,6 +107,9 @@ def test_autodoc_doctest_becomes_pyrepl(autodoc_project): assert "no-header" in html assert "no-banner" in html + script_path = outdir / "_static" / "pyrepl" / script_name + assert script_path.is_file(), f"missing replay script at {script_path}" + def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): srcdir, outdir, doctreedir, _ = autodoc_project diff --git a/tests/test_autodoc_include.py b/tests/test_autodoc_include.py new file mode 100644 index 0000000..3e09d59 --- /dev/null +++ b/tests/test_autodoc_include.py @@ -0,0 +1,97 @@ +import json +import sys +from pathlib import Path + +import pytest +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def included_example_project(tmp_path): + """Project where example content is included into index.rst (like RTD docs).""" + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + f""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent / "_static")) +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_pyrepl_web", +] +master_doc = "index" +pyrepl_js = "pyrepl.js" +exclude_patterns = ["example.rst"] +""", + encoding="utf-8", + ) + (srcdir / "_static").mkdir() + (srcdir / "_static" / "repl_include_demo.py").write_text( + ''' +def example_generator(n): + """Example. + + Examples: + >>> print(list(example_generator(2))) + [0, 1] + + """ + yield from range(n) +'''.strip() + + "\n", + encoding="utf-8", + ) + (srcdir / "example.rst").write_text( + """ +.. py-repl:: + :no-header: + + >>> 1 + 1 + +.. autofunction:: repl_include_demo.example_generator +""".strip() + + "\n", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + ".. include:: example.rst\n", + encoding="utf-8", + ) + return srcdir, outdir, doctreedir + + +def test_included_example_writes_all_replay_scripts(included_example_project): + srcdir, outdir, doctreedir = included_example_project + 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() + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 2 + + html = (outdir / "index.html").read_text(encoding="utf-8") + for script_name in replay_files: + replay_src = f'_static/pyrepl/{script_name}' + assert replay_src in html + script_path = outdir / "_static" / "pyrepl" / script_name + assert script_path.is_file(), f"missing replay script at {script_path}" From 5ac1c63e390fc95d3c68abc1e9792d1d0920088f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 22:37:23 +0000 Subject: [PATCH 03/14] Auto-bootstrap autodoc REPLs with silent :src: startup script Load the documented module's source before replaying doctest examples, using the existing src + replay-src pattern from pyrepl-web. - Modules under srcdir: reuse the source file via :src: - Modules elsewhere: generate a bootstrap script in _static/pyrepl/ - Prefer sys.modules (already imported by autodoc) over re-import - Add pyrepl_autodoc_bootstrap config (default: True) Co-authored-by: chrizzftd --- README.md | 3 +- docs/example.rst | 2 + sphinx_pyrepl_web/__init__.py | 133 +++++++++++++++++++++++++++----- tests/test_autodoc_bootstrap.py | 68 ++++++++++++++++ tests/test_autodoc_doctest.py | 29 ++++--- tests/test_autodoc_include.py | 6 +- 6 files changed, 211 insertions(+), 30 deletions(-) create mode 100644 tests/test_autodoc_bootstrap.py diff --git a/README.md b/README.md index 3782e89..5396d3c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Optional Sphinx config: ```python pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script pyrepl_doctest_blocks = "autodoc" # default; see Autodoc integration below +pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs ``` ### Autodoc integration @@ -112,7 +113,7 @@ The static doctest block is replaced with an interactive REPL at build time. Exp | `"autodoc"` (default) | Transform doctests inside autodoc API entries only | | `"all"` | Transform every doctest block on every page | -**Note:** the browser REPL runs in Pyodide and cannot import your local package unless you bootstrap it (e.g. with a global `:src:` setup script). Autodoc REPL examples work best for stdlib snippets or self-contained code. Auto-bootstrap is planned for a future release. +**Note:** autodoc REPLs automatically bootstrap the documented module's source as a silent `:src:` startup script (`pyrepl_autodoc_bootstrap = True` by default), so doctest examples can call documented functions. Modules outside the Sphinx source directory get a generated bootstrap script under `_static/pyrepl/`. Disable with `pyrepl_autodoc_bootstrap = False`. Modules that import unavailable packages may still fail in the browser REPL. ## Updating pyrepl-web diff --git a/docs/example.rst b/docs/example.rst index 12399a7..d91a282 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -120,6 +120,8 @@ Autodoc integration When ``sphinx.ext.autodoc`` is enabled, doctest examples in API docstrings are automatically converted into interactive REPLs (``pyrepl_doctest_blocks = "autodoc"``, the default). Expected output lines are stripped; only executable input is replayed. +The documented module's source is loaded silently via ``:src:`` before replay, so +functions like ``example_generator`` are available in the REPL namespace. Source module: diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 010d9ac..1fe4e68 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -2,9 +2,12 @@ __version__ = "0.1.1" +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 @@ -23,6 +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", "autodoc", "env") + app.add_config_value("pyrepl_autodoc_bootstrap", True, "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) app.connect("doctree-read", transform_doctest_blocks) @@ -66,35 +70,115 @@ def strip_doctest_prompts(lines: list[str]) -> list[str]: return result -def register_replay_script(env, docname: str, body_text: str) -> str: - """Record a replay script in env metadata and return its replay-src path.""" +def _next_replay_counter(replay_files: dict[str, str]) -> int: + return sum(1 for name in replay_files if not name.endswith("-bootstrap.py")) + 1 + + +def register_autodoc_repl( + env, + docname: str, + replay_text: str, + *, + bootstrap_src: str | None = None, + bootstrap_content: str | None = None, +) -> tuple[str, str | None]: + """Record replay and optional bootstrap scripts, returning HTML src paths.""" replay_files = json.loads( env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}") ) - counter = len(replay_files) + 1 - script_name = f"{docname.replace('/', '-')}-{counter}.py" - replay_files[script_name] = body_text + counter = _next_replay_counter(replay_files) + base = f"{docname.replace('/', '-')}-{counter}" + replay_name = f"{base}.py" + replay_files[replay_name] = replay_text + + startup_src = bootstrap_src + if bootstrap_content is not None: + bootstrap_name = f"{base}-bootstrap.py" + replay_files[bootstrap_name] = bootstrap_content + startup_src = f"_static/pyrepl/{bootstrap_name}" + env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files) - return f"_static/pyrepl/{script_name}" + return f"_static/pyrepl/{replay_name}", startup_src -def make_pyrepl_raw(replay_src: str) -> nodes.raw: - """Build a raw HTML node for an autodoc doctest replay widget.""" - return nodes.raw( - "", - f'\n', - format="html", +def register_replay_script(env, docname: str, body_text: str) -> str: + """Record a replay script in env metadata and return its replay-src path.""" + replay_src, _ = register_autodoc_repl(env, docname, body_text) + return replay_src + + +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, "[]") ) + 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 -def _inside_autodoc_desc(node: nodes.Node) -> bool: - """Return True if *node* is nested inside an autodoc desc entry.""" +def make_pyrepl_raw(replay_src: str, 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}"'] + if src: + attrs.insert(0, f'src="{src}"') + attr_str = " ".join(attrs) + return nodes.raw("", f"\n", format="html") + + +def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: + """Return the enclosing autodoc desc node, if any.""" current = node.parent while current is not None: if isinstance(current, addnodes.desc): - return True + return current current = current.parent - return False + return None + + +def _resolve_autodoc_bootstrap( + app: Sphinx, env, docname: str, desc: addnodes.desc +) -> tuple[str | None, str | None]: + """Return (startup src path, generated bootstrap content) for autodoc REPLs.""" + 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 + + 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, inspect.getsource(mod_obj) + "\n" + except (AttributeError, ImportError, OSError, TypeError): + return None, None + + +def _inside_autodoc_desc(node: nodes.Node) -> bool: + """Return True if *node* is nested inside an autodoc desc entry.""" + return _find_autodoc_desc(node) is not None def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): @@ -112,8 +196,21 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): source = extract_doctest_source(node.astext()) if not source.strip(): continue - replay_src = register_replay_script(env, docname, source) - node.replace_self(make_pyrepl_raw(replay_src)) + bootstrap_src = None + bootstrap_content = None + desc = _find_autodoc_desc(node) + if desc is not None: + bootstrap_src, bootstrap_content = _resolve_autodoc_bootstrap( + app, env, docname, desc + ) + replay_src, startup_src = register_autodoc_repl( + env, + docname, + source, + bootstrap_src=bootstrap_src, + bootstrap_content=bootstrap_content, + ) + node.replace_self(make_pyrepl_raw(replay_src, startup_src)) replaced = True if replaced: diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py new file mode 100644 index 0000000..460f8a8 --- /dev/null +++ b/tests/test_autodoc_bootstrap.py @@ -0,0 +1,68 @@ +import json +import sys +from pathlib import Path + +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def test_autodoc_bootstrap_uses_srcdir_module(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + (srcdir / "_static").mkdir() + (srcdir / "_static" / "demo.py").write_text( + ''' +def greet(): + """Say hello. + + Examples: + >>> greet() + 'hi' + + """ + return "hi" +'''.strip() + + "\n", + encoding="utf-8", + ) + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + """ +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent / "_static")) +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text(".. autofunction:: demo.greet\n", encoding="utf-8") + + 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() + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert 'src="_static/demo.py"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + assert (outdir / "_static" / "demo.py").is_file() + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert list(replay_files) == ["index-1.py"] diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index 96bd5dd..17c82b6 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -1,12 +1,10 @@ import json -import sys from pathlib import Path import pytest from sphinx.application import Sphinx ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) EXAMPLE_GENERATOR_SOURCE = ''' def example_generator(n): @@ -28,7 +26,7 @@ def example_generator(n): def autodoc_project(tmp_path): mod_dir = tmp_path / "pkg" mod_dir.mkdir() - (mod_dir / "demo.py").write_text(EXAMPLE_GENERATOR_SOURCE, encoding="utf-8") + (mod_dir / "repl_test_demo.py").write_text(EXAMPLE_GENERATOR_SOURCE, encoding="utf-8") srcdir = tmp_path / "docs" srcdir.mkdir() @@ -54,7 +52,7 @@ def autodoc_project(tmp_path): API === -.. autofunction:: demo.example_generator +.. autofunction:: repl_test_demo.example_generator Tutorial ======== @@ -96,19 +94,32 @@ def test_autodoc_doctest_becomes_pyrepl(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 1 - script_name = next(iter(replay_files)) - script = replay_files[script_name] + assert len(replay_files) == 2 + replay_scripts = { + name: content for name, content in replay_files.items() if not name.endswith("-bootstrap.py") + } + bootstrap_scripts = { + name: content for name, content in replay_files.items() if name.endswith("-bootstrap.py") + } + assert len(replay_scripts) == 1 + assert len(bootstrap_scripts) == 1 + script_name = next(iter(replay_scripts)) + script = replay_scripts[script_name] assert script == "print([i for i in example_generator(4)])\n" assert "[0, 1, 2, 3]" not in script + assert "def example_generator" in next(iter(bootstrap_scripts.values())) html = (outdir / "index.html").read_text(encoding="utf-8") assert f'replay-src="_static/pyrepl/{script_name}"' in html + assert 'src="_static/pyrepl/' in html assert "no-header" in html assert "no-banner" in html script_path = outdir / "_static" / "pyrepl" / script_name assert script_path.is_file(), f"missing replay script at {script_path}" + bootstrap_name = next(iter(bootstrap_scripts)) + bootstrap_path = outdir / "_static" / "pyrepl" / bootstrap_name + assert bootstrap_path.is_file(), f"missing bootstrap script at {bootstrap_path}" def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): @@ -118,7 +129,7 @@ def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 1 + assert len(replay_files) == 2 html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 1 @@ -135,7 +146,7 @@ def test_all_scope_transforms_plain_rst_doctest(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 2 + assert len(replay_files) == 3 html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 2 diff --git a/tests/test_autodoc_include.py b/tests/test_autodoc_include.py index 3e09d59..72fde55 100644 --- a/tests/test_autodoc_include.py +++ b/tests/test_autodoc_include.py @@ -90,8 +90,10 @@ def test_included_example_writes_all_replay_scripts(included_example_project): assert len(replay_files) == 2 html = (outdir / "index.html").read_text(encoding="utf-8") + assert 'src="_static/repl_include_demo.py"' in html + assert html.count("replay-src=") == 2 + for script_name in replay_files: - replay_src = f'_static/pyrepl/{script_name}' - assert replay_src in html script_path = outdir / "_static" / "pyrepl" / script_name assert script_path.is_file(), f"missing replay script at {script_path}" + assert (outdir / "_static" / "repl_include_demo.py").is_file() From 9093ca25ea6573c159b4eba5f4378b097dd9f819 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 22:55:53 +0000 Subject: [PATCH 04/14] Ignore Sphinx 11 autodoc-options deprecation in pytest CI sets PYTHONWARNINGS=error; Napoleon triggers RemovedInSphinx11Warning during autodoc integration tests. Filter this upstream deprecation until Napoleon is updated. Co-authored-by: chrizzftd --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 254604b..17db7c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,8 @@ test = [ docs = [ "myst-parser", ] + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:The mapping interface for autodoc options objects is deprecated:sphinx.deprecation.RemovedInSphinx11Warning", +] From a94b06611903ec779908b46215aadeeb467f4be1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Jun 2026 00:20:24 +0000 Subject: [PATCH 05/14] Set doctree pyrepl flag for autodoc-only pages transform_doctest_blocks runs after doctree_read on the same event, so autodoc-only pages never got doctree["pyrepl"] set and pyrepl.js was omitted from HTML output. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 1 + tests/test_autodoc_bootstrap.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 1fe4e68..9205e7b 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -215,6 +215,7 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): if replaced: env.metadata[docname]["pyrepl"] = True + doctree["pyrepl"] = True class PyRepl(SphinxDirective): diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 460f8a8..580d4ad 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -60,8 +60,12 @@ def greet(): html = (outdir / "index.html").read_text(encoding="utf-8") assert 'src="_static/demo.py"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html + assert "pyrepl.js" in html assert (outdir / "_static" / "demo.py").is_file() + doctree = app.env.get_doctree("index") + assert doctree.get("pyrepl") + replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) From 72993bd3424880024045ed0b12b98661dab32028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 10:22:40 +1000 Subject: [PATCH 06/14] update minor version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- sphinx_pyrepl_web/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 9205e7b..c94d765 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -1,6 +1,6 @@ """A Sphinx extension for embedding pyrepl-web Python REPLs in documentation.""" -__version__ = "0.1.1" +__version__ = "0.2.0" import importlib import inspect From 96fc50ac140e4e9a0e235caaab495821af3d69fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 10:44:32 +1000 Subject: [PATCH 07/14] minimize doc repetition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 71 +++++++++++++----------------------- docs/_static/autodoc_demo.py | 4 +- docs/example.rst | 13 +++---- 3 files changed, 31 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 5396d3c..93b2d69 100644 --- a/README.md +++ b/README.md @@ -46,22 +46,22 @@ Embed a REPL with the `py-repl` directive: ### Directive options -Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with a few exceptions unique to this extension: - -| Option | Description | `pyrepl-web` attr? | -|--------|-------------|------------------| -| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) | ✅ | -| `:packages:` | Comma-separated PyPI packages to preload | ✅ | -| `:repl-title:` | Title in the REPL header | ✅ | -| `:src:` | Path to a Python startup script | ✅ | -| `:replay:` | Replay `:src:` with interactive prompts instead of silent load | ✅ | -| `:silent:` | Keep `:src:` silent even when combined with a directive body | ❌ | -| `:no-header:` | Hide the header bar | ✅ | -| `:no-buttons:` | Hide copy/clear buttons | ✅ | -| `:readonly:` | Disable input | ✅ | -| `:no-banner:` | Hide the Python version banner | ✅ | - -Directive body content (inline Python in the `.. py-repl::` block) is also extension-only: it is written to `_static/pyrepl/` at build time and emitted as `replay-src`. +Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, the exception of `silent`: + +| Option | Description | +|--------|-------------| +| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) | +| `:packages:` | Comma-separated PyPI packages to preload | +| `:repl-title:` | Title in the REPL header | +| `:src:` | Path to a Python startup script | +| `:replay:` | Replay `:src:` with interactive prompts instead of silent load | +| `:silent:` | Keep `:src:` silent even when combined with a directive body | +| `:no-header:` | Hide the header bar | +| `:no-buttons:` | Hide copy/clear buttons | +| `:readonly:` | Disable input | +| `:no-banner:` | Hide the Python version banner | + +Directive body content (inline Python in the `.. py-repl::` block) is written to `_static/pyrepl/` at build time and emitted as `replay-src`. Optional Sphinx config: @@ -71,49 +71,28 @@ pyrepl_doctest_blocks = "autodoc" # default; see Autodoc integration below pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs ``` -### Autodoc integration +### Docstring conversion -When used with `sphinx.ext.autodoc` (and optionally `sphinx.ext.napoleon` for Google/NumPy-style docstrings), doctest examples in documented API entries are automatically converted into interactive REPLs. +Doctest examples in docstrings are converted into executable replay input with an interactive REPL, this integrates with `sphinx.ext.autodoc`. ```python # conf.py extensions = [ "sphinx.ext.autodoc", - "sphinx.ext.napoleon", "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" # default ``` -Write doctest examples in your docstrings as usual: +The static doctest block is replaced at build time. -```python -def example_generator(n): - """Generators have a ``Yields`` section instead of a ``Returns`` section. - - Examples: - >>> print([i for i in example_generator(4)]) - [0, 1, 2, 3] - - """ - yield from range(n) -``` - -Document the function with autodoc — no manual `.. py-repl::` directive needed: - -```rst -.. autofunction:: my_module.example_generator -``` - -The static doctest block is replaced with an interactive REPL at build time. Expected output lines (e.g. `[0, 1, 2, 3]`) are stripped; only executable input is replayed. - -| `pyrepl_doctest_blocks` | Behavior | -|---|---| -| `False` | Disable autodoc transform; only explicit `.. py-repl::` directives | -| `"autodoc"` (default) | Transform doctests inside autodoc API entries only | -| `"all"` | Transform every doctest block on every page | +| `pyrepl_doctest_blocks` | Behavior | +|---|---------------------------------------| +| `False` | Disable autodoc conversion | +| `"autodoc"` (default) | Only convert doctests through autodoc | +| `"all"` | Transform every doctest block found | -**Note:** autodoc REPLs automatically bootstrap the documented module's source as a silent `:src:` startup script (`pyrepl_autodoc_bootstrap = True` by default), so doctest examples can call documented functions. Modules outside the Sphinx source directory get a generated bootstrap script under `_static/pyrepl/`. Disable with `pyrepl_autodoc_bootstrap = False`. Modules that import unavailable packages may still fail in the browser REPL. +**Note:** autodoc REPLs automatically bootstrap the documented module's source as a silent `:src:` startup script (`pyrepl_autodoc_bootstrap = True` by default), so doctest examples can call documented functions. Modules outside the Sphinx source directory get a generated bootstrap script under `_static/pyrepl/`. Disable with `pyrepl_autodoc_bootstrap = False`. Modules that import unavailable packages will fail in the browser REPL. ## Updating pyrepl-web diff --git a/docs/_static/autodoc_demo.py b/docs/_static/autodoc_demo.py index 8701778..55ae571 100644 --- a/docs/_static/autodoc_demo.py +++ b/docs/_static/autodoc_demo.py @@ -2,11 +2,9 @@ def example_generator(n): - """Generators have a ``Yields`` section instead of a ``Returns`` section. + """Generators yield values useful for iteration. Examples: - Examples should be written in doctest format, and should illustrate how - to use the function. >>> print([i for i in example_generator(4)]) [0, 1, 2, 3] diff --git a/docs/example.rst b/docs/example.rst index d91a282..226173b 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -114,21 +114,18 @@ The replay script: .. literalinclude:: _static/replay_demo.py :language: python -Autodoc integration -------------------- +Autodoc +------- -When ``sphinx.ext.autodoc`` is enabled, doctest examples in API docstrings are -automatically converted into interactive REPLs (``pyrepl_doctest_blocks = "autodoc"``, -the default). Expected output lines are stripped; only executable input is replayed. -The documented module's source is loaded silently via ``:src:`` before replay, so -functions like ``example_generator`` are available in the REPL namespace. +The documented module's source is loaded in advanced via ``:src:`` before replay, so +module members are available in the REPL namespace. Source module: .. literalinclude:: _static/autodoc_demo.py :language: python -RST page (autodoc only — no manual ``.. py-repl::`` needed): +RST content: .. code-block:: rst From 52ca902acd239c326c957acbd32a239625f141a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 10:54:41 +1000 Subject: [PATCH 08/14] further cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 14 +++++++------- docs/_static/autodoc_demo.py | 3 +-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 93b2d69..bcf052d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Embed a REPL with the `py-repl` directive: ### Directive options -Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, the exception of `silent`: +Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`: | Option | Description | |--------|-------------| @@ -67,7 +67,7 @@ Optional Sphinx config: ```python pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script -pyrepl_doctest_blocks = "autodoc" # default; see Autodoc integration below +pyrepl_doctest_blocks = "autodoc" # default; see Docstring conversion below pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs ``` @@ -86,11 +86,11 @@ pyrepl_doctest_blocks = "autodoc" # default The static doctest block is replaced at build time. -| `pyrepl_doctest_blocks` | Behavior | -|---|---------------------------------------| -| `False` | Disable autodoc conversion | -| `"autodoc"` (default) | Only convert doctests through autodoc | -| `"all"` | Transform every doctest block found | +| `pyrepl_doctest_blocks` | Behavior | +|---|-------------------------------------| +| `False` | Disable autodoc conversion | +| `"autodoc"` | Convert doctests found by autodoc | +| `"all"` | Transform every doctest block found | **Note:** autodoc REPLs automatically bootstrap the documented module's source as a silent `:src:` startup script (`pyrepl_autodoc_bootstrap = True` by default), so doctest examples can call documented functions. Modules outside the Sphinx source directory get a generated bootstrap script under `_static/pyrepl/`. Disable with `pyrepl_autodoc_bootstrap = False`. Modules that import unavailable packages will fail in the browser REPL. diff --git a/docs/_static/autodoc_demo.py b/docs/_static/autodoc_demo.py index 55ae571..09f42fb 100644 --- a/docs/_static/autodoc_demo.py +++ b/docs/_static/autodoc_demo.py @@ -1,10 +1,9 @@ """Demo module for autodoc doctest REPL integration.""" - def example_generator(n): """Generators yield values useful for iteration. - Examples: + Example: >>> print([i for i in example_generator(4)]) [0, 1, 2, 3] From 7a3b594f2de74e48e8fa6b67036e07265d9b0161 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Jun 2026 00:55:25 +0000 Subject: [PATCH 09/14] Condense autodoc bootstrap docs into config table Co-authored-by: chrizzftd --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bcf052d..0cd4a9e 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,18 @@ pyrepl_doctest_blocks = "autodoc" # default The static doctest block is replaced at build time. -| `pyrepl_doctest_blocks` | Behavior | -|---|-------------------------------------| -| `False` | Disable autodoc conversion | -| `"autodoc"` | Convert doctests found by autodoc | +| `pyrepl_doctest_blocks` | Behavior | +|---|---| +| `False` | Disable autodoc conversion | +| `"autodoc"` | Convert doctests found by autodoc | | `"all"` | Transform every doctest block found | -**Note:** autodoc REPLs automatically bootstrap the documented module's source as a silent `:src:` startup script (`pyrepl_autodoc_bootstrap = True` by default), so doctest examples can call documented functions. Modules outside the Sphinx source directory get a generated bootstrap script under `_static/pyrepl/`. Disable with `pyrepl_autodoc_bootstrap = False`. Modules that import unavailable packages will fail in the browser REPL. +| `pyrepl_autodoc_bootstrap` | Behavior | +|---|---| +| `True` (default) | Silently load the documented module as `:src:` before replay (from srcdir, or generated under `_static/pyrepl/`) | +| `False` | Replay doctest input only; documented names are not pre-defined | + +Modules with imports unavailable in Pyodide may still fail at runtime. ## Updating pyrepl-web From c775c4ba33d00b3d6491b518397e88699f18d468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 19:28:12 +1000 Subject: [PATCH 10/14] minor refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0cd4a9e..8d11394 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Embed a REPL with the `py-repl` directive: ### Directive options -Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`: +All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`: | Option | Description | |--------|-------------| @@ -61,7 +61,7 @@ Most options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attri | `:readonly:` | Disable input | | `:no-banner:` | Hide the Python version banner | -Directive body content (inline Python in the `.. py-repl::` block) is written to `_static/pyrepl/` at build time and emitted as `replay-src`. +Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. Optional Sphinx config: @@ -73,7 +73,7 @@ pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc R ### Docstring conversion -Doctest examples in docstrings are converted into executable replay input with an interactive REPL, this integrates with `sphinx.ext.autodoc`. +Doctest examples in docstrings can be converted into a REPL at build time, which integrates with `sphinx.ext.autodoc`. ```python # conf.py From 6916b24777997faef1eb9d2c87eb4bb5e214ae24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 19:28:16 +1000 Subject: [PATCH 11/14] minor refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8d11394..8bc192f 100644 --- a/README.md +++ b/README.md @@ -84,20 +84,20 @@ extensions = [ pyrepl_doctest_blocks = "autodoc" # default ``` -The static doctest block is replaced at build time. +`pyrepl_doctest_blocks` options: -| `pyrepl_doctest_blocks` | Behavior | -|---|---| -| `False` | Disable autodoc conversion | -| `"autodoc"` | Convert doctests found by autodoc | -| `"all"` | Transform every doctest block found | +| | | +|-----------------------|-------------------------------------| +| `False` | Disable autodoc conversion | +| `"autodoc"` (default) | Convert doctests found by autodoc | +| `"all"` | Transform every doctest block found | -| `pyrepl_autodoc_bootstrap` | Behavior | -|---|---| -| `True` (default) | Silently load the documented module as `:src:` before replay (from srcdir, or generated under `_static/pyrepl/`) | -| `False` | Replay doctest input only; documented names are not pre-defined | +`pyrepl_autodoc_bootstrap` options: -Modules with imports unavailable in Pyodide may still fail at runtime. +| | | +|------------------|-----------------------------------------------------------------| +| `True` (default) | Silently load the documented module as `:src:` before replay | +| `False` | Replay doctest input only; documented names are not pre-defined | ## Updating pyrepl-web From 55dbe44e8b61918947858d614b8a7b9ce461c729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 28 Jun 2026 19:33:48 +1000 Subject: [PATCH 12/14] use option table headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8bc192f..06e421f 100644 --- a/README.md +++ b/README.md @@ -84,17 +84,14 @@ extensions = [ pyrepl_doctest_blocks = "autodoc" # default ``` -`pyrepl_doctest_blocks` options: - -| | | +| | `pyrepl_doctest_blocks` options | |-----------------------|-------------------------------------| | `False` | Disable autodoc conversion | | `"autodoc"` (default) | Convert doctests found by autodoc | | `"all"` | Transform every doctest block found | -`pyrepl_autodoc_bootstrap` options: -| | | +| | `pyrepl_autodoc_bootstrap` options | |------------------|-----------------------------------------------------------------| | `True` (default) | Silently load the documented module as `:src:` before replay | | `False` | Replay doctest input only; documented names are not pre-defined | From 7deb2014c3fef476946b258a244a1c0c4cf9e08c Mon Sep 17 00:00:00 2001 From: chrizzftd Date: Mon, 29 Jun 2026 19:46:44 +1000 Subject: [PATCH 13/14] Unify doctest replay extraction and fix installed-package bootstrap (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Unify doctest replay extraction and fix installed-package bootstrap Add doctest_to_replay_source() as the single conversion path for both autodoc doctest blocks and py-repl directive bodies. DocTestParser strips expected output and joins separate examples with blank lines so multiline class definitions remain valid Python. For installed modules outside the Sphinx source tree, autodoc REPLs now emit packages= via micropip instead of exec'ing module source (which fails for packages with relative imports). In-tree modules still use silent :src:. --------- Signed-off-by: Christian López Barrón Co-authored-by: Cursor Agent Co-authored-by: chrizzftd --- README.md | 26 +++--- docs/conf.py | 1 + docs/example.rst | 39 +++++---- sphinx_pyrepl_web/__init__.py | 105 ++++++++----------------- tests/test_autodoc_bootstrap.py | 78 ++++++++++++++++++ tests/test_autodoc_doctest.py | 29 +++---- tests/test_autodoc_include.py | 1 + tests/test_doctest_to_replay_source.py | 55 +++++++++++++ tests/test_strip_doctest_prompts.py | 53 ------------- 9 files changed, 213 insertions(+), 174 deletions(-) create mode 100644 tests/test_doctest_to_replay_source.py delete mode 100644 tests/test_strip_doctest_prompts.py diff --git a/README.md b/README.md index 06e421f..5cf50f0 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,13 @@ Optional Sphinx config: ```python pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script -pyrepl_doctest_blocks = "autodoc" # default; see Docstring conversion below +pyrepl_doctest_blocks = False # default; see Docstring conversion below pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs ``` ### Docstring conversion -Doctest examples in docstrings can be converted into a REPL at build time, which integrates with `sphinx.ext.autodoc`. +Converting doctest examples from docstrings into a REPL is opt-in with `sphinx.ext.autodoc`: ```python # conf.py @@ -81,20 +81,20 @@ extensions = [ "sphinx.ext.autodoc", "sphinx_pyrepl_web", ] -pyrepl_doctest_blocks = "autodoc" # default +pyrepl_doctest_blocks = "autodoc" ``` -| | `pyrepl_doctest_blocks` options | -|-----------------------|-------------------------------------| -| `False` | Disable autodoc conversion | -| `"autodoc"` (default) | Convert doctests found by autodoc | -| `"all"` | Transform every doctest block found | +| | `pyrepl_doctest_blocks` options | +|-------------------|-------------------------------------| +| `False` (default) | Disable autodoc conversion | +| `"autodoc"` | Convert doctests found by autodoc | +| `"all"` | Transform every doctest block found | -| | `pyrepl_autodoc_bootstrap` options | -|------------------|-----------------------------------------------------------------| -| `True` (default) | Silently load the documented module as `:src:` before replay | -| `False` | Replay doctest input only; documented names are not pre-defined | +| | `pyrepl_autodoc_bootstrap` options | +|------------------|------------------------------------------------------------------------------| +| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:`, packages via `packages=` | +| `False` | Replay doctest input only; documented names are not pre-defined | ## Updating pyrepl-web @@ -107,7 +107,7 @@ python scripts/vendor_repl.py The `grill` branch is used by default. Use the `branch` argument to specify a different one: ```bash -python scripts/vendor_repl.py --branch cursor/repl-startup-replay-2e3f +python scripts/vendor_repl.py --branch custom/feature-branch ``` This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/). diff --git a/docs/conf.py b/docs/conf.py index 2bb8173..5ee02fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ "sphinx.ext.napoleon", "sphinx_pyrepl_web", ] +pyrepl_doctest_blocks = "autodoc" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] html_sidebars = { diff --git a/docs/example.rst b/docs/example.rst index 226173b..3dccde5 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -31,25 +31,27 @@ Startup script The ``:src:`` option loads a Python script into the REPL namespace. If the script defines a ``setup()`` function, its output is shown when the REPL starts. +Startup script: + +.. literalinclude:: _static/setup.py + :language: python + +RST content: + .. code-block:: rst .. py-repl:: :src: _static/setup.py +Rendered result: + .. py-repl:: :src: _static/setup.py -The startup script: - -.. literalinclude:: _static/setup.py - :language: python - Replay session -------------- -Inline directive content is replayed with ``>>>`` prompts, syntax highlighting, -and live output. Doctest-style ``>>>`` / ``...`` prefixes and bare ``...`` -block terminators are stripped automatically. +Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is used as replay prompts. .. code-block:: rst @@ -93,7 +95,14 @@ Combine a silent bootstrap file with a visible replay body: >>> print(message) -Use ``:replay:`` on ``:src:`` to replay a file with prompts instead of silent load: +Use ``:replay:`` on ``:src:`` to source a file as replay. + +Source script: + +.. literalinclude:: _static/replay_demo.py + :language: python + +RST content: .. code-block:: rst @@ -103,22 +112,20 @@ Use ``:replay:`` on ``:src:`` to replay a file with prompts instead of silent lo :no-header: :no-banner: +Rendered result: + .. py-repl:: :src: _static/replay_demo.py :replay: :no-header: :no-banner: -The replay script: - -.. literalinclude:: _static/replay_demo.py - :language: python - Autodoc ------- -The documented module's source is loaded in advanced via ``:src:`` before replay, so -module members are available in the REPL namespace. +The documented module's source is loaded in advance before replay, so +module members are available in the REPL namespace. Modules under the Sphinx +source tree use silent ``:src:``; installed packages use ``packages=``. Source module: diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index c94d765..af4539e 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -25,7 +25,7 @@ def setup(app: Sphinx): """Setup the extension.""" app.add_config_value("pyrepl_js", "../pyrepl.js", "env") - app.add_config_value("pyrepl_doctest_blocks", "autodoc", "env") + app.add_config_value("pyrepl_doctest_blocks", False, "env") app.add_config_value("pyrepl_autodoc_bootstrap", True, "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) @@ -35,76 +35,38 @@ def setup(app: Sphinx): return {"version": __version__, "parallel_read_safe": True} -def extract_doctest_source(text: str) -> str: - """Extract executable Python source from doctest-formatted text.""" - lines: list[str] = [] - for example in _DOCTEST_PARSER.get_examples(text): - lines.extend(example.source.splitlines()) - if not lines: +def doctest_to_replay_source(text_or_lines: str | list[str]) -> str: + """Convert doctest-formatted text into executable replay script source.""" + text = ( + "\n".join(text_or_lines) + if isinstance(text_or_lines, list) + else text_or_lines + ) + examples = _DOCTEST_PARSER.get_examples(text) + if not examples: return "" - return "\n".join(lines) + "\n" - - -def strip_doctest_prompts(lines: list[str]) -> list[str]: - """Remove leading ``>>> `` / ``... `` prompts from doctest-style lines. - - Bare ``...`` lines (doctest block terminators with no trailing content) become - blank lines, since they mark the end of a multi-line input block in the REPL. - """ - result: list[str] = [] - for line in lines: - stripped = line.lstrip() - if stripped.startswith(">>> "): - result.append(stripped[4:]) - elif stripped.startswith("..."): - if stripped.startswith("... "): - remainder = stripped[4:] - else: - remainder = stripped[3:] - if remainder.strip() == "": - result.append("") - else: - result.append(remainder) - else: - result.append(line) - return result + parts = [example.source.rstrip("\n") for example in examples] + return "\n\n".join(parts) + "\n" def _next_replay_counter(replay_files: dict[str, str]) -> int: - return sum(1 for name in replay_files if not name.endswith("-bootstrap.py")) + 1 + return len(replay_files) + 1 def register_autodoc_repl( env, docname: str, replay_text: str, - *, - bootstrap_src: str | None = None, - bootstrap_content: str | None = None, -) -> tuple[str, str | None]: - """Record replay and optional bootstrap scripts, returning HTML src paths.""" +) -> str: + """Record a replay script in env metadata and return its replay-src path.""" replay_files = json.loads( env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}") ) counter = _next_replay_counter(replay_files) - base = f"{docname.replace('/', '-')}-{counter}" - replay_name = f"{base}.py" + replay_name = f"{docname.replace('/', '-')}-{counter}.py" replay_files[replay_name] = replay_text - - startup_src = bootstrap_src - if bootstrap_content is not None: - bootstrap_name = f"{base}-bootstrap.py" - replay_files[bootstrap_name] = bootstrap_content - startup_src = f"_static/pyrepl/{bootstrap_name}" - env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files) - return f"_static/pyrepl/{replay_name}", startup_src - - -def register_replay_script(env, docname: str, body_text: str) -> str: - """Record a replay script in env metadata and return its replay-src path.""" - replay_src, _ = register_autodoc_repl(env, docname, body_text) - return replay_src + return f"_static/pyrepl/{replay_name}" def register_startup_file(env, docname: str, path: Path) -> str: @@ -121,9 +83,15 @@ def register_startup_file(env, docname: str, path: Path) -> str: return rel_src -def make_pyrepl_raw(replay_src: str, src: str | None = None) -> nodes.raw: +def make_pyrepl_raw( + replay_src: str, + src: str | None = None, + packages: 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}"'] + if packages: + attrs.insert(0, f'packages="{packages}"') if src: attrs.insert(0, f'src="{src}"') attr_str = " ".join(attrs) @@ -143,7 +111,7 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: def _resolve_autodoc_bootstrap( app: Sphinx, env, docname: str, desc: addnodes.desc ) -> tuple[str | None, str | None]: - """Return (startup src path, generated bootstrap content) for autodoc REPLs.""" + """Return (startup src path, packages) for autodoc REPLs.""" if not app.config.pyrepl_autodoc_bootstrap: return None, None @@ -171,7 +139,7 @@ def _resolve_autodoc_bootstrap( source_path.relative_to(srcdir) return register_startup_file(env, docname, source_path), None except ValueError: - return None, inspect.getsource(mod_obj) + "\n" + return None, module_name.split(".")[0] except (AttributeError, ImportError, OSError, TypeError): return None, None @@ -193,24 +161,18 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): for node in doctree.findall(nodes.doctest_block): if scope == "autodoc" and not _inside_autodoc_desc(node): continue - source = extract_doctest_source(node.astext()) + source = doctest_to_replay_source(node.astext()) if not source.strip(): continue bootstrap_src = None - bootstrap_content = None + packages = None desc = _find_autodoc_desc(node) if desc is not None: - bootstrap_src, bootstrap_content = _resolve_autodoc_bootstrap( + bootstrap_src, packages = _resolve_autodoc_bootstrap( app, env, docname, desc ) - replay_src, startup_src = register_autodoc_repl( - env, - docname, - source, - bootstrap_src=bootstrap_src, - bootstrap_content=bootstrap_content, - ) - node.replace_self(make_pyrepl_raw(replay_src, startup_src)) + replay_src = register_autodoc_repl(env, docname, source) + node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages)) replaced = True if replaced: @@ -282,9 +244,8 @@ def run(self): attrs.append(f'src="{rel_src}"') if has_body: - body_lines = strip_doctest_prompts(list(self.content)) - body_text = "\n".join(body_lines) + "\n" - replay_src = register_replay_script(env, env.docname, body_text) + 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}"') self.env.metadata[self.env.docname]["pyrepl"] = True diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 580d4ad..e5244f7 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -38,6 +38,7 @@ def greet(): extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] master_doc = "index" pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" """, encoding="utf-8", ) @@ -70,3 +71,80 @@ def greet(): app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) assert list(replay_files) == ["index-1.py"] + + +def test_autodoc_bootstrap_uses_packages_for_installed_module(tmp_path): + pkg_dir = tmp_path / "installed_pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( + ''' +from .core import Widget as _Widget + +class Widget(_Widget): + """A demo widget. + + Example: + >>> w = Widget() + >>> w.label + 'ready' + + """ + pass +'''.strip() + + "\n", + encoding="utf-8", + ) + (pkg_dir / "core.py").write_text( + ''' +class Widget: + label = "ready" +'''.strip() + + "\n", + encoding="utf-8", + ) + + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + f""" +import sys +sys.path.insert(0, {str(pkg_dir.parent)!r}) +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") + + 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() + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert 'packages="installed_pkg"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + assert "", html.index("")] + assert ' src="' not in pyrepl_tag + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 1 + script = next(iter(replay_files.values())) + assert script == "w = Widget()\n\nw.label\n" diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index 17c82b6..42556ca 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -44,6 +44,7 @@ def autodoc_project(tmp_path): ] master_doc = "index" pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" """, encoding="utf-8", ) @@ -94,32 +95,20 @@ def test_autodoc_doctest_becomes_pyrepl(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 2 - replay_scripts = { - name: content for name, content in replay_files.items() if not name.endswith("-bootstrap.py") - } - bootstrap_scripts = { - name: content for name, content in replay_files.items() if name.endswith("-bootstrap.py") - } - assert len(replay_scripts) == 1 - assert len(bootstrap_scripts) == 1 - script_name = next(iter(replay_scripts)) - script = replay_scripts[script_name] + assert len(replay_files) == 1 + script_name = next(iter(replay_files)) + script = replay_files[script_name] assert script == "print([i for i in example_generator(4)])\n" assert "[0, 1, 2, 3]" not in script - assert "def example_generator" in next(iter(bootstrap_scripts.values())) html = (outdir / "index.html").read_text(encoding="utf-8") assert f'replay-src="_static/pyrepl/{script_name}"' in html - assert 'src="_static/pyrepl/' in html + assert 'packages="repl_test_demo"' in html assert "no-header" in html assert "no-banner" in html script_path = outdir / "_static" / "pyrepl" / script_name assert script_path.is_file(), f"missing replay script at {script_path}" - bootstrap_name = next(iter(bootstrap_scripts)) - bootstrap_path = outdir / "_static" / "pyrepl" / bootstrap_name - assert bootstrap_path.is_file(), f"missing bootstrap script at {bootstrap_path}" def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): @@ -129,7 +118,7 @@ def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 2 + assert len(replay_files) == 1 html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 1 @@ -146,17 +135,17 @@ def test_all_scope_transforms_plain_rst_doctest(autodoc_project): replay_files = json.loads( app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) - assert len(replay_files) == 3 + assert len(replay_files) == 2 html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 2 -def test_disabled_scope_leaves_doctest_static(autodoc_project): +def test_default_scope_leaves_doctest_static(autodoc_project): srcdir, outdir, doctreedir, _ = autodoc_project conf = (srcdir / "conf.py").read_text(encoding="utf-8") (srcdir / "conf.py").write_text( - conf + "\npyrepl_doctest_blocks = False\n", encoding="utf-8" + conf.replace('pyrepl_doctest_blocks = "autodoc"\n', ""), encoding="utf-8" ) app = _build_sphinx(srcdir, outdir, doctreedir) diff --git a/tests/test_autodoc_include.py b/tests/test_autodoc_include.py index 72fde55..01464ce 100644 --- a/tests/test_autodoc_include.py +++ b/tests/test_autodoc_include.py @@ -29,6 +29,7 @@ def included_example_project(tmp_path): ] master_doc = "index" pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" exclude_patterns = ["example.rst"] """, encoding="utf-8", diff --git a/tests/test_doctest_to_replay_source.py b/tests/test_doctest_to_replay_source.py new file mode 100644 index 0000000..d1fb725 --- /dev/null +++ b/tests/test_doctest_to_replay_source.py @@ -0,0 +1,55 @@ +from sphinx_pyrepl_web import doctest_to_replay_source + + +def test_strips_expected_output(): + block = ">>> add(1, 2)\n3\n>>> add(0, 0)\n0" + assert doctest_to_replay_source(block) == "add(1, 2)\n\nadd(0, 0)\n" + + +def test_example_generator(): + block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" + assert doctest_to_replay_source(block) == ( + "print([i for i in example_generator(4)])\n" + ) + + +def test_multiline_class(): + block = ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n" + assert doctest_to_replay_source(block) == "class Foo:\n x = 1\n\nFoo()\n" + + +def test_name_class_example(): + block = ( + ">>> from naming import Name\n" + ">>> class MyName(Name):\n" + "... config = dict(base=r'\\w+')\n" + "...\n" + ">>> n = MyName()\n" + "'{base}'" + ) + assert doctest_to_replay_source(block) == ( + "from naming import Name\n" + "\n" + "class MyName(Name):\n" + " config = dict(base=r'\\w+')\n" + "\n" + "n = MyName()\n" + ) + + +def test_from_directive_lines(): + lines = [ + ">>> class Foo:", + "... x = 1", + "...", + ">>> Foo()", + ] + assert doctest_to_replay_source(lines) == "class Foo:\n x = 1\n\nFoo()\n" + + +def test_empty_for_non_doctest(): + assert doctest_to_replay_source("not a doctest") == "" + + +def test_explicit_ellipsis(): + assert doctest_to_replay_source([">>> ..."]) == "...\n" diff --git a/tests/test_strip_doctest_prompts.py b/tests/test_strip_doctest_prompts.py deleted file mode 100644 index 17c55e2..0000000 --- a/tests/test_strip_doctest_prompts.py +++ /dev/null @@ -1,53 +0,0 @@ -from sphinx_pyrepl_web import extract_doctest_source, strip_doctest_prompts - - -def test_extract_doctest_source_strips_expected_output(): - block = ">>> add(1, 2)\n3\n>>> add(0, 0)\n0" - assert extract_doctest_source(block) == "add(1, 2)\nadd(0, 0)\n" - - -def test_extract_doctest_source_example_generator(): - block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" - assert extract_doctest_source(block) == ( - "print([i for i in example_generator(4)])\n" - ) - - -def test_extract_doctest_source_multiline_class(): - block = ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n" - assert extract_doctest_source(block) == "class Foo:\n x = 1\nFoo()\n" - - -def test_extract_doctest_source_empty(): - assert extract_doctest_source("not a doctest") == "" - - -def test_issue_7_multiline_class_with_bare_terminator(): - lines = [ - ">>> class Foo:", - "... x = 1", - "...", - ">>> Foo()", - ] - assert strip_doctest_prompts(lines) == [ - "class Foo:", - " x = 1", - "", - "Foo()", - ] - - -def test_bare_terminator_with_trailing_whitespace(): - assert strip_doctest_prompts([" ... "]) == [""] - - -def test_continuation_line_preserved(): - assert strip_doctest_prompts(["... x = 1"]) == [" x = 1"] - - -def test_single_line_prompts(): - assert strip_doctest_prompts([">>> x = 1", ">>> x + 1"]) == ["x = 1", "x + 1"] - - -def test_explicit_ellipsis(): - assert strip_doctest_prompts([">>> ..."]) == ["..."] From 4b639f69992080d97c02394ffa80803245a0601a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 10:26:09 +0000 Subject: [PATCH 14/14] Log bootstrap failures and fix README grammar - logger.error when autodoc REPL bootstrap cannot resolve module source - Fix docstring conversion sentence in README - Add test for bootstrap failure logging Co-authored-by: chrizzftd --- README.md | 2 +- sphinx_pyrepl_web/__init__.py | 10 ++++++++- tests/test_autodoc_bootstrap.py | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5cf50f0..d66e548 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc R ### Docstring conversion -Converting doctest examples from docstrings into a REPL is opt-in with `sphinx.ext.autodoc`: +Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`: ```python # conf.py diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index af4539e..95eaf82 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -13,6 +13,7 @@ from docutils.parsers.rst import directives from sphinx import addnodes from sphinx.application import Sphinx +from sphinx.util import logging from sphinx.util.docutils import SphinxDirective from sphinx.util.fileutil import copy_asset_file @@ -20,6 +21,7 @@ STARTUP_FILES_KEY = "pyrepl-startup-files" REPLAY_FILES_KEY = "pyrepl-replay-files" _DOCTEST_PARSER = DocTestParser() +logger = logging.getLogger(__name__) def setup(app: Sphinx): @@ -124,6 +126,7 @@ def _resolve_autodoc_bootstrap( 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: @@ -140,7 +143,12 @@ def _resolve_autodoc_bootstrap( return register_startup_file(env, docname, source_path), None except ValueError: return None, module_name.split(".")[0] - except (AttributeError, ImportError, OSError, TypeError): + except (AttributeError, ImportError, OSError, TypeError) as exc: + logger.error( + "Could not bootstrap autodoc REPL for %s: %s", + target, + exc, + ) return None, None diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index e5244f7..2f2d473 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -1,9 +1,13 @@ import json +import logging import sys from pathlib import Path +from unittest.mock import MagicMock from sphinx.application import Sphinx +from sphinx_pyrepl_web import _resolve_autodoc_bootstrap + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) @@ -73,6 +77,9 @@ def greet(): assert list(replay_files) == ["index-1.py"] + assert list(replay_files) == ["index-1.py"] + + def test_autodoc_bootstrap_uses_packages_for_installed_module(tmp_path): pkg_dir = tmp_path / "installed_pkg" pkg_dir.mkdir() @@ -148,3 +155,33 @@ class Widget: assert len(replay_files) == 1 script = next(iter(replay_files.values())) assert script == "w = Widget()\n\nw.label\n" + + +def test_bootstrap_failure_logs_error(caplog): + caplog.set_level(logging.ERROR, logger="sphinx_pyrepl_web") + + app = MagicMock() + app.config.pyrepl_autodoc_bootstrap = True + + env = MagicMock() + env.srcdir = "/tmp/docs" + env.metadata = {"index": {}} + env.note_dependency = MagicMock() + + sig = MagicMock() + sig.get.side_effect = lambda key, default=None: { + "module": "nonexistent_bootstrap_mod_xyz", + "fullname": "missing", + }.get(key, default) + + desc = MagicMock() + desc.next_node.return_value = sig + + result = _resolve_autodoc_bootstrap(app, env, "index", desc) + + assert result == (None, None) + assert any( + "Could not bootstrap autodoc REPL for nonexistent_bootstrap_mod_xyz.missing" + in record.message + for record in caplog.records + )