2424
2525from ._try_examples import examples_to_notebook , insert_try_examples_directive
2626
27+ import jupytext
2728import nbformat
2829
2930try :
@@ -478,6 +479,72 @@ class _LiteDirective(SphinxDirective):
478479 "new_tab_button_text" : directives .unchanged ,
479480 }
480481
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+
481548 def run (self ):
482549 width = self .options .pop ("width" , "100%" )
483550 height = self .options .pop ("height" , "1000px" )
@@ -498,43 +565,59 @@ def run(self):
498565 )
499566
500567 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+
501575 # As with other directives like literalinclude, an absolute path is
502576 # assumed to be relative to the document root, and a relative path
503577 # is assumed to be relative to the source file
504578 rel_filename , notebook = self .env .relfn2path (self .arguments [0 ])
505579 self .env .note_dependency (rel_filename )
506580
507- notebook_name = os .path .basename (notebook )
581+ notebook_path = Path (notebook )
582+
583+ self .env .jupyterlite_notebooks .add (str (notebook_path ))
508584
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
510591
511592 notebook_is_stripped : bool = self .env .config .strip_tagged_cells
512593
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
533604 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+
538621 else :
539622 notebook_name = None
540623
0 commit comments