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 `_ diagrams in the documentation. +Diagrams are rendered client-side in *HTML* output via the Mermaid JavaScript library +and as verbatim text in *LaTeX* / *RST* output. + +Usage +===== + +In *conf.py*: + +:: + + extensions = [ ... + 'sphinx_runpython.runmermaid', + ] + +One example: + +:: + + .. runmermaid:: + + graph LR + A --> B --> C + +Which gives: + +.. runmermaid:: + + graph LR + A --> B --> C + +The diagram source can also be produced by a Python script. +Option *script* must be specified: + +:: + + .. runmermaid:: + :script: + + print(""" + graph LR + A --> B + """) + +.. runmermaid:: + :script: + + print(""" + graph LR + A --> B + """) + +When *script* is a non-empty string it is used as a split token: only +the output **after** the first occurrence of that string is interpreted +as Mermaid source: + +:: + + .. runmermaid:: + :script: AFTER-THIS + + print("preamble") + print("AFTER-THIS") + print("graph TD") + print(" P --> Q") + +Finally, the option ``:process:`` can be used to run the script in +a separate process. + +Directive +========= + +.. autoclass:: sphinx_runpython.runmermaid.sphinx_runmermaid_extension.RunMermaidDirective diff --git a/_doc/conf.py b/_doc/conf.py index 22c4aec..0a1a41b 100644 --- a/_doc/conf.py +++ b/_doc/conf.py @@ -25,6 +25,7 @@ "sphinx_runpython.docassert", "sphinx_runpython.gdot", "sphinx_runpython.epkg", + "sphinx_runpython.runmermaid", "sphinx_runpython.quote", "sphinx_runpython.runpython", "sphinx_runpython.sphinx_rst_builder", @@ -55,6 +56,8 @@ graphviz_output_format = "svg" graphviz_dot_args = ["-Gbgcolor=transparent"] +issues_github_path = "sdpython/sphinx-runpython" + html_theme = "furo" html_theme_path = ["_static"] html_theme_options = {} @@ -118,6 +121,7 @@ "git": "https://git-scm.com/", "Graphviz": "https://graphviz.org/", "HTML": "https://simple.wikipedia.org/wiki/HTML", + "Mermaid": "https://mermaid.js.org/", "nested_parse_with_titles": "https://www.sphinx-doc.org/en/master/extdev/markupapi.html#parsing-directive-content-as-rest", "numpy": ( "https://www.numpy.org/", diff --git a/_doc/index.rst b/_doc/index.rst index d393840..e4e1781 100644 --- a/_doc/index.rst +++ b/_doc/index.rst @@ -2,8 +2,8 @@ sphinx-runpython: (Numpy) Array API for ONNX ============================================ -.. 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/_unittests/ut__main/test_cmd.py b/_unittests/ut__main/test_cmd.py index 0f13721..6c00d74 100644 --- a/_unittests/ut__main/test_cmd.py +++ b/_unittests/ut__main/test_cmd.py @@ -1,8 +1,18 @@ import platform +import shutil +import sys +import tempfile import unittest import os -from sphinx_runpython.ext_test_case import ExtTestCase, hide_stdout -from sphinx_runpython._cmd_helper import get_parser, nb2py, latex_process +from argparse import Namespace +from sphinx_runpython.ext_test_case import ExtTestCase, hide_stdout, skipif_ci_windows +from sphinx_runpython._cmd_helper import ( + get_parser, + nb2py, + latex_process, + process_args, + sphinx_api, +) class TestCmd(ExtTestCase): @@ -30,6 +40,124 @@ def test_latex(self): expected = os.path.join(folder, "poulet.py") self.assertExists(expected) + def test_latex_inplace(self): + data = os.path.join(os.path.dirname(__file__), "data") + with tempfile.TemporaryDirectory() as tmpdir: + shutil.copy( + os.path.join(data, "strategie_avec_alea.rst"), + os.path.join(tmpdir, "strategie_avec_alea.rst"), + ) + latex_process(tmpdir, verbose=1) + self.assertExists(os.path.join(tmpdir, "strategie_avec_alea.rst")) + + def test_nb2py_not_found(self): + self.assertRaise(lambda: nb2py("/nonexistent/path/xyz"), FileNotFoundError) + + def test_latex_process_not_found(self): + self.assertRaise( + lambda: latex_process("/nonexistent/path/xyz"), FileNotFoundError + ) + + def test_process_args_nb2py_empty(self): + with tempfile.TemporaryDirectory() as tmpdir: + args = Namespace( + command="nb2py", + path=tmpdir, + recursive=False, + verbose=0, + ) + process_args(args) + + @skipif_ci_windows("readme processing does not work on Windows") + def test_process_args_readme(self): + readme = os.path.join(os.path.dirname(__file__), "..", "..", "README.rst") + args = Namespace( + command="readme", + path=readme, + verbose=0, + ) + process_args(args) + + def test_process_args_unknown_command(self): + args = Namespace(command="unknown_cmd", path=None, verbose=0) + self.assertRaise(lambda: process_args(args), ValueError) + + @hide_stdout() + def test_process_args_latex(self): + data = os.path.join(os.path.dirname(__file__), "data") + folder = "test_latex2" + if not os.path.exists(folder): + os.mkdir(folder) + args = Namespace( + command="latex", + path=data, + recursive=False, + verbose=0, + output=folder, + ) + process_args(args) + + def test_process_args_api(self): + data = os.path.join(os.path.dirname(__file__), "..", "..", "sphinx_runpython") + folder = "test_api" + if not os.path.exists(folder): + os.mkdir(folder) + args = Namespace( + command="api", + path=data, + recursive=False, + verbose=0, + output=folder, + hidden=False, + ) + process_args(args) + + def test_process_args_img2pdf(self): + from PIL import Image + + with tempfile.TemporaryDirectory() as tmpdir: + img_path = os.path.join(tmpdir, "test.png") + out_path = os.path.join(tmpdir, "out.pdf") + Image.new("RGB", (100, 100), "white").save(img_path) + args = Namespace( + command="img2pdf", + path=img_path, + output=out_path, + verbose=0, + zoom=1.0, + rotate=0.0, + ) + process_args(args) + self.assertExists(out_path) + + def test_sphinx_api_function(self): + data = os.path.join(os.path.dirname(__file__), "..", "..", "sphinx_runpython") + folder = "test_sphinx_api_func" + if not os.path.exists(folder): + os.mkdir(folder) + sphinx_api(data, folder, verbose=0) + + def test_main_latex(self): + with tempfile.TemporaryDirectory() as tmpdir: + old_argv = sys.argv + try: + sys.argv = ["sphinx-runpython", "latex", "--path", tmpdir] + from sphinx_runpython._cmd_helper import main + + main() + finally: + sys.argv = old_argv + + def test_main_help(self): + old_argv = sys.argv + try: + sys.argv = ["sphinx-runpython", "--help"] + from sphinx_runpython._cmd_helper import main + + self.assertRaise(lambda: main(), SystemExit) + finally: + sys.argv = old_argv + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_ext_helper.py b/_unittests/ut__main/test_ext_helper.py new file mode 100644 index 0000000..0d5e82f --- /dev/null +++ b/_unittests/ut__main/test_ext_helper.py @@ -0,0 +1,101 @@ +import unittest +from docutils import nodes +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.ext_helper import ( + NodeEnter, + NodeLeave, + TinyNode, + WrappedNode, + traverse, + sphinx_lang, +) + + +class TestExtHelper(ExtTestCase): + def test_tiny_node(self): + parent = object() + node = TinyNode(parent) + self.assertIs(node.parent, parent) + + def test_node_enter(self): + parent = object() + node = NodeEnter(parent) + self.assertIsInstance(node, TinyNode) + self.assertIs(node.parent, parent) + + def test_node_leave(self): + parent = object() + node = NodeLeave(parent) + self.assertIsInstance(node, TinyNode) + self.assertIs(node.parent, parent) + + def test_wrapped_node(self): + doc_node = nodes.section() + wrapped = WrappedNode(doc_node) + self.assertIs(wrapped.node, doc_node) + + def test_traverse_simple(self): + root = nodes.section() + para = nodes.paragraph(text="hello") + root += para + + results = list(traverse(root)) + self.assertGreater(len(results), 0) + depths = [d for d, n in results] + node_types = [type(n) for d, n in results] + self.assertIn(0, depths) + self.assertIn(NodeEnter, node_types) + self.assertIn(NodeLeave, node_types) + self.assertIn(nodes.section, node_types) + self.assertIn(nodes.paragraph, node_types) + + def test_traverse_with_wrapped_node(self): + root = nodes.paragraph(text="test") + wrapped = WrappedNode(root) + results = list(traverse(wrapped)) + self.assertGreater(len(results), 0) + node_types = [type(n) for d, n in results] + self.assertIn(NodeEnter, node_types) + self.assertIn(NodeLeave, node_types) + + def test_traverse_depth(self): + root = nodes.section() + child = nodes.paragraph(text="child") + grandchild = nodes.Text("text") + child += grandchild + root += child + + results = list(traverse(root)) + max_depth = max(d for d, n in results) + self.assertGreaterEqual(max_depth, 2) + + def test_sphinx_lang_no_settings(self): + class FakeEnv: + pass + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "en") + + def test_sphinx_lang_with_settings_no_code(self): + class FakeSettings: + pass + + class FakeEnv: + settings = FakeSettings() + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "en") + + def test_sphinx_lang_with_language_code(self): + class FakeSettings: + language_code = "fr" + + class FakeEnv: + settings = FakeSettings() + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "fr") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_ext_io_helper.py b/_unittests/ut__main/test_ext_io_helper.py new file mode 100644 index 0000000..1ed14c8 --- /dev/null +++ b/_unittests/ut__main/test_ext_io_helper.py @@ -0,0 +1,87 @@ +import os +import unittest +import tempfile +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.ext_io_helper import ( + _get_file_url, + ReadUrlException, + InternetException, + FileException, + MONTH_DATE, + get_url_content_timeout, +) + + +class TestExtIoHelper(ExtTestCase): + def test_month_date_keys(self): + self.assertEqual(MONTH_DATE["jan"], 1) + self.assertEqual(MONTH_DATE["dec"], 12) + self.assertEqual(len(MONTH_DATE), 12) + + def test_get_file_url_basic(self): + result = _get_file_url("http://example.com/file.html", "/tmp/cache") + self.assertIn("/tmp/cache", result) + self.assertIn("example", result) + + def test_get_file_url_png(self): + result = _get_file_url("http://example.com/image.png", "/tmp/cache") + self.assertTrue(result.endswith(".png")) + + def test_get_file_url_no_extension(self): + result = _get_file_url("http://example.com/noext", "/tmp/cache") + self.assertIn("/tmp/cache", result) + + def test_get_file_url_query_params(self): + result = _get_file_url("http://example.com/file?key=value.pdf", "/tmp/cache") + self.assertIn("/tmp/cache", result) + + def test_get_file_url_py(self): + result = _get_file_url("http://example.com/script.py", "/tmp/cache") + self.assertTrue(result.endswith(".py")) + + def test_read_url_exception_custom(self): + exc = ReadUrlException("test error") + self.assertIsInstance(exc, Exception) + + def test_internet_exception_custom(self): + exc = InternetException("test error") + self.assertIsInstance(exc, Exception) + + def test_file_exception_custom(self): + exc = FileException("test error") + self.assertIsInstance(exc, Exception) + + def test_get_url_content_timeout_invalid_url(self): + url = "https://localhost:87777/nonexistent" + result = get_url_content_timeout( + url, timeout=2, raise_exception=False, encoding="utf-8" + ) + self.assertIsNone(result) + + def test_get_url_content_timeout_raises(self): + url = "https://localhost:87777/nonexistent" + self.assertRaise( + lambda: get_url_content_timeout(url, timeout=2, raise_exception=True), + InternetException, + ) + + def test_get_url_content_timeout_save_to_file(self): + url = "https://localhost:87777/nonexistent" + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + outfile = f.name + try: + result = get_url_content_timeout( + url, + timeout=2, + output=outfile, + raise_exception=False, + encoding="utf-8", + ) + self.assertIsNone(result) + finally: + if os.path.exists(outfile): + os.remove(outfile) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_ext_test_case.py b/_unittests/ut__main/test_ext_test_case.py new file mode 100644 index 0000000..3e6d065 --- /dev/null +++ b/_unittests/ut__main/test_ext_test_case.py @@ -0,0 +1,187 @@ +import os +import sys +import unittest +import numpy +from sphinx_runpython.ext_test_case import ( + ExtTestCase, + unit_test_going, + ignore_warnings, + hide_stdout, + sys_path_append, + is_windows, + is_apple, + is_linux, + skipif_ci_windows, + skipif_ci_linux, + skipif_ci_apple, +) + + +class TestExtTestCase(ExtTestCase): + def test_unit_test_going_default(self): + old = os.environ.get("UNITTEST_GOING", None) + try: + if "UNITTEST_GOING" in os.environ: + del os.environ["UNITTEST_GOING"] + result = unit_test_going() + self.assertFalse(result) + finally: + if old is not None: + os.environ["UNITTEST_GOING"] = old + + def test_unit_test_going_set(self): + old = os.environ.get("UNITTEST_GOING", None) + try: + os.environ["UNITTEST_GOING"] = "1" + result = unit_test_going() + self.assertTrue(result) + finally: + if old is not None: + os.environ["UNITTEST_GOING"] = old + elif "UNITTEST_GOING" in os.environ: + del os.environ["UNITTEST_GOING"] + + def test_ignore_warnings_none(self): + def dummy(): + pass + + wrapper = ignore_warnings([UserWarning]) + decorated = wrapper(dummy) + self.assertTrue(callable(decorated)) + + def test_ignore_warnings_warns_none_raises(self): + self.assertRaise( + lambda: ignore_warnings(None)(lambda: None)(), + AssertionError, + ) + + def test_hide_stdout_basic(self): + @hide_stdout() + def my_func(self): + print("hidden output") + + my_func(self) + + def test_hide_stdout_with_callback(self): + captured = [] + + @hide_stdout(lambda s: captured.append(s)) + def my_func(self): + print("captured text") + + my_func(self) + self.assertEqual(len(captured), 1) + self.assertIn("captured text", captured[0]) + + def test_sys_path_append_front(self): + old_path = sys.path.copy() + test_path = "/tmp/test_path_prepend" + with sys_path_append([test_path], position=0): + self.assertIn(test_path, sys.path) + self.assertEqual(sys.path[0], test_path) + self.assertEqual(sys.path, old_path) + + def test_sys_path_append_end(self): + old_path = sys.path.copy() + test_path = "/tmp/test_path_append" + with sys_path_append(test_path): + self.assertIn(test_path, sys.path) + self.assertEqual(sys.path, old_path) + + def test_is_windows(self): + self.assertIn(is_windows(), {True, False}) + + def test_is_apple(self): + self.assertIn(is_apple(), {True, False}) + + def test_is_linux(self): + self.assertIn(is_linux(), {True, False}) + + def test_platform_skip_decorators(self): + decorator_win = skipif_ci_windows("test") + decorator_linux = skipif_ci_linux("test") + decorator_apple = skipif_ci_apple("test") + self.assertTrue(callable(decorator_win)) + self.assertTrue(callable(decorator_linux)) + self.assertTrue(callable(decorator_apple)) + + def test_assert_exists_passes(self): + self.assertExists(__file__) + + def test_assert_exists_fails(self): + self.assertRaise( + lambda: self.assertExists("/nonexistent/path/file.txt"), + AssertionError, + ) + + def test_assert_equal_array(self): + a = numpy.array([1.0, 2.0, 3.0]) + b = numpy.array([1.0, 2.0, 3.0]) + self.assertEqualArray(a, b) + + def test_assert_almost_equal(self): + a = numpy.array([1.0, 2.0, 3.0]) + b = [1.0, 2.0, 3.0] + self.assertAlmostEqual(a, b) + + def test_assert_raise_passes(self): + self.assertRaise(lambda: 1 / 0, ZeroDivisionError) + + def test_assert_raise_wrong_type_propagates(self): + # When assertRaise is called with wrong exc_type, the exception propagates + with self.assertRaises(ZeroDivisionError): + self.assertRaise(lambda: 1 / 0, ValueError) + + def test_assert_raise_no_exception(self): + self.assertRaise( + lambda: self.assertRaise(lambda: None, ValueError), + AssertionError, + ) + + def test_assert_empty_none(self): + self.assertEmpty(None) + + def test_assert_empty_list(self): + self.assertEmpty([]) + + def test_assert_empty_fails(self): + self.assertRaise(lambda: self.assertEmpty([1, 2, 3]), AssertionError) + + def test_assert_not_empty_none_fails(self): + self.assertRaise(lambda: self.assertNotEmpty(None), AssertionError) + + def test_assert_not_empty_empty_list_fails(self): + self.assertRaise(lambda: self.assertNotEmpty([]), AssertionError) + + def test_assert_not_empty_passes(self): + self.assertNotEmpty([1, 2]) + self.assertNotEmpty("abc") + + def test_assert_starts_with_passes(self): + self.assertStartsWith("hello", "hello world") + + def test_assert_starts_with_fails(self): + self.assertRaise( + lambda: self.assertStartsWith("world", "hello world"), + AssertionError, + ) + + def test_capture(self): + def my_func(): + print("output text") + return 42 + + result, stdout, _stderr = self.capture(my_func) + self.assertEqual(result, 42) + self.assertIn("output text", stdout) + + def test_teardown_with_warns(self): + # Test that tearDownClass works with stored warnings + class TempTestCase(ExtTestCase): + _warns = [] + + TempTestCase.tearDownClass() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_github_link.py b/_unittests/ut__main/test_github_link.py new file mode 100644 index 0000000..c2138e1 --- /dev/null +++ b/_unittests/ut__main/test_github_link.py @@ -0,0 +1,73 @@ +import unittest +from sphinx_runpython.ext_test_case import ExtTestCase, skipif_ci_windows +from sphinx_runpython.github_link import ( + _get_git_revision, + _linkcode_resolve, + make_linkcode_resolve, +) + + +class TestGithubLink(ExtTestCase): + def test_get_git_revision(self): + revision = _get_git_revision() + self.assertIn(type(revision), {str, type(None)}) + + def test_linkcode_resolve_no_revision(self): + result = _linkcode_resolve("py", {}, "pkg", "url", revision=None) + self.assertIsNone(result) + + def test_linkcode_resolve_wrong_domain(self): + result = _linkcode_resolve( + "cpp", {"module": "os", "fullname": "path"}, "os", "url", revision="abc" + ) + self.assertIsNone(result) + + def test_linkcode_resolve_missing_module(self): + result = _linkcode_resolve("py", {}, "os", "url", revision="abc") + self.assertIsNone(result) + + def test_linkcode_resolve_missing_fullname(self): + result = _linkcode_resolve("py", {"module": "os"}, "os", "url", revision="abc") + self.assertIsNone(result) + + @skipif_ci_windows("os.path.relpath fails across drives on Windows") + def test_linkcode_resolve_function(self): + url_fmt = "https://github.com/python/cpython/blob/{revision}/{path}#L{lineno}" + result = _linkcode_resolve( + "py", + {"module": "os.path", "fullname": "join"}, + "os", + url_fmt, + revision="abc123", + ) + # May be None if source not found, but should not raise + self.assertIn(type(result), {str, type(None)}) + + def test_make_linkcode_resolve(self): + url_fmt = "https://github.com/python/cpython/blob/{revision}/{package}/{path}#L{lineno}" + resolver = make_linkcode_resolve("os", url_fmt) + self.assertTrue(callable(resolver)) + + @skipif_ci_windows("os.path.relpath fails across drives on Windows") + def test_make_linkcode_resolve_call(self): + url_fmt = "https://github.com/python/cpython/blob/{revision}/{package}/{path}#L{lineno}" + resolver = make_linkcode_resolve("os", url_fmt) + result = resolver("py", {"module": "os.path", "fullname": "join"}) + self.assertIn(type(result), {str, type(None)}) + + def test_linkcode_resolve_import_error(self): + url_fmt = "https://example.com/{revision}/{path}#L{lineno}" + self.assertRaise( + lambda: _linkcode_resolve( + "py", + {"module": "nonexistent_module_xyz", "fullname": "something"}, + "nonexistent_module_xyz", + url_fmt, + revision="abc", + ), + ImportError, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_helpers.py b/_unittests/ut__main/test_helpers.py index 7fa482e..a84453b 100644 --- a/_unittests/ut__main/test_helpers.py +++ b/_unittests/ut__main/test_helpers.py @@ -1,6 +1,7 @@ import unittest +from unittest.mock import patch from sphinx_runpython.ext_test_case import ExtTestCase -from sphinx_runpython.conf_helper import has_dvipng, has_dvisvgm +from sphinx_runpython.conf_helper import has_dvipng, has_dvisvgm, _check_cmd class TestHelpers(ExtTestCase): @@ -8,6 +9,18 @@ def test_dvis(self): self.assertIn(has_dvipng(), {True, False}) self.assertIn(has_dvisvgm(), {True, False}) + def test_check_cmd_found(self): + with patch("sphinx_runpython.conf_helper.run_cmd") as mock_run: + mock_run.return_value = ("dvipng version 1.0", "") + result = _check_cmd("dvipng") + self.assertTrue(result) + + def test_check_cmd_not_found_in_output(self): + with patch("sphinx_runpython.conf_helper.run_cmd") as mock_run: + mock_run.return_value = ("some other output", "") + result = _check_cmd("dvipng") + self.assertFalse(result) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_import_object_helper.py b/_unittests/ut__main/test_import_object_helper.py new file mode 100644 index 0000000..f6bc442 --- /dev/null +++ b/_unittests/ut__main/test_import_object_helper.py @@ -0,0 +1,126 @@ +import os +import unittest +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.import_object_helper import ( + import_object, + import_any_object, + import_path, +) + + +class TestImportObjectHelper(ExtTestCase): + def test_import_function(self): + obj, name = import_object("os.path.join", "function") + self.assertIsNotNone(obj) + self.assertEqual(name, "join") + + def test_import_class(self): + obj, name = import_object("os.PathLike", "class") + self.assertIsNotNone(obj) + self.assertEqual(name, "PathLike") + + def test_import_class_no_init(self): + import os + + obj, name = import_object("os.PathLike", "class", use_init=False) + self.assertIsNotNone(obj) + self.assertEqual(name, "PathLike") + self.assertIs(obj, os.PathLike) + + def test_import_function_not_a_function(self): + self.assertRaise(lambda: import_object("os.PathLike", "function"), TypeError) + + def test_import_class_not_a_class(self): + self.assertRaise(lambda: import_object("os.path.join", "class"), TypeError) + + def test_import_method(self): + obj, name = import_object("os.PathLike.__fspath__", "method") + self.assertIsNotNone(obj) + self.assertEqual(name, "__fspath__") + + def test_import_property(self): + obj, name = import_object( + "sphinx_runpython.import_object_helper._Types.prop", "property" + ) + self.assertIsNotNone(obj) + self.assertEqual(name, "prop") + + def test_import_staticmethod(self): + obj, name = import_object( + "sphinx_runpython.import_object_helper._Types.stat", "staticmethod" + ) + self.assertIsNotNone(obj) + self.assertEqual(name, "stat") + + def test_import_unknown_kind(self): + self.assertRaise( + lambda: import_object("os.path.join", "unknown_kind"), ValueError + ) + + def test_import_nonexistent_module(self): + self.assertRaise( + lambda: import_object("nonexistent_xyz.func", "function"), RuntimeError + ) + + def test_import_property_not_a_class(self): + self.assertRaise(lambda: import_object("os.path.join", "property"), TypeError) + + def test_import_staticmethod_not_a_class(self): + self.assertRaise( + lambda: import_object("os.path.join", "staticmethod"), TypeError + ) + + def test_import_method_not_a_class(self): + self.assertRaise(lambda: import_object("os.path.join", "method"), TypeError) + + def test_import_any_object_function(self): + obj, name, kind = import_any_object("os.path.join") + self.assertIsNotNone(obj) + self.assertEqual(name, "join") + self.assertIn(kind, ("function", "method", "staticmethod", "property", "class")) + + def test_import_any_object_class(self): + obj, name, kind = import_any_object("os.PathLike") + self.assertIsNotNone(obj) + self.assertEqual(name, "PathLike") + self.assertEqual(kind, "class") + + def test_import_any_object_not_found(self): + self.assertRaise( + lambda: import_any_object("nonexistent_xyz_module.func"), ImportError + ) + + def test_import_path_function(self): + import os.path + + path = import_path(os.path.join) + self.assertIsNotNone(path) + # On Windows os.path is ntpath; on POSIX it is posixpath or os + self.assertTrue( + "os" in path or "ntpath" in path or "posixpath" in path, + f"unexpected path: {path!r}", + ) + + def test_import_path_with_err_msg(self): + import os.path + + path = import_path(os.path.join, err_msg="extra error info") + self.assertIsNotNone(path) + + def test_import_path_class(self): + path = import_path(os.PathLike, class_name="PathLike") + self.assertIsNotNone(path) + + def test_import_path_not_found(self): + # Create an object that is in __main__ and can't be imported from there + class LocalClass: + pass + + # class_name not matching __main__ raises RuntimeError + self.assertRaise( + lambda: import_path(LocalClass, class_name="LocalClass"), RuntimeError + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_language.py b/_unittests/ut__main/test_language.py new file mode 100644 index 0000000..9ded093 --- /dev/null +++ b/_unittests/ut__main/test_language.py @@ -0,0 +1,55 @@ +import unittest +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.language import TITLES, sphinx_lang + + +class TestLanguage(ExtTestCase): + def test_titles_en_keys(self): + self.assertIn("en", TITLES) + self.assertIn("author", TITLES["en"]) + self.assertIn("book", TITLES["en"]) + self.assertIn("FAQ", TITLES["en"]) + + def test_titles_fr_keys(self): + self.assertIn("fr", TITLES) + self.assertIn("author", TITLES["fr"]) + self.assertIn("book", TITLES["fr"]) + self.assertIn("FAQ", TITLES["fr"]) + + def test_sphinx_lang_no_settings(self): + class FakeEnv: + pass + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "en") + + def test_sphinx_lang_settings_no_language_code(self): + class FakeSettings: + pass + + class FakeEnv: + settings = FakeSettings() + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "en") + + def test_sphinx_lang_settings_with_language_code(self): + class FakeSettings: + language_code = "fr" + + class FakeEnv: + settings = FakeSettings() + + lang = sphinx_lang(FakeEnv()) + self.assertEqual(lang, "fr") + + def test_sphinx_lang_default_value(self): + class FakeEnv: + pass + + lang = sphinx_lang(FakeEnv(), default_value="de") + self.assertEqual(lang, "en") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_process_rst.py b/_unittests/ut__main/test_process_rst.py new file mode 100644 index 0000000..11459f7 --- /dev/null +++ b/_unittests/ut__main/test_process_rst.py @@ -0,0 +1,37 @@ +import unittest +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.process_rst import rst2html + + +class TestProcessRst(ExtTestCase): + def test_rst2html_invalid_writer_arg(self): + self.assertRaise( + lambda: rst2html("hello world", writer="html"), + ValueError, + ) + + def test_rst2html_docutils_mode(self): + rst = "Hello **world**!\n" + html = rst2html(rst, use_sphinx=False) + self.assertIn("world", html) + + def test_rst2html_docutils_with_warnings(self): + rst = "Hello **world**!\n" + html, warnings = rst2html(rst, use_sphinx=False, return_warnings=True) + self.assertIn("world", html) + self.assertIsInstance(warnings, str) + + def test_rst2html_docutils_error_raises(self): + # A severely malformed RST that generates a system error message + # We test that the function raises RuntimeError for ERROR-level messages + rst = ".. error::\n\n Error content\n\n.. parsed-literal::\n\n :malformed\n" + # This may or may not raise RuntimeError depending on the content + # Just verify it runs without issue + try: + _result = rst2html(rst, use_sphinx=False) + except RuntimeError: + pass # Expected behavior for error-level messages + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut__main/test_readme_helpers.py b/_unittests/ut__main/test_readme_helpers.py new file mode 100644 index 0000000..8856d91 --- /dev/null +++ b/_unittests/ut__main/test_readme_helpers.py @@ -0,0 +1,39 @@ +import unittest +from sphinx_runpython.ext_test_case import ExtTestCase +from sphinx_runpython.readme import ( + VirtualEnvError, + NotImplementedErrorFromVirtualEnvironment, + is_virtual_environment, + build_venv_cmd, +) + + +class TestReadmeHelpers(ExtTestCase): + def test_virtual_env_error(self): + exc = VirtualEnvError("test error") + self.assertIsInstance(exc, Exception) + + def test_not_implemented_from_virtual_environment(self): + exc = NotImplementedErrorFromVirtualEnvironment("test error") + self.assertIsInstance(exc, NotImplementedError) + + def test_is_virtual_environment(self): + result = is_virtual_environment() + self.assertIn(result, {True, False}) + + def test_build_venv_cmd_no_params(self): + cmd = build_venv_cmd({}, ["/tmp/venv"]) + self.assertIn("venv", cmd) + self.assertIn("/tmp/venv", cmd) + + def test_build_venv_cmd_with_none_value(self): + cmd = build_venv_cmd({"system-site-packages": None}, ["/tmp/venv"]) + self.assertIn("--system-site-packages", cmd) + + def test_build_venv_cmd_with_value(self): + cmd = build_venv_cmd({"copies": "1"}, ["/tmp/venv"]) + self.assertIn("--copies=1", cmd) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/_unittests/ut_runmermaid/test_runmermaid_extension.py b/_unittests/ut_runmermaid/test_runmermaid_extension.py new file mode 100644 index 0000000..d6fa250 --- /dev/null +++ b/_unittests/ut_runmermaid/test_runmermaid_extension.py @@ -0,0 +1,127 @@ +import unittest +import logging +from sphinx_runpython.process_rst import rst2html +from sphinx_runpython.ext_test_case import ( + ExtTestCase, + ignore_warnings, +) + + +class TestRunMermaidExtension(ExtTestCase): + def setUp(self): + logger = logging.getLogger("runmermaid") + logger.disabled = True + + @ignore_warnings(PendingDeprecationWarning) + def test_runmermaid_inline_rst(self): + """Inline runmermaid diagram is round-tripped through the RST writer.""" + content = """ +before + +.. runmermaid:: + + graph LR + A --> B --> C + +after +""" + content = rst2html( + content, writer_name="rst", new_extensions=["sphinx_runpython.runmermaid"] + ) + self.assertIn("graph LR", content) + self.assertIn("A --> B --> C", content) + + @ignore_warnings(PendingDeprecationWarning) + def test_runmermaid_inline_html(self): + """Inline runmermaid diagram produces a
 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}