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