From b630ca2a381a19085154904bd78d393e645afc11 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:15:10 +0200 Subject: [PATCH 1/5] Switch from Azure Pipelines to GitHub Actions (#62) * Initial plan * Switch from Azure Pipelines to GitHub Actions Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/eaf8a233-884a-4e06-8add-2956315a7d82 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * Fix sphinx_issues missing issues_github_path in conf.py Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/785e87ae-9f95-42cb-a3d1-47bfd405c950 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- README.rst | 4 +- _doc/conf.py | 2 + _doc/index.rst | 4 +- azure-pipelines.yml | 169 ------------------------------ sphinx_runpython/ext_test_case.py | 10 +- 5 files changed, 11 insertions(+), 178 deletions(-) delete mode 100644 azure-pipelines.yml 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/conf.py b/_doc/conf.py index 22c4aec..bf4fd26 100644 --- a/_doc/conf.py +++ b/_doc/conf.py @@ -55,6 +55,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 = {} 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/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/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 From fd97dcb71036396fc4a411fa8954c13e4a364f51 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 13:17:01 +0200 Subject: [PATCH 2/5] Add `sphinx_runpython.runmermaid` extension for Mermaid diagram support (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * add mermaid sphinx extension with tests and docs Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/d8720735-e1ac-42f5-8f74-5cb189420a5e Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * fix unreachable branch and improve docstrings in mermaid extension Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/d8720735-e1ac-42f5-8f74-5cb189420a5e Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * rename mermaid to mermaidjs to avoid conflicts with existing packages Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/0b9e316d-85dd-491f-a8fe-fcb57e2c1479 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * rename mermaidjs to runmermaid Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/d779d538-fb95-4f79-ba03-95dfc33cf6e0 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * move runmermaid after quote in api index Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/08981185-c76f-49f9-bc90-02ba60e6c5ac Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * fix --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> Co-authored-by: Xavier Dupré --- _doc/api/index.rst | 1 + _doc/api/runmermaid.rst | 77 ++++++ _doc/conf.py | 2 + .../test_runmermaid_extension.py | 127 +++++++++ sphinx_runpython/runmermaid/__init__.py | 3 + .../runmermaid/sphinx_runmermaid_extension.py | 242 ++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 _doc/api/runmermaid.rst create mode 100644 _unittests/ut_runmermaid/test_runmermaid_extension.py create mode 100644 sphinx_runpython/runmermaid/__init__.py create mode 100644 sphinx_runpython/runmermaid/sphinx_runmermaid_extension.py 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 bf4fd26..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", @@ -120,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/_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/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} From e805bf119441faa4685fb208454a9a76f10e19cc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 14:40:27 +0200 Subject: [PATCH 3/5] Extend code coverage across sphinx-runpython utilities (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * initial plan for code coverage extension Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/5f7ddf50-ca98-4180-bfc7-e949b3b79b24 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * Add tests to improve code coverage for multiple modules Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/5f7ddf50-ca98-4180-bfc7-e949b3b79b24 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * Add more tests for higher coverage: latex_functions, img_export, conf_helper, process_rst, readme Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/5f7ddf50-ca98-4180-bfc7-e949b3b79b24 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * Add tests for run_cmd, ext_test_case utilities improving coverage further Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/5f7ddf50-ca98-4180-bfc7-e949b3b79b24 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * black * Skip test_process_args_readme on Windows Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/6bff9a98-f076-48c0-b958-a41e73f5b704 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> * Fix Windows CI failures in github_link, import_path, and run_cmd tests Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/673d9b92-6119-4171-8dc5-2c28bc233e20 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> Co-authored-by: Xavier Dupré --- .gitignore | 5 + _unittests/ut__main/test_cmd.py | 132 ++++++++++++- _unittests/ut__main/test_ext_helper.py | 101 ++++++++++ _unittests/ut__main/test_ext_io_helper.py | 87 ++++++++ _unittests/ut__main/test_ext_test_case.py | 187 ++++++++++++++++++ _unittests/ut__main/test_github_link.py | 73 +++++++ _unittests/ut__main/test_helpers.py | 15 +- .../ut__main/test_import_object_helper.py | 126 ++++++++++++ _unittests/ut__main/test_language.py | 55 ++++++ _unittests/ut__main/test_process_rst.py | 37 ++++ _unittests/ut__main/test_readme_helpers.py | 39 ++++ _unittests/ut_runpython/test_run_cmd.py | 125 +++++++++++- _unittests/ut_tools/test_imgexport.py | 78 ++++++++ _unittests/ut_tools/test_latex_functions.py | 19 ++ pyproject.toml | 2 +- 15 files changed, 1075 insertions(+), 6 deletions(-) create mode 100644 _unittests/ut__main/test_ext_helper.py create mode 100644 _unittests/ut__main/test_ext_io_helper.py create mode 100644 _unittests/ut__main/test_ext_test_case.py create mode 100644 _unittests/ut__main/test_github_link.py create mode 100644 _unittests/ut__main/test_import_object_helper.py create mode 100644 _unittests/ut__main/test_language.py create mode 100644 _unittests/ut__main/test_process_rst.py create mode 100644 _unittests/ut__main/test_readme_helpers.py 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/_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_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/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"] From 38089114e451dd76887ca09439aabf8756236215 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:41:05 +0000 Subject: [PATCH 4/5] Initial plan From 9f4527ab599e8c26febe0ec0e69461abeeb9cc89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:43:09 +0000 Subject: [PATCH 5/5] Update version to 0.4.4 and add changelog entries for PRs #58, #60, #62 Agent-Logs-Url: https://github.com/sdpython/sphinx-runpython/sessions/15edfe84-fbd3-4ec9-a96d-dd20182e8f72 Co-authored-by: xadupre <22452781+xadupre@users.noreply.github.com> --- CHANGELOGS.rst | 7 +++++++ sphinx_runpython/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/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/"