def test_configure_specified(tmp_path): export_formats = ["script", "html"] organize_by = "notebook" clean_exclude = ["README.md", "images/*"] assert export_formats != DEFAULT_EXPORT_FORMATS assert organize_by != DEFAULT_ORGANIZE_BY cmd_list = ["configure", str(tmp_path)] for fmt in export_formats: cmd_list.extend(["-f", fmt]) cmd_list.extend(["-b", organize_by]) for excl in clean_exclude: cmd_list.extend(["-e", excl]) result = CliRunner().invoke(app, cmd_list) assert result.exit_code == 0 config = NbAutoexportConfig.parse_file( path=tmp_path / SAVE_PROGRESS_INDICATOR_FILE, content_type="application/json", encoding="utf-8", ) expected_config = NbAutoexportConfig( export_formats=export_formats, organize_by=organize_by, clean=CleanConfig(exclude=clean_exclude), ) assert config == expected_config
def post_save(model: dict, os_path: str, contents_manager: FileContentsManager): """Post-save hook for converting notebooks to other formats using Jupyter nbconvert and saving in a subfolder. The following arguments are standard for Jupyter post-save hooks. See [Jupyter Documentation]( https://jupyter-notebook.readthedocs.io/en/stable/extending/savehooks.html). Args: model (dict): the model representing the file. See [Jupyter documentation]( https://jupyter-notebook.readthedocs.io/en/stable/extending/contents.html#data-model). os_path (str): the filesystem path to the file just written contents_manager (FileContentsManager): FileContentsManager instance that hook is bound to """ # only do this for notebooks if model["type"] != "notebook": return # only do this if we've added the special indicator file to the working directory os_path = Path(os_path) cwd = os_path.parent save_progress_indicator = cwd / SAVE_PROGRESS_INDICATOR_FILE should_convert = save_progress_indicator.exists() if should_convert: config = NbAutoexportConfig.parse_file(path=save_progress_indicator, content_type="application/json", encoding="utf-8") export_notebook(os_path, config=config)
def test_configure_defaults(tmp_path): result = CliRunner().invoke(app, ["configure", str(tmp_path)]) assert result.exit_code == 0 config = NbAutoexportConfig.parse_file( path=tmp_path / SAVE_PROGRESS_INDICATOR_FILE, content_type="application/json", encoding="utf-8", ) expected_config = NbAutoexportConfig() assert config == expected_config
def post_save(model: dict, os_path: str, contents_manager: FileContentsManager): """Post-save hook for converting notebooks to other formats using Jupyter nbconvert and saving in a subfolder. The following arguments are standard for Jupyter post-save hooks. See [Jupyter Documentation]( https://jupyter-notebook.readthedocs.io/en/stable/extending/savehooks.html). Args: model (dict): the model representing the file. See [Jupyter documentation]( https://jupyter-notebook.readthedocs.io/en/stable/extending/contents.html#data-model). os_path (str): the filesystem path to the file just written contents_manager (FileContentsManager): FileContentsManager instance that hook is bound to """ logger.debug("nbautoexport | Executing nbautoexport.export.post_save ...") try: # only do this for notebooks if model["type"] != "notebook": logger.debug( f"nbautoexport | {os_path} is not a notebook. Nothing to do.") return # only do this if we've added the special indicator file to the working directory notebook_path = Path(os_path) cwd = notebook_path.parent save_progress_indicator = cwd / SAVE_PROGRESS_INDICATOR_FILE should_convert = save_progress_indicator.exists() if should_convert: logger.info( f"nbautoexport | {save_progress_indicator} found. Exporting notebook ..." ) config = NbAutoexportConfig.parse_file( path=save_progress_indicator, content_type="application/json", encoding="utf-8") export_notebook(notebook_path, config=config) else: logger.debug( f"nbautoexport | {save_progress_indicator} not found. Nothing to do." ) logger.debug("nbautoexport | post_save successful.") except Exception as e: logger.error( f"nbautoexport | post_save failed due to {type(e).__name__}: {e}")
def clean( directory: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True, writable=True, help="Directory to clean."), yes: bool = typer.Option( False, "--yes", "-y", help="Assume 'yes' answer to confirmation prompt to delete files."), dry_run: bool = typer.Option( False, "--dry-run", help="Show files that would be removed, without actually removing."), ): """Remove subfolders/files not matching .nbautoconvert configuration and existing notebooks. """ sentinel_path = directory / SAVE_PROGRESS_INDICATOR_FILE validate_sentinel_path(sentinel_path) config = NbAutoexportConfig.parse_file(path=sentinel_path, content_type="application/json") files_to_clean = find_files_to_clean(directory, config) if len(files_to_clean) == 0: typer.echo("No files identified for cleaning. Exiting.") raise typer.Exit(code=0) typer.echo("Identified following files to clean up:") for path in sorted(files_to_clean): typer.echo(f" {path}") if dry_run: typer.echo("Dry run completed. Exiting.") raise typer.Exit(code=0) if not yes: typer.confirm("Are you sure you want to delete these files?", abort=True) typer.echo("Removing identified files...") for path in files_to_clean: if path.is_file(): path.unlink() # Remove empty subdirectories typer.echo("Removing empty subdirectories...") subfolders = (d for d in directory.iterdir() if d.is_dir()) for subfolder in subfolders: for subsubfolder in subfolder.iterdir(): if subsubfolder.is_dir() and not any(subsubfolder.iterdir()): typer.echo(f" {subsubfolder}") subsubfolder.rmdir() if not any(subfolder.iterdir()): typer.echo(f" {subfolder}") subfolder.rmdir() typer.echo("Cleaning complete.")
def export( input: Path = typer.Argument( ..., exists=True, file_okay=True, dir_okay=True, writable=True, help="Path to notebook file or directory of notebook files to export.", ), export_formats: Optional[List[ExportFormat]] = typer.Option( None, "--export-format", "-f", show_default=True, help= ("File format(s) to save for each notebook. Multiple formats should be provided using " "multiple flags, e.g., '-f script -f html -f markdown'. Provided values will override " "existing .nbautoexport config files. If neither provided, defaults to " f"{DEFAULT_EXPORT_FORMATS}."), ), organize_by: Optional[OrganizeBy] = typer.Option( None, "--organize-by", "-b", show_default=True, help= ("Whether to save exported file(s) in a subfolder per notebook or per export format. " "Provided values will override existing .nbautoexport config files. If neither " f"provided, defaults to '{DEFAULT_ORGANIZE_BY}'."), ), ): """Manually export notebook or directory of notebooks. An .nbautoexport configuration file in same directory as notebook(s) will be used if it exists. Configuration options specified by command-line options will override configuration file. If no existing configuration option exists and no values are provided, default values will be used. The export command will not do cleaning, regardless of the 'clean' setting in an .nbautoexport configuration file. """ if input.is_dir(): sentinel_path = input / SAVE_PROGRESS_INDICATOR_FILE notebook_paths = [nb.path for nb in find_notebooks(input)] if len(notebook_paths) == 0: typer.echo(f"No notebooks found in directory [{input}]. Exiting.") raise typer.Exit(code=1) else: sentinel_path = input.parent / SAVE_PROGRESS_INDICATOR_FILE notebook_paths = [input] # Configuration: input options override existing sentinel file if sentinel_path.exists(): typer.echo( f"Reading existing configuration file from {sentinel_path} ...") config = NbAutoexportConfig.parse_file(path=sentinel_path, content_type="application/json") # Overrides if len(export_formats) > 0: typer.echo( f"Overriding config with specified export formats: {export_formats}" ) config.export_formats = export_formats if organize_by is not None: typer.echo( f"Overriding config with specified organization strategy: {export_formats}" ) config.organize_by = organize_by else: typer.echo( "No configuration found. Using command options as configuration ..." ) if len(export_formats) == 0: typer.echo( f"No export formats specified. Using default: {DEFAULT_EXPORT_FORMATS}" ) export_formats = DEFAULT_EXPORT_FORMATS if organize_by is None: typer.echo( f"No organize-by specified. Using default: {DEFAULT_ORGANIZE_BY}" ) organize_by = DEFAULT_ORGANIZE_BY config = NbAutoexportConfig(export_formats=export_formats, organize_by=organize_by) for notebook_path in notebook_paths: export_notebook(notebook_path, config=config)
def clean( directory: Path = typer.Argument( ..., exists=True, file_okay=False, dir_okay=True, writable=True, help=f"Directory to clean. Must have a {SAVE_PROGRESS_INDICATOR_FILE} config file.", ), exclude: List[str] = typer.Option( [], "--exclude", "-e", help=( "Glob-style patterns that designate files to exclude from deletion. Combined with any " f"patterns specified in {SAVE_PROGRESS_INDICATOR_FILE} config file." ), ), yes: bool = typer.Option( False, "--yes", "-y", help="Assume 'yes' answer to confirmation prompt to delete files." ), dry_run: bool = typer.Option( False, "--dry-run", help="Show files that would be removed, without actually removing." ), ): """(EXPERIMENTAL) Remove subfolders/files not matching .nbautoexport configuration and existing notebooks. Known limitations: - Not able to correctly handle additional intended files, such as image assets or non-notebook-related files. """ sentinel_path = directory / SAVE_PROGRESS_INDICATOR_FILE validate_sentinel_path(sentinel_path) config = NbAutoexportConfig.parse_file( path=sentinel_path, content_type="application/json", encoding="utf-8" ) # Combine exclude patterns from config and command-line config.clean.exclude.extend(exclude) if len(config.clean.exclude) > 0: typer.echo("Excluding files from cleaning using the following patterns:") for pattern in config.clean.exclude: typer.echo(f" {pattern}") files_to_clean = find_files_to_clean(directory, config) if len(files_to_clean) == 0: typer.echo("No files identified for cleaning. Exiting.") raise typer.Exit(code=0) typer.echo("Identified following files to clean up:") for path in sorted(files_to_clean): typer.echo(f" {path}") if dry_run: typer.echo("Dry run completed. Exiting.") raise typer.Exit(code=0) if not yes: typer.confirm("Are you sure you want to delete these files?", abort=True) typer.echo("Removing identified files...") for path in files_to_clean: if path.is_file(): path.unlink() # Remove empty subdirectories typer.echo("Removing empty subdirectories...") subfolders = (d for d in directory.iterdir() if d.is_dir()) for subfolder in subfolders: for subsubfolder in subfolder.iterdir(): if subsubfolder.is_dir() and not any(subsubfolder.iterdir()): typer.echo(f" {subsubfolder}") subsubfolder.rmdir() if not any(subfolder.iterdir()): typer.echo(f" {subfolder}") subfolder.rmdir() typer.echo("Cleaning complete.")