From d6c933f38cd3e62e49d5a3dde4684c9cb47460a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 09:54:41 +0000 Subject: [PATCH 1/4] Config-driven autodoc packages via wheel path Replace module introspection and silent :src: bootstrap for autodoc REPLs with a single pyrepl_autodoc_packages config value. Remove pyrepl_autodoc_bootstrap. Move the autodoc doctest example into pyrepl_test_pkg, rebuild the committed wheel fixture, and add scripts/build_test_pkg_wheel.py. Wire the fixture into the [docs] optional extra for one-command local and RTD doc builds. Update docs, README, and tests to match the wheel-based autodoc workflow. Co-authored-by: chrizzftd --- README.md | 24 ++- .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 1115 -> 1451 bytes docs/conf.py | 5 +- docs/example.rst | 27 ++- pyproject.toml | 1 + scripts/build_test_pkg_wheel.py | 59 ++++++ sphinx_pyrepl_web/__init__.py | 63 +----- .../pyrepl_test_pkg/pyrepl_test_pkg/demo.py | 1 + .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 1115 -> 1451 bytes tests/test_autodoc_bootstrap.py | 195 +++++++++--------- tests/test_autodoc_include.py | 3 +- 11 files changed, 208 insertions(+), 170 deletions(-) create mode 100644 scripts/build_test_pkg_wheel.py rename docs/_static/autodoc_demo.py => tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py (99%) diff --git a/README.md b/README.md index f281a66..610538f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ 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 +pyrepl_autodoc_packages = None # optional; wheel path or PyPI name for autodoc REPLs ``` ### Docstring conversion @@ -82,6 +82,7 @@ extensions = [ "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" +pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" ``` | | `pyrepl_doctest_blocks` options | @@ -91,10 +92,23 @@ pyrepl_doctest_blocks = "autodoc" | `"all"` | Transform every doctest block found | -| | `pyrepl_autodoc_bootstrap` options | -|------------------|------------------------------------------------------------------------------| -| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:` only | -| `False` | Replay doctest input only; documented names are not pre-defined | +| | `pyrepl_autodoc_packages` options | +|-------------------------|------------------------------------------------------------------| +| unset / `None` / `""` | Replay doctest input only | +| wheel path or PyPI name | Preload the documented package before replay (comma-separated) | + +Autodoc integration assumes a single documented package. The wheel (or PyPI +name) preloads it in the browser REPL; autodoc still imports the package on +the host at build time. + +To build this project's docs locally: + +```bash +pip install -e ".[docs]" +``` + +The `[docs]` extra installs doc build dependencies plus the `pyrepl_test_pkg` +fixture used in the examples. ### Local Pyodide wheels diff --git a/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl index 527687e7961e7f657de0a858b95a7e3b29c7f67f..d8a2685440b2e115ab04f17e457d212d248abd3b 100644 GIT binary patch delta 665 zcmcc3v6`DVz?+#xgn@y9gW;~{!->2z>r4C|#>k$WF?A(SkeQKzK^#aIR2HQcS#uvU5V-eQyU#~gfXO^-|iaF{0<-WA^ z>nH5T3;W+6w6tHOxO! zs6zT;qTQtRF|`R3Cf56%Hag(Fc;W{EwZsOI09E7YmmTrGW}9~{D%ZHoo_qVylg-sv zUpt)){P*MC>xKU&{wigj&9;sA4coROGpjil*+hOg)H~zzCi9JRq8K^P`SF!A0DaUk z`9Gt6{m!B$2L^@<_wpmBus+oYYS=xIAx@`hNuogDgly(+rug|L=TdU!T(`F};na1S zwnb;j&r-3r$f+9*=3HXjlBuGzgx{~_^QM{~bC=!^x@djs_T^c}zVmV=nW($=?rqXi zTl%|oXN?yGE*@GvQPiwR8pHO(Wcz@UK6SB%T z6_?vEOn83(S@xSOkK(P*>zD6Yw{+W=7p1!||597K#^!+1Zf});hSE!4m31AM-4T71 zf$P%k`v*_2ZDyG9hlxMHn~_O`86NeM`Iy}VG2(u5DzgF~EV>~&CN7ZT0VWABSkhR< zI606}Y4S8?eI{WhAX7^R*<1z&MGttW14CX7rV&UlX*>qfpfuT@g^!Pwft8^fsDO(F Gqyhk5oC!n# delta 361 zcmZ3@eVc3%)+>9OP|$f zxi9ECGv~4RVW)3jZ>hgM^;d6A#r_*I*NxuWWy}h;UX!P@ZpmZMpDS}3F4QRfpUyVx zkbV5pOKy?#^EaoQobms}%N2z$=50>&QC)M(bhkr?Rh=^XE}q48J0foD>`L!eu;~&} z;F$j`TTIYP>C+Eu&e)(IJLF^bt2zf?(Vl4JU2buve$TO$x7Q{+^-I0CbWp4N#r~#L zIDkQIe}Fe5lL#~1-;*U-+$Jw!k(*r0B0SlFNpSKr7JVjD#>x6jS~|!|85k5j;6cO4 hz@P>UA{bcG_yVLsX>va+A0G None: + print(f"+ {' '.join(cmd)}") + subprocess.run(cmd, cwd=cwd, check=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--keep-dist", + action="store_true", + help="Leave the build dist/ directory in the fixture package tree", + ) + args = parser.parse_args() + + dist_dir = PKG_DIR / "dist" + if dist_dir.exists(): + shutil.rmtree(dist_dir) + + run([sys.executable, "-m", "pip", "wheel", ".", "-w", "dist"], cwd=PKG_DIR) + + built = dist_dir / WHEEL_NAME + if not built.is_file(): + wheels = sorted(dist_dir.glob("*.whl")) + if len(wheels) != 1: + sys.exit(f"expected one wheel in {dist_dir}, found: {wheels!r}") + built = wheels[0] + + for dest_dir in DEST_DIRS: + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / WHEEL_NAME + shutil.copy2(built, dest) + print(f"copied {built.name} -> {dest}") + + if not args.keep_dist: + shutil.rmtree(dist_dir) + + +if __name__ == "__main__": + main() diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 26af36f..b3e09ec 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -2,12 +2,9 @@ __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 @@ -28,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", False, "env") - app.add_config_value("pyrepl_autodoc_bootstrap", True, "env") + app.add_config_value("pyrepl_autodoc_packages", None, "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) app.connect("doctree-read", transform_doctest_blocks) @@ -110,51 +107,9 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: 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. - - Only modules whose source lives under the Sphinx source directory are - bootstrapped via silent ``:src:``. Installed or out-of-tree modules are - left without bootstrap; use explicit ``:packages:`` wheel paths instead. - """ - 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, None - except (AttributeError, ImportError, OSError, TypeError) as exc: - logger.error( - "Could not bootstrap autodoc REPL for %s: %s", - target, - exc, - ) - return None, None +def _autodoc_packages(app: Sphinx) -> str | None: + """Return configured package preload for autodoc doctest REPLs.""" + return app.config.pyrepl_autodoc_packages or None def _inside_autodoc_desc(node: nodes.Node) -> bool: @@ -177,15 +132,11 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): 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 - ) + if _find_autodoc_desc(node) is not None: + packages = _autodoc_packages(app) replay_src = register_autodoc_repl(env, docname, source) - node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages)) + node.replace_self(make_pyrepl_raw(replay_src, packages=packages)) replaced = True if replaced: diff --git a/docs/_static/autodoc_demo.py b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py similarity index 99% rename from docs/_static/autodoc_demo.py rename to tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py index 09f42fb..dbc19f6 100644 --- a/docs/_static/autodoc_demo.py +++ b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py @@ -1,5 +1,6 @@ """Demo module for autodoc doctest REPL integration.""" + def example_generator(n): """Generators yield values useful for iteration. diff --git a/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl index 527687e7961e7f657de0a858b95a7e3b29c7f67f..d8a2685440b2e115ab04f17e457d212d248abd3b 100644 GIT binary patch delta 665 zcmcc3v6`DVz?+#xgn@y9gW;~{!->2z>r4C|#>k$WF?A(SkeQKzK^#aIR2HQcS#uvU5V-eQyU#~gfXO^-|iaF{0<-WA^ z>nH5T3;W+6w6tHOxO! zs6zT;qTQtRF|`R3Cf56%Hag(Fc;W{EwZsOI09E7YmmTrGW}9~{D%ZHoo_qVylg-sv zUpt)){P*MC>xKU&{wigj&9;sA4coROGpjil*+hOg)H~zzCi9JRq8K^P`SF!A0DaUk z`9Gt6{m!B$2L^@<_wpmBus+oYYS=xIAx@`hNuogDgly(+rug|L=TdU!T(`F};na1S zwnb;j&r-3r$f+9*=3HXjlBuGzgx{~_^QM{~bC=!^x@djs_T^c}zVmV=nW($=?rqXi zTl%|oXN?yGE*@GvQPiwR8pHO(Wcz@UK6SB%T z6_?vEOn83(S@xSOkK(P*>zD6Yw{+W=7p1!||597K#^!+1Zf});hSE!4m31AM-4T71 zf$P%k`v*_2ZDyG9hlxMHn~_O`86NeM`Iy}VG2(u5DzgF~EV>~&CN7ZT0VWABSkhR< zI606}Y4S8?eI{WhAX7^R*<1z&MGttW14CX7rV&UlX*>qfpfuT@g^!Pwft8^fsDO(F Gqyhk5oC!n# delta 361 zcmZ3@eVc3%)+>9OP|$f zxi9ECGv~4RVW)3jZ>hgM^;d6A#r_*I*NxuWWy}h;UX!P@ZpmZMpDS}3F4QRfpUyVx zkbV5pOKy?#^EaoQobms}%N2z$=50>&QC)M(bhkr?Rh=^XE}q48J0foD>`L!eu;~&} z;F$j`TTIYP>C+Eu&e)(IJLF^bt2zf?(Vl4JU2buve$TO$x7Q{+^-I0CbWp4N#r~#L zIDkQIe}Fe5lL#~1-;*U-+$Jw!k(*r0B0SlFNpSKr7JVjD#>x6jS~|!|85k5j;6cO4 hz@P>UA{bcG_yVLsX>va+A0G Sphinx: + 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() + return app - Examples: - >>> greet() - 'hi' - """ - return "hi" -'''.strip() - + "\n", - encoding="utf-8", - ) +def _wheel_conf_extra() -> str: + return f""" +html_static_path = ["_static"] +pyrepl_autodoc_packages = {WHEEL_PATH!r} +""" + + +def test_autodoc_packages_emits_configured_wheel(tmp_path): + wheels_dir = tmp_path / "docs" / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + + srcdir = tmp_path / "docs" 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")) +sys.path.insert(0, {str(FIXTURES / "pyrepl_test_pkg")!r}) extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] master_doc = "index" pyrepl_js = "pyrepl.js" pyrepl_doctest_blocks = "autodoc" +{_wheel_conf_extra()} """, encoding="utf-8", ) - (srcdir / "index.rst").write_text(".. autofunction:: demo.greet\n", encoding="utf-8") + (srcdir / "index.rst").write_text( + ".. autofunction:: pyrepl_test_pkg.demo.example_generator\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() + app = _build_sphinx(srcdir, outdir, doctreedir) html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'src="_static/demo.py"' in html + assert f'packages="{WHEEL_PATH}"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html - assert "pyrepl.js" in html - assert (outdir / "_static" / "demo.py").is_file() + pyrepl_tag = html[html.index("", html.index("")] + assert ' src="' not in pyrepl_tag + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() doctree = app.env.get_doctree("index") assert doctree.get("pyrepl") @@ -77,17 +84,12 @@ def greet(): assert list(replay_files) == ["index-1.py"] - assert list(replay_files) == ["index-1.py"] - - -def test_autodoc_bootstrap_skips_installed_module(tmp_path): +def test_autodoc_packages_for_out_of_tree_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): +class Widget: """A demo widget. Example: @@ -96,14 +98,57 @@ class Widget(_Widget): 'ready' """ - pass + label = "ready" '''.strip() + "\n", encoding="utf-8", ) - (pkg_dir / "core.py").write_text( + + wheels_dir = tmp_path / "docs" / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + + srcdir = tmp_path / "docs" + 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" +{_wheel_conf_extra()} +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") + + _build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + pyrepl_tag = html[html.index("", html.index("")] + assert ' src="' not in pyrepl_tag + + +def test_autodoc_without_packages_is_replay_only(tmp_path): + pkg_dir = tmp_path / "installed_pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( ''' class Widget: + """A demo widget. + + Example: + >>> w = Widget() + >>> w.label + 'ready' + + """ label = "ready" '''.strip() + "\n", @@ -128,60 +173,22 @@ class Widget: ) (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() + _build_sphinx(srcdir, outdir, doctreedir) html = (outdir / "index.html").read_text(encoding="utf-8") assert 'replay-src="_static/pyrepl/index-1.py"' in html - assert "", html.index("")] assert 'packages="' not in pyrepl_tag 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") +def test_autodoc_packages_config_helper(): app = MagicMock() - app.config.pyrepl_autodoc_bootstrap = True - - env = MagicMock() - env.srcdir = "/tmp/docs" - env.metadata = {"index": {}} - env.note_dependency = MagicMock() + app.config.pyrepl_autodoc_packages = WHEEL_PATH + assert _autodoc_packages(app) == WHEEL_PATH - sig = MagicMock() - sig.get.side_effect = lambda key, default=None: { - "module": "nonexistent_bootstrap_mod_xyz", - "fullname": "missing", - }.get(key, default) + app.config.pyrepl_autodoc_packages = "" + assert _autodoc_packages(app) is None - 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 - ) + app.config.pyrepl_autodoc_packages = None + assert _autodoc_packages(app) is None diff --git a/tests/test_autodoc_include.py b/tests/test_autodoc_include.py index 01464ce..500229c 100644 --- a/tests/test_autodoc_include.py +++ b/tests/test_autodoc_include.py @@ -91,10 +91,9 @@ def test_included_example_writes_all_replay_scripts(included_example_project): 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 + assert 'src="_static/repl_include_demo.py"' not in html 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() From 45128d7052d67d65caf1d889e34c02c9f141f11c Mon Sep 17 00:00:00 2001 From: chrizzftd Date: Thu, 2 Jul 2026 07:59:12 +1000 Subject: [PATCH 2/4] Auto-import documented objects into autodoc doctest REPLs (#18) When pyrepl_autodoc_packages is set, generate a silent bootstrap script that imports the documented object from autodoc desc_signature metadata before replaying doctest input. This lets unqualified doctest names like example_generator work without changing docstrings. Bootstrap scripts are written alongside replay files under _static/pyrepl/ and emitted as src= on autodoc py-repl widgets. Co-authored-by: Cursor Agent Co-authored-by: chrizzftd --- README.md | 9 ++-- docs/example.rst | 6 +-- sphinx_pyrepl_web/__init__.py | 72 +++++++++++++++++++++++--- tests/test_autodoc_bootstrap.py | 23 ++++++-- tests/test_autodoc_bootstrap_source.py | 29 +++++++++++ 5 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 tests/test_autodoc_bootstrap_source.py diff --git a/README.md b/README.md index 610538f..c9353e7 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,13 @@ pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" | | `pyrepl_autodoc_packages` options | |-------------------------|------------------------------------------------------------------| -| unset / `None` / `""` | Replay doctest input only | -| wheel path or PyPI name | Preload the documented package before replay (comma-separated) | +| unset / `None` / `""` | Replay doctest input only (no wheel install or auto-import) | +| wheel path or PyPI name | Install the package and import the documented object before replay (comma-separated) | Autodoc integration assumes a single documented package. The wheel (or PyPI -name) preloads it in the browser REPL; autodoc still imports the package on -the host at build time. +name) is installed in the browser REPL and the documented name is imported +automatically so doctest examples can use unqualified names. Autodoc still +imports the package on the host at build time. To build this project's docs locally: diff --git a/docs/example.rst b/docs/example.rst index f15f546..77573bc 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -124,9 +124,9 @@ Autodoc ------- When ``pyrepl_doctest_blocks = "autodoc"``, doctest examples in documented -APIs become interactive REPLs. Set ``pyrepl_autodoc_packages`` to preload the -documented package from a Pyodide-compatible wheel (or PyPI name) before -replay: +APIs become interactive REPLs. Set ``pyrepl_autodoc_packages`` to install the +documented package from a Pyodide-compatible wheel (or PyPI name) and +automatically import the documented object before replay: .. code-block:: python diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index b3e09ec..1f2eb91 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -17,6 +17,7 @@ PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" REPLAY_FILES_KEY = "pyrepl-replay-files" +BOOTSTRAP_FILES_KEY = "pyrepl-bootstrap-files" _DOCTEST_PARSER = DocTestParser() logger = logging.getLogger(__name__) @@ -56,8 +57,8 @@ def register_autodoc_repl( env, docname: str, replay_text: str, -) -> str: - """Record a replay script in env metadata and return its replay-src path.""" +) -> tuple[str, str]: + """Record a replay script in env metadata and return (replay-src path, name).""" replay_files = json.loads( env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}") ) @@ -65,7 +66,42 @@ def register_autodoc_repl( 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}" + return f"_static/pyrepl/{replay_name}", replay_name + + +def register_autodoc_bootstrap( + env, + docname: str, + bootstrap_text: str, + replay_name: str, +) -> str: + """Record a silent bootstrap script paired with a replay file.""" + bootstrap_files = json.loads( + env.metadata[docname].setdefault(BOOTSTRAP_FILES_KEY, "{}") + ) + bootstrap_name = replay_name.replace(".py", "-bootstrap.py") + bootstrap_files[bootstrap_name] = bootstrap_text + env.metadata[docname][BOOTSTRAP_FILES_KEY] = json.dumps(bootstrap_files) + return f"_static/pyrepl/{bootstrap_name}" + + +def autodoc_bootstrap_source( + module: str | None, + fullname: str | None, + objtype: str | None, +) -> str | None: + """Return a silent import script for the documented autodoc object.""" + if not module: + return None + + if objtype == "module" or not fullname: + return f"import {module}\n" + + if "." in fullname: + root = fullname.split(".", 1)[0] + return f"from {module} import {root}\n" + + return f"from {module} import {fullname}\n" def register_startup_file(env, docname: str, path: Path) -> str: @@ -133,10 +169,26 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): if not source.strip(): continue packages = None - if _find_autodoc_desc(node) is not None: + bootstrap_src = None + desc = _find_autodoc_desc(node) + if desc is not None: packages = _autodoc_packages(app) - replay_src = register_autodoc_repl(env, docname, source) - node.replace_self(make_pyrepl_raw(replay_src, packages=packages)) + replay_src, replay_name = register_autodoc_repl(env, docname, source) + if desc is not None and packages: + sig = desc.next_node(addnodes.desc_signature) + if sig is not None: + bootstrap_text = autodoc_bootstrap_source( + sig.get("module"), + sig.get("fullname"), + desc.get("objtype"), + ) + if bootstrap_text: + bootstrap_src = register_autodoc_bootstrap( + env, docname, bootstrap_text, replay_name + ) + node.replace_self( + make_pyrepl_raw(replay_src, src=bootstrap_src, packages=packages) + ) replaced = True if replaced: @@ -209,7 +261,7 @@ def run(self): if has_body: body_text = doctest_to_replay_source(list(self.content)) - replay_src = register_autodoc_repl(env, env.docname, body_text) + 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 @@ -254,6 +306,12 @@ def copy_asset_files(app, _): for name, content in replay_files.items(): (replay_dest / name).write_text(content, encoding="utf-8") + raw_bootstrap = metadata.get(BOOTSTRAP_FILES_KEY) + if raw_bootstrap: + bootstrap_files = json.loads(raw_bootstrap) + for name, content in bootstrap_files.items(): + (replay_dest / name).write_text(content, encoding="utf-8") + srcdir = Path(app.builder.srcdir) copied = set() for docname, metadata in app.env.metadata.items(): diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 41eb59a..c291a0a 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -72,7 +72,7 @@ def test_autodoc_packages_emits_configured_wheel(tmp_path): assert f'packages="{WHEEL_PATH}"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html pyrepl_tag = html[html.index("", html.index("")] - assert ' src="' not in pyrepl_tag + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() doctree = app.env.get_doctree("index") @@ -82,6 +82,18 @@ def test_autodoc_packages_emits_configured_wheel(tmp_path): app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) assert list(replay_files) == ["index-1.py"] + assert replay_files["index-1.py"] == ( + "print([i for i in example_generator(4)])\n" + ) + + bootstrap_files = json.loads( + app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") + ) + assert list(bootstrap_files) == ["index-1-bootstrap.py"] + assert bootstrap_files["index-1-bootstrap.py"] == ( + "from pyrepl_test_pkg.demo import example_generator\n" + ) + assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() def test_autodoc_packages_for_out_of_tree_module(tmp_path): @@ -126,13 +138,18 @@ class Widget: ) (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") - _build_sphinx(srcdir, outdir, doctreedir) + app = _build_sphinx(srcdir, outdir, doctreedir) html = (outdir / "index.html").read_text(encoding="utf-8") assert f'packages="{WHEEL_PATH}"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html pyrepl_tag = html[html.index("", html.index("")] - assert ' src="' not in pyrepl_tag + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag + + bootstrap_files = json.loads( + app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") + ) + assert bootstrap_files["index-1-bootstrap.py"] == "from installed_pkg import Widget\n" def test_autodoc_without_packages_is_replay_only(tmp_path): diff --git a/tests/test_autodoc_bootstrap_source.py b/tests/test_autodoc_bootstrap_source.py new file mode 100644 index 0000000..4a0e35b --- /dev/null +++ b/tests/test_autodoc_bootstrap_source.py @@ -0,0 +1,29 @@ +from sphinx_pyrepl_web import autodoc_bootstrap_source + + +def test_function_import(): + assert autodoc_bootstrap_source( + "pyrepl_test_pkg.demo", "example_generator", "function" + ) == "from pyrepl_test_pkg.demo import example_generator\n" + + +def test_class_import(): + assert autodoc_bootstrap_source("installed_pkg", "Widget", "class") == ( + "from installed_pkg import Widget\n" + ) + + +def test_method_imports_outer_class(): + assert autodoc_bootstrap_source("pkg.mod", "Widget.label", "method") == ( + "from pkg.mod import Widget\n" + ) + + +def test_module_import(): + assert autodoc_bootstrap_source("pyrepl_test_pkg.demo", "", "module") == ( + "import pyrepl_test_pkg.demo\n" + ) + + +def test_missing_module_returns_none(): + assert autodoc_bootstrap_source(None, "foo", "function") is None From 1acf6238cad8904a07610f6ef917b7577d85397f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 22:03:43 +0000 Subject: [PATCH 3/4] Remove dead register_startup_file and unused make_pyrepl_raw src arg Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 1f2eb91..aa8ed7d 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -104,31 +104,14 @@ def autodoc_bootstrap_source( return f"from {module} import {fullname}\n" -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") From c6dcd980e2c405d42134bebdc35a304ab6274a33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 22:10:49 +0000 Subject: [PATCH 4/4] Restore make_pyrepl_raw src param for autodoc bootstrap scripts PR #18 merged auto-import bootstrap that emits silent src= bootstrap scripts alongside packages=. The prior cleanup commit removed the src parameter from make_pyrepl_raw, causing TypeError in transform_doctest_blocks and failing CI/RTD builds. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index aa8ed7d..881a4d7 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -107,11 +107,14 @@ def autodoc_bootstrap_source( def make_pyrepl_raw( replay_src: str, packages: str | None = None, + src: 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")