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([">>> ..."]) == ["..."]