Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test package for sphinx-pyrepl-web."""
58 changes: 58 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions tests/doctree/test_autodoc_scope.py
Original file line number Diff line number Diff line change
@@ -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("<py-repl")


def test_autodoc_doctest_becomes_pyrepl(autodoc_doctree):
result = autodoc_doctree(AUTODOC_SCOPE_RST)
html = result.pformat()
assert 'replay-src="_static/pyrepl/index-1.py"' in html
assert 'packages="' not in html
assert "no-header" in html
assert "no-banner" in html


@pytest.mark.parametrize(
("scope", "expected_count"),
[
("autodoc", 1),
("all", 2),
(False, 0),
],
ids=["autodoc-only", "all-doctests", "disabled"],
)
def test_doctest_scope(sphinx_doctree, scope, expected_count):
(sphinx_doctree.srcdir / "repl_test_demo.py").write_text(
EXAMPLE_GENERATOR_SOURCE,
encoding="utf-8",
)
_write_autodoc_conf(sphinx_doctree.srcdir, scope)
sphinx_doctree.buildername = "html"
result = sphinx_doctree(AUTODOC_SCOPE_RST)
assert count_pyrepl_nodes(result) == expected_count
72 changes: 55 additions & 17 deletions tests/test_basic.py → tests/doctree/test_pyrepl_directive.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from sphinx_pytest.plugin import CreateDoctree


Expand All @@ -18,7 +19,6 @@ def test_basic(sphinx_doctree: CreateDoctree):
"""
)
lines = [line.rstrip() for line in result.pformat().strip().splitlines()]
# Sphinx may serialize doctree bool attrs as "1" or "True" depending on version.
lines[0] = lines[0].replace('pyrepl="True"', 'pyrepl="1"')
assert lines == """
<document pyrepl="1" source="<src>/index.rst">
Expand All @@ -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(
{
Expand All @@ -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
1 change: 1 addition & 0 deletions tests/fixtures/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Shared test fixtures and sample sources."""
45 changes: 45 additions & 0 deletions tests/fixtures/sources.py
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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 ``<py-repl ...></py-repl>`` tag from HTML."""
start = html.index("<py-repl")
end = html.index("></py-repl>", start) + len("></py-repl>")
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
Loading
Loading