|
16 | 16 |
|
17 | 17 | import abc |
18 | 18 | import inspect |
| 19 | +import io |
19 | 20 | import re |
20 | 21 | import textwrap |
21 | 22 | from pathlib import Path |
|
45 | 46 | show_bloq(bloq)\ |
46 | 47 | """ |
47 | 48 |
|
48 | | - |
49 | 49 | _K_CQ_AUTOGEN = 'cq.autogen' |
50 | 50 | """The jupyter metadata key we use to identify cells we've autogenerated. |
51 | 51 |
|
@@ -310,6 +310,84 @@ def _init_notebook( |
310 | 310 | return nb, nb_path |
311 | 311 |
|
312 | 312 |
|
| 313 | +class WriteIfDifferent: |
| 314 | + """A file-like object that only writes to disk if the new content |
| 315 | + differs from the existing content. |
| 316 | +
|
| 317 | + Args: |
| 318 | + path: The path to write, which may already exist. |
| 319 | + """ |
| 320 | + |
| 321 | + def __init__(self, path: Path): |
| 322 | + self.path = path |
| 323 | + self._buffer = io.StringIO() |
| 324 | + |
| 325 | + def write(self, s: str): |
| 326 | + return self._buffer.write(s) |
| 327 | + |
| 328 | + def writelines(self, lines): |
| 329 | + for line in lines: |
| 330 | + self.write(line) |
| 331 | + |
| 332 | + def flush(self): |
| 333 | + self._buffer.flush() |
| 334 | + |
| 335 | + def close(self): |
| 336 | + """Closes the adapter. |
| 337 | +
|
| 338 | + This triggers the comparison of buffered content |
| 339 | + with the disk file's content and writes to disk only if different. |
| 340 | + """ |
| 341 | + new_content = self._buffer.getvalue() |
| 342 | + self._buffer.close() |
| 343 | + |
| 344 | + existing_content = None |
| 345 | + if self.path.is_file(): |
| 346 | + with self.path.open('r') as f_read: |
| 347 | + existing_content = f_read.read() |
| 348 | + if new_content == existing_content: |
| 349 | + print(f"{self.path} unchanged.") |
| 350 | + return |
| 351 | + |
| 352 | + with self.path.open('w') as f_write: |
| 353 | + f_write.write(new_content) |
| 354 | + |
| 355 | + def __enter__(self): |
| 356 | + return self |
| 357 | + |
| 358 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 359 | + self.close() |
| 360 | + # Do not suppress exceptions from the 'with' block body. |
| 361 | + return False |
| 362 | + |
| 363 | + @property |
| 364 | + def closed(self): |
| 365 | + return self._buffer.closed |
| 366 | + |
| 367 | + def readable(self): |
| 368 | + """Returns False, as this adapter is write-only like a file from `open('w')`.""" |
| 369 | + return False |
| 370 | + |
| 371 | + def writable(self): |
| 372 | + """Returns True if the adapter is not closed, False otherwise.""" |
| 373 | + return self._buffer.writable() |
| 374 | + |
| 375 | + def seekable(self): |
| 376 | + """Returns False, as this adapter is not seekable like a disk file opened in 'w' mode.""" |
| 377 | + return False |
| 378 | + |
| 379 | + def tell(self): |
| 380 | + """Returns the current stream position in the internal buffer.""" |
| 381 | + return self._buffer.tell() |
| 382 | + |
| 383 | + def truncate(self, size=None): |
| 384 | + """ |
| 385 | + Resizes the internal buffer to the given number of bytes. |
| 386 | + If size is not specified, resizes to the current position. |
| 387 | + """ |
| 388 | + return self._buffer.truncate(size) |
| 389 | + |
| 390 | + |
313 | 391 | def render_notebook(nbspec: NotebookSpecV2) -> None: |
314 | 392 | # 1. get a notebook (existing or empty) |
315 | 393 | nb, nb_path = _init_notebook(path_stem=nbspec.path_stem, directory=nbspec.directory) |
@@ -349,5 +427,5 @@ def render_notebook(nbspec: NotebookSpecV2) -> None: |
349 | 427 | nb.cells.append(new_nbnode) |
350 | 428 |
|
351 | 429 | # 5. Write the notebook. |
352 | | - with nb_path.open('w') as f: |
353 | | - nbformat.write(nb, f) |
| 430 | + with WriteIfDifferent(nb_path) as woc: |
| 431 | + nbformat.write(nb, woc) |
0 commit comments