1"""Code for converting notebooks to and from v3."""
2
3# Copyright (c) IPython Development Team.
4# Distributed under the terms of the Modified BSD License.
5from __future__ import annotations
6
7import json
8import re
9
10from traitlets.log import get_logger
11
12from nbformat import v3, validator
13from nbformat.corpus.words import generate_corpus_id as random_cell_id
14from nbformat.notebooknode import NotebookNode
15
16from .nbbase import nbformat, nbformat_minor
17
18
19def _warn_if_invalid(nb, version):
20 """Log validation errors, if there are any."""
21 from nbformat import ValidationError, validate
22
23 try:
24 validate(nb, version=version)
25 except ValidationError as e:
26 get_logger().error("Notebook JSON is not valid v%i: %s", version, e)
27
28
29def upgrade(nb, from_version=None, from_minor=None):
30 """Convert a notebook to latest v4.
31
32 Parameters
33 ----------
34 nb : NotebookNode
35 The Python representation of the notebook to convert.
36 from_version : int
37 The original version of the notebook to convert.
38 from_minor : int
39 The original minor version of the notebook to convert (only relevant for v >= 3).
40 """
41 if not from_version:
42 from_version = nb["nbformat"]
43 if not from_minor:
44 if "nbformat_minor" not in nb:
45 if from_version == 4:
46 msg = "The v4 notebook does not include the nbformat minor, which is needed."
47 raise validator.ValidationError(msg)
48 from_minor = 0
49 else:
50 from_minor = nb["nbformat_minor"]
51
52 if from_version == 3:
53 # Validate the notebook before conversion
54 _warn_if_invalid(nb, from_version)
55
56 # Mark the original nbformat so consumers know it has been converted
57 orig_nbformat = nb.pop("orig_nbformat", None)
58 orig_nbformat_minor = nb.pop("orig_nbformat_minor", None)
59 nb.metadata.orig_nbformat = orig_nbformat or 3
60 nb.metadata.orig_nbformat_minor = orig_nbformat_minor or 0
61
62 # Mark the new format
63 nb.nbformat = nbformat
64 nb.nbformat_minor = nbformat_minor
65
66 # remove worksheet(s)
67 nb["cells"] = cells = []
68 # In the unlikely event of multiple worksheets,
69 # they will be flattened
70 for ws in nb.pop("worksheets", []):
71 # upgrade each cell
72 for cell in ws["cells"]:
73 cells.append(upgrade_cell(cell))
74 # upgrade metadata
75 nb.metadata.pop("name", "")
76 nb.metadata.pop("signature", "")
77 # Validate the converted notebook before returning it
78 _warn_if_invalid(nb, nbformat)
79 return nb
80 if from_version == 4:
81 if from_minor == nbformat_minor:
82 return nb
83
84 # other versions migration code e.g.
85 # if from_minor < 3:
86 # if from_minor < 4:
87
88 if from_minor < 5:
89 for cell in nb.cells:
90 cell.id = random_cell_id()
91
92 nb.metadata.orig_nbformat_minor = from_minor
93 nb.nbformat_minor = nbformat_minor
94
95 return nb
96 raise ValueError(
97 "Cannot convert a notebook directly from v%s to v4. "
98 "Try using the nbformat.convert module." % from_version
99 )
100
101
102def upgrade_cell(cell):
103 """upgrade a cell from v3 to v4
104
105 heading cell:
106 - -> markdown heading
107 code cell:
108 - remove language metadata
109 - cell.input -> cell.source
110 - cell.prompt_number -> cell.execution_count
111 - update outputs
112 """
113 cell.setdefault("metadata", NotebookNode())
114 cell.id = random_cell_id()
115 if cell.cell_type == "code":
116 cell.pop("language", "")
117 if "collapsed" in cell:
118 cell.metadata["collapsed"] = cell.pop("collapsed")
119 cell.source = cell.pop("input", "")
120 cell.execution_count = cell.pop("prompt_number", None)
121 cell.outputs = upgrade_outputs(cell.outputs)
122 elif cell.cell_type == "heading":
123 cell.cell_type = "markdown"
124 level = cell.pop("level", 1)
125 cell.source = "{hashes} {single_line}".format(
126 hashes="#" * level,
127 single_line=" ".join(cell.get("source", "").splitlines()),
128 )
129 elif cell.cell_type == "html":
130 # Technically, this exists. It will never happen in practice.
131 cell.cell_type = "markdown"
132 return cell
133
134
135def downgrade_cell(cell):
136 """downgrade a cell from v4 to v3
137
138 code cell:
139 - set cell.language
140 - cell.input <- cell.source
141 - cell.prompt_number <- cell.execution_count
142 - update outputs
143 markdown cell:
144 - single-line heading -> heading cell
145 """
146 if cell.cell_type == "code":
147 cell.language = "python"
148 cell.input = cell.pop("source", "")
149 cell.prompt_number = cell.pop("execution_count", None)
150 cell.collapsed = cell.metadata.pop("collapsed", False)
151 cell.outputs = downgrade_outputs(cell.outputs)
152 elif cell.cell_type == "markdown":
153 source = cell.get("source", "")
154 if "\n" not in source and source.startswith("#"):
155 match = re.match(r"(#+)\s*(.*)", source)
156 assert match is not None
157 prefix, text = match.groups()
158 cell.cell_type = "heading"
159 cell.source = text
160 cell.level = len(prefix)
161 cell.pop("id", None)
162 cell.pop("attachments", None)
163 return cell
164
165
166_mime_map = {
167 "text": "text/plain",
168 "html": "text/html",
169 "svg": "image/svg+xml",
170 "png": "image/png",
171 "jpeg": "image/jpeg",
172 "latex": "text/latex",
173 "json": "application/json",
174 "javascript": "application/javascript",
175}
176
177
178def to_mime_key(d):
179 """convert dict with v3 aliases to plain mime-type keys"""
180 for alias, mime in _mime_map.items():
181 if alias in d:
182 d[mime] = d.pop(alias)
183 return d
184
185
186def from_mime_key(d):
187 """convert dict with mime-type keys to v3 aliases"""
188 d2 = {}
189 for alias, mime in _mime_map.items():
190 if mime in d:
191 d2[alias] = d[mime]
192 return d2
193
194
195def upgrade_output(output):
196 """upgrade a single code cell output from v3 to v4
197
198 - pyout -> execute_result
199 - pyerr -> error
200 - output.type -> output.data.mime/type
201 - mime-type keys
202 - stream.stream -> stream.name
203 """
204 if output["output_type"] in {"pyout", "display_data"}:
205 output.setdefault("metadata", NotebookNode())
206 if output["output_type"] == "pyout":
207 output["output_type"] = "execute_result"
208 output["execution_count"] = output.pop("prompt_number", None)
209
210 # move output data into data sub-dict
211 data = {}
212 for key in list(output):
213 if key in {"output_type", "execution_count", "metadata"}:
214 continue
215 data[key] = output.pop(key)
216 to_mime_key(data)
217 output["data"] = data
218 to_mime_key(output.metadata)
219 if "application/json" in data:
220 data["application/json"] = json.loads(data["application/json"])
221 # promote ascii bytes (from v2) to unicode
222 for key in ("image/png", "image/jpeg"):
223 if key in data and isinstance(data[key], bytes):
224 data[key] = data[key].decode("ascii")
225 elif output["output_type"] == "pyerr":
226 output["output_type"] = "error"
227 elif output["output_type"] == "stream":
228 output["name"] = output.pop("stream", "stdout")
229 return output
230
231
232def downgrade_output(output):
233 """downgrade a single code cell output to v3 from v4
234
235 - pyout <- execute_result
236 - pyerr <- error
237 - output.data.mime/type -> output.type
238 - un-mime-type keys
239 - stream.stream <- stream.name
240 """
241 if output["output_type"] in {"execute_result", "display_data"}:
242 if output["output_type"] == "execute_result":
243 output["output_type"] = "pyout"
244 output["prompt_number"] = output.pop("execution_count", None)
245
246 # promote data dict to top-level output namespace
247 data = output.pop("data", {})
248 if "application/json" in data:
249 data["application/json"] = json.dumps(data["application/json"])
250 data = from_mime_key(data)
251 output.update(data)
252 from_mime_key(output.get("metadata", {}))
253 elif output["output_type"] == "error":
254 output["output_type"] = "pyerr"
255 elif output["output_type"] == "stream":
256 output["stream"] = output.pop("name")
257 return output
258
259
260def upgrade_outputs(outputs):
261 """upgrade outputs of a code cell from v3 to v4"""
262 return [upgrade_output(op) for op in outputs]
263
264
265def downgrade_outputs(outputs):
266 """downgrade outputs of a code cell to v3 from v4"""
267 return [downgrade_output(op) for op in outputs]
268
269
270def downgrade(nb):
271 """Convert a v4 notebook to v3.
272
273 Parameters
274 ----------
275 nb : NotebookNode
276 The Python representation of the notebook to convert.
277 """
278 if nb.nbformat != nbformat:
279 return nb
280
281 # Validate the notebook before conversion
282 _warn_if_invalid(nb, nbformat)
283
284 nb.nbformat = v3.nbformat
285 nb.nbformat_minor = v3.nbformat_minor
286 cells = [downgrade_cell(cell) for cell in nb.pop("cells")]
287 nb.worksheets = [v3.new_worksheet(cells=cells)]
288 nb.metadata.setdefault("name", "")
289
290 # Validate the converted notebook before returning it
291 _warn_if_invalid(nb, v3.nbformat)
292
293 nb.orig_nbformat = nb.metadata.pop("orig_nbformat", nbformat)
294 nb.orig_nbformat_minor = nb.metadata.pop("orig_nbformat_minor", nbformat_minor)
295
296 return nb