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

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 

16 

17 

18class gdot_node(nodes.admonition): 

19 """ 

20 defines ``gdot`` node. 

21 """ 

22 pass 

23 

24 

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. 

30 

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 

36 

37 Example:: 

38 

39 .. gdot:: 

40 

41 digraph foo { 

42 "bar" -> "baz"; 

43 } 

44 

45 Which gives: 

46 

47 .. gdot:: 

48 

49 digraph foo { 

50 "bar" -> "baz"; 

51 } 

52 

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: 

59 

60 Example:: 

61 

62 .. gdot:: 

63 :format: png 

64 

65 digraph foo { 

66 "bar" -> "baz"; 

67 } 

68 

69 .. gdot:: 

70 :format: png 

71 

72 digraph foo { 

73 "bar" -> "baz"; 

74 } 

75 

76 The output can be produced by a script. 

77 

78 .. gdot:: 

79 :script: 

80 

81 print(''' 

82 digraph foo { 

83 "bar" -> "baz"; 

84 } 

85 ''') 

86 

87 .. gdot:: 

88 :script: 

89 

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 } 

107 

108 _default_url = ( 

109 "https://github.com/sdpython/jyquickhelper/raw/master/src/" 

110 "jyquickhelper/js/vizjs/viz.js") 

111 

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) 

138 

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) 

151 

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 

163 

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] 

179 

180 node = gdot_node(format=format, code=content, url=url, 

181 options={'docname': docname}) 

182 return [node] 

183 

184 

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) 

198 

199 

200def depart_gdot_node_rst(self, node): 

201 """ 

202 depart collapse_node 

203 """ 

204 self.end_state() 

205 self.end_state(wrap=False) 

206 

207 

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 

217 

218 nid = str(id(node)) 

219 

220 content = """ 

221 <div id="gdot-{0}-cont"><div id="gdot-{0}" style="width:100%;height:100%;"></div> 

222 """.format(nid) 

223 

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']) 

229 

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) 

236 

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 

249 

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)) 

266 

267 

268def depart_gdot_node_html_svg(self, node): 

269 """ 

270 depart collapse_node 

271 """ 

272 self.body.append("</div>") 

273 

274 

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']}'.") 

288 

289 

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) 

297 

298 

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 

307 

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 

317 

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}'.") 

323 

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 

329 

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) 

352 

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) 

362 

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) 

369 

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) 

377 

378 

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) 

386 

387 app.connect('builder-inited', copy_js_files) 

388 

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)) 

399 

400 app.add_directive('gdot', GDotDirective) 

401 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}