Coverage for src/mkdocs_gallery/notebook.py: 43%

103 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-30 08:26 +0000

1# Authors: Sylvain MARIE <sylvain.marie@se.com> 

2# + All contributors to <https://github.com/smarie/mkdocs-gallery> 

3# 

4# Original idea and code: sphinx-gallery, <https://sphinx-gallery.github.io> 

5# License: 3-clause BSD, <https://github.com/smarie/mkdocs-gallery/blob/master/LICENSE> 

6""" 

7Parser for Jupyter notebooks 

8""" 

9 

10from __future__ import absolute_import, division, print_function 

11 

12import argparse 

13import base64 

14import copy 

15import json 

16import mimetypes 

17import os 

18import re 

19import sys 

20from functools import partial 

21from pathlib import Path 

22from typing import Dict, List 

23 

24from . import mkdocs_compatibility 

25from .errors import ExtensionError 

26from .gen_data_model import GalleryScript 

27from .py_source_parser import split_code_and_text_blocks 

28 

29logger = mkdocs_compatibility.getLogger("mkdocs-gallery") 

30 

31 

32def jupyter_notebook_skeleton(): 

33 """Returns a dictionary with the elements of a Jupyter notebook""" 

34 py_version = sys.version_info 

35 notebook_skeleton = { 

36 "cells": [], 

37 "metadata": { 

38 "kernelspec": { 

39 "display_name": "Python " + str(py_version[0]), 

40 "language": "python", 

41 "name": "python" + str(py_version[0]), 

42 }, 

43 "language_info": { 

44 "codemirror_mode": {"name": "ipython", "version": py_version[0]}, 

45 "file_extension": ".py", 

46 "mimetype": "text/x-python", 

47 "name": "python", 

48 "nbconvert_exporter": "python", 

49 "pygments_lexer": "ipython" + str(py_version[0]), 

50 "version": "{0}.{1}.{2}".format(*sys.version_info[:3]), 

51 }, 

52 }, 

53 "nbformat": 4, 

54 "nbformat_minor": 0, 

55 } 

56 return notebook_skeleton 

57 

58 

59def directive_fun(match, directive): 

60 """Helper to fill in directives""" 

61 directive_to_alert = dict(note="info", warning="danger") 

62 return '<div class="alert alert-{0}"><h4>{1}</h4><p>{2}</p></div>'.format( 

63 directive_to_alert[directive], directive.capitalize(), match.group(1).strip() 

64 ) 

65 

66 

67def rst2md(text, gallery_conf, target_dir, heading_levels): 

68 """Converts the RST text from the examples docstrings and comments 

69 into markdown text for the Jupyter notebooks 

70 

71 Parameters 

72 ---------- 

73 text: str 

74 RST input to be converted to MD 

75 gallery_conf : dict 

76 The mkdocs-gallery configuration dictionary. 

77 target_dir : str 

78 Path that notebook is intended for. Used where relative paths 

79 may be required. 

80 heading_levels: dict 

81 Mapping of heading style ``(over_char, under_char)`` to heading level. 

82 Note that ``over_char`` is `None` when only underline is present. 

83 """ 

84 

85 # Characters recommended for use with headings 

86 # https://docutils.readthedocs.io/en/sphinx-docs/user/rst/quickstart.html#sections 

87 adornment_characters = "=`:.'\"~^_*+#<>-" 

88 headings = re.compile( 

89 # Start of string or blank line 

90 r"(?P<pre>\A|^[ \t]*\n)" 

91 # Optional over characters, allowing leading space on heading text 

92 r"(?:(?P<over>[{0}])(?P=over)*\n[ \t]*)?" 

93 # The heading itself, with at least one non-white space character 

94 r"(?P<heading>\S[^\n]*)\n" 

95 # Under character, setting to same character if over present. 

96 r"(?P<under>(?(over)(?P=over)|[{0}]))(?P=under)*$" r"".format(adornment_characters), 

97 flags=re.M, 

98 ) 

99 

100 text = re.sub( 

101 headings, 

102 lambda match: "{1}{0} {2}".format( 

103 "#" * heading_levels[match.group("over", "under")], *match.group("pre", "heading") 

104 ), 

105 text, 

106 ) 

107 

108 math_eq = re.compile(r"^\.\. math::((?:.+)?(?:\n+^ .+)*)", flags=re.M) 

109 text = re.sub( 

110 math_eq, 

111 lambda match: r"\begin{{align}}{0}\end{{align}}".format(match.group(1).strip()), 

112 text, 

113 ) 

114 inline_math = re.compile(r":math:`(.+?)`", re.DOTALL) 

115 text = re.sub(inline_math, r"$\1$", text) 

116 

117 directives = ("warning", "note") 

118 for directive in directives: 

119 directive_re = re.compile(r"^\.\. %s::((?:.+)?(?:\n+^ .+)*)" % directive, flags=re.M) 

120 text = re.sub(directive_re, partial(directive_fun, directive=directive), text) 

121 

122 links = re.compile(r"^ *\.\. _.*:.*$\n", flags=re.M) 

123 text = re.sub(links, "", text) 

124 

125 refs = re.compile(r":ref:`") 

126 text = re.sub(refs, "`", text) 

127 

128 contents = re.compile(r"^\s*\.\. contents::.*$(\n +:\S+: *$)*\n", flags=re.M) 

129 text = re.sub(contents, "", text) 

130 

131 images = re.compile(r"^\.\. image::(.*$)((?:\n +:\S+:.*$)*)\n", flags=re.M) 

132 image_opts = re.compile(r"\n +:(\S+): +(.*)$", flags=re.M) 

133 text = re.sub( 

134 images, 

135 lambda match: '<img src="{}"{}>\n'.format( 

136 generate_image_src(match.group(1).strip(), gallery_conf, target_dir), 

137 re.sub(image_opts, r' \1="\2"', match.group(2) or ""), 

138 ), 

139 text, 

140 ) 

141 

142 return text 

143 

144 

145def generate_image_src(image_path, gallery_conf, target_dir): 

146 if re.match(r"https?://", image_path): 

147 return image_path 

148 

149 if not gallery_conf["notebook_images"]: 

150 return "file://" + image_path.lstrip("/") 

151 

152 # If absolute path from source directory given 

153 if image_path.startswith("/"): 

154 # Path should now be relative to source dir, not target dir 

155 target_dir = mkdocs_conf["docs_dir"] 

156 image_path = image_path.lstrip("/") 

157 full_path = os.path.join(target_dir, image_path.replace("/", os.sep)) 

158 

159 if isinstance(gallery_conf["notebook_images"], str): 

160 # Use as prefix e.g. URL 

161 prefix = gallery_conf["notebook_images"] 

162 rel_path = os.path.relpath(full_path, mkdocs_conf["docs_dir"]) 

163 return prefix + rel_path.replace(os.sep, "/") 

164 else: 

165 # True, but not string. Embed as data URI. 

166 try: 

167 with open(full_path, "rb") as image_file: 

168 data = base64.b64encode(image_file.read()) 

169 except OSError: 

170 raise ExtensionError("Unable to open {} to generate notebook data URI" "".format(full_path)) 

171 mime_type = mimetypes.guess_type(full_path) 

172 return "data:{};base64,{}".format(mime_type[0], data.decode("ascii")) 

173 

174 

175def jupyter_notebook(script: GalleryScript, script_blocks: List): 

176 """Generate a Jupyter notebook file cell-by-cell 

177 

178 Parameters 

179 ---------- 

180 script : GalleryScript 

181 Script 

182 

183 script_blocks : list 

184 Script execution cells. 

185 """ 

186 # Grab the possibly custom first and last cells 

187 first_cell = script.gallery_conf["first_notebook_cell"] 

188 last_cell = script.gallery_conf["last_notebook_cell"] 

189 

190 # Initialize with a notebook skeleton 

191 work_notebook = jupyter_notebook_skeleton() 

192 

193 # Custom first cell 

194 if first_cell is not None: 194 ↛ 198line 194 didn't jump to line 198 because the condition on line 194 was always true

195 add_code_cell(work_notebook, first_cell) 

196 

197 # Fill the notebook per se 

198 fill_notebook(work_notebook, script_blocks) 

199 

200 # Custom last cell 

201 if last_cell is not None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 add_code_cell(work_notebook, last_cell) 

203 

204 return work_notebook 

205 

206 

207def add_code_cell(work_notebook, code): 

208 """Add a code cell to the notebook 

209 

210 Parameters 

211 ---------- 

212 code : str 

213 Cell content 

214 """ 

215 

216 code_cell = { 

217 "cell_type": "code", 

218 "execution_count": None, 

219 "metadata": {"collapsed": False}, 

220 "outputs": [], 

221 "source": [code.strip()], 

222 } 

223 work_notebook["cells"].append(code_cell) 

224 

225 

226def add_markdown_cell(work_notebook, markdown): 

227 """Add a markdown cell to the notebook 

228 

229 Parameters 

230 ---------- 

231 markdown : str 

232 Markdown cell content. 

233 """ 

234 markdown_cell = {"cell_type": "markdown", "metadata": {}, "source": [markdown]} 

235 work_notebook["cells"].append(markdown_cell) 

236 

237 

238def fill_notebook(work_notebook, script_blocks): 

239 """Writes the Jupyter notebook cells 

240 

241 If available, uses pypandoc to convert rst to markdown >> not anymore. 

242 

243 Parameters 

244 ---------- 

245 script_blocks : list 

246 Each list element should be a tuple of (label, content, lineno). 

247 """ 

248 # heading_level_counter = count(start=1) 

249 # heading_levels = defaultdict(lambda: next(heading_level_counter)) 

250 for blabel, bcontent, _lineno in script_blocks: 

251 if blabel == "code": 

252 add_code_cell(work_notebook, bcontent) 

253 else: 

254 # if gallery_conf["pypandoc"] is False: 

255 # markdown = rst2md( 

256 # bcontent + '\n', gallery_conf, target_dir, heading_levels) 

257 # else: 

258 # import pypandoc 

259 # # pandoc automatically addds \n to the end 

260 # markdown = pypandoc.convert_text( 

261 # bcontent, to='md', format='rst', **gallery_conf["pypandoc"] 

262 # ) 

263 markdown = bcontent + "\n" 

264 add_markdown_cell(work_notebook, markdown) 

265 

266 

267def save_notebook(work_notebook: Dict, write_file: Path): 

268 """Saves the Jupyter work_notebook to write_file""" 

269 with open(str(write_file), "w") as out_nb: 

270 json.dump(work_notebook, out_nb, indent=2) 

271 

272 

273############################################################################### 

274# Notebook shell utility 

275 

276 

277def python_to_jupyter_cli(args=None, namespace=None): 

278 """Exposes the jupyter notebook renderer to the command line 

279 

280 Takes the same arguments as ArgumentParser.parse_args 

281 """ 

282 from . import gen_gallery # To avoid circular import 

283 

284 parser = argparse.ArgumentParser(description="mkdocs-gallery Notebook converter") 

285 parser.add_argument( 

286 "python_src_file", 

287 nargs="+", 

288 help="Input Python file script to convert. " "Supports multiple files and shell wildcards" " (e.g. *.py)", 

289 ) 

290 args = parser.parse_args(args, namespace) 

291 

292 for src_file in args.python_src_file: 

293 file_conf, blocks = split_code_and_text_blocks(src_file) 

294 print("Converting {0}".format(src_file)) # noqa # this is a cli 

295 gallery_conf = copy.deepcopy(gen_gallery.DEFAULT_GALLERY_CONF) 

296 target_dir = os.path.dirname(src_file) 

297 example_nb = jupyter_notebook(blocks, gallery_conf, target_dir) 

298 save_notebook(example_nb, get_ipynb_for_py_script(src_file))