Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,40 @@ Optional Sphinx config:
pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script
```

## Autodoc integration

When `sphinx.ext.autodoc` is enabled, you can automatically convert docstring
``Example:`` / ``Examples:`` doctest blocks into interactive REPLs at build time.
Python source docstrings stay unchanged (full doctests with expected output are
preserved for IDE hover docs and optional ``doctest`` runs).

Add to `conf.py`:

```python
extensions = [
"sphinx.ext.autodoc",
"sphinx_pyrepl_web",
]

pyrepl_autodoc = True
pyrepl_autodoc_packages = "my_package" # required for Pyodide import
pyrepl_autodoc_sections = ["Example"]
pyrepl_autodoc_options = {"no-banner": True}
```

| Config | Default | Description |
|--------|---------|-------------|
| `pyrepl_autodoc` | `False` | Enable autodoc docstring → REPL conversion |
| `pyrepl_autodoc_packages` | `None` | Value for `:packages:` on generated directives (auto-derived from object name when unset) |
| `pyrepl_autodoc_sections` | `["Example", "Examples"]` | Section titles to convert |
| `pyrepl_autodoc_options` | `{}` | Default directive flags, e.g. `{"no-banner": True}` |

**Limitations (v0.2.0):**

- Targets raw Google-style / plain-text section headers (without `sphinx.ext.napoleon`). Napoleon-converted sections are not detected yet.
- `:packages:` (or auto-derivation) is required so Pyodide can import your library.
- Live REPL output may differ from doctest expected output (e.g. `PosixPath` vs `WindowsPath`, traceback formatting).

## 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:
Expand Down
35 changes: 21 additions & 14 deletions sphinx_pyrepl_web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""A Sphinx extension for embedding pyrepl-web Python REPLs in documentation."""

__version__ = "0.1.1"
__version__ = "0.2.0"

import json
from pathlib import Path
Expand All @@ -23,6 +23,11 @@ def setup(app: Sphinx):
app.connect("doctree-read", doctree_read)
app.connect("html-page-context", add_html_context)
app.connect("env-updated", copy_asset_files)

from sphinx_pyrepl_web.autodoc import register as register_autodoc

register_autodoc(app)

return {"version": __version__, "parallel_read_safe": True}


Expand Down Expand Up @@ -51,6 +56,20 @@ def strip_doctest_prompts(lines: list[str]) -> list[str]:
return result


def register_replay_script(env, docname: str, body_lines: list[str]) -> str:
"""Strip doctest prompts, store replay script in metadata, return replay-src path."""
body_text = "\n".join(strip_doctest_prompts(body_lines)) + "\n"

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}"


class PyRepl(SphinxDirective):
"""Embed a pyrepl-web ``<py-repl>`` element."""

Expand Down Expand Up @@ -116,19 +135,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"

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, list(self.content))
attrs.append(f'replay-src="{replay_src}"')

self.env.metadata[self.env.docname]["pyrepl"] = True
Expand Down
214 changes: 214 additions & 0 deletions sphinx_pyrepl_web/autodoc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""Autodoc integration: convert docstring Example blocks to py-repl directives."""

from __future__ import annotations

import re
from typing import Any

from sphinx.application import Sphinx

from sphinx_pyrepl_web.doctest import _is_doctest_input_line, extract_doctest_inputs

# Common docstring section headers that end an Example doctest block.
_OTHER_SECTION_HEADERS = frozenset(
{
"Args",
"Arguments",
"Attributes",
"Caution",
"Danger",
"Error",
"Hint",
"Important",
"Keyword Args",
"Keyword Arguments",
"Methods",
"Note",
"Notes",
"Parameters",
"Raises",
"Return",
"Returns",
"See Also",
"Tip",
"Todo",
"Warning",
"Warnings",
"Warns",
"Yield",
"Yields",
}
)

_DEFAULT_WHAT = frozenset({"class", "function", "method"})


def _section_header_pattern(sections: list[str]) -> re.Pattern[str]:
names = "|".join(re.escape(section) for section in sections)
return re.compile(rf"^\s*({names})\s*:?\s*$")


def _matches_configured_section(line: str, pattern: re.Pattern[str]) -> bool:
return bool(pattern.match(line))


def _matches_other_section_header(line: str) -> bool:
stripped = line.lstrip()
if not stripped.endswith(":"):
return False
title = stripped[:-1].strip()
return title in _OTHER_SECTION_HEADERS


def _collect_doctest_block(
lines: list[str], start: int, section_pattern: re.Pattern[str]
) -> tuple[list[str], int]:
"""Return doctest block lines and index after the block."""
block: list[str] = []
block_started = False
i = start

while i < len(lines):
line = lines[i]
stripped = line.lstrip()

if stripped == "":
if block_started:
block.append(line)
i += 1
continue

if _matches_configured_section(line, section_pattern):
break

if _matches_other_section_header(stripped):
break

if _is_doctest_input_line(line):
block_started = True
block.append(line)
i += 1
continue

if block_started and (line.startswith(" ") or line.startswith("\t")):
block.append(line)
i += 1
continue

break

return block, i


def _has_primary_prompt(lines: list[str]) -> bool:
return any(line.lstrip().startswith(">>>") for line in lines)


def format_pyrepl_directive(
input_lines: list[str], options: dict[str, str | bool]
) -> list[str]:
"""Format a ``.. py-repl::`` directive block for injection into a docstring."""
result = [".. py-repl::"]

for key in sorted(options):
value = options[key]
if value is True:
result.append(f" :{key}:")
elif value:
result.append(f" :{key}: {value}")

if input_lines:
result.append("")
for line in input_lines:
normalized = line.lstrip()
result.append(f" {normalized}" if normalized else "")

return result


def transform_docstring_lines(
lines: list[str],
sections: list[str],
options: dict[str, str | bool],
) -> None:
"""Replace configured Example sections with ``.. py-repl::`` directives in place."""
section_pattern = _section_header_pattern(sections)
i = 0

while i < len(lines):
line = lines[i]
if not _matches_configured_section(line, section_pattern):
i += 1
continue

header_line = line
block, next_i = _collect_doctest_block(lines, i + 1, section_pattern)

if not block or not _has_primary_prompt(block):
i = next_i
continue

input_lines = extract_doctest_inputs(block)
if not input_lines:
i = next_i
continue

replacement = [header_line, ""] + format_pyrepl_directive(input_lines, options)
lines[i:next_i] = replacement
i += len(replacement)


def derive_packages(qualified_name: str) -> str:
"""Derive a PyPI/import package name from a documented object's qualified name."""
if "." in qualified_name:
return qualified_name.rsplit(".", 1)[0]
return qualified_name


def process_autodoc_docstring(
app: Sphinx,
what: str,
name: str,
obj: Any,
options: Any,
lines: list[str],
) -> None:
"""Convert matching docstring Example blocks into ``.. py-repl::`` directives."""
if what not in _DEFAULT_WHAT:
return

sections: list[str] = app.config.pyrepl_autodoc_sections
if not sections:
return

directive_options: dict[str, str | bool] = dict(app.config.pyrepl_autodoc_options)
packages = app.config.pyrepl_autodoc_packages
if packages is None:
packages = derive_packages(name)
if packages:
directive_options["packages"] = packages

transform_docstring_lines(lines, sections, directive_options)


def register(app: Sphinx) -> None:
"""Register autodoc config values and hook when enabled."""
app.add_config_value("pyrepl_autodoc", False, "env")
app.add_config_value("pyrepl_autodoc_packages", None, "env")
app.add_config_value("pyrepl_autodoc_sections", ["Example", "Examples"], "env")
app.add_config_value("pyrepl_autodoc_options", {}, "env")

if not app.config.pyrepl_autodoc:
return

if "sphinx.ext.autodoc" not in app.config.extensions:
from sphinx.util import logging

logger = logging.getLogger(__name__)
logger.warning(
"pyrepl_autodoc is enabled but sphinx.ext.autodoc is not loaded; "
"autodoc integration will have no effect."
)
return

app.connect("autodoc-process-docstring", process_autodoc_docstring)
50 changes: 50 additions & 0 deletions sphinx_pyrepl_web/doctest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Utilities for parsing doctest blocks in docstrings."""


def _is_doctest_input_line(line: str) -> bool:
"""Return True if *line* is a doctest input or continuation prompt."""
stripped = line.lstrip()
if stripped.startswith(">>> "):
return True
if stripped == ">>>":
return True
if stripped.startswith("..."):
return True
return False


def extract_doctest_inputs(lines: list[str]) -> list[str]:
"""Return input-only doctest lines, dropping expected output and tracebacks.

Keeps lines starting with ``>>>`` or ``...`` (including bare ``...`` block
terminators). Drops expected output lines and traceback blocks (from
``Traceback`` through the error message).
"""
result: list[str] = []
in_traceback = False
in_block = False

for line in lines:
stripped = line.lstrip()

if stripped.startswith("Traceback"):
in_traceback = True
continue

if in_traceback:
# Only resume on a new primary prompt; traceback ellipsis uses ``...``.
if stripped.startswith(">>> "):
in_traceback = False
else:
continue

if _is_doctest_input_line(line):
in_block = True
result.append(line)
elif in_block and stripped == "":
result.append(line)
else:
# Expected output or trailing non-doctest content.
pass

return result
Loading
Loading