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