Coverage for pyquickhelper/sphinxext/sphinx_gdot_extension.py: 63%
183 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief Defines a sphinx extension to show :epkg:`DOT` graph
5with :epkg:`viz.js` or :epkg:`graphviz`.
6"""
7import os
8import logging
9import shutil
10from docutils import nodes
11from docutils.parsers.rst import directives
12import sphinx
13from docutils.parsers.rst import Directive
14from .sphinxext_helper import get_env_state_info
15from .sphinx_runpython_extension import run_python_script
18class gdot_node(nodes.admonition):
19 """
20 defines ``gdot`` node.
21 """
22 pass
25class GDotDirective(Directive):
26 """
27 A ``gdot`` node displays a :epkg:`DOT` graph.
28 The build choose :epkg:`SVG` for :epkg:`HTML` format and image for
29 other format unless it is specified.
31 * *format*: SVG or HTML
32 * *script*: boolean or a string to indicate than the standard output
33 should only be considered after this substring
34 * *url*: url to :epkg:`viz.js`, only if format *SVG* is selected
35 * *process*: run the script in an another process
37 Example::
39 .. gdot::
41 digraph foo {
42 "bar" -> "baz";
43 }
45 Which gives:
47 .. gdot::
49 digraph foo {
50 "bar" -> "baz";
51 }
53 The directive also accepts scripts producing
54 dot graphs on the standard output. Option *script*
55 must be specified. This extension loads
56 `sphinx.ext.graphviz <https://www.sphinx-doc.org/
57 en/master/usage/extensions/graphviz.html>`_
58 if not added to the list of extensions:
60 Example::
62 .. gdot::
63 :format: png
65 digraph foo {
66 "bar" -> "baz";
67 }
69 .. gdot::
70 :format: png
72 digraph foo {
73 "bar" -> "baz";
74 }
76 The output can be produced by a script.
78 .. gdot::
79 :script:
81 print('''
82 digraph foo {
83 "bar" -> "baz";
84 }
85 ''')
87 .. gdot::
88 :script:
90 print('''
91 digraph foo {
92 "bar" -> "baz";
93 }
94 ''')
95 """
96 node_class = gdot_node
97 has_content = True
98 required_arguments = 0
99 optional_arguments = 0
100 final_argument_whitespace = False
101 option_spec = {
102 'format': directives.unchanged,
103 'script': directives.unchanged,
104 'url': directives.unchanged,
105 'process': directives.unchanged,
106 }
108 _default_url = (
109 "https://github.com/sdpython/jyquickhelper/raw/master/src/"
110 "jyquickhelper/js/vizjs/viz.js")
112 def run(self):
113 """
114 Builds the collapse text.
115 """
116 # retrieves the parameters
117 if 'format' in self.options:
118 format = self.options['format']
119 else:
120 format = '?'
121 url = self.options.get('url', 'local')
122 bool_set_ = (True, 1, "True", "1", "true", '')
123 process = 'process' in self.options and self.options['process'] in bool_set_
124 if url == 'local':
125 try:
126 import jyquickhelper
127 path = os.path.join(os.path.dirname(
128 jyquickhelper.__file__), "js", "vizjs", "viz.js")
129 if not os.path.exists(path):
130 raise ImportError(
131 "jyquickelper needs to be updated to get viz.js.")
132 url = 'local'
133 except ImportError:
134 url = GDotDirective._default_url
135 logger = logging.getLogger("gdot")
136 logger.warning(
137 "[gdot] jyquickhelper not installed, falling back to %r", url)
139 info = get_env_state_info(self)
140 docname = info['docname']
141 if url == 'local':
142 if docname is None or 'HERE' not in info:
143 url = GDotDirective._default_url
144 logger = logging.getLogger("gdot")
145 logger.warning(
146 "[gdot] docname is none, falling back to %r.", url)
147 else:
148 spl = docname.split("/")
149 sp = ['..'] * (len(spl) - 1) + ['_static', 'viz.js']
150 url = "/".join(sp)
152 if 'script' in self.options:
153 script = self.options['script']
154 if script in (0, "0", "False", 'false'):
155 script = None
156 elif script in (1, "1", "True", 'true', ''):
157 script = ''
158 elif len(script) == 0:
159 raise RuntimeError("script should be a string to indicate"
160 " the beginning of DOT graph.")
161 else:
162 script = False
164 # executes script if any
165 content = "\n".join(self.content)
166 if script or script == '':
167 stdout, stderr, _ = run_python_script(content, process=process)
168 if stderr:
169 raise RuntimeError(
170 f"A graph cannot be draw due to {stderr}")
171 content = stdout
172 if script:
173 spl = content.split(script)
174 if len(spl) > 2:
175 raise RuntimeError(
176 "'{}' indicates the beginning of the graph "
177 "but there are many in\n{}".format(script, content))
178 content = spl[-1]
180 node = gdot_node(format=format, code=content, url=url,
181 options={'docname': docname})
182 return [node]
185def visit_gdot_node_rst(self, node):
186 """
187 visit collapse_node
188 """
189 self.new_state(0)
190 self.add_text('.. gdot::' + self.nl)
191 if node['format'] != '?':
192 self.add_text(' :format: ' + node['format'] + self.nl)
193 if node['url']:
194 self.add_text(' :url: ' + node['url'] + self.nl)
195 self.new_state(self.indent)
196 for row in node['code'].split('\n'):
197 self.add_text(row + self.nl)
200def depart_gdot_node_rst(self, node):
201 """
202 depart collapse_node
203 """
204 self.end_state()
205 self.end_state(wrap=False)
208def visit_gdot_node_html_svg(self, node):
209 """
210 visit collapse_node
211 """
212 def process(text):
213 text = text.replace("\\", "\\\\")
214 text = text.replace("\n", "\\n")
215 text = text.replace('"', '\\"')
216 return text
218 nid = str(id(node))
220 content = """
221 <div id="gdot-{0}-cont"><div id="gdot-{0}" style="width:100%;height:100%;"></div>
222 """.format(nid)
224 script = """
225 require(['__URL__'], function() { var svgGraph = Viz("__DOT__");
226 document.getElementById('gdot-__ID__').innerHTML = svgGraph; });
227 """.replace('__ID__', nid).replace('__DOT__', process(node['code'])).replace(
228 "__URL__", node['url'])
230 # find the path
231 source = self.document.attributes["source"]
232 folder = os.path.dirname(source)
233 # from_ = self.builder.get_target_uri(source)
234 # req = self.builder.get_target_uri("_static/require.js")
235 # rel = self.builder.get_relative_uri(from_, req)
237 if os.path.exists(folder):
238 while not os.path.exists(os.path.join(folder, "conf.py")):
239 cts = set(os.listdir(folder))
240 if "conf.py" in cts:
241 break
242 exts = {os.path.splitext(name)[-1] for name in cts}
243 if ".rst" not in exts:
244 folder = None
245 break
246 folder = os.path.split(folder)[0]
247 else:
248 folder = None
250 self.body.append(content)
251 if folder is None:
252 self.body.append(
253 '<script src="_static/require.js"></script><script>'
254 '{0}{1}{0}</script>{0}'.format("\n", script))
255 else:
256 current = os.path.dirname(source)
257 rel = os.path.relpath(current, folder)
258 if rel not in {"", "."}:
259 rel = rel.replace("\\", "/")
260 rel = f"{'/'.join(['..'] * len(rel.split('/')))}/"
261 else:
262 rel = ""
263 self.body.append(
264 '<script src="{2}_static/require.js"></script><script>'
265 '{0}{1}{0}</script>{0}'.format("\n", script, rel))
268def depart_gdot_node_html_svg(self, node):
269 """
270 depart collapse_node
271 """
272 self.body.append("</div>")
275def visit_gdot_node_html(self, node):
276 """
277 visit collapse_node, the function switches between
278 `graphviz.py <https://github.com/sphinx-doc/sphinx/blob/
279 master/sphinx/ext/graphviz.py>`_ and the :epkg:`SVG` format.
280 """
281 if node['format'].lower() == 'png':
282 from sphinx.ext.graphviz import html_visit_graphviz
283 return html_visit_graphviz(self, node)
284 if node['format'].lower() in ('?', 'svg'):
285 return visit_gdot_node_html_svg(self, node)
286 raise RuntimeError(
287 f"Unexpected format for graphviz '{node['format']}'.")
290def depart_gdot_node_html(self, node):
291 """
292 depart collapse_node
293 """
294 if node['format'] == 'png':
295 return None
296 return depart_gdot_node_html_svg(self, node)
299def copy_js_files(app):
300 from ..helpgen.install_custom import download_requirejs
301 from ..filehelper.download_helper import get_url_content_timeout
302 try:
303 import jyquickhelper
304 local = True
305 except ImportError:
306 local = False
308 logger = logging.getLogger("gdot")
309 dest = app.config.html_static_path
310 if isinstance(dest, list) and len(dest) > 0:
311 dest = dest[0]
312 else:
313 logger.warning("[gdot] unable to locate 'html_static_path' (%r), "
314 "unable to use local viz.js.",
315 app.config.html_static_path)
316 return
318 srcdir = app.builder.srcdir
319 if "IMPOSSIBLE:TOFIND" not in srcdir:
320 if not os.path.exists(srcdir):
321 raise FileNotFoundError(
322 f"Source file is wrong '{srcdir}'.")
324 destf = os.path.join(os.path.abspath(srcdir), dest)
325 if not os.path.exists(destf):
326 logger.warning("[gdot] destination folder %r does not exists, "
327 "unable to use local viz.js.", destf)
328 return
330 # viz.js
331 file_dest = os.path.join(destf, "viz.js")
332 if os.path.exists(file_dest):
333 logger.info("[gdot] %r already installed.", file_dest)
334 else:
335 if local:
336 path = os.path.join(os.path.dirname(
337 jyquickhelper.__file__), "js", "vizjs", "viz.js")
338 if os.path.exists(path):
339 # We copy the file to static path.
340 try:
341 shutil.copy(path, file_dest)
342 logger.info("[gdot] copy %r to %r.", path, file_dest)
343 except PermissionError as e: # pragma: no cover
344 logger.warning("[gdot] permission error: %r, "
345 "unable to use local viz.js.", e)
346 else:
347 logger.warning(
348 "[gdot] jyquickhelper needs to be update, unable to find %r.", path)
349 else:
350 logger.warning("[gdot] jyquickhelper not installed, falling back to "
351 "%r", GDotDirective._default_url)
353 file_dest = os.path.join(destf, "require.js")
354 content = get_url_content_timeout(
355 GDotDirective._default_url, output=file_dest, raise_exception=False)
356 if content is None:
357 logger.warning("[gdot] unable to download: %r to %r",
358 GDotDirective._default_url, file_dest)
359 else:
360 logger.info("[gdot] download %r to %r.",
361 GDotDirective._default_url, file_dest)
363 # require.js
364 file_dest = os.path.join(destf, "require.js")
365 if os.path.exists(file_dest):
366 logger.info("[gdot] %r already installed.", file_dest)
367 else:
368 download_requirejs(destf, fLOG=lambda *args, **kwargs: None)
370 if os.path.exists(file_dest):
371 # It adds <script async="defer" src="_static/require.js"></script>
372 # at the bottom of the file. It needs to be at the beginning.
373 # app.add_js_file("require.js", priority=200)
374 logger.info("[gdot] %r installed.", file_dest)
375 else:
376 logger.warning("[gdot] %r not installed.", file_dest)
379def setup(app):
380 """
381 setup for ``gdot`` (sphinx)
382 """
383 if 'sphinx.ext.graphviz' not in app.config.extensions:
384 from sphinx.ext.graphviz import setup as setup_g # pylint: disable=W0611
385 setup_g(app)
387 app.connect('builder-inited', copy_js_files)
389 from sphinx.ext.graphviz import latex_visit_graphviz, man_visit_graphviz # pylint: disable=W0611
390 from sphinx.ext.graphviz import text_visit_graphviz # pylint: disable=W0611
391 app.add_node(gdot_node,
392 html=(visit_gdot_node_html, depart_gdot_node_html),
393 epub=(visit_gdot_node_html, depart_gdot_node_html),
394 elatex=(latex_visit_graphviz, None),
395 latex=(latex_visit_graphviz, None),
396 text=(text_visit_graphviz, None),
397 md=(text_visit_graphviz, None),
398 rst=(visit_gdot_node_rst, depart_gdot_node_rst))
400 app.add_directive('gdot', GDotDirective)
401 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}