diff --git a/README.md b/README.md index f281a66..c9353e7 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,24 @@ 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 (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) 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: + +```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 527687e..d8a2685 100644 Binary files a/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl and b/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl differ diff --git a/docs/conf.py b/docs/conf.py index f1c65f0..262ee14 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,11 +1,7 @@ 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" @@ -18,6 +14,7 @@ "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" +pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" html_static_path = ["_static"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/docs/example.rst b/docs/example.rst index 4c1ac71..77573bc 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -123,31 +123,40 @@ Rendered result: Autodoc ------- -The documented module's source is loaded in advance before replay, so -module members are available in the REPL namespace when the module lives -under the Sphinx source tree (silent ``:src:``). +When ``pyrepl_doctest_blocks = "autodoc"``, doctest examples in documented +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 + + # conf.py + html_static_path = ["_static"] + pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" + +Autodoc still imports the package on the host at build time (for example via +``pip install -e ".[docs]"`` in this repository). Source module: -.. literalinclude:: _static/autodoc_demo.py +.. literalinclude:: ../../tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py :language: python RST content: .. code-block:: rst - .. autofunction:: autodoc_demo.example_generator + .. autofunction:: pyrepl_test_pkg.demo.example_generator Rendered result: -.. autofunction:: autodoc_demo.example_generator +.. autofunction:: pyrepl_test_pkg.demo.example_generator Local Pyodide wheels -------------------- -Preload a Pyodide-compatible wheel from ``_static/wheels/`` via ``:packages:``. -Combine with ``:src:`` for optional post-install bootstrap and a doctest replay -body: +The same wheel can be referenced manually from ``.. py-repl::`` when you want +a standalone REPL without autodoc: .. code-block:: rst diff --git a/pyproject.toml b/pyproject.toml index 17db7c4..1f32c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ test = [ ] docs = [ "myst-parser", + "pyrepl_test_pkg @ file:./tests/fixtures/pyrepl_test_pkg", ] [tool.pytest.ini_options] diff --git a/scripts/build_test_pkg_wheel.py b/scripts/build_test_pkg_wheel.py new file mode 100644 index 0000000..00bd9f2 --- /dev/null +++ b/scripts/build_test_pkg_wheel.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Build pyrepl_test_pkg wheel and copy it into docs and test fixture paths.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PKG_DIR = ROOT / "tests" / "fixtures" / "pyrepl_test_pkg" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +DEST_DIRS = ( + ROOT / "tests" / "fixtures" / "wheels", + ROOT / "docs" / "_static" / "wheels", +) + + +def run(cmd: list[str], *, cwd: Path) -> 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..881a4d7 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 @@ -20,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__) @@ -28,7 +26,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) @@ -59,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, "{}") ) @@ -68,27 +66,48 @@ 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_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, "[]") +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, "{}") ) - 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 + 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 make_pyrepl_raw( replay_src: str, - src: str | None = None, 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}"'] @@ -110,51 +129,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 +154,27 @@ 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 + bootstrap_src = 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)) + packages = _autodoc_packages(app) + 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: @@ -258,7 +247,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 @@ -303,6 +292,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/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 527687e..d8a2685 100644 Binary files a/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl and b/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl differ diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 1084478..c291a0a 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -1,72 +1,79 @@ import json -import logging +import shutil import sys from pathlib import Path from unittest.mock import MagicMock from sphinx.application import Sphinx -from sphinx_pyrepl_web import _resolve_autodoc_bootstrap +from sphinx_pyrepl_web import _autodoc_packages ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" + 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. +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> 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="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() doctree = app.env.get_doctree("index") assert doctree.get("pyrepl") @@ -75,19 +82,26 @@ def greet(): 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" + ) - - assert list(replay_files) == ["index-1.py"] + 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_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 +110,62 @@ 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") + + 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="_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): + 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 +190,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_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 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()