diff --git a/.gitignore b/.gitignore
index 20a9325..b2f9495 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,8 @@ _unittests/ut__main/*.html
_unittests/ut_runpython/*.png
_unittests/ut_runpython/*.html
test_latex/*
+test_latex2/*
+test_api/*
+test_sphinx_api_func/*
+*.pdf
+_unittests/ut_runpython/exescript.py
diff --git a/CHANGELOGS.rst b/CHANGELOGS.rst
index 94aa6c3..c7fd52b 100644
--- a/CHANGELOGS.rst
+++ b/CHANGELOGS.rst
@@ -1,6 +1,13 @@
Change Logs
===========
+0.4.4
++++++
+
+* :pr:`62`: Switch from Azure Pipelines to GitHub Actions
+* :pr:`60`: Extend code coverage across sphinx-runpython utilities
+* :pr:`58`: Add ``sphinx_runpython.runmermaid`` extension for Mermaid diagram support
+
0.4.3
+++++
diff --git a/README.rst b/README.rst
index 5c934a5..0eaf603 100644
--- a/README.rst
+++ b/README.rst
@@ -5,8 +5,8 @@
sphinx-runpython: run python code in sphinx
===========================================
-.. image:: https://dev.azure.com/xavierdupre3/sphinx-runpython/_apis/build/status/sdpython.sphinx-runpython
- :target: https://dev.azure.com/xavierdupre3/sphinx-runpython/
+.. image:: https://github.com/sdpython/sphinx-runpython/actions/workflows/tests.yml/badge.svg
+ :target: https://github.com/sdpython/sphinx-runpython/actions/workflows/tests.yml
.. image:: https://badge.fury.io/py/sphinx-runpython.svg
:target: http://badge.fury.io/py/sphinx-runpython
diff --git a/_doc/api/index.rst b/_doc/api/index.rst
index c386747..3bbee13 100644
--- a/_doc/api/index.rst
+++ b/_doc/api/index.rst
@@ -16,6 +16,7 @@ Extensions
epkg
gdot
quote
+ runmermaid
runpython
tools
rst_builder
diff --git a/_doc/api/runmermaid.rst b/_doc/api/runmermaid.rst
new file mode 100644
index 0000000..678fa4b
--- /dev/null
+++ b/_doc/api/runmermaid.rst
@@ -0,0 +1,77 @@
+==========
+runmermaid
+==========
+
+This directive displays `Mermaid
element."""
+ content = """
+before
+
+.. runmermaid::
+
+ graph LR
+ A --> B
+
+after
+"""
+ html = rst2html(
+ content, writer_name="html", new_extensions=["sphinx_runpython.runmermaid"]
+ )
+ self.assertIn('class="mermaid"', html)
+ self.assertIn("graph LR", html)
+ self.assertIn("A --> B", html)
+
+ @ignore_warnings(PendingDeprecationWarning)
+ def test_runmermaid_script(self):
+ """Script-generated runmermaid diagram is included in the RST output."""
+ content = """
+before
+
+.. runmermaid::
+ :script:
+
+ print(\"\"\"graph LR
+ X --> Y\"\"\")
+
+after
+"""
+ content = rst2html(
+ content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+ )
+ self.assertIn("graph LR", content)
+ self.assertIn("X --> Y", content)
+
+ @ignore_warnings(PendingDeprecationWarning)
+ def test_runmermaid_script_split(self):
+ """When :script: has a value it is used as a split token."""
+ content = """
+before
+
+.. runmermaid::
+ :script: BEGIN
+
+ print("preamble")
+ print("BEGIN")
+ print("graph TD")
+ print(" P --> Q")
+
+after
+"""
+ content = rst2html(
+ content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+ )
+ self.assertNotIn("preamble", content)
+ self.assertNotIn("BEGIN", content)
+ self.assertIn("graph TD", content)
+ self.assertIn("P --> Q", content)
+
+ @ignore_warnings(PendingDeprecationWarning)
+ def test_runmermaid_script_cache(self):
+ """Identical scripts produce the same output and are cached."""
+ script_body = 'print("graph LR\\n A --> B")'
+ content = f"""
+before
+
+.. runmermaid::
+ :script:
+
+ {script_body}
+
+middle
+
+.. runmermaid::
+ :script:
+
+ {script_body}
+
+after
+"""
+ content = rst2html(
+ content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"]
+ )
+ count = content.count("graph LR")
+ self.assertEqual(count, 2, f"Expected diagram code twice, got {count}")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/_unittests/ut_runpython/test_run_cmd.py b/_unittests/ut_runpython/test_run_cmd.py
index 6fbf7ba..69682e2 100644
--- a/_unittests/ut_runpython/test_run_cmd.py
+++ b/_unittests/ut_runpython/test_run_cmd.py
@@ -1,8 +1,16 @@
import sys
import os
+import tempfile
import unittest
-from sphinx_runpython.runpython.run_cmd import run_cmd, skip_run_cmd
-from sphinx_runpython.ext_test_case import ExtTestCase
+from sphinx_runpython.runpython.run_cmd import (
+ run_cmd,
+ skip_run_cmd,
+ get_interpreter_path,
+ split_cmp_command,
+ decode_outerr,
+ RunCmdException,
+)
+from sphinx_runpython.ext_test_case import ExtTestCase, skipif_ci_windows
class TestRunCmd(ExtTestCase):
@@ -63,6 +71,119 @@ def test_run_cmd_more(self):
self.assertGreater(len(out), 10)
self.assertEqual(len(err), 0)
+ def test_get_interpreter_path(self):
+ path = get_interpreter_path()
+ self.assertIsNotNone(path)
+ self.assertIn("python", path.lower())
+
+ def test_split_cmp_command_simple(self):
+ result = split_cmp_command("echo hello world")
+ self.assertEqual(result, ["echo", "hello", "world"])
+
+ def test_split_cmp_command_with_quotes(self):
+ result = split_cmp_command('echo "hello world"')
+ self.assertEqual(result, ["echo", "hello world"])
+
+ def test_split_cmp_command_no_remove_quotes(self):
+ result = split_cmp_command('echo "hello world"', remove_quotes=False)
+ self.assertEqual(result, ["echo", '"hello world"'])
+
+ def test_split_cmp_command_list(self):
+ cmd = ["echo", "hello"]
+ result = split_cmp_command(cmd)
+ self.assertIs(result, cmd)
+
+ def test_decode_outerr_bytes(self):
+ result = decode_outerr(b"hello world", "utf-8", "ignore", "test")
+ self.assertEqual(result, "hello world")
+
+ def test_decode_outerr_none_encoding(self):
+ result = decode_outerr(b"hello", None, "ignore", "test")
+ self.assertEqual(result, "hello")
+
+ def test_decode_outerr_not_bytes(self):
+ self.assertRaise(
+ lambda: decode_outerr("hello", "utf-8", "ignore", "test"), TypeError
+ )
+
+ @skipif_ci_windows("pwd is Unix-only and temp dirs may be on a different drive")
+ def test_run_cmd_with_change_path(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ out, _err = run_cmd("pwd", wait=True, change_path=tmpdir)
+ self.assertIn(os.path.realpath(tmpdir), os.path.realpath(out.strip()))
+
+ def test_run_cmd_with_logf(self):
+ logs = []
+
+ def logf(prefix, msg):
+ logs.append((prefix, msg))
+
+ cmd = "echo hello"
+ _out, _err = run_cmd(cmd, wait=True, logf=logf)
+ self.assertGreater(len(logs), 0)
+
+ def test_run_cmd_list_cmd(self):
+ out, _err = run_cmd(["echo", "test"], wait=True)
+ self.assertIn("test", out)
+
+ def test_run_cmd_preprocess_false(self):
+ out, _err = run_cmd("echo hello", wait=True, preprocess=False, shell=True)
+ self.assertIn("hello", out)
+
+ def test_run_cmd_exception_class(self):
+ exc = RunCmdException("test error")
+ self.assertIsInstance(exc, Exception)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
+
+
+class TestRunCmdExtra(ExtTestCase):
+ def test_decode_outerr_unicode_fallback(self):
+ # Bytes that fail ASCII but succeed with utf8 fallback
+ # 0xc3 0xa9 is the UTF-8 encoding of 'é'
+ result = decode_outerr(b"\xc3\xa9 hello", "ascii", "strict", "test")
+ self.assertIn("hello", result)
+
+ def test_decode_outerr_unicode_error(self):
+ # Bytes that fail both ASCII and UTF-8 strict decoding
+ # 0x80 is not valid in ascii strict or utf-8 strict
+ self.assertRaise(
+ lambda: decode_outerr(b"\x80\x81\x82", "ascii", "strict", "test"),
+ RuntimeError,
+ )
+
+ def test_run_cmd_with_logf_list(self):
+ logs = []
+
+ def logf(prefix, msg):
+ logs.append((prefix, msg))
+
+ out, _err = run_cmd(["echo", "hello"], wait=True, logf=logf)
+ self.assertGreater(len(logs), 0)
+ self.assertIn("hello", out)
+
+ def test_run_cmd_catch_exit(self):
+ out, _err = run_cmd("echo hello", wait=True, catch_exit=True)
+ self.assertIn("hello", out)
+
+ def test_run_cmd_with_prefix_log(self):
+ logs = []
+
+ def logf(prefix, msg):
+ logs.append((prefix, msg))
+
+ _out, _err = run_cmd("echo hello", wait=True, logf=logf, prefix_log="[test] ")
+ self.assertGreater(len(logs), 0)
+ self.assertTrue(any("[test]" in str(log) for log in logs))
+
+ def test_run_cmd_nowait(self):
+ # run_cmd with wait=False returns (pproc, None)
+ result = run_cmd("echo hello", wait=False)
+ pproc, _ = result
+ pproc.__exit__(None, None, None)
+
if __name__ == "__main__":
unittest.main(verbosity=2)
diff --git a/_unittests/ut_tools/test_imgexport.py b/_unittests/ut_tools/test_imgexport.py
index c2ffe23..d65aba7 100644
--- a/_unittests/ut_tools/test_imgexport.py
+++ b/_unittests/ut_tools/test_imgexport.py
@@ -1,4 +1,6 @@
+import io
import os
+import tempfile
import unittest
from sphinx_runpython.ext_test_case import ExtTestCase
from sphinx_runpython.tools.img_export import images2pdf
@@ -30,6 +32,82 @@ def test_export_zoom(self):
self.assertExists(dest)
self.assertEqual(len(res), 3)
+ def test_export_single_file_string(self):
+ data = os.path.join(os.path.dirname(__file__), "data", "mazures1.jpg")
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
+ dest = f.name
+ try:
+ res = images2pdf(data, dest)
+ self.assertExists(dest)
+ self.assertEqual(len(res), 1)
+ finally:
+ if os.path.exists(dest):
+ os.remove(dest)
+
+ def test_export_glob_string(self):
+ datap = os.path.join(os.path.dirname(__file__), "data", "*.jpg")
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
+ dest = f.name
+ try:
+ res = images2pdf(datap, dest)
+ self.assertExists(dest)
+ self.assertGreater(len(res), 0)
+ finally:
+ if os.path.exists(dest):
+ os.remove(dest)
+
+ def test_export_invalid_string(self):
+ self.assertRaise(
+ lambda: images2pdf("/nonexistent/path/image.jpg", "out.pdf"),
+ RuntimeError,
+ )
+
+ def test_export_invalid_type(self):
+ self.assertRaise(
+ lambda: images2pdf(42, "out.pdf"),
+ TypeError,
+ )
+
+ def test_export_verbose(self):
+ data = os.path.join(os.path.dirname(__file__), "data", "mazures1.jpg")
+ datap = os.path.join(os.path.dirname(__file__), "data", "*.jpg")
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
+ dest = f.name
+ try:
+ res = images2pdf([data, datap], dest, verbose=2)
+ self.assertGreater(len(res), 0)
+ finally:
+ if os.path.exists(dest):
+ os.remove(dest)
+
+ def test_export_to_stream(self):
+ data = os.path.join(os.path.dirname(__file__), "data", "mazures1.jpg")
+ stream = io.BytesIO()
+ _res = images2pdf([data], stream)
+ self.assertGreater(stream.tell(), 0)
+
+ def test_export_rotate(self):
+ data = os.path.join(os.path.dirname(__file__), "data", "mazures1.jpg")
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
+ dest = f.name
+ try:
+ _res = images2pdf([data], dest, rotate=90, verbose=1)
+ self.assertExists(dest)
+ finally:
+ if os.path.exists(dest):
+ os.remove(dest)
+
+ def test_export_zoom_with_verbose(self):
+ data = os.path.join(os.path.dirname(__file__), "data", "mazures1.jpg")
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
+ dest = f.name
+ try:
+ _res = images2pdf([data], dest, zoom=0.5, verbose=1)
+ self.assertExists(dest)
+ finally:
+ if os.path.exists(dest):
+ os.remove(dest)
+
if __name__ == "__main__":
unittest.main(verbosity=2)
diff --git a/_unittests/ut_tools/test_latex_functions.py b/_unittests/ut_tools/test_latex_functions.py
index da313d7..1216c08 100644
--- a/_unittests/ut_tools/test_latex_functions.py
+++ b/_unittests/ut_tools/test_latex_functions.py
@@ -41,6 +41,25 @@ def test_replace_pattern(self):
" {1\\!\\!1}_{\\left\\{ N < X \\right\\}} ",
)
+ def test_replace_pattern_invalid_regex(self):
+ patterns = {"test": ("(invalid[", "replacement")}
+ self.assertRaise(
+ lambda: replace_latex_command("some text", patterns=patterns),
+ AssertionError,
+ )
+
+ def test_replace_pattern_unknown_type(self):
+ patterns = {"test": 42}
+ self.assertRaise(
+ lambda: replace_latex_command("some text", patterns=patterns),
+ AssertionError,
+ )
+
+ def test_replace_pattern_callable(self):
+ patterns = {"test": lambda t: t.replace("hello", "world")}
+ result = replace_latex_command("hello world", patterns=patterns)
+ self.assertEqual(result, "world world")
+
if __name__ == "__main__":
unittest.main(verbosity=2)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index 4949857..0000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,169 +0,0 @@
-jobs:
-- job: 'TestLinuxWheelPip313'
- pool:
- vmImage: 'ubuntu-latest'
- strategy:
- matrix:
- Python312-Linux:
- python.version: '3.13'
- maxParallel: 3
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: '$(python.version)'
- architecture: 'x64'
- - script: sudo apt-get update
- displayName: 'AptGet Update'
- - script: sudo apt-get install -y pandoc
- displayName: 'Install Pandoc'
- - script: sudo apt-get install -y graphviz
- displayName: 'Install Graphviz'
- - script: python -m pip install --upgrade pip setuptools wheel
- displayName: 'Install tools'
- - script: pip install -r requirements.txt
- displayName: 'Install Requirements'
- - script: pip install -r requirements-dev.txt
- displayName: 'Install Requirements dev'
- - script: |
- ruff check .
- displayName: 'Ruff'
- - script: |
- black --diff .
- displayName: 'Black'
- - script: |
- python -m pip wheel . --wheel-dir dist -v -v -v
- displayName: 'build wheel'
- - script: |
- python -m pip install . -v -v -v
- displayName: 'install wheel'
- - script: |
- python -m pytest --durations=10 --ignore-glob=**LONG*.py --ignore-glob=**notebook*.py
- displayName: 'Runs Unit Tests'
- - task: PublishPipelineArtifact@0
- inputs:
- artifactName: 'wheel-linux-wheel-$(python.version)'
- targetPath: 'dist'
-
-- job: 'TestLinux312'
- pool:
- vmImage: 'ubuntu-latest'
- strategy:
- matrix:
- Python312-Linux:
- python.version: '3.12'
- maxParallel: 3
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: '$(python.version)'
- architecture: 'x64'
- - script: sudo apt-get update
- displayName: 'AptGet Update'
- - script: sudo apt-get install -y pandoc
- displayName: 'Install Pandoc'
- - script: sudo apt-get install -y inkscape
- displayName: 'Install Inkscape'
- - script: sudo apt-get install -y graphviz
- displayName: 'Install Graphviz'
- - script: python -m pip install --upgrade pip setuptools wheel
- displayName: 'Install tools'
- - script: pip install -r requirements.txt
- displayName: 'Install Requirements'
- - script: pip install -r requirements-dev.txt
- displayName: 'Install Requirements dev'
- - script: |
- ruff check .
- displayName: 'Ruff'
- - script: |
- black --diff .
- displayName: 'Black'
- - script: |
- python -m pytest --durations=10 --ignore-glob=**LONG*.py --ignore-glob=**notebook*.py
- displayName: 'Runs Unit Tests'
- - script: |
- python -u setup.py bdist_wheel
- displayName: 'Build Package'
- #- script: |
- # python -m sphinx _doc dist/html
- # displayName: 'Builds Documentation'
- - task: PublishPipelineArtifact@0
- inputs:
- artifactName: 'wheel-linux-$(python.version)'
- targetPath: 'dist'
-
-- job: 'TestWindows312'
- pool:
- vmImage: 'windows-latest'
- strategy:
- matrix:
- Python312-Windows:
- python.version: '3.12'
- maxParallel: 3
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: '$(python.version)'
- architecture: 'x64'
- - script: python -m pip install --upgrade pip setuptools wheel
- displayName: 'Install tools'
- - script: pip install -r requirements.txt
- displayName: 'Install Requirements'
- - script: pip install -r requirements-dev.txt
- displayName: 'Install Requirements dev'
- - script: |
- python -m pytest --durations=10 --ignore-glob=**LONG*.py --ignore-glob=**notebook*.py
- displayName: 'Runs Unit Tests'
- - script: |
- python -u setup.py bdist_wheel
- displayName: 'Build Package'
- - task: PublishPipelineArtifact@0
- inputs:
- artifactName: 'wheel-windows-$(python.version)'
- targetPath: 'dist'
-
-- job: 'TestMac312'
- pool:
- vmImage: 'macOS-latest'
- strategy:
- matrix:
- Python312-Mac:
- python.version: '3.12'
- maxParallel: 3
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: '$(python.version)'
- architecture: 'x64'
- - script: gcc --version
- displayName: 'gcc version'
- - script: |
- brew update
- displayName: 'brew update'
- - script: export
- displayName: 'export'
- - script: gcc --version
- displayName: 'gcc version'
- - script: brew install p7zip
- displayName: 'Install p7zip'
- - script: python -m pip install --upgrade pip setuptools wheel
- displayName: 'Install tools'
- - script: brew install pybind11
- displayName: 'Install pybind11'
- - script: pip install -r requirements.txt
- displayName: 'Install Requirements'
- - script: pip install -r requirements-dev.txt
- displayName: 'Install Requirements dev'
- - script: |
- python -m pytest --durations=10 --ignore-glob=**LONG*.py --ignore-glob=**notebook*.py
- displayName: 'Runs Unit Tests'
- - script: |
- python -u setup.py bdist_wheel
- displayName: 'Build Package'
- - task: PublishPipelineArtifact@0
- inputs:
- artifactName: 'wheel-mac-$(python.version)'
- targetPath: 'dist'
diff --git a/pyproject.toml b/pyproject.toml
index 34ddfac..988d6e3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ select = [
"PIE790",
"PYI041",
"RUF012", "RUF100", "RUF010",
- "SIM108", "SIM102", "SIM114", "SIM103", "SIM910",
+ "SIM105", "SIM108", "SIM102", "SIM114", "SIM103", "SIM910",
"UP006", "UP007", "UP015", "UP027", "UP031", "UP034", "UP035", "UP032", "UP045"
]
"_doc/examples/plot_*.py" = ["E402", "B018", "PIE808", "SIM105", "SIM117"]
diff --git a/sphinx_runpython/__init__.py b/sphinx_runpython/__init__.py
index a8a9b0a..4e02c57 100644
--- a/sphinx_runpython/__init__.py
+++ b/sphinx_runpython/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.4.3"
+__version__ = "0.4.4"
__author__ = "Xavier Dupré"
__github__ = "https://github.com/sdpython/sphinx-runpython"
__url__ = "https://sdpython.github.io/doc/sphinx-runpython/dev/"
diff --git a/sphinx_runpython/ext_test_case.py b/sphinx_runpython/ext_test_case.py
index 3ad3bd3..53d345b 100644
--- a/sphinx_runpython/ext_test_case.py
+++ b/sphinx_runpython/ext_test_case.py
@@ -121,15 +121,15 @@ def is_linux() -> bool:
def skipif_ci_windows(msg) -> Callable:
- """Skips a unit test if it runs on :epkg:`azure pipeline` on :epkg:`Windows`."""
+ """Skips a unit test if it runs on :epkg:`GitHub Actions` on :epkg:`Windows`."""
if is_windows():
- msg = f"Test does not work on azure pipeline (Windows). {msg}"
+ msg = f"Test does not work on GitHub Actions (Windows). {msg}"
return unittest.skip(msg)
return lambda x: x
def skipif_ci_linux(msg) -> Callable:
- """Skips a unit test if it runs on :epkg:`azure pipeline` on :epkg:`Linux`."""
+ """Skips a unit test if it runs on :epkg:`GitHub Actions` on :epkg:`Linux`."""
if is_linux():
msg = f"Takes too long (Linux). {msg}"
return unittest.skip(msg)
@@ -137,9 +137,9 @@ def skipif_ci_linux(msg) -> Callable:
def skipif_ci_apple(msg) -> Callable:
- """Skips a unit test if it runs on :epkg:`azure pipeline` on :epkg:`Windows`."""
+ """Skips a unit test if it runs on :epkg:`GitHub Actions` on :epkg:`macOS`."""
if is_apple():
- msg = f"Test does not work on azure pipeline (Apple). {msg}"
+ msg = f"Test does not work on GitHub Actions (Apple). {msg}"
return unittest.skip(msg)
return lambda x: x
diff --git a/sphinx_runpython/runmermaid/__init__.py b/sphinx_runpython/runmermaid/__init__.py
new file mode 100644
index 0000000..185687f
--- /dev/null
+++ b/sphinx_runpython/runmermaid/__init__.py
@@ -0,0 +1,3 @@
+from .sphinx_runmermaid_extension import setup
+
+__all__ = ["setup"]
diff --git a/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py b/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py
new file mode 100644
index 0000000..11f19b9
--- /dev/null
+++ b/sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py
@@ -0,0 +1,242 @@
+import hashlib
+import logging
+from docutils import nodes
+from docutils.parsers.rst import directives, Directive
+import sphinx
+from ..ext_helper import get_env_state_info
+from ..runpython.sphinx_runpython_extension import run_python_script
+
+logger = logging.getLogger("runmermaid")
+
+#: Default CDN URL for the mermaid JavaScript library.
+_MERMAID_JS_URL = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"
+
+
+class runmermaid_node(nodes.General, nodes.Element):
+ """
+ Defines ``runmermaid`` node.
+ """
+
+ pass
+
+
+class RunMermaidDirective(Directive):
+ """
+ A ``runmermaid`` node displays a `Mermaid `_ diagram.
+
+ For *HTML* output the diagram is rendered client-side by embedding the
+ Mermaid JavaScript library (loaded from a CDN or a local copy).
+ For *LaTeX* / *text* / *RST* output the raw Mermaid source is included.
+
+ Supported options:
+
+ * *script*: boolean or a string that marks the beginning of the Mermaid
+ source in the standard output of the embedded Python script. When this
+ option is present the directive body is interpreted as Python code whose
+ ``stdout`` contains the diagram definition.
+ * *process*: run the Python script in a separate process.
+
+ Example - inline diagram::
+
+ .. runmermaid::
+
+ graph LR
+ A --> B --> C
+
+ Which gives:
+
+ .. runmermaid::
+
+ graph LR
+ A --> B --> C
+
+ Example - script-generated diagram::
+
+ .. runmermaid::
+ :script:
+
+ print(\"\"\"
+ graph LR
+ A --> B
+ \"\"\")
+
+ .. runmermaid::
+ :script:
+
+ print(\"\"\"
+ graph LR
+ A --> B
+ \"\"\")
+ """
+
+ node_class = runmermaid_node
+ has_content = True
+ required_arguments = 0
+ optional_arguments = 0
+ final_argument_whitespace = False
+ option_spec = {
+ "script": directives.unchanged,
+ "process": directives.unchanged,
+ }
+
+ def run(self):
+ """Build the runmermaid node."""
+ bool_set_ = (True, 1, "True", "1", "true", "")
+ process = "process" in self.options and self.options["process"] in bool_set_
+
+ info = get_env_state_info(self)
+ docname = info["docname"]
+
+ if "script" in self.options:
+ script = self.options["script"]
+ if script in (0, "0", "False", "false"):
+ script = None
+ elif script in (1, "1", "True", "true", ""):
+ script = ""
+ # else: keep script as-is to use it as a split token
+ else:
+ script = False
+
+ # Execute the script and use its stdout as diagram source, if requested.
+ content = "\n".join(self.content)
+ if script or script == "":
+ env = info.get("env")
+ doc_prefix = docname.split("/")[-1] if docname else ""
+ cache_key = (
+ f"{doc_prefix}:"
+ + hashlib.sha256(f"{content}:{process}".encode()).hexdigest()
+ )
+ if env is not None:
+ if not hasattr(env, "runmermaid_script_cache"):
+ env.runmermaid_script_cache = {}
+ cached = env.runmermaid_script_cache.get(cache_key, None)
+ else:
+ cached = None
+
+ if cached is not None:
+ stdout, stderr = cached
+ else:
+ stdout, stderr, _ = run_python_script(content, process=process)
+ if env is not None:
+ env.runmermaid_script_cache[cache_key] = (stdout, stderr)
+
+ if stderr:
+ logger.warning(
+ "[runmermaid] a diagram cannot be drawn due to %s", stderr
+ )
+ content = stdout
+ if script:
+ spl = content.split(script)
+ if len(spl) > 2:
+ logger.warning("[runmermaid] too many output lines %s", content)
+ content = spl[-1]
+
+ node = runmermaid_node(code=content, options={"docname": docname})
+ return [node]
+
+
+# ---------------------------------------------------------------------------
+# Visitor helpers
+# ---------------------------------------------------------------------------
+
+
+def visit_runmermaid_node_html(self, node):
+ """Render the runmermaid node in HTML output."""
+ code = node["code"].strip()
+ # Emit a block; mermaid.js will replace it at runtime.
+ self.body.append(
+ f''
+ f'{self.encode(code)}'
+ f"\n"
+ )
+ raise nodes.SkipNode
+
+
+def depart_runmermaid_node_html(self, node):
+ """Depart the runmermaid HTML node. Not called because the visitor raises SkipNode."""
+
+
+def visit_runmermaid_node_rst(self, node):
+ """Render the runmermaid node in RST output."""
+ self.new_state(0)
+ self.add_text(".. runmermaid::" + self.nl)
+ self.new_state(self.indent)
+ for row in node["code"].split("\n"):
+ self.add_text(row + self.nl)
+
+
+def depart_runmermaid_node_rst(self, node):
+ """Depart runmermaid node in RST output."""
+ self.end_state()
+ self.end_state(wrap=False)
+
+
+def visit_runmermaid_node_text(self, node):
+ """Render the runmermaid node in plain-text output."""
+ self.new_state(0)
+ self.add_text("[runmermaid diagram]\n")
+ self.new_state(self.indent)
+ for row in node["code"].split("\n"):
+ self.add_text(row + self.nl)
+
+
+def depart_runmermaid_node_text(self, node):
+ """Depart runmermaid node in text output."""
+ self.end_state()
+ self.end_state(wrap=False)
+
+
+def visit_runmermaid_node_latex(self, node):
+ """Render the runmermaid node in LaTeX output (verbatim source)."""
+ code = node["code"].strip()
+ self.body.append("\n\\begin{verbatim}\n")
+ self.body.append(code)
+ self.body.append("\n\\end{verbatim}\n")
+ raise nodes.SkipNode
+
+
+def depart_runmermaid_node_latex(self, node):
+ """Depart the runmermaid LaTeX node. Not called because the visitor raises SkipNode."""
+
+
+# ---------------------------------------------------------------------------
+# JS injection
+# ---------------------------------------------------------------------------
+
+
+def add_mermaid_js(app):
+ """Inject the Mermaid JS library into HTML pages."""
+ if app.builder.format != "html":
+ return
+ app.add_js_file(_MERMAID_JS_URL, loading_method="async")
+ # Initialise mermaid after the DOM is ready.
+ app.add_js_file(
+ None,
+ body="document.addEventListener('DOMContentLoaded', function() "
+ "{ mermaid.initialize({startOnLoad: true}); });",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Extension setup
+# ---------------------------------------------------------------------------
+
+
+def setup(app):
+ """
+ setup for ``runmermaid`` (sphinx)
+ """
+ app.connect("builder-inited", add_mermaid_js)
+
+ app.add_node(
+ runmermaid_node,
+ html=(visit_runmermaid_node_html, depart_runmermaid_node_html),
+ epub=(visit_runmermaid_node_html, depart_runmermaid_node_html),
+ latex=(visit_runmermaid_node_latex, depart_runmermaid_node_latex),
+ text=(visit_runmermaid_node_text, depart_runmermaid_node_text),
+ rst=(visit_runmermaid_node_rst, depart_runmermaid_node_rst),
+ md=(visit_runmermaid_node_text, depart_runmermaid_node_text),
+ )
+
+ app.add_directive("runmermaid", RunMermaidDirective)
+ return {"version": sphinx.__display_version__, "parallel_read_safe": True}