diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9a1763a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for sphinx-pyrepl-web.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fa87a25 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared pytest fixtures.""" + +import sys +from pathlib import Path + +import pytest + +from tests.support import build_sphinx, copy_wheel_to, pyrepl_conf_header + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def sphinx_project(tmp_path): + """Minimal Sphinx project with a py-repl directive body.""" + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "conf.py").write_text( + pyrepl_conf_header(), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + """ +Example +======= + +.. py-repl:: + :no-header: + + >>> x = 2 + 2 + >>> print(x) +""".strip() + + "\n", + encoding="utf-8", + ) + return srcdir, outdir, doctreedir + + +@pytest.fixture +def wheel_project(tmp_path): + """Sphinx project with the test wheel and bootstrap script in _static.""" + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + (srcdir / "_static" / "bootstrap.py").write_text( + "# Optional post-install bootstrap for local wheel REPLs.\n", + encoding="utf-8", + ) + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "conf.py").write_text( + pyrepl_conf_header(extra='html_static_path = ["_static"]\n'), + encoding="utf-8", + ) + return srcdir, outdir, doctreedir diff --git a/tests/doctree/test_autodoc_scope.py b/tests/doctree/test_autodoc_scope.py new file mode 100644 index 0000000..fdc7821 --- /dev/null +++ b/tests/doctree/test_autodoc_scope.py @@ -0,0 +1,70 @@ +import pytest +from sphinx_pytest.plugin import CreateDoctree + +from tests.fixtures.sources import AUTODOC_SCOPE_RST, EXAMPLE_GENERATOR_SOURCE + + +def _write_autodoc_conf(srcdir, scope) -> None: + scope_literal = repr(scope) + (srcdir / "conf.py").write_text( + f""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_pyrepl_web", +] +pyrepl_doctest_blocks = {scope_literal} +pyrepl_js = "pyrepl.js" +""".strip() + + "\n", + encoding="utf-8", + ) + + +@pytest.fixture +def autodoc_doctree(sphinx_doctree: CreateDoctree): + """Configure sphinx_doctree with an autodoc module and doctest scope RST.""" + (sphinx_doctree.srcdir / "repl_test_demo.py").write_text( + EXAMPLE_GENERATOR_SOURCE, + encoding="utf-8", + ) + _write_autodoc_conf(sphinx_doctree.srcdir, "autodoc") + sphinx_doctree.buildername = "html" + return sphinx_doctree + + +def count_pyrepl_nodes(result) -> int: + """Return the number of py-repl raw HTML nodes in a doctree result.""" + return result.pformat().count(" @@ -32,22 +32,6 @@ def test_basic(sphinx_doctree: CreateDoctree): """.strip().splitlines() -def test_replay_body(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - result = sphinx_doctree( - """ -.. py-repl:: - :no-header: - - >>> x = 1 - >>> x + 1 - """ - ) - html = result.pformat() - assert 'replay-src="_static/pyrepl/index-1.py"' in html - - def test_replay_file_flag(sphinx_doctree: CreateDoctree): sphinx_doctree.set_conf( { @@ -67,3 +51,57 @@ def test_replay_file_flag(sphinx_doctree: CreateDoctree): html = result.pformat() assert 'src="demo.py"' in html assert "replay" in html + + +@pytest.mark.parametrize( + "options,expected_fragments", + [ + (":no-buttons:\n :readonly:", ["no-buttons", "readonly"]), + (":repl-title: My REPL", ['repl-title="My REPL"']), + ], + ids=["flag-options", "repl-title"], +) +def test_pyrepl_directive_options( + sphinx_doctree: CreateDoctree, options, expected_fragments +): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"], "root_doc": "index"}) + sphinx_doctree.buildername = "html" + (sphinx_doctree.srcdir / "demo.py").write_text("print('hi')\n", encoding="utf-8") + result = sphinx_doctree( + f""" +.. py-repl:: + {options} + + >>> 1 + 1 +""" + ) + html = result.pformat() + for fragment in expected_fragments: + assert fragment in html + + +def test_silent_src_omitted_without_body(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"], "root_doc": "index"}) + sphinx_doctree.buildername = "html" + (sphinx_doctree.srcdir / "demo.py").write_text("print('hi')\n", encoding="utf-8") + result = sphinx_doctree( + """ +.. py-repl:: + :silent: + :src: demo.py +""" + ) + html = result.pformat() + assert 'src="demo.py"' not in html + + +def test_missing_src_file_reports_error(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) + sphinx_doctree.buildername = "html" + result = sphinx_doctree( + """ +.. py-repl:: + :src: missing.py +""" + ) + assert "Could not read file" in result.warnings diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..315591d --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Shared test fixtures and sample sources.""" diff --git a/tests/fixtures/sources.py b/tests/fixtures/sources.py new file mode 100644 index 0000000..51600d1 --- /dev/null +++ b/tests/fixtures/sources.py @@ -0,0 +1,45 @@ +"""Shared RST snippets and inline module sources for doctree tests.""" + +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" + +AUTODOC_SCOPE_RST = """ +API +=== + +.. autofunction:: repl_test_demo.example_generator + +Tutorial +======== + +Plain doctest (outside autodoc): + +>>> x = 1 +>>> x + 1 +2 +""".strip() + +WIDGET_SOURCE = ''' +class Widget: + """A demo widget. + + Example: + >>> w = Widget() + >>> w.label + 'ready' + + """ + label = "ready" +'''.strip() + "\n" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..45307ae --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,64 @@ +"""Shared assertion helpers for sphinx-pyrepl-web tests.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +from sphinx.application import Sphinx + + +def mock_html_builder(docname: str = "api/module") -> MagicMock: + """Return a mock HTML builder whose target URIs match a nested page layout.""" + builder = MagicMock() + builder.format = "html" + + def get_target_uri(name: str) -> str: + if name == "index": + return "index.html" + return f"{name}.html" + + builder.get_target_uri.side_effect = get_target_uri + return builder + + +def pyrepl_tag(html: str) -> str: + """Extract the first ```` tag from HTML.""" + start = html.index("", start) + len(">") + return html[start:end] + + +def load_replay_files(app: Sphinx, docname: str) -> dict[str, str]: + """Load replay script metadata for a document.""" + return json.loads( + app.env.metadata[docname].get("pyrepl-replay-files", "{}") + ) + + +def load_bootstrap_files(app: Sphinx, docname: str) -> dict[str, str]: + """Load bootstrap script metadata for a document.""" + return json.loads( + app.env.metadata[docname].get("pyrepl-bootstrap-files", "{}") + ) + + +def assert_replay_artifacts( + app: Sphinx, + outdir: Path, + docname: str, + *, + count: int | None = None, + html: str | None = None, +) -> dict[str, str]: + """Assert replay metadata, on-disk scripts, and optional HTML references.""" + replay_files = load_replay_files(app, docname) + if count is not None: + assert len(replay_files) == count + + 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}" + if html is not None: + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + return replay_files diff --git a/tests/integration/test_autodoc_wiring.py b/tests/integration/test_autodoc_wiring.py new file mode 100644 index 0000000..d955d71 --- /dev/null +++ b/tests/integration/test_autodoc_wiring.py @@ -0,0 +1,70 @@ +from tests.helpers import load_bootstrap_files, pyrepl_tag +from tests.support import ( + FIXTURES, + WHEEL_NAME, + WHEEL_PATH, + autodoc_conf_header, + build_sphinx, + copy_wheel_to, + wheel_conf_extra, +) + + +def test_autodoc_with_packages_writes_bootstrap_and_wheel(tmp_path): + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + autodoc_conf_header( + sys_path=str(FIXTURES / "pyrepl_test_pkg"), + extra=wheel_conf_extra(), + ), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + ".. autofunction:: pyrepl_test_pkg.demo.example_generator\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 + tag = pyrepl_tag(html) + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in tag + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() + assert (outdir / "_static" / "pyrepl" / "index-1.py").is_file() + assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() + + assert app.env.get_doctree("index").get("pyrepl") + assert list(load_bootstrap_files(app, "index")) == ["index-1-bootstrap.py"] + + +def test_autodoc_without_packages_is_replay_only(tmp_path): + from tests.fixtures.sources import WIDGET_SOURCE + + pkg_dir = tmp_path / "installed_pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text(WIDGET_SOURCE, encoding="utf-8") + + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + autodoc_conf_header(sys_path=str(pkg_dir.parent)), + 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 'replay-src="_static/pyrepl/index-1.py"' in html + tag = pyrepl_tag(html) + assert 'packages="' not in tag + assert ' src="' not in tag diff --git a/tests/integration/test_build_output.py b/tests/integration/test_build_output.py new file mode 100644 index 0000000..09b1713 --- /dev/null +++ b/tests/integration/test_build_output.py @@ -0,0 +1,125 @@ +from tests.helpers import assert_replay_artifacts +from tests.support import WHEEL_NAME, WHEEL_PATH, build_sphinx + +from sphinx_pyrepl_web import PYREPL_DIR + + +def test_build_writes_replay_script_from_metadata(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + app = build_sphinx(srcdir, outdir, doctreedir) + + replay_files = assert_replay_artifacts(app, outdir, "index") + script_name = next(iter(replay_files)) + script_path = outdir / "_static" / "pyrepl" / script_name + assert "x = 2 + 2" in script_path.read_text(encoding="utf-8") + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + +def test_build_writes_replay_script_with_parallel_read(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + build_sphinx(srcdir, outdir, doctreedir, parallel=2) + + script_path = outdir / "_static" / "pyrepl" / "index-1.py" + assert script_path.is_file(), f"missing replay script at {script_path}" + + +def test_local_wheel_copied_to_output_tree(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: +""", + encoding="utf-8", + ) + + build_sphinx(srcdir, outdir, doctreedir) + + wheel_out = outdir / "_static" / "wheels" / WHEEL_NAME + assert wheel_out.is_file(), f"missing wheel at {wheel_out}" + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert "pyrepl.js" in html + + +def test_local_wheel_with_src_and_replay_body(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :src: _static/bootstrap.py + :repl-title: test + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() +""", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() + assert (outdir / "_static" / "bootstrap.py").is_file() + + replay_files = assert_replay_artifacts(app, outdir, "index", count=1) + script_name = next(iter(replay_files)) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'src="_static/bootstrap.py"' in html + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + +def test_vendored_pyrepl_assets_copied(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + build_sphinx(srcdir, outdir, doctreedir) + + copied = {path.name for path in PYREPL_DIR.iterdir() if path.is_file()} + assert copied + for name in copied: + assert (outdir / name).is_file(), f"missing vendored asset {name}" + + +def test_startup_src_deduplicated_on_build(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "shared.py").write_text("print('shared')\n", encoding="utf-8") + (srcdir / "conf.py").write_text( + """ +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + """ +Example +======= + +.. py-repl:: + :src: shared.py + +.. py-repl:: + :src: shared.py +""", + encoding="utf-8", + ) + + build_sphinx(srcdir, outdir, doctreedir) + assert (outdir / "shared.py").is_file() + assert (outdir / "shared.py").read_text(encoding="utf-8") == "print('shared')\n" diff --git a/tests/test_autodoc_include.py b/tests/integration/test_include.py similarity index 70% rename from tests/test_autodoc_include.py rename to tests/integration/test_include.py index 500229c..0bc3741 100644 --- a/tests/test_autodoc_include.py +++ b/tests/integration/test_include.py @@ -1,12 +1,7 @@ -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)) +from tests.helpers import assert_replay_artifacts +from tests.support import build_sphinx @pytest.fixture @@ -18,7 +13,7 @@ def included_example_project(tmp_path): 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")) @@ -71,24 +66,9 @@ def example_generator(n): 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() + app = build_sphinx(srcdir, outdir, doctreedir) - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 2 + replay_files = assert_replay_artifacts(app, outdir, "index", count=2) html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 2 diff --git a/tests/integration/test_nested_pages.py b/tests/integration/test_nested_pages.py new file mode 100644 index 0000000..4c6c955 --- /dev/null +++ b/tests/integration/test_nested_pages.py @@ -0,0 +1,42 @@ +from tests.support import WHEEL_PATH, build_sphinx, copy_wheel_to, pyrepl_conf_header + + +def test_nested_page_emits_page_relative_static_paths(tmp_path): + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + (srcdir / "api").mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + pyrepl_conf_header(extra='html_static_path = ["_static"]\n'), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") + (srcdir / "api" / "index.rst").write_text( + f""" +API +=== + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: + + >>> 1 + 1 +""", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "api" / "index.html").read_text(encoding="utf-8") + assert 'packages="../_static/wheels/' in html + assert 'packages="api/_static/' not in html + assert 'packages="/_static/' not in html + assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html + + root_html = (outdir / "index.html").read_text(encoding="utf-8") + assert "py-repl" not in root_html + + replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") + assert replay_files is not None diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..7742620 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,77 @@ +"""Shared constants and Sphinx project setup helpers.""" + +import shutil +from pathlib import Path + +from sphinx.application import Sphinx + +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}" + + +def build_sphinx( + srcdir: Path, + outdir: Path, + doctreedir: Path, + *, + parallel: int = 0, + **kwargs, +) -> Sphinx: + """Run a full Sphinx HTML build and return the application.""" + 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, + parallel=parallel, + **kwargs, + ) + app.build() + return app + + +def copy_wheel_to(srcdir: Path) -> Path: + """Copy the test wheel into ``srcdir/_static/wheels`` and return that directory.""" + wheels_dir = srcdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + return wheels_dir + + +def wheel_conf_extra() -> str: + """Return conf.py snippet enabling the test wheel for autodoc REPLs.""" + return f""" +html_static_path = ["_static"] +pyrepl_autodoc_packages = {WHEEL_PATH!r} +""" + + +def autodoc_conf_header(*, sys_path: str, extra: str = "") -> str: + """Return a minimal autodoc-enabled conf.py header.""" + return f""" +import sys +sys.path.insert(0, {sys_path!r}) +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" +{extra} +""" + + +def pyrepl_conf_header(*, extra: str = "") -> str: + """Return a minimal pyrepl-only conf.py header.""" + return f""" +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +{extra} +""" diff --git a/tests/test_asset_href.py b/tests/test_asset_href.py deleted file mode 100644 index fca2fb0..0000000 --- a/tests/test_asset_href.py +++ /dev/null @@ -1,98 +0,0 @@ -import shutil -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from sphinx.application import Sphinx - -from sphinx_pyrepl_web import _asset_href, _asset_href_packages - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - - -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 - - -@pytest.fixture -def html_builder(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - (srcdir / "conf.py").write_text("", encoding="utf-8") - (srcdir / "index.rst").write_text("Test\n====\n", encoding="utf-8") - (srcdir / "api").mkdir() - (srcdir / "api" / "module.rst").write_text("API\n===\n", encoding="utf-8") - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - app = _build_sphinx(srcdir, outdir, doctreedir) - return app.builder - - -def test_asset_href_rewrites_static_path_on_nested_page(html_builder): - assert ( - _asset_href(html_builder, "api/module", "_static/wheels/foo.whl") - == "../_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_root_page_static_path_unchanged(html_builder): - assert ( - _asset_href(html_builder, "index", "_static/wheels/foo.whl") - == "_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_root_absolute_path_unchanged(html_builder): - assert ( - _asset_href(html_builder, "api/module", "/_static/wheels/foo.whl") - == "/_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_https_url_unchanged(html_builder): - url = "https://cdn.example/w.whl" - assert _asset_href(html_builder, "api/module", url) == url - - -def test_asset_href_leaves_pypi_name_unchanged(html_builder): - assert _asset_href(html_builder, "api/module", "numpy") == "numpy" - - -def test_asset_href_rewrites_src_relative_to_source_root(html_builder): - assert _asset_href(html_builder, "api/module", "demo.py") == "../demo.py" - - -def test_asset_href_leaves_micropip_spec_unchanged(html_builder): - spec = "mypkg @ https://example.com/wheels/mypkg.whl" - assert _asset_href(html_builder, "api/module", spec) == spec - - -def test_asset_href_packages_rewrites_only_file_like_entries(html_builder): - packages = "numpy, _static/wheels/foo.whl" - assert _asset_href_packages(html_builder, "api/module", packages) == ( - "numpy, ../_static/wheels/foo.whl" - ) - - -def test_asset_href_skips_normalization_for_non_html_builder(): - builder = MagicMock() - builder.format = "" - assert ( - _asset_href(builder, "api/module", "_static/wheels/foo.whl") - == "_static/wheels/foo.whl" - ) diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py deleted file mode 100644 index c291a0a..0000000 --- a/tests/test_autodoc_bootstrap.py +++ /dev/null @@ -1,211 +0,0 @@ -import json -import shutil -import sys -from pathlib import Path -from unittest.mock import MagicMock - -from sphinx.application import Sphinx - -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 _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 - - -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 -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:: pyrepl_test_pkg.demo.example_generator\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 - assert (outdir / "_static" / "wheels" / WHEEL_NAME).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 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): - 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", - encoding="utf-8", - ) - - 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", - 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") - - _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'replay-src="_static/pyrepl/index-1.py"' in html - pyrepl_tag = html[html.index("", html.index("")] - assert 'packages="' not in pyrepl_tag - assert ' src="' not in pyrepl_tag - - -def test_autodoc_packages_config_helper(): - app = MagicMock() - app.config.pyrepl_autodoc_packages = WHEEL_PATH - assert _autodoc_packages(app) == WHEEL_PATH - - app.config.pyrepl_autodoc_packages = "" - assert _autodoc_packages(app) is None - - 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 deleted file mode 100644 index 4a0e35b..0000000 --- a/tests/test_autodoc_bootstrap_source.py +++ /dev/null @@ -1,29 +0,0 @@ -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_doctest.py b/tests/test_autodoc_doctest.py deleted file mode 100644 index 1433396..0000000 --- a/tests/test_autodoc_doctest.py +++ /dev/null @@ -1,158 +0,0 @@ -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="' not 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_build_replay.py b/tests/test_build_replay.py deleted file mode 100644 index 622bb94..0000000 --- a/tests/test_build_replay.py +++ /dev/null @@ -1,134 +0,0 @@ -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 sphinx_project(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - "extensions = ['sphinx_pyrepl_web']\n" - "pyrepl_js = 'pyrepl.js'\n" - "master_doc = 'index'\n", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - """ -Example -======= - -.. py-repl:: - :no-header: - - >>> x = 2 + 2 - >>> print(x) -""".strip() - + "\n", - encoding="utf-8", - ) - return srcdir, outdir, doctreedir - - -def test_build_writes_replay_script_from_metadata(sphinx_project): - srcdir, outdir, doctreedir = sphinx_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 replay_files - script_name = next(iter(replay_files)) - script_path = outdir / "_static" / "pyrepl" / script_name - assert script_path.is_file(), f"missing replay script at {script_path}" - assert "x = 2 + 2" in script_path.read_text(encoding="utf-8") - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'replay-src="_static/pyrepl/{script_name}"' in html - - -def test_build_writes_replay_script_with_parallel_read(sphinx_project): - srcdir, outdir, doctreedir = sphinx_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, - parallel=2, - ) - app.build() - - script_path = outdir / "_static" / "pyrepl" / "index-1.py" - assert script_path.is_file(), f"missing replay script at {script_path}" - - -def test_build_omits_bare_doctest_terminator(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - "extensions = ['sphinx_pyrepl_web']\n" - "pyrepl_js = 'pyrepl.js'\n" - "master_doc = 'index'\n", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - """ -Example -======= - -.. py-repl:: - :no-header: - - >>> class Foo: - ... x = 1 - ... - >>> Foo() -""".strip() - + "\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() - - script_path = outdir / "_static" / "pyrepl" / "index-1.py" - script = script_path.read_text(encoding="utf-8") - assert "class Foo:" in script - assert "\n...\n" not in script - assert " x = 1\n\nFoo()" in script diff --git a/tests/test_doctest_to_replay_source.py b/tests/test_doctest_to_replay_source.py deleted file mode 100644 index d1fb725..0000000 --- a/tests/test_doctest_to_replay_source.py +++ /dev/null @@ -1,55 +0,0 @@ -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_local_wheel.py b/tests/test_local_wheel.py deleted file mode 100644 index 8cb19f4..0000000 --- a/tests/test_local_wheel.py +++ /dev/null @@ -1,148 +0,0 @@ -import json -import shutil -import sys -from pathlib import Path - -import pytest -from sphinx.application import Sphinx -from sphinx_pytest.plugin import CreateDoctree - -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 _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path, **kwargs) -> 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, - **kwargs, - ) - app.build() - return app - - -@pytest.fixture -def wheel_project(tmp_path): - srcdir = tmp_path / "docs" - wheels_dir = srcdir / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - (srcdir / "_static" / "bootstrap.py").write_text( - "# Optional post-install bootstrap for local wheel REPLs.\n", - encoding="utf-8", - ) - - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - """ -extensions = ["sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -html_static_path = ["_static"] -""", - encoding="utf-8", - ) - return srcdir, outdir, doctreedir - - -def test_local_wheel_packages_emitted_in_doctree(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - result = sphinx_doctree( - f""" -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: -""" - ) - html = result.pformat() - assert f'packages="{WHEEL_PATH}"' in html - - -def test_local_wheel_copied_to_output_tree(wheel_project): - srcdir, outdir, doctreedir = wheel_project - (srcdir / "index.rst").write_text( - f""" -Example -======= - -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: -""", - encoding="utf-8", - ) - - _build_sphinx(srcdir, outdir, doctreedir) - - wheel_out = outdir / "_static" / "wheels" / WHEEL_NAME - assert wheel_out.is_file(), f"missing wheel at {wheel_out}" - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert "pyrepl.js" in html - - -def test_local_wheel_with_src_and_replay_body(wheel_project): - srcdir, outdir, doctreedir = wheel_project - (srcdir / "index.rst").write_text( - f""" -Example -======= - -.. py-repl:: - :packages: {WHEEL_PATH} - :src: _static/bootstrap.py - :repl-title: test - :no-banner: - - >>> import pyrepl_test_pkg - >>> pyrepl_test_pkg.ping() -""", - encoding="utf-8", - ) - - app = _build_sphinx(srcdir, outdir, doctreedir) - - assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() - assert (outdir / "_static" / "bootstrap.py").is_file() - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 1 - script_name = next(iter(replay_files)) - assert (outdir / "_static" / "pyrepl" / script_name).is_file() - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert 'src="_static/bootstrap.py"' in html - assert f'replay-src="_static/pyrepl/{script_name}"' in html - - -def test_comma_separated_local_wheel_and_pypi_package(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - packages = f"numpy, {WHEEL_PATH}" - result = sphinx_doctree( - f""" -.. py-repl:: - :packages: {packages} - :no-header: -""" - ) - html = result.pformat() - assert f'packages="{packages}"' in html diff --git a/tests/test_nested_static_paths.py b/tests/test_nested_static_paths.py deleted file mode 100644 index 2acc8cc..0000000 --- a/tests/test_nested_static_paths.py +++ /dev/null @@ -1,77 +0,0 @@ -import shutil -import sys -from pathlib import Path - -from sphinx.application import Sphinx - -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 _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 - - -def test_nested_page_emits_page_relative_static_paths(tmp_path): - srcdir = tmp_path / "docs" - wheels_dir = srcdir / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - (srcdir / "api").mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - """ -extensions = ["sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -html_static_path = ["_static"] -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") - (srcdir / "api" / "index.rst").write_text( - f""" -API -=== - -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: - - >>> 1 + 1 -""", - encoding="utf-8", - ) - - app = _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "api" / "index.html").read_text(encoding="utf-8") - assert 'packages="../_static/wheels/' in html - assert 'packages="api/_static/' not in html - assert 'packages="/_static/' not in html - assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html - - root_html = (outdir / "index.html").read_text(encoding="utf-8") - assert "py-repl" not in root_html - - replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") - assert replay_files is not None diff --git a/tests/unit/test_asset_href.py b/tests/unit/test_asset_href.py new file mode 100644 index 0000000..66d39f6 --- /dev/null +++ b/tests/unit/test_asset_href.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock + +import pytest + +from tests.helpers import mock_html_builder +from sphinx_pyrepl_web import _asset_href, _asset_href_packages + + +@pytest.mark.parametrize( + ("docname", "path", "expected"), + [ + ("api/module", "_static/wheels/foo.whl", "../_static/wheels/foo.whl"), + ("index", "_static/wheels/foo.whl", "_static/wheels/foo.whl"), + ("api/module", "/_static/wheels/foo.whl", "/_static/wheels/foo.whl"), + ("api/module", "https://cdn.example/w.whl", "https://cdn.example/w.whl"), + ("api/module", "numpy", "numpy"), + ("api/module", "demo.py", "../demo.py"), + ( + "api/module", + "mypkg @ https://example.com/wheels/mypkg.whl", + "mypkg @ https://example.com/wheels/mypkg.whl", + ), + ], + ids=[ + "nested-static", + "root-static", + "root-absolute", + "https-url", + "pypi-name", + "src-relative", + "micropip-spec", + ], +) +def test_asset_href(docname, path, expected): + builder = mock_html_builder() + assert _asset_href(builder, docname, path) == expected + + +def test_asset_href_packages_rewrites_only_file_like_entries(): + builder = mock_html_builder() + packages = "numpy, _static/wheels/foo.whl" + assert _asset_href_packages(builder, "api/module", packages) == ( + "numpy, ../_static/wheels/foo.whl" + ) + + +def test_asset_href_skips_normalization_for_non_html_builder(): + builder = MagicMock() + builder.format = "" + assert ( + _asset_href(builder, "api/module", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) diff --git a/tests/unit/test_autodoc_bootstrap_source.py b/tests/unit/test_autodoc_bootstrap_source.py new file mode 100644 index 0000000..32c4ca6 --- /dev/null +++ b/tests/unit/test_autodoc_bootstrap_source.py @@ -0,0 +1,38 @@ +import pytest + +from sphinx_pyrepl_web import autodoc_bootstrap_source + + +@pytest.mark.parametrize( + ("module", "fullname", "objtype", "expected"), + [ + ( + "pyrepl_test_pkg.demo", + "example_generator", + "function", + "from pyrepl_test_pkg.demo import example_generator\n", + ), + ( + "installed_pkg", + "Widget", + "class", + "from installed_pkg import Widget\n", + ), + ( + "pkg.mod", + "Widget.label", + "method", + "from pkg.mod import Widget\n", + ), + ( + "pyrepl_test_pkg.demo", + "", + "module", + "import pyrepl_test_pkg.demo\n", + ), + (None, "foo", "function", None), + ], + ids=["function", "class", "method", "module", "missing-module"], +) +def test_autodoc_bootstrap_source(module, fullname, objtype, expected): + assert autodoc_bootstrap_source(module, fullname, objtype) == expected diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..de0668f --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,20 @@ +import pytest +from unittest.mock import MagicMock + +from tests.support import WHEEL_PATH +from sphinx_pyrepl_web import _autodoc_packages + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + (WHEEL_PATH, WHEEL_PATH), + ("", None), + (None, None), + ], + ids=["wheel-path", "empty-string", "none"], +) +def test_autodoc_packages(configured, expected): + app = MagicMock() + app.config.pyrepl_autodoc_packages = configured + assert _autodoc_packages(app) == expected diff --git a/tests/unit/test_copy_assets.py b/tests/unit/test_copy_assets.py new file mode 100644 index 0000000..78c182e --- /dev/null +++ b/tests/unit/test_copy_assets.py @@ -0,0 +1,55 @@ +import json +from unittest.mock import MagicMock + +from docutils import nodes +from docutils.utils import new_document + +from sphinx_pyrepl_web import PYREPL_DIR, REPLAY_FILES_KEY, copy_asset_files, transform_doctest_blocks + + +def test_copy_asset_files_skips_non_html_builder(tmp_path): + outdir = tmp_path / "out" + outdir.mkdir() + + app = MagicMock() + app.builder.format = "latex" + app.builder.outdir = str(outdir) + app.env.metadata = { + "index": { + REPLAY_FILES_KEY: json.dumps({"index-1.py": "print('hi')\n"}), + } + } + + copy_asset_files(app, None) + + assert not any(outdir.iterdir()) + assert not (outdir / "pyrepl.js").exists() + assert not (outdir / "_static").exists() + for asset in PYREPL_DIR.iterdir(): + if asset.is_file(): + assert not (outdir / asset.name).exists() + + +def test_copy_asset_files_skips_empty_replay_metadata(tmp_path): + app = MagicMock() + app.builder.format = "html" + app.builder.outdir = str(tmp_path / "out") + app.env.metadata = {"index": {REPLAY_FILES_KEY: json.dumps({})}} + copy_asset_files(app, None) + assert not (tmp_path / "out" / "_static" / "pyrepl").exists() + + +def test_transform_skips_empty_converted_doctest(): + app = MagicMock() + app.config.pyrepl_doctest_blocks = "all" + app.env.docname = "index" + app.env.metadata = {"index": {}} + app.builder.format = "html" + + doctree = new_document("") + block = nodes.doctest_block("", "") + doctree += block + + transform_doctest_blocks(app, doctree) + assert len(list(doctree.findall(nodes.doctest_block))) == 1 + assert len(list(doctree.findall(nodes.raw))) == 0 diff --git a/tests/unit/test_doctest_to_replay_source.py b/tests/unit/test_doctest_to_replay_source.py new file mode 100644 index 0000000..bd9a5ec --- /dev/null +++ b/tests/unit/test_doctest_to_replay_source.py @@ -0,0 +1,64 @@ +import pytest + +from sphinx_pyrepl_web import doctest_to_replay_source + +MULTILINE_CLASS_EXPECTED = "class Foo:\n x = 1\n\nFoo()\n" + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ( + ">>> add(1, 2)\n3\n>>> add(0, 0)\n0", + "add(1, 2)\n\nadd(0, 0)\n", + ), + ( + ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]", + "print([i for i in example_generator(4)])\n", + ), + ( + ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n", + MULTILINE_CLASS_EXPECTED, + ), + ( + ( + ">>> from naming import Name\n" + ">>> class MyName(Name):\n" + "... config = dict(base=r'\\w+')\n" + "...\n" + ">>> n = MyName()\n" + "'{base}'" + ), + ( + "from naming import Name\n" + "\n" + "class MyName(Name):\n" + " config = dict(base=r'\\w+')\n" + "\n" + "n = MyName()\n" + ), + ), + ( + [ + ">>> class Foo:", + "... x = 1", + "...", + ">>> Foo()", + ], + MULTILINE_CLASS_EXPECTED, + ), + ("not a doctest", ""), + ([">>> ..."], "...\n"), + ], + ids=[ + "strips-output", + "example-generator", + "multiline-class", + "name-class", + "directive-lines", + "non-doctest", + "explicit-ellipsis", + ], +) +def test_doctest_to_replay_source(source, expected): + assert doctest_to_replay_source(source) == expected