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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/images/readme_trafficlightmachine.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 28 additions & 7 deletions statemachine/contrib/diagram/sphinx_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.util.docutils import SphinxDirective
from sphinx.util.osutil import relative_uri

if TYPE_CHECKING:
from sphinx.application import Sphinx
Expand Down Expand Up @@ -118,7 +119,7 @@ def run(self) -> list[nodes.Node]:
svg_tag = svg_tag.replace("<svg ", f"<svg {svg_styles} ", 1)

alt_text = html_mod.escape(self.options.get("alt", qualname.rsplit(".", 1)[-1]))
target = self._resolve_target(svg_text)
target, zoom_filename = self._resolve_target(svg_text)

img_html = f'<div role="img" aria-label="{alt_text}">{svg_tag}</div>'
if target:
Expand All @@ -142,6 +143,7 @@ def run(self) -> list[nodes.Node]:
html = f"<div{class_attr}>{img_html}</div>"

raw_node = nodes.raw("", html, format="html")
raw_node["zoom_filename"] = zoom_filename

if "name" in self.options:
self.add_name(raw_node)
Expand Down Expand Up @@ -232,18 +234,21 @@ def _build_svg_styles(self, intrinsic_width: str, intrinsic_height: str) -> str:

return f'style="{"; ".join(parts)}"'

def _resolve_target(self, svg_text: str) -> str:
"""Return the href for the wrapper ``<a>`` tag, if any.
def _resolve_target(self, svg_text: str) -> tuple[str, str]:
"""Return ``(href, zoom_filename)`` for the wrapper ``<a>`` tag.

When ``:target:`` is given without a value (or as empty string), the
raw SVG is written to ``_images/`` and linked so the user can open
the full diagram in a new browser tab for zooming.
the full diagram in a new browser tab for zooming. The href is
root-relative at this point; ``_resolve_zoom_links`` rewrites it
relative to each page at write time, once the builder knows the
page's final URI.
"""
if "target" not in self.options:
return ""
return "", ""
target = (self.options["target"] or "").strip()
if target:
return target
return target, ""

# Auto-generate a standalone SVG file for zoom.
qualname = self.arguments[0]
Expand All @@ -258,7 +263,7 @@ def _resolve_target(self, svg_text: str) -> str:
with open(outpath, "w", encoding="utf-8") as f:
f.write(svg_text)

return f"/_images/{filename}"
return f"_images/{filename}", filename

def _build_wrapper_classes(self) -> list[str]:
"""Build CSS class list for the outer wrapper element."""
Expand All @@ -275,6 +280,22 @@ def _split_length(value: str) -> tuple[float, str]:
return 0.0, value


def _resolve_zoom_links(app: "Sphinx", doctree: nodes.document, docname: str) -> None:
"""Rewrite auto-generated zoom hrefs relative to the page being written."""
builder = app.builder
if builder.format != "html":
return
page_uri = builder.get_target_uri(docname).split("#", 1)[0]
for node in doctree.findall(nodes.raw):
filename = node.get("zoom_filename")
if not filename:
continue
href = relative_uri(page_uri, f"_images/{filename}")
html = node.astext().replace(f'href="_images/{filename}"', f'href="{href}"')
node[0] = nodes.Text(html)


def setup(app: "Sphinx") -> dict[str, Any]:
app.add_directive("statemachine-diagram", StateMachineDiagram)
app.connect("doctree-resolved", _resolve_zoom_links)
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
107 changes: 101 additions & 6 deletions tests/test_contrib_diagram.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import re
from contextlib import contextmanager
from unittest import mock
from urllib.parse import urljoin
from xml.etree import ElementTree

import pytest
from docutils import nodes
from sphinx.testing.util import SphinxTestApp
from statemachine.contrib.diagram import DotGraphMachine
from statemachine.contrib.diagram import main
from statemachine.contrib.diagram import quickchart_write_svg
Expand Down Expand Up @@ -1150,19 +1152,20 @@ def _make_directive(self, options=None, tmp_path=None):

def test_no_target_option(self):
directive = self._make_directive()
assert directive._resolve_target("<svg/>") == ""
assert directive._resolve_target("<svg/>") == ("", "")

def test_explicit_target_url(self):
directive = self._make_directive({"target": "https://example.com/diagram.svg"})
assert directive._resolve_target("<svg/>") == "https://example.com/diagram.svg"
assert directive._resolve_target("<svg/>") == ("https://example.com/diagram.svg", "")

def test_empty_target_generates_file(self, tmp_path):
directive = self._make_directive({"target": ""}, tmp_path=tmp_path)
svg_data = "<svg><rect/></svg>"
result = directive._resolve_target(svg_data)
href, filename = directive._resolve_target(svg_data)

assert result.startswith("/_images/statemachine-")
assert result.endswith(".svg")
assert filename.startswith("statemachine-")
assert filename.endswith(".svg")
assert href == f"_images/{filename}"

# Verify the file was written
images_dir = tmp_path / "_images"
Expand All @@ -1185,6 +1188,98 @@ def test_different_events_different_filename(self, tmp_path):
assert d1._resolve_target("<svg/>") != d2._resolve_target("<svg/>")


@pytest.mark.parametrize("buildername", ["html", "dirhtml", "singlehtml"])
@pytest.mark.parametrize("docname", ["index", "guide/overview", "guide/index"])
def test_sphinx_zoom_link_resolves_under_documentation_prefix(
tmp_path, request, buildername, docname
):
srcdir = tmp_path / "src"
srcdir.mkdir()
(srcdir / "conf.py").write_text(
'extensions = ["statemachine.contrib.diagram.sphinx_ext"]\n', encoding="utf-8"
)
document = srcdir / f"{docname}.rst"
document.parent.mkdir(parents=True, exist_ok=True)
document.write_text(
"Diagram\n=======\n\n"
".. statemachine-diagram:: tests.examples.traffic_light_machine.TrafficLightMachine\n"
" :target:\n",
encoding="utf-8",
)
if docname != "index":
(srcdir / "index.rst").write_text(
f"Index\n=====\n\n.. toctree::\n\n {docname}\n", encoding="utf-8"
)
app = SphinxTestApp(
buildername=buildername, srcdir=srcdir, builddir=tmp_path / "build", freshenv=True
)
request.addfinalizer(app.cleanup)
app.build()
assert app.statuscode == 0
assert app.warning.getvalue() == ""
page = (
app.outdir / "index.html"
if buildername == "singlehtml"
else app.builder.get_outfilename(docname)
)
with open(page, encoding="utf-8") as f:
match = re.search(r'href="([^"]*statemachine-[^"]+\.svg)"', f.read())
assert match is not None
href = match.group(1)
filename = href.rsplit("/", 1)[-1]
prefix = "https://example.com/project/en/stable/"
page_url = urljoin(prefix, app.builder.get_target_uri(docname))
assert urljoin(page_url, href) == f"{prefix}_images/{filename}"
assert (app.outdir / "_images" / filename).is_file()


class TestResolveZoomLinks:
"""Tests for the ``doctree-resolved`` handler that relativizes zoom hrefs."""

def _doctree(self, html):
doctree = nodes.document(None, None)
raw = nodes.raw("", html, format="html")
doctree += raw
return doctree, raw

def _app(self, fmt="html", page_uri="guide/overview.html"):
app = mock.MagicMock()
app.builder.format = fmt
app.builder.get_target_uri.return_value = page_uri
return app

def test_rewrites_href_relative_to_page(self):
from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links

doctree, raw = self._doctree('<a href="_images/statemachine-abc.svg">x</a>')
raw["zoom_filename"] = "statemachine-abc.svg"
_resolve_zoom_links(self._app(), doctree, "guide/overview")
assert raw.astext() == '<a href="../_images/statemachine-abc.svg">x</a>'

def test_strips_fragment_from_singlehtml_page_uri(self):
from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links

doctree, raw = self._doctree('<a href="_images/statemachine-abc.svg">x</a>')
raw["zoom_filename"] = "statemachine-abc.svg"
_resolve_zoom_links(self._app(page_uri="#document-index"), doctree, "index")
assert raw.astext() == '<a href="_images/statemachine-abc.svg">x</a>'

def test_skips_raw_nodes_without_zoom_file(self):
from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links

doctree, raw = self._doctree('<a href="https://example.com/x.svg">x</a>')
_resolve_zoom_links(self._app(), doctree, "guide/overview")
assert raw.astext() == '<a href="https://example.com/x.svg">x</a>'

def test_skips_non_html_builders(self):
from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links

doctree, raw = self._doctree('<a href="_images/statemachine-abc.svg">x</a>')
raw["zoom_filename"] = "statemachine-abc.svg"
_resolve_zoom_links(self._app(fmt="latex"), doctree, "guide/overview")
assert raw.astext() == '<a href="_images/statemachine-abc.svg">x</a>'


class TestDirectiveRun:
"""Integration tests for StateMachineDiagram.run()."""

Expand Down Expand Up @@ -1273,7 +1368,7 @@ def test_render_with_empty_target(self, tmp_path):
"""Empty target auto-generates a zoom SVG file."""
_, result = self._run(tmp_path, options={"target": ""})

assert 'href="/_images/statemachine-' in result[0].astext()
assert 'href="_images/statemachine-' in result[0].astext()
images_dir = tmp_path / "_images"
assert any(images_dir.glob("statemachine-*.svg"))

Expand Down
Loading