24
24
25
25
from ._try_examples import examples_to_notebook , insert_try_examples_directive
26
26
27
+ import jupytext
27
28
import nbformat
28
29
29
30
try :
@@ -478,6 +479,72 @@ class _LiteDirective(SphinxDirective):
478
479
"new_tab_button_text" : directives .unchanged ,
479
480
}
480
481
482
+ def _target_is_stale (self , source_path : Path , target_path : Path ) -> bool :
483
+ # Used as a heuristic to determine if a markdown notebook needs to be
484
+ # converted or reconverted to ipynb.
485
+ if not target_path .exists ():
486
+ return True
487
+
488
+ return source_path .stat ().st_mtime > target_path .stat ().st_mtime
489
+
490
+ # TODO: Jupytext support many more formats for conversion, but we only
491
+ # consider Markdown and IPyNB for now. If we add more formats someday,
492
+ # we should also consider them here.
493
+ def _assert_no_conflicting_nb_names (
494
+ self , source_path : Path , notebooks_dir : Path
495
+ ) -> None :
496
+ """Check for duplicate notebook names in the documentation sources.
497
+ Raises if any notebooks would conflict when converted to IPyNB."""
498
+ target_stem = source_path .stem
499
+ target_ipynb = f"{ target_stem } .ipynb"
500
+
501
+ # Only look for conflicts in source directories and among referenced notebooks.
502
+ # We do this to prevent conflicts with other files, say, in the "_contents/"
503
+ # directory as a result of a previous failed/interrupted build.
504
+ if source_path .parent != notebooks_dir :
505
+
506
+ # We only consider conflicts if notebooks are actually referenced in
507
+ # a directive, to prevent false posiitves from being raised.
508
+ if hasattr (self .env , "jupyterlite_notebooks" ):
509
+ for existing_nb in self .env .jupyterlite_notebooks :
510
+ existing_path = Path (existing_nb )
511
+ if (
512
+ existing_path .stem == target_stem
513
+ and existing_path != source_path
514
+ ):
515
+
516
+ raise RuntimeError (
517
+ "All notebooks marked for inclusion with JupyterLite must have a "
518
+ f"unique file basename. Found conflict between { source_path } and { existing_path } ."
519
+ )
520
+
521
+ return target_ipynb
522
+
523
+ def _strip_notebook_cells (
524
+ self , nb : nbformat .NotebookNode
525
+ ) -> List [nbformat .NotebookNode ]:
526
+ """Strip cells based on the presence of the "jupyterlite_sphinx_strip" tag
527
+ in the metadata. The content meant to be stripped must be inside its own cell
528
+ cell so that the cell itself gets removed from the notebooks. This is so that
529
+ we don't end up removing useful data or directives that are not meant to be
530
+ removed.
531
+
532
+ Parameters
533
+ ----------
534
+ nb : nbformat.NotebookNode
535
+ The notebook object to be stripped.
536
+
537
+ Returns
538
+ -------
539
+ List[nbformat.NotebookNode]
540
+ A list of cells that are not meant to be stripped.
541
+ """
542
+ return [
543
+ cell
544
+ for cell in nb .cells
545
+ if "jupyterlite_sphinx_strip" not in cell .metadata .get ("tags" , [])
546
+ ]
547
+
481
548
def run (self ):
482
549
width = self .options .pop ("width" , "100%" )
483
550
height = self .options .pop ("height" , "1000px" )
@@ -498,43 +565,59 @@ def run(self):
498
565
)
499
566
500
567
if self .arguments :
568
+ # Keep track of the notebooks we are going through, so that we don't
569
+ # operate on notebooks that are not meant to be included in the built
570
+ # docs, i.e., those that have not been referenced in the docs via our
571
+ # directives anywhere.
572
+ if not hasattr (self .env , "jupyterlite_notebooks" ):
573
+ self .env .jupyterlite_notebooks = set ()
574
+
501
575
# As with other directives like literalinclude, an absolute path is
502
576
# assumed to be relative to the document root, and a relative path
503
577
# is assumed to be relative to the source file
504
578
rel_filename , notebook = self .env .relfn2path (self .arguments [0 ])
505
579
self .env .note_dependency (rel_filename )
506
580
507
- notebook_name = os .path .basename (notebook )
581
+ notebook_path = Path (notebook )
582
+
583
+ self .env .jupyterlite_notebooks .add (str (notebook_path ))
508
584
509
- notebooks_dir = Path (self .env .app .srcdir ) / CONTENT_DIR / notebook_name
585
+ notebooks_dir = Path (self .env .app .srcdir ) / CONTENT_DIR
586
+ os .makedirs (notebooks_dir , exist_ok = True )
587
+
588
+ self ._assert_no_conflicting_nb_names (notebook_path , notebooks_dir )
589
+ target_name = f"{ notebook_path .stem } .ipynb"
590
+ target_path = notebooks_dir / target_name
510
591
511
592
notebook_is_stripped : bool = self .env .config .strip_tagged_cells
512
593
513
- # Create a folder to copy the notebooks to and for NotebookLite to find
514
- os .makedirs (os .path .dirname (notebooks_dir ), exist_ok = True )
515
-
516
- if notebook_is_stripped :
517
- # Note: the directives meant to be stripped must be inside their own
518
- # cell so that the cell itself gets removed from the notebook. This
519
- # is so that we don't end up removing useful data or directives that
520
- # are not meant to be removed.
521
-
522
- nb = nbformat .read (notebook , as_version = 4 )
523
- nb .cells = [
524
- cell
525
- for cell in nb .cells
526
- if "jupyterlite_sphinx_strip" not in cell .metadata .get ("tags" , [])
527
- ]
528
- nbformat .write (nb , notebooks_dir , version = 4 )
529
-
530
- # If notebook_is_stripped is False, then copy the notebook(s) to notebooks_dir.
531
- # If it is True, then they have already been copied to notebooks_dir by the
532
- # nbformat.write() function above.
594
+ if notebook_path .suffix .lower () == ".md" :
595
+ if self ._target_is_stale (notebook_path , target_path ):
596
+ nb = jupytext .read (str (notebook_path ))
597
+ if notebook_is_stripped :
598
+ nb .cells = self ._strip_notebook_cells (nb )
599
+ with open (target_path , "w" , encoding = "utf-8" ) as f :
600
+ nbformat .write (nb , f , version = 4 )
601
+
602
+ notebook = str (target_path )
603
+ notebook_name = target_name
533
604
else :
534
- try :
535
- shutil .copy (notebook , notebooks_dir )
536
- except shutil .SameFileError :
537
- pass
605
+ notebook_name = notebook_path .name
606
+ target_path = notebooks_dir / notebook_name
607
+
608
+ if notebook_is_stripped :
609
+ nb = nbformat .read (notebook , as_version = 4 )
610
+ nb .cells = self ._strip_notebook_cells (nb )
611
+ nbformat .write (nb , target_path , version = 4 )
612
+ # If notebook_is_stripped is False, then copy the notebook(s) to notebooks_dir.
613
+ # If it is True, then they have already been copied to notebooks_dir by the
614
+ # nbformat.write() function above.
615
+ else :
616
+ try :
617
+ shutil .copy (notebook , target_path )
618
+ except shutil .SameFileError :
619
+ pass
620
+
538
621
else :
539
622
notebook_name = None
540
623
0 commit comments