From 05f1d31832a52688a9c2783e6934af210db39ead Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Jun 2026 22:14:14 +0000 Subject: [PATCH 1/8] 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:. Includes naming-shaped doctest tests and an installed-package integration test with relative imports in __init__.py. Co-authored-by: chrizzftd --- README.md | 2 +- docs/example.rst | 5 +- sphinx_pyrepl_web/__init__.py | 71 +++++++++++++++++++-------- tests/test_autodoc_bootstrap.py | 76 +++++++++++++++++++++++++++++ tests/test_autodoc_doctest.py | 25 +++------- tests/test_strip_doctest_prompts.py | 58 ++++++++++++++++++---- 6 files changed, 186 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 06e421f..c5a6367 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ pyrepl_doctest_blocks = "autodoc" # default | | `pyrepl_autodoc_bootstrap` options | |------------------|-----------------------------------------------------------------| -| `True` (default) | Silently load the documented module as `:src:` before replay | +| `True` (default) | Bootstrap autodoc REPLs: in-tree modules via silent `:src:`, installed packages via `packages=` | | `False` | Replay doctest input only; documented names are not pre-defined | ## Updating pyrepl-web diff --git a/docs/example.rst b/docs/example.rst index 226173b..56900a3 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -117,8 +117,9 @@ The replay script: 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..1478d69 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -35,14 +35,39 @@ def setup(app: Sphinx): return {"version": __version__, "parallel_read_safe": True} +def doctest_to_replay_source(text_or_lines: str | list[str]) -> str: + """Convert doctest-formatted text into executable replay script source. + + Uses :class:`doctest.DocTestParser` to strip expected output and split + separate examples, inserting blank lines between them so multiline blocks + (for example class definitions) stay valid Python. Falls back to prompt + stripping when the parser finds no examples but doctest markers are present. + """ + if isinstance(text_or_lines, list): + lines = text_or_lines + text = "\n".join(lines) + else: + text = text_or_lines + lines = text.splitlines() + + examples = _DOCTEST_PARSER.get_examples(text) + if examples: + parts = [example.source.rstrip("\n") for example in examples] + return "\n\n".join(parts) + "\n" + + if any( + line.lstrip().startswith(">>>") or line.lstrip().startswith("...") + for line in lines + ): + stripped = strip_doctest_prompts(lines) + if stripped: + return "\n".join(stripped) + "\n" + return "" + + 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" + return doctest_to_replay_source(text) def strip_doctest_prompts(lines: list[str]) -> list[str]: @@ -121,9 +146,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) @@ -142,19 +173,19 @@ 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.""" +) -> tuple[str | None, str | None, str | None]: + """Return (startup src path, bootstrap content, packages) for autodoc REPLs.""" if not app.config.pyrepl_autodoc_bootstrap: - return None, None + return None, None, None sig = desc.next_node(addnodes.desc_signature) if sig is None: - return None, None + return None, None, None module_name = sig.get("module") fullname = sig.get("fullname") if not module_name: - return None, None + return None, None, None try: mod = sys.modules.get(module_name) @@ -169,11 +200,11 @@ def _resolve_autodoc_bootstrap( srcdir = Path(env.srcdir).resolve() try: source_path.relative_to(srcdir) - return register_startup_file(env, docname, source_path), None + return register_startup_file(env, docname, source_path), None, None except ValueError: - return None, inspect.getsource(mod_obj) + "\n" + return None, None, module_name.split(".")[0] except (AttributeError, ImportError, OSError, TypeError): - return None, None + return None, None, None def _inside_autodoc_desc(node: nodes.Node) -> bool: @@ -193,14 +224,15 @@ 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, bootstrap_content, packages = _resolve_autodoc_bootstrap( app, env, docname, desc ) replay_src, startup_src = register_autodoc_repl( @@ -210,7 +242,7 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): bootstrap_src=bootstrap_src, bootstrap_content=bootstrap_content, ) - node.replace_self(make_pyrepl_raw(replay_src, startup_src)) + node.replace_self(make_pyrepl_raw(replay_src, startup_src, packages)) replaced = True if replaced: @@ -282,8 +314,7 @@ 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" + body_text = doctest_to_replay_source(list(self.content)) replay_src = register_replay_script(env, env.docname, body_text) attrs.append(f'replay-src="{replay_src}"') diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 580d4ad..ca80893 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -70,3 +70,79 @@ 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" +""", + 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..0bd8b57 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -94,32 +94,21 @@ 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 not any(name.endswith("-bootstrap.py") for name in replay_files) 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,7 +135,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) == 3 + assert len(replay_files) == 2 html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 2 diff --git a/tests/test_strip_doctest_prompts.py b/tests/test_strip_doctest_prompts.py index 17c55e2..3f0b2b0 100644 --- a/tests/test_strip_doctest_prompts.py +++ b/tests/test_strip_doctest_prompts.py @@ -1,25 +1,63 @@ -from sphinx_pyrepl_web import extract_doctest_source, strip_doctest_prompts +from sphinx_pyrepl_web import ( + doctest_to_replay_source, + extract_doctest_source, + strip_doctest_prompts, +) -def test_extract_doctest_source_strips_expected_output(): +def test_doctest_to_replay_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" + assert doctest_to_replay_source(block) == "add(1, 2)\n\nadd(0, 0)\n" -def test_extract_doctest_source_example_generator(): +def test_extract_doctest_source_is_alias(): block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" - assert extract_doctest_source(block) == ( + assert extract_doctest_source(block) == doctest_to_replay_source(block) + + +def test_doctest_to_replay_source_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_extract_doctest_source_multiline_class(): +def test_doctest_to_replay_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" + assert doctest_to_replay_source(block) == "class Foo:\n x = 1\n\nFoo()\n" + + +def test_doctest_to_replay_source_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_doctest_to_replay_source_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_extract_doctest_source_empty(): - assert extract_doctest_source("not a doctest") == "" +def test_doctest_to_replay_source_empty(): + assert doctest_to_replay_source("not a doctest") == "" def test_issue_7_multiline_class_with_bare_terminator(): @@ -50,4 +88,4 @@ def test_single_line_prompts(): def test_explicit_ellipsis(): - assert strip_doctest_prompts([">>> ..."]) == ["..."] + assert doctest_to_replay_source([">>> ..."]) == "...\n" From 6338008bfe6314f4608b81414a625ac368959f1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 08:34:57 +0000 Subject: [PATCH 2/8] Simplify doctest_to_replay_source to DocTestParser only Remove strip_doctest_prompts fallback and extract_doctest_source alias. DocTestParser is the sole conversion path for both autodoc blocks and py-repl directive bodies. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 66 +++---------------- tests/test_doctest_to_replay_source.py | 55 ++++++++++++++++ tests/test_strip_doctest_prompts.py | 91 -------------------------- 3 files changed, 65 insertions(+), 147 deletions(-) create mode 100644 tests/test_doctest_to_replay_source.py delete mode 100644 tests/test_strip_doctest_prompts.py diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 1478d69..c6eb322 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -36,63 +36,17 @@ def setup(app: Sphinx): def doctest_to_replay_source(text_or_lines: str | list[str]) -> str: - """Convert doctest-formatted text into executable replay script source. - - Uses :class:`doctest.DocTestParser` to strip expected output and split - separate examples, inserting blank lines between them so multiline blocks - (for example class definitions) stay valid Python. Falls back to prompt - stripping when the parser finds no examples but doctest markers are present. - """ - if isinstance(text_or_lines, list): - lines = text_or_lines - text = "\n".join(lines) - else: - text = text_or_lines - lines = text.splitlines() - + """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 examples: - parts = [example.source.rstrip("\n") for example in examples] - return "\n\n".join(parts) + "\n" - - if any( - line.lstrip().startswith(">>>") or line.lstrip().startswith("...") - for line in lines - ): - stripped = strip_doctest_prompts(lines) - if stripped: - return "\n".join(stripped) + "\n" - return "" - - -def extract_doctest_source(text: str) -> str: - """Extract executable Python source from doctest-formatted text.""" - return doctest_to_replay_source(text) - - -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 + if not examples: + return "" + 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: 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 3f0b2b0..0000000 --- a/tests/test_strip_doctest_prompts.py +++ /dev/null @@ -1,91 +0,0 @@ -from sphinx_pyrepl_web import ( - doctest_to_replay_source, - extract_doctest_source, - strip_doctest_prompts, -) - - -def test_doctest_to_replay_source_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_extract_doctest_source_is_alias(): - block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" - assert extract_doctest_source(block) == doctest_to_replay_source(block) - - -def test_doctest_to_replay_source_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_doctest_to_replay_source_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_doctest_to_replay_source_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_doctest_to_replay_source_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_doctest_to_replay_source_empty(): - assert doctest_to_replay_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 doctest_to_replay_source([">>> ..."]) == "...\n" From 1acdd1a36a90328e3969d4c3ff48012deba64a84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 09:03:07 +0000 Subject: [PATCH 3/8] Remove dead bootstrap_content replay path Drop generated -bootstrap.py support from register_autodoc_repl and simplify _resolve_autodoc_bootstrap to return (src, packages) only. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 58 ++++++++++------------------------- tests/test_autodoc_doctest.py | 1 - 2 files changed, 17 insertions(+), 42 deletions(-) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index c6eb322..0d0698a 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -50,40 +50,23 @@ def doctest_to_replay_source(text_or_lines: str | list[str]) -> str: 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: @@ -127,19 +110,19 @@ 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, str | None]: - """Return (startup src path, bootstrap content, packages) for autodoc REPLs.""" +) -> tuple[str | None, str | None]: + """Return (startup src path, packages) for autodoc REPLs.""" if not app.config.pyrepl_autodoc_bootstrap: - return None, None, None + return None, None sig = desc.next_node(addnodes.desc_signature) if sig is None: - return None, None, None + return None, None module_name = sig.get("module") fullname = sig.get("fullname") if not module_name: - return None, None, None + return None, None try: mod = sys.modules.get(module_name) @@ -154,11 +137,11 @@ def _resolve_autodoc_bootstrap( srcdir = Path(env.srcdir).resolve() try: source_path.relative_to(srcdir) - return register_startup_file(env, docname, source_path), None, None + return register_startup_file(env, docname, source_path), None except ValueError: - return None, None, module_name.split(".")[0] + return None, module_name.split(".")[0] except (AttributeError, ImportError, OSError, TypeError): - return None, None, None + return None, None def _inside_autodoc_desc(node: nodes.Node) -> bool: @@ -182,21 +165,14 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): 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, packages = _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, packages)) + replay_src = register_autodoc_repl(env, docname, source) + node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages)) replaced = True if replaced: @@ -269,7 +245,7 @@ def run(self): if has_body: body_text = doctest_to_replay_source(list(self.content)) - replay_src = register_replay_script(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 diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index 0bd8b57..ef6d315 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -103,7 +103,6 @@ def test_autodoc_doctest_becomes_pyrepl(autodoc_project): html = (outdir / "index.html").read_text(encoding="utf-8") assert f'replay-src="_static/pyrepl/{script_name}"' in html assert 'packages="repl_test_demo"' in html - assert not any(name.endswith("-bootstrap.py") for name in replay_files) assert "no-header" in html assert "no-banner" in html From ebf032ecd267f305147a8816135b00c19e428325 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 09:25:14 +0000 Subject: [PATCH 4/8] Default pyrepl_doctest_blocks to False for gradual opt-in Autodoc doctest conversion is now disabled unless clients set pyrepl_doctest_blocks to "autodoc" or "all". Extension docs and autodoc tests opt in explicitly. Co-authored-by: chrizzftd --- README.md | 9 ++++----- docs/conf.py | 1 + sphinx_pyrepl_web/__init__.py | 2 +- tests/test_autodoc_bootstrap.py | 2 ++ tests/test_autodoc_doctest.py | 5 +++-- tests/test_autodoc_include.py | 1 + 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c5a6367..52f83b8 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,12 @@ 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_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`. +Doctest examples in docstrings can be converted into a REPL at build time when you opt in with `sphinx.ext.autodoc`: ```python # conf.py @@ -81,13 +80,13 @@ 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 | +| `False` (default) | Disable autodoc conversion | +| `"autodoc"` | Convert doctests found by autodoc | | `"all"` | Transform every doctest block found | 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/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 0d0698a..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) diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index ca80893..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", ) @@ -114,6 +115,7 @@ class Widget: extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] master_doc = "index" pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" """, encoding="utf-8", ) diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index ef6d315..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", ) @@ -140,11 +141,11 @@ def test_all_scope_transforms_plain_rst_doctest(autodoc_project): 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", From 53f3781f8f95cb4696c5e590372483b2539e09f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 29 Jun 2026 19:35:11 +1000 Subject: [PATCH 5/8] minor 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 | 19 ++++++++++--------- docs/example.rst | 30 +++++++++++++++++++----------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 52f83b8..e86e75e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,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 ``` @@ -83,17 +84,17 @@ extensions = [ pyrepl_doctest_blocks = "autodoc" ``` -| | `pyrepl_doctest_blocks` options | -|-----------------------|-------------------------------------| -| `False` (default) | Disable autodoc conversion | -| `"autodoc"` | 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) | Bootstrap autodoc REPLs: in-tree modules via silent `:src:`, installed packages via `packages=` | -| `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 diff --git a/docs/example.rst b/docs/example.rst index 56900a3..6be101f 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -31,19 +31,23 @@ 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 -------------- @@ -93,7 +97,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,17 +114,14 @@ 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 ------- From decccb7442a81ac0a86da306a1aa9dd1d8152001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 29 Jun 2026 19:39:36 +1000 Subject: [PATCH 6/8] minor reword 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e86e75e..1016a0d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc R ### Docstring conversion -Doctest examples in docstrings can be converted into a REPL at build time when you opt in with `sphinx.ext.autodoc`: +Converting doctest examples from docstrings into a REPL is opt-in with `sphinx.ext.autodoc`: ```python # conf.py From f9ff3e75205f16191b324f00e5c019ec5e36ed62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 29 Jun 2026 19:40:11 +1000 Subject: [PATCH 7/8] example branch name 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1016a0d..5cf50f0 100644 --- a/README.md +++ b/README.md @@ -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/). From c6902e144c48c7f08f038294358d1631ff8466b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 29 Jun 2026 19:44:35 +1000 Subject: [PATCH 8/8] summarized doc sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- docs/example.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/example.rst b/docs/example.rst index 6be101f..3dccde5 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -51,9 +51,7 @@ Rendered result: 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