# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for the ``python:*`` Sphinx directive family.
Covers ``python:source``, ``python:run``, and the three render variants:
- ``python:render`` (host parser)
- ``python:render-myst`` (forced MyST, regardless of host)
- ``python:render-rst`` (forced reST, regardless of host)
"""
from __future__ import annotations
import sys
from pathlib import Path
from textwrap import dedent
from click.testing import CliRunner
from click_extra.cli import refresh_directives_cmd
from click_extra.sphinx.python import (
MIRROR_MARKER_END,
MIRROR_MARKER_START,
MIRROR_SRC_MARKER_END,
_rewrite_mirror_regions,
_rewrite_mirror_src_regions,
update_mirror_blocks,
)
from .conftest import FormatType, SphinxAppWrapper
[docs]
def test_python_run_renders_stdout(sphinx_app_myst):
"""``python:run`` captures ``print`` output and renders it in a code block."""
content = dedent("""
```{python:run}
print("It works!")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert 'class="highlight-text' in html or 'class="highlight-default' in html
assert "It works!" in html
[docs]
def test_python_source_seeds_namespace_for_python_run(sphinx_app_myst):
"""``python:source`` runs silently; a follow-up ``python:run`` reuses its imports."""
content = dedent("""
```{python:source}
from textwrap import dedent
greeting = "hello, sphinx"
```
```{python:run}
print(dedent(greeting).upper())
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert 'class="highlight-python' in html
assert "HELLO, SPHINX" in html
[docs]
def test_python_run_language_override(sphinx_app_myst):
"""``:language:`` overrides the default ``text`` lexer for the result block."""
content = dedent("""
```{python:run} json
import json
print(json.dumps({"name": "sphinx", "kind": "doc"}))
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert 'class="highlight-json' in html
assert ""name"" in html
assert ""sphinx"" in html
[docs]
def test_python_run_emphasize_lines_split(sphinx_app_myst):
"""``:emphasize-lines:`` highlights source only; ``:emphasize-result-lines:``
highlights result only: independently, on the same block."""
content = dedent("""
```{python:run}
:show-source:
:emphasize-lines: 1
:emphasize-result-lines: 2
print("first line")
print("second line")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# Source block carries the source emphasis on line 1 (the `print("first line")`).
assert '<span class="hll"><span class="nb">print</span>' in html
# Result block carries the result emphasis on line 2 (the literal `second line`).
assert '<span class="hll">second line\n</span>' in html
# And the sibling lines are not highlighted.
assert '<span class="hll">first line\n</span>' not in html, (
"result emphasis must not bleed onto line 1 of the result block"
)
[docs]
def test_python_render_passes_block_level_html(sphinx_app_myst):
"""``python:render`` passes block-level raw HTML through unchanged.
A naked ``print('<div>...</div>')`` should reach the rendered page without
any ``{raw} html`` wrapping. Locks down the natural-form pattern so a future
MyST upgrade or extension reordering can't silently regress it.
"""
content = dedent("""
```{python:render}
print('<div class="custom-marker">')
print('<span style="color: #ff8800">orange span</span>')
print('</div>')
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert '<div class="custom-marker">' in html
assert '<span style="color: #ff8800">orange span</span>' in html
[docs]
def test_python_render_host_myst_injects_table(sphinx_app_myst):
"""``python:render`` parses captured stdout with the host (MyST) parser."""
content = dedent("""
```{python:render}
rows = [("apple", 3), ("banana", 5), ("cherry", 8)]
print("| Fruit | Count |")
print("|-------|------:|")
for name, count in rows:
print(f"| {name} | {count} |")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<table" in html
assert "<th" in html
for fruit in ("apple", "banana", "cherry"):
assert f"<td>{fruit}</td>" in html or f">{fruit}<" in html
[docs]
def test_python_render_host_myst_injects_heading(sphinx_app_myst):
"""A heading printed by ``python:render`` becomes a real heading node."""
content = dedent("""
```{python:render}
print("## Generated section")
print()
print("Body text under the generated heading.")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "Generated section" in html
assert "<h2" in html or "<h3" in html
assert "Body text under the generated heading." in html
[docs]
def test_python_render_host_rst_injects_admonition(sphinx_app_rst):
"""``python:render`` in an rST host: stdout is parsed as reST."""
content = dedent("""
.. python:render::
print(".. note::")
print()
print(" A pear ripens after picking.")
""")
html = sphinx_app_rst.build_document(content)
assert html is not None
assert "admonition" in html and "note" in html
assert "A pear ripens after picking." in html
[docs]
def test_python_render_myst_in_rst_host(sphinx_app_rst):
"""``python:render-myst`` forces MyST parsing inside an rST host document.
This is the headline use case: an rST file embeds Python that prints
MyST markup and the directive parses it as MyST regardless of host.
"""
content = dedent("""
.. python:render-myst::
print("| Fruit | Count |")
print("|-------|------:|")
print("| mango | 4 |")
print("| kiwi | 7 |")
""")
html = sphinx_app_rst.build_document(content)
assert html is not None
assert "<table" in html
for fruit in ("mango", "kiwi"):
assert f"<td>{fruit}</td>" in html or f">{fruit}<" in html
[docs]
def test_python_render_rst_in_myst_host(sphinx_app_myst):
"""``python:render-rst`` forces reST parsing inside a MyST host document."""
content = dedent("""
```{python:render-rst}
print(".. note::")
print()
print(" A persimmon must be very ripe to eat raw.")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "admonition" in html and "note" in html
assert "A persimmon must be very ripe to eat raw." in html
[docs]
def test_python_render_myst_in_myst_host_still_works(sphinx_app_myst):
"""``python:render-myst`` works in MyST hosts too; it always picks MyST."""
content = dedent("""
```{python:render-myst}
print("**Bold tomato.**")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "<strong>Bold tomato.</strong>" in html
[docs]
def test_python_render_rst_in_rst_host_still_works(sphinx_app_rst):
"""``python:render-rst`` works in rST hosts too; it always picks reST."""
content = dedent("""
.. python:render-rst::
print("**Bold cucumber.**")
""")
html = sphinx_app_rst.build_document(content)
assert html is not None
assert "<strong>Bold cucumber.</strong>" in html
[docs]
def test_exec_directives_disabled_by_default(tmp_path):
"""Without the opt-in flag, ``click:*`` and ``python:*`` are not registered.
The Sphinx build still succeeds but neither family's directive body is
ever executed. This is the desired security default: a project that
adds ``click_extra.sphinx`` to its extensions list does not silently
gain build-time arbitrary Python execution.
The exact rendering of an unrecognized directive is parser-dependent
(MyST silently swallows it; reST emits a system message), so the
assertion focuses on the security-relevant invariant: the directive
body's ``print`` output never reaches the rendered HTML.
"""
factory = SphinxAppWrapper.create(
FormatType.MYST, tmp_path, enable_exec_directives=False
)
app = next(factory)
assert isinstance(app, SphinxAppWrapper)
sentinel = "EXEC-MUST-NOT-HAPPEN-9c1f8"
content = dedent(f"""
```{{python:run}}
print("{sentinel}")
```
```{{click:source}}
from click import command, echo
@command
def boom():
echo("{sentinel}-click")
```
```{{click:run}}
invoke(boom)
```
""")
html = app.build_document(content)
assert html is not None
assert sentinel not in html, "directive executed despite the opt-in gate being off"
[docs]
def test_exec_directives_enabled_with_opt_in(tmp_path):
"""Setting ``click_extra_enable_exec_directives = True`` activates them."""
factory = SphinxAppWrapper.create(
FormatType.MYST, tmp_path, enable_exec_directives=True
)
app = next(factory)
assert isinstance(app, SphinxAppWrapper)
content = dedent("""
```{python:run}
print("Opt-in works.")
```
""")
html = app.build_document(content)
assert html is not None
assert "Opt-in works." in html
[docs]
def test_python_runner_isolated_from_click_runner(sphinx_app_myst):
"""The Python and Click runners hold independent namespaces."""
content = dedent("""
```{python:source}
secret = "python-only"
```
```{click:source}
from click import command, echo
@command
def show():
echo("click-only")
```
```{click:run}
invoke(show)
```
```{python:run}
print(secret)
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
assert "click-only" in html
assert "python-only" in html
# --- python:render :mirror: source mirroring ----------------------------------
_MIRROR_TABLE_BLOCK = dedent("""
# Title
```{python:render}
:mirror:
rows = [("apple", 3), ("banana", 5), ("cherry", 8)]
print("| Fruit | Count |")
print("| :----- | ----: |")
for name, count in rows:
print(f"| {name} | {count} |")
```
Trailing prose.
""")
_MIRROR_SRC_BLOCK = dedent("""
# Title
<!-- mirror-src
rows = [("apple", 3), ("banana", 5)]
print("| Fruit | Count |")
print("| :----- | ----: |")
for name, count in rows:
print(f"| {name} | {count} |")
-->
Trailing prose.
""")
[docs]
def test_mirror_src_rewrite_inserts_region():
"""A mirror-src comment with no region yet gets one, generator kept hidden."""
out = _rewrite_mirror_src_regions(_MIRROR_SRC_BLOCK, "<test>")
assert out.count(MIRROR_SRC_MARKER_END) == 1
assert "| apple | 3 |" in out
# The generator lives in an HTML comment (invisible on GitHub); the generated
# body renders below it, before the trailing prose.
assert "<!-- mirror-src" in out
assert out.index("<!-- mirror-src") < out.index("| apple | 3 |")
assert out.index("| apple | 3 |") < out.index(MIRROR_SRC_MARKER_END)
assert out.index(MIRROR_SRC_MARKER_END) < out.index("Trailing prose.")
[docs]
def test_mirror_src_rewrite_is_idempotent():
"""Re-running over an already-filled mirror-src region is a no-op."""
once = _rewrite_mirror_src_regions(_MIRROR_SRC_BLOCK, "<test>")
twice = _rewrite_mirror_src_regions(once, "<test>")
assert once == twice
assert twice.count(MIRROR_SRC_MARKER_END) == 1
[docs]
def test_mirror_src_via_update_mirror_blocks(tmp_path):
"""The offline refresher fills a mirror-src region, then round-trips clean."""
doc = tmp_path / "readme.md"
doc.write_text(_MIRROR_SRC_BLOCK, encoding="utf-8")
assert update_mirror_blocks([doc]) == [doc]
source = doc.read_text(encoding="utf-8")
assert MIRROR_SRC_MARKER_END in source
assert "| apple | 3 |" in source
assert update_mirror_blocks([doc]) == []
[docs]
def test_mirror_src_rewrite_skips_example_nested_in_code_block():
"""A mirror-src comment shown inside a longer code-block fence is never run."""
documented = dedent("""
````{code-block} markdown
<!-- mirror-src
print("MUST-NOT-RUN")
-->
````
""")
out = _rewrite_mirror_src_regions(documented, "<test>")
# No region is generated: the commented block was copied verbatim, not run.
assert MIRROR_SRC_MARKER_END not in out
assert out == documented
[docs]
def test_mirror_rewrite_inserts_region():
"""A mirror block with no region yet gets one inserted below the fence."""
out = _rewrite_mirror_regions(_MIRROR_TABLE_BLOCK, "<test>")
assert out.count(MIRROR_MARKER_START) == 1
assert out.count(MIRROR_MARKER_END) == 1
assert "| apple | 3 |" in out
# The fence and the trailing prose are preserved around the region.
assert "```{python:render}" in out
assert out.index("```{python:render}") < out.index(MIRROR_MARKER_START)
assert out.index(MIRROR_MARKER_END) < out.index("Trailing prose.")
[docs]
def test_mirror_rewrite_is_idempotent():
"""Re-running over an already-mirrored document is a no-op."""
once = _rewrite_mirror_regions(_MIRROR_TABLE_BLOCK, "<test>")
twice = _rewrite_mirror_regions(once, "<test>")
assert once == twice
# The region is replaced in place, never appended a second time.
assert twice.count(MIRROR_MARKER_START) == 1
[docs]
def test_mirror_rewrite_replaces_stale_region():
"""A stale region is refreshed from the block's current output."""
stale = dedent(f"""
```{{python:render}}
:mirror:
print("| Fruit |")
print("| :---- |")
print("| apple |")
```
{MIRROR_MARKER_START}
| Fruit |
| :---- |
| STALE |
{MIRROR_MARKER_END}
After.
""")
out = _rewrite_mirror_regions(stale, "<test>")
assert "| apple |" in out
assert "STALE" not in out
assert out.count(MIRROR_MARKER_START) == 1
assert "After." in out
[docs]
def test_mirror_rewrite_skips_example_nested_in_code_block():
"""A mirror block shown inside a longer code-block fence is never executed."""
documented = dedent("""
````{code-block} markdown
```{python:render}
:mirror:
print("MUST-NOT-RUN")
```
````
""")
out = _rewrite_mirror_regions(documented, "<test>")
# No region is generated: the inner block was copied verbatim, not run.
assert MIRROR_MARKER_START not in out
assert out == documented
[docs]
def test_python_render_mirror_renders_fresh_without_touching_source(sphinx_app_myst):
"""`:mirror:` renders the table once; the build never writes to the source."""
html = sphinx_app_myst.build_document(_MIRROR_TABLE_BLOCK)
assert html is not None
# Rendered exactly once (from the in-memory mirrored region; the directive
# itself emits nothing in mirror mode, so there is no second copy).
assert html.count("<table") == 1
for fruit in ("apple", "banana", "cherry"):
assert fruit in html
# The build is read-only: the committed source is byte-for-byte untouched
# (the disk region is refreshed offline by update_mirror_blocks).
source = (Path(sphinx_app_myst.srcdir) / "index.md").read_text(encoding="utf-8")
assert source == _MIRROR_TABLE_BLOCK
[docs]
def test_update_mirror_blocks_populates_and_idempotent(tmp_path):
"""The offline refresher inserts the region, then round-trips clean."""
doc = tmp_path / "page.md"
doc.write_text(_MIRROR_TABLE_BLOCK, encoding="utf-8")
# First pass inserts the region on disk.
assert update_mirror_blocks([doc]) == [doc]
source = doc.read_text(encoding="utf-8")
assert MIRROR_MARKER_START in source
assert MIRROR_MARKER_END in source
assert "| apple | 3 |" in source
# Second pass is a no-op.
assert update_mirror_blocks([doc]) == []
[docs]
def test_update_mirror_blocks_check_mode(tmp_path):
"""`check=True` reports the stale file without writing it."""
doc = tmp_path / "page.md"
doc.write_text(_MIRROR_TABLE_BLOCK, encoding="utf-8")
assert update_mirror_blocks([doc], check=True) == [doc]
assert doc.read_text(encoding="utf-8") == _MIRROR_TABLE_BLOCK
[docs]
def test_update_mirror_blocks_imports_sibling_module(tmp_path):
"""A mirror block imports a helper module living next to the page.
Matches the Sphinx build, where the `conf.py` directory is importable, so
a `docs_update.py`-style generator module works offline too. The path
entry is scoped to the execution and removed afterwards.
"""
(tmp_path / "sibling_helper_module.py").write_text(
'VALUE = "sibling-import-works"\n',
encoding="utf-8",
)
doc = tmp_path / "page.md"
doc.write_text(
dedent("""\
```{python:render}
:mirror:
from sibling_helper_module import VALUE
print(VALUE)
```
"""),
encoding="utf-8",
)
assert update_mirror_blocks([doc]) == [doc]
assert "sibling-import-works" in doc.read_text(encoding="utf-8")
assert str(tmp_path) not in sys.path
[docs]
def test_refresh_directives_cli_refreshes_mirror_blocks(tmp_path):
"""`click-extra refresh-directives` covers mirror regions too."""
doc = tmp_path / "page.md"
doc.write_text(_MIRROR_TABLE_BLOCK, encoding="utf-8")
runner = CliRunner()
# A missing region exits non-zero under --check, without writing.
result = runner.invoke(refresh_directives_cmd, ["--check", str(doc)])
assert result.exit_code == 1
assert doc.read_text(encoding="utf-8") == _MIRROR_TABLE_BLOCK
# Write mode inserts the region and names the file.
result = runner.invoke(refresh_directives_cmd, [str(doc)])
assert result.exit_code == 0
assert "refreshed" in result.output
assert MIRROR_MARKER_START in doc.read_text(encoding="utf-8")
# A freshly refreshed region is clean.
result = runner.invoke(refresh_directives_cmd, ["--check", str(doc)])
assert result.exit_code == 0
[docs]
def test_python_render_mirror_show_source_still_single_table(sphinx_app_myst):
"""`:mirror: :show-source:` shows the Python but still renders one table."""
content = dedent("""
```{python:render}
:mirror:
:show-source:
print("| Fruit | Count |")
print("| :----- | ----: |")
print("| apple | 3 |")
```
""")
html = sphinx_app_myst.build_document(content)
assert html is not None
# The Python source is shown as a highlighted block...
assert 'class="highlight-python' in html
# ...and the generated table renders exactly once.
assert html.count("<table") == 1
assert "apple" in html