diff --git a/README.md b/README.md
index 9196b8c..d66e548 100644
--- a/README.md
+++ b/README.md
@@ -46,30 +46,56 @@ 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 | ❌ |
-| `:strip-prompts:` | Strip ``>>>`` / ``...`` prefixes from 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`.
+All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with 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 |
+
+Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`.
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
```
+### Docstring conversion
+
+Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`:
+
+```python
+# conf.py
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx_pyrepl_web",
+]
+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_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
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:
@@ -81,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/_static/autodoc_demo.py b/docs/_static/autodoc_demo.py
new file mode 100644
index 0000000..09f42fb
--- /dev/null
+++ b/docs/_static/autodoc_demo.py
@@ -0,0 +1,12 @@
+"""Demo module for autodoc doctest REPL integration."""
+
+def example_generator(n):
+ """Generators yield values useful for iteration.
+
+ Example:
+
+ >>> 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..5ee02fd 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,8 +13,11 @@
extensions = [
"myst_parser",
+ "sphinx.ext.autodoc",
+ "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 d254db6..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,13 +112,32 @@ 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:
+Autodoc
+-------
-.. literalinclude:: _static/replay_demo.py
+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:
+
+.. literalinclude:: _static/autodoc_demo.py
:language: python
+
+RST content:
+
+.. code-block:: rst
+
+ .. autofunction:: autodoc_demo.example_generator
+
+Rendered result:
+
+.. autofunction:: autodoc_demo.example_generator
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",
+]
diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py
index 24ea698..95eaf82 100644
--- a/sphinx_pyrepl_web/__init__.py
+++ b/sphinx_pyrepl_web/__init__.py
@@ -1,54 +1,191 @@
"""A Sphinx extension for embedding pyrepl-web Python REPLs in documentation."""
-__version__ = "0.1.1"
+__version__ = "0.2.0"
+import importlib
+import inspect
import json
+from doctest import DocTestParser
from pathlib import Path
+import sys
from docutils import nodes
from docutils.parsers.rst import directives
+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
PYREPL_DIR = Path(__file__).parent / "pyrepl"
STARTUP_FILES_KEY = "pyrepl-startup-files"
REPLAY_FILES_KEY = "pyrepl-replay-files"
+_DOCTEST_PARSER = DocTestParser()
+logger = logging.getLogger(__name__)
def setup(app: Sphinx):
"""Setup the extension."""
app.add_config_value("pyrepl_js", "../pyrepl.js", "env")
+ app.add_config_value("pyrepl_doctest_blocks", False, "env")
+ app.add_config_value("pyrepl_autodoc_bootstrap", True, "env")
app.add_directive("py-repl", PyRepl)
app.connect("doctree-read", doctree_read)
+ 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}
-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
+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 ""
+ 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 len(replay_files) + 1
+
+
+def register_autodoc_repl(
+ env,
+ docname: str,
+ replay_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 = _next_replay_counter(replay_files)
+ replay_name = f"{docname.replace('/', '-')}-{counter}.py"
+ replay_files[replay_name] = replay_text
+ env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files)
+ return f"_static/pyrepl/{replay_name}"
+
+
+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 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)
+ 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 current
+ current = current.parent
+ return None
+
+
+def _resolve_autodoc_bootstrap(
+ app: Sphinx, env, docname: str, desc: addnodes.desc
+) -> tuple[str | None, str | None]:
+ """Return (startup src path, packages) for autodoc REPLs."""
+ if not app.config.pyrepl_autodoc_bootstrap:
+ return None, None
+
+ sig = desc.next_node(addnodes.desc_signature)
+ if sig is None:
+ return None, None
+
+ module_name = sig.get("module")
+ fullname = sig.get("fullname")
+ if not module_name:
+ return None, None
+
+ target = f"{module_name}.{fullname}" if fullname else module_name
+ try:
+ mod = sys.modules.get(module_name)
+ if mod is None:
+ mod = importlib.import_module(module_name)
+ obj = mod
+ if fullname:
+ for part in fullname.split("."):
+ obj = getattr(obj, part)
+ mod_obj = inspect.getmodule(obj) or mod
+ source_path = Path(inspect.getfile(mod_obj)).resolve()
+ srcdir = Path(env.srcdir).resolve()
+ try:
+ source_path.relative_to(srcdir)
+ return register_startup_file(env, docname, source_path), None
+ except ValueError:
+ return None, module_name.split(".")[0]
+ except (AttributeError, ImportError, OSError, TypeError) as exc:
+ logger.error(
+ "Could not bootstrap autodoc REPL for %s: %s",
+ target,
+ exc,
+ )
+ 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):
+ """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):
+ continue
+ source = doctest_to_replay_source(node.astext())
+ if not source.strip():
+ continue
+ bootstrap_src = None
+ packages = None
+ desc = _find_autodoc_desc(node)
+ if desc is not None:
+ bootstrap_src, packages = _resolve_autodoc_bootstrap(
+ app, env, docname, desc
+ )
+ replay_src = register_autodoc_repl(env, docname, source)
+ node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages))
+ replaced = True
+
+ if replaced:
+ env.metadata[docname]["pyrepl"] = True
+ doctree["pyrepl"] = True
class PyRepl(SphinxDirective):
@@ -66,7 +203,6 @@ class PyRepl(SphinxDirective):
"no-banner": directives.flag,
"replay": directives.flag,
"silent": directives.flag,
- "strip-prompts": directives.flag,
}
def run(self):
@@ -116,19 +252,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_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}"
+ 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
new file mode 100644
index 0000000..2f2d473
--- /dev/null
+++ b/tests/test_autodoc_bootstrap.py
@@ -0,0 +1,187 @@
+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))
+
+
+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"
+pyrepl_doctest_blocks = "autodoc"
+""",
+ 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 "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", "{}")
+ )
+ 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()
+ (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"
+
+
+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
+ )
diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py
new file mode 100644
index 0000000..42556ca
--- /dev/null
+++ b/tests/test_autodoc_doctest.py
@@ -0,0 +1,158 @@
+import json
+from pathlib import Path
+
+import pytest
+from sphinx.application import Sphinx
+
+ROOT = Path(__file__).resolve().parents[1]
+
+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 / "repl_test_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"
+pyrepl_doctest_blocks = "autodoc"
+""",
+ encoding="utf-8",
+ )
+ (srcdir / "index.rst").write_text(
+ """
+API
+===
+
+.. autofunction:: repl_test_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 '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}"
+
+
+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_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.replace('pyrepl_doctest_blocks = "autodoc"\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_autodoc_include.py b/tests/test_autodoc_include.py
new file mode 100644
index 0000000..01464ce
--- /dev/null
+++ b/tests/test_autodoc_include.py
@@ -0,0 +1,100 @@
+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"
+pyrepl_doctest_blocks = "autodoc"
+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")
+ assert 'src="_static/repl_include_demo.py"' in html
+ assert html.count("replay-src=") == 2
+
+ for script_name in replay_files:
+ 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()
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 088fef0..0000000
--- a/tests/test_strip_doctest_prompts.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from sphinx_pyrepl_web import strip_doctest_prompts
-
-
-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([">>> ..."]) == ["..."]