jupynium 0.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jupynium/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from ._version import get_version_dict
4
+
5
+ __version__ = get_version_dict()["version"]
6
+
7
+
8
+ __all__ = ["__version__"]
jupynium/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cmds.jupynium import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
jupynium/_version.py ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ # THIS "versionfile" IS GENERATED BY version-pioneer-0.0.13
3
+ # by evaluating the original versionscript and storing the computed versions as a constant.
4
+
5
+ def get_version_dict():
6
+ return {'version': '0.2.6', 'full_revisionid': 'f8f6ee57b83395f73abd831bde28b3206ec09d97', 'dirty': False, 'error': None, 'date': '2025-02-18T00:28:20+0000'}
7
+
8
+
9
+ if __name__ == "__main__":
10
+ import json
11
+
12
+ print(json.dumps(get_version_dict()))
jupynium/buffer.py ADDED
@@ -0,0 +1,491 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from importlib.resources import files as resfiles
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .jupyter_notebook_selenium import insert_cell_at
8
+
9
+ if TYPE_CHECKING:
10
+ from selenium.webdriver.remote.webdriver import WebDriver
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ set_cell_text_js_code = (resfiles("jupynium") / "js" / "set_cell_text.js").read_text()
16
+
17
+
18
+ class JupyniumBuffer:
19
+ """
20
+ Deal with the Nvim buffer and its cell information.
21
+
22
+ This does have a functionality to sync with the Notebook.
23
+ It can also be part of the buffer, and anything above the first cell will be `header_cell_type`.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ buf: list[str] | None = None,
29
+ header_cell_type: str = "header",
30
+ ):
31
+ """
32
+ self.buf is a list of lines of the nvim buffer.
33
+
34
+ Args:
35
+ header_cell_type: Use only when partial update.
36
+ """
37
+ if buf is None:
38
+ # each cell's row length. 0-th cell is not a cell, but it's the header.
39
+ # You can put anything above and it won't be synced to Jupyter Notebook.
40
+ self.buf = [""]
41
+ self.num_rows_per_cell: list[int] = [1]
42
+
43
+ self.cell_types = ["header"] # 0-th cell is not a cell.
44
+ else:
45
+ self.buf = buf
46
+ self.full_analyse_buf(header_cell_type)
47
+
48
+ def full_analyse_buf(self, header_cell_type: str = "header"):
49
+ """
50
+ Main parser for the jupynium format (*.ju.*).
51
+
52
+ This function needs to support partial update.
53
+
54
+ E.g. by looking at 1 line of change, it should be able to understand if:
55
+ - the change is within a cell
56
+ - cell creation/deletion
57
+ - cell type change
58
+
59
+ During the partial update, the header cell will be continuation from
60
+ the existing cell.
61
+ We don't know if it will be header/cell/markdown.
62
+ So we need to pass the header_cell_type.
63
+ (This is deprecated, in favour of self.get_cells_text() dealing with
64
+ content processing.)
65
+
66
+ Args:
67
+ header_cell_type: Used to be used only when partial update.
68
+ Now deprecated.
69
+ """
70
+ num_rows_this_cell = 0
71
+ num_rows_per_cell = []
72
+ cell_types = [header_cell_type]
73
+ for _row, line in enumerate(self.buf):
74
+ if line.startswith(("# %% [md]", "# %% [markdown]")):
75
+ num_rows_per_cell.append(num_rows_this_cell)
76
+ num_rows_this_cell = 1
77
+ cell_types.append("markdown")
78
+ # Treat '# %% Cell Title' as a code cell
79
+ # But not magic commands such as '# %%timeit'.
80
+ # See: https://github.com/kiyoon/jupynium.nvim/pull/127
81
+ elif line[:5].strip() == "# %%":
82
+ num_rows_per_cell.append(num_rows_this_cell)
83
+ num_rows_this_cell = 1
84
+ cell_types.append("code")
85
+ else:
86
+ num_rows_this_cell += 1
87
+ num_rows_per_cell.append(num_rows_this_cell)
88
+
89
+ self.num_rows_per_cell = num_rows_per_cell
90
+ self.cell_types = cell_types
91
+
92
+ def _process_cell_text(self, cell_type: str, lines: list[str]):
93
+ """
94
+ Assuming that lines is just one cell's content, process it.
95
+ """
96
+ if cell_type == "code":
97
+ return "\n".join(
98
+ line[2:] if line.startswith("# %") else line for line in lines
99
+ )
100
+ elif cell_type == "markdown":
101
+ if len(lines) > 0 and lines[0] == '"""':
102
+ return "\n".join(line for line in lines if not line.startswith('"""'))
103
+ else:
104
+ return "\n".join(line.removeprefix("# ") for line in lines)
105
+ else:
106
+ # header
107
+ return "\n".join(lines)
108
+
109
+ def get_cells_text(
110
+ self, start_cell_idx: int, end_cell_idx: int, *, strip: bool = True
111
+ ) -> list[str]:
112
+ """
113
+ Get processed cell text.
114
+
115
+ In a code cell, remove comments for the magic commands.
116
+ e.g. '# %time' -> '%time'
117
+ In a markdown cell, remove the leading # from the lines or multiline string.
118
+ e.g. '# # Markdown header' -> '# Markdown header'
119
+ """
120
+ if start_cell_idx == 0:
121
+ start_row_offset = 0
122
+ else:
123
+ start_row_offset = 1
124
+
125
+ texts_per_cell = []
126
+ start_row = self.get_cell_start_row(start_cell_idx)
127
+ texts_per_cell.append(
128
+ self._process_cell_text(
129
+ self.cell_types[start_cell_idx],
130
+ self.buf[
131
+ start_row + start_row_offset : start_row
132
+ + self.num_rows_per_cell[start_cell_idx]
133
+ ],
134
+ )
135
+ )
136
+
137
+ for cell_idx in range(start_cell_idx + 1, end_cell_idx + 1):
138
+ start_row += self.num_rows_per_cell[cell_idx - 1]
139
+ texts_per_cell.append(
140
+ self._process_cell_text(
141
+ self.cell_types[cell_idx],
142
+ self.buf[
143
+ start_row + 1 : start_row + self.num_rows_per_cell[cell_idx]
144
+ ],
145
+ )
146
+ )
147
+
148
+ if strip:
149
+ texts_per_cell = [x.strip() for x in texts_per_cell]
150
+
151
+ return texts_per_cell
152
+
153
+ def get_cell_text(self, cell_idx: int, *, strip: bool = True) -> str:
154
+ return self.get_cells_text(cell_idx, cell_idx, strip=strip)[0]
155
+
156
+ def process_on_lines(
157
+ self,
158
+ driver: WebDriver,
159
+ *,
160
+ strip: bool,
161
+ lines: list[str],
162
+ start_row: int,
163
+ old_end_row: int,
164
+ new_end_row: int,
165
+ ):
166
+ (
167
+ notebook_cell_operations,
168
+ modified_cell_idx_start,
169
+ modified_cell_idx_end,
170
+ ) = self._on_lines_update_buf(lines, start_row, old_end_row, new_end_row)
171
+ self._apply_cell_operations(driver, notebook_cell_operations)
172
+
173
+ num_cells = self.num_cells_in_notebook
174
+ num_cells_in_notebook = driver.execute_script(
175
+ "return Jupyter.notebook.ncells();"
176
+ )
177
+
178
+ if num_cells_in_notebook != num_cells:
179
+ self.full_sync_to_notebook(driver, strip=strip)
180
+ else:
181
+ self._partial_sync_to_notebook(
182
+ driver, modified_cell_idx_start, modified_cell_idx_end, strip=strip
183
+ )
184
+
185
+ def _on_lines_update_buf(
186
+ self, lines: list[str], start_row: int, old_end_row: int, new_end_row: int
187
+ ):
188
+ """Replace start_row:old_end_row to lines from self.buf."""
189
+ # Analyse how many cells are removed
190
+ notebook_cell_delete_operations = []
191
+ notebook_cell_operations = []
192
+
193
+ try:
194
+ cell_idx, _, row_within_cell = self.get_cell_index_from_row(start_row)
195
+
196
+ if row_within_cell == 0 and cell_idx > 0:
197
+ # If the row is the first row of a cell, and it's not the first cell,
198
+ # then it's a cell separator.
199
+ row_within_cell = self.num_rows_per_cell[cell_idx - 1]
200
+ cell_idx -= 1
201
+ except IndexError:
202
+ assert start_row == old_end_row == self.num_rows
203
+ cell_idx = self.num_cells - 1
204
+ row_within_cell = self.num_rows_per_cell[-1]
205
+
206
+ modified_cell_idx_start = cell_idx
207
+
208
+ lines_to_remove = old_end_row - start_row
209
+
210
+ while lines_to_remove > 0:
211
+ if row_within_cell < self.num_rows_per_cell[cell_idx]:
212
+ # If the row is within the cell, then it's a cell content.
213
+ self.num_rows_per_cell[cell_idx] -= 1
214
+ else:
215
+ # If the row is not within the cell, then it's a cell separator.
216
+ notebook_cell_delete_operations.append(("delete", cell_idx + 1, None))
217
+ self.num_rows_per_cell[cell_idx] += (
218
+ self.num_rows_per_cell[cell_idx + 1] - 1
219
+ )
220
+ del self.num_rows_per_cell[cell_idx + 1]
221
+ del self.cell_types[cell_idx + 1]
222
+ lines_to_remove -= 1
223
+
224
+ # Analyse how many cells are added
225
+ new_lines_buf = JupyniumBuffer(
226
+ lines,
227
+ header_cell_type=self.cell_types[
228
+ cell_idx
229
+ ], # This is required as we're analysing partially.
230
+ )
231
+ if new_lines_buf.num_cells - 1 == 0:
232
+ self.num_rows_per_cell[cell_idx] += new_lines_buf.num_rows_per_cell[0]
233
+ notebook_cell_operations = notebook_cell_delete_operations
234
+ else:
235
+ num_delete_cells = len(notebook_cell_delete_operations)
236
+ num_insert_cells = new_lines_buf.num_cells - 1
237
+ if num_delete_cells > num_insert_cells:
238
+ notebook_cell_operations = notebook_cell_delete_operations[
239
+ :num_insert_cells
240
+ ]
241
+ elif num_delete_cells < num_insert_cells:
242
+ notebook_cell_operations = [
243
+ (
244
+ "cell_type",
245
+ cell_idx + 1,
246
+ new_lines_buf.cell_types[
247
+ 1 : 1 + len(notebook_cell_delete_operations)
248
+ ],
249
+ )
250
+ ]
251
+ notebook_cell_operations.append(
252
+ (
253
+ "insert",
254
+ cell_idx + 1,
255
+ new_lines_buf.cell_types[
256
+ 1 + len(notebook_cell_delete_operations) :
257
+ ],
258
+ )
259
+ )
260
+ else:
261
+ notebook_cell_operations = [
262
+ (
263
+ "cell_type",
264
+ cell_idx + 1,
265
+ new_lines_buf.cell_types[1:],
266
+ )
267
+ ]
268
+
269
+ num_tail_rows = self.num_rows_per_cell[cell_idx] - row_within_cell
270
+ self.num_rows_per_cell[cell_idx] = (
271
+ row_within_cell + new_lines_buf.num_rows_per_cell[0]
272
+ )
273
+ new_lines_buf.num_rows_per_cell[-1] += num_tail_rows
274
+ self.num_rows_per_cell[cell_idx + 1 : cell_idx + 1] = (
275
+ new_lines_buf.num_rows_per_cell[1:]
276
+ )
277
+ self.cell_types[cell_idx + 1 : cell_idx + 1] = new_lines_buf.cell_types[1:]
278
+
279
+ modified_cell_idx_end = modified_cell_idx_start + new_lines_buf.num_cells - 1
280
+
281
+ # Now actually replace the lines
282
+ # Optimisation: if the number of lines is not changed,
283
+ # which is most of the cases,
284
+ # then we can just replace the the strings in the list
285
+ # instead of modifying list itself.
286
+ if old_end_row == new_end_row:
287
+ for i, line in enumerate(lines):
288
+ self.buf[start_row + i] = line
289
+ else:
290
+ # If the number of lines is changed,
291
+ # then we need to remove the old lines and insert the new lines.
292
+ self.buf[start_row:old_end_row] = lines
293
+
294
+ return notebook_cell_operations, modified_cell_idx_start, modified_cell_idx_end
295
+
296
+ def _apply_cell_operations(
297
+ self,
298
+ driver: WebDriver,
299
+ notebook_cell_operations: list[tuple[str, int, list[str]]],
300
+ ):
301
+ # Remove / create cells in Notebook
302
+ for operation, cell_idx, cell_types in notebook_cell_operations:
303
+ nb_cell_idx = cell_idx - 1
304
+ if operation == "delete":
305
+ logger.info(f"Deleting cell {nb_cell_idx} from Notebook")
306
+ driver.execute_script(
307
+ "Jupyter.notebook.delete_cell(arguments[0]);", nb_cell_idx
308
+ )
309
+ elif operation == "insert":
310
+ for i, cell_type in enumerate(cell_types):
311
+ logger.info(f"Inserting cell {nb_cell_idx + i} from Notebook")
312
+ insert_cell_at(driver, cell_type, nb_cell_idx + i)
313
+ elif operation == "cell_type":
314
+ for i, cell_type in enumerate(cell_types):
315
+ logger.info(
316
+ f"Cell {nb_cell_idx + i} type change to {cell_type} "
317
+ "from Notebook"
318
+ )
319
+ # "markdown"
320
+ if cell_type == "markdown":
321
+ driver.execute_script(
322
+ "Jupyter.notebook.cells_to_markdown([arguments[0]]);",
323
+ nb_cell_idx + i,
324
+ )
325
+ elif cell_type == "code":
326
+ driver.execute_script(
327
+ "Jupyter.notebook.cells_to_code([arguments[0]]);",
328
+ nb_cell_idx + i,
329
+ )
330
+ else:
331
+ raise ValueError(f"Unknown cell type {cell_type}")
332
+
333
+ def get_cell_start_row(self, cell_idx: int):
334
+ return sum(self.num_rows_per_cell[:cell_idx])
335
+
336
+ def get_cell_index_from_row(
337
+ self,
338
+ row: int,
339
+ num_rows_per_cell: list[int] | None = None,
340
+ *,
341
+ raise_out_of_bound: bool = True,
342
+ ) -> tuple[int, int, int]:
343
+ """
344
+ Returns the cell index for the given row.
345
+
346
+ Args:
347
+ row: row index
348
+ num_rows_per_cell: number of rows per cell. If None, use self.num_rows_per_cell
349
+ raise_out_of_bound: whether to raise an IndexError if the row is out of bound
350
+
351
+ Returns:
352
+ int: cell index
353
+ int: cell start row
354
+ int: row index within the cell
355
+ """
356
+ if num_rows_per_cell is None:
357
+ num_rows_per_cell = self.num_rows_per_cell
358
+
359
+ cell_start_row = 0
360
+ i = 0
361
+ for i, num_rows in enumerate(num_rows_per_cell):
362
+ if cell_start_row + num_rows > row:
363
+ return i, cell_start_row, row - cell_start_row
364
+ cell_start_row += num_rows
365
+
366
+ # Out of bound. Could be adding a new line.
367
+ if raise_out_of_bound:
368
+ raise IndexError(f"Could not find cell for row {row}")
369
+ else:
370
+ return i, cell_start_row, row - cell_start_row
371
+
372
+ def _check_validity(self):
373
+ assert len(self.buf) == sum(self.num_rows_per_cell)
374
+ assert len(self.cell_types) == len(self.num_rows_per_cell)
375
+ assert self.cell_types[0] == "header"
376
+ assert all(x in ("code", "markdown") for x in self.cell_types[1:])
377
+
378
+ def _partial_sync_to_notebook(
379
+ self, driver: WebDriver, start_cell_idx: int, end_cell_idx: int, *, strip=True
380
+ ):
381
+ """
382
+ Given the range of cells to update, sync the JupyniumBuffer with the notebook.
383
+
384
+ Note:
385
+ Cell 1 in JupyniumBuffer is cell 0 in Notebook.
386
+ Args are inclusive range in ju.py JupyniumBuffer
387
+ """
388
+ assert start_cell_idx <= end_cell_idx < self.num_cells
389
+
390
+ if self.num_cells == 1:
391
+ # Markdown file
392
+ driver.execute_script(
393
+ "Jupyter.notebook.cells_to_markdown([0]);"
394
+ "Jupyter.notebook.get_cell(0).set_text(arguments[0]);"
395
+ "Jupyter.notebook.get_cell(0).render()",
396
+ "\n".join(self.buf),
397
+ )
398
+ else:
399
+ # Notebook file
400
+
401
+ if end_cell_idx == 0:
402
+ # Nothing to update
403
+ return
404
+
405
+ if start_cell_idx == 0:
406
+ start_cell_idx = 1
407
+
408
+ texts_per_cell = self.get_cells_text(
409
+ start_cell_idx, end_cell_idx, strip=strip
410
+ )
411
+
412
+ code_cell_indices = [
413
+ start_cell_idx + i
414
+ for i, cell_type in enumerate(
415
+ self.cell_types[start_cell_idx : end_cell_idx + 1]
416
+ )
417
+ if cell_type == "code"
418
+ ]
419
+ markdown_cell_indices = [
420
+ start_cell_idx + i
421
+ for i, cell_type in enumerate(
422
+ self.cell_types[start_cell_idx : end_cell_idx + 1]
423
+ )
424
+ if cell_type == "markdown"
425
+ ]
426
+
427
+ if len(code_cell_indices) > 0:
428
+ logger.info(f"Converting to code cells: {code_cell_indices}")
429
+ for i in code_cell_indices:
430
+ driver.execute_script(
431
+ "Jupyter.notebook.cells_to_code([arguments[0]]);", i - 1
432
+ )
433
+
434
+ if len(markdown_cell_indices) > 0:
435
+ logger.info(f"Converting to markdown cells: {markdown_cell_indices}")
436
+ for i in markdown_cell_indices:
437
+ driver.execute_script(
438
+ "Jupyter.notebook.cells_to_markdown([arguments[0]]);", i - 1
439
+ )
440
+
441
+ # This will render markdown cells
442
+ driver.execute_script(
443
+ set_cell_text_js_code,
444
+ start_cell_idx - 1,
445
+ end_cell_idx - 1,
446
+ *texts_per_cell,
447
+ )
448
+
449
+ def full_sync_to_notebook(self, driver: WebDriver, *, strip: bool = True):
450
+ # Full sync with notebook.
451
+ # WARNING: syncing may result in data loss.
452
+ num_cells = self.num_cells_in_notebook
453
+ num_cells_in_notebook = driver.execute_script(
454
+ "return Jupyter.notebook.ncells();"
455
+ )
456
+ if num_cells > num_cells_in_notebook:
457
+ for _ in range(num_cells - num_cells_in_notebook):
458
+ driver.execute_script("Jupyter.notebook.insert_cell_below();")
459
+ elif num_cells < num_cells_in_notebook:
460
+ for _ in range(num_cells_in_notebook - num_cells):
461
+ driver.execute_script("Jupyter.notebook.delete_cell(-1);")
462
+
463
+ self._partial_sync_to_notebook(driver, 0, self.num_cells - 1, strip=strip)
464
+
465
+ @property
466
+ def num_cells(self):
467
+ return len(self.num_rows_per_cell)
468
+
469
+ @property
470
+ def num_cells_in_notebook(self):
471
+ """
472
+ Get the number of cells in the notebook.
473
+
474
+ If the buffer has 1 cell (no separator), it will be treated as markdown file.
475
+ If the buffer has more than 1 cell, it will be treated as notebook.
476
+
477
+ Notebook always has 1 cell minimum.
478
+ """
479
+ return max(self.num_cells - 1, 1)
480
+
481
+ @property
482
+ def num_rows(self):
483
+ return len(self.buf)
484
+
485
+ def __eq__(self, other: object) -> bool:
486
+ assert isinstance(other, JupyniumBuffer)
487
+ return (
488
+ self.buf == other.buf
489
+ and self.num_rows_per_cell == other.num_rows_per_cell
490
+ and self.cell_types == other.cell_types
491
+ )
File without changes
@@ -0,0 +1,76 @@
1
+ #!/use/bin/env python3
2
+ # ruff: noqa: T201
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from jupynium.ipynb import ipynb2jupytext, load_ipynb
9
+
10
+
11
+ def get_parser():
12
+ parser = argparse.ArgumentParser(
13
+ description="Convert ipynb to a jupytext percent format (.ju.py).",
14
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
15
+ )
16
+ parser.add_argument("ipynb_path", help="Path to ipynb file")
17
+ parser.add_argument(
18
+ "output_jupy_path",
19
+ nargs="?",
20
+ help="Path to output jupynium file. "
21
+ "If not specified, use file name of ipynb file or print to stdout (--stdout)",
22
+ )
23
+ parser.add_argument(
24
+ "-y", "--yes", action="store_true", help="Do not ask for confirmation"
25
+ )
26
+ parser.add_argument(
27
+ "-c", "--code_only", action="store_true", help="Only convert code cells"
28
+ )
29
+ parser.add_argument("-s", "--stdout", action="store_true", help="Print to stdout")
30
+ return parser
31
+
32
+
33
+ def check_args(args, parser):
34
+ if args.stdout and args.yes:
35
+ parser.error("Either one of --stdout or --yes can be specified")
36
+
37
+ if args.output_jupy_path is not None and args.stdout:
38
+ parser.error("Either one of --stdout or output_jupy_path can be specified")
39
+
40
+
41
+ def main():
42
+ parser = get_parser()
43
+ args = parser.parse_args()
44
+ check_args(args, parser)
45
+
46
+ ipynb = load_ipynb(args.ipynb_path)
47
+ jupy = ipynb2jupytext(ipynb, code_only=args.code_only)
48
+
49
+ if args.stdout:
50
+ for line in jupy:
51
+ print(line)
52
+ else:
53
+ if args.output_jupy_path is None:
54
+ output_jupy_path = Path(args.ipynb_path).with_suffix(".ju.py")
55
+ else:
56
+ output_jupy_path = Path(args.output_jupy_path)
57
+
58
+ output_jupy_path.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ if output_jupy_path.is_file() and not args.yes:
61
+ print(f"Do you want to overwrite {output_jupy_path}?")
62
+ answer = input("y/n: ")
63
+ if answer != "y":
64
+ print("Aborted")
65
+ return
66
+
67
+ with open(output_jupy_path, "w") as f:
68
+ for line in jupy:
69
+ f.write(line)
70
+ f.write("\n")
71
+
72
+ print(f'Converted "{args.ipynb_path}" to "{output_jupy_path}"')
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()