Repository: jupyter/nbconvert
Pull request #1967 (2023-03-28 20:42:47)

Allow pattern in output_base
This PR adds additional functionality to the `output_base` configuration option (`--output`) in order to allow patterning of output filenames when converting multiple notebooks at once.

Fairly exhaustive tests of the changes are also implemented.

I don't believe this requires changes to the documentation outside what is auto-generated from the doc-strings and descriptions defined in the Python files.

I am using this in my configuration to make the rendered markdown files have an additional marker to distinguish them from manually created markdown files:

```python
# This causes the output files to be '{notebook_name}.generated.md' for markdown files
c.NbConvertApp.output_base = "{notebook_name}.generated"
```

See #1966 for more details.

Fix: #1966 

JeppeKlitgaard: I am not sure I am able to set PR labels
blink1073: The link failure is being addressed in #1968 

Patch:
diff --git a/nbconvert/nbconvertapp.py b/nbconvert/nbconvertapp.py
index 0b28965a..3ff3d3ba 100755
--- a/nbconvert/nbconvertapp.py
+++ b/nbconvert/nbconvertapp.py
@@ -216,9 +216,9 @@ class NbConvertApp(JupyterApp):
     )
 
     output_base = Unicode(
-        "",
-        help="""overwrite base name use for output files.
-            can only be used when converting one notebook at a time.
+        "{notebook_name}",
+        help="""Overwrite base name use for output files.
+            Supports pattern replacements '{notebook_name}' and '{notebook_filename}'.
             """,
     ).tag(config=True)
 
@@ -413,6 +413,19 @@ class NbConvertApp(JupyterApp):
         super().start()
         self.convert_notebooks()
 
+    def _notebook_filename_to_name(self, notebook_filename):
+        """
+        Returns the notebook name from the notebook filename by
+        applying `output_base` pattern and stripping extension
+        """
+        basename = os.path.basename(notebook_filename)
+        notebook_name = basename[: basename.rfind(".")]
+        notebook_name = self.output_base.format(
+            notebook_name=notebook_name, notebook_filename=notebook_filename
+        )
+
+        return notebook_name
+
     def init_single_notebook_resources(self, notebook_filename):
         """Step 1: Initialize resources
 
@@ -427,16 +440,7 @@ class NbConvertApp(JupyterApp):
                 - output_files_dir: a directory where output files (not
                   including the notebook itself) should be saved
         """
-        basename = os.path.basename(notebook_filename)
-        notebook_name = basename[: basename.rfind(".")]
-        if self.output_base:
-            # strip duplicate extension from output_base, to avoid Basename.ext.ext
-            if getattr(self.exporter, "file_extension", False):
-                base, ext = os.path.splitext(self.output_base)
-                if ext == self.exporter.file_extension:
-                    self.output_base = base
-            notebook_name = self.output_base
-
+        notebook_name = self._notebook_filename_to_name(notebook_filename)
         self.log.debug("Notebook name is '%s'", notebook_name)
 
         # first initialize the resources we want to use
@@ -509,7 +513,7 @@ class NbConvertApp(JupyterApp):
             raise KeyError(msg)
 
         notebook_name = resources["unique_key"]
-        if self.use_output_suffix and not self.output_base:
+        if self.use_output_suffix:
             notebook_name += resources.get("output_suffix", "")
 
         write_results = self.writer.write(output, resources, notebook_name=notebook_name)
@@ -557,17 +561,7 @@ class NbConvertApp(JupyterApp):
         self.postprocess_single_notebook(write_results)
 
     def convert_notebooks(self):
-        """Convert the notebooks in the self.notebook traitlet"""
-        # check that the output base isn't specified if there is more than
-        # one notebook to convert
-        if self.output_base != "" and len(self.notebooks) > 1:
-            self.log.error(
-                """
-                UsageError: --output flag or `NbConvertApp.output_base` config option
-                cannot be used when converting multiple notebooks.
-                """
-            )
-            self.exit(1)
+        """Convert the notebooks in the self.notebooks traitlet"""
 
         # no notebooks to convert!
         if len(self.notebooks) == 0 and not self.from_stdin:
@@ -585,6 +579,24 @@ class NbConvertApp(JupyterApp):
         cls = get_exporter(self.export_format)
         self.exporter = cls(config=self.config)
 
+        # strip duplicate extension from output_base, to avoid Basename.ext.ext
+        if getattr(self.exporter, "file_extension", False):
+            base, ext = os.path.splitext(self.output_base)
+            if ext == self.exporter.file_extension:
+                self.output_base = base
+
+        # Validate that output_base does not cause us to overwrite already generated
+        # files
+        notebook_names = [self._notebook_filename_to_name(fn) for fn in self.notebooks]
+        if len(notebook_names) != len(set(notebook_names)):
+            msg = (
+                "Conversion would override an already generated output. "
+                "This is probably due to --output or output_base configuration "
+                "leading to non-unique output names. "
+                f"Output notebook names were: {notebook_names}"
+            )
+            raise ValueError(msg)
+
         # convert each notebook
         if not self.from_stdin:
             for notebook_filename in self.notebooks:


Test patch:
diff --git a/nbconvert/tests/test_nbconvertapp.py b/nbconvert/tests/test_nbconvertapp.py
index 64cd5027..17feb64a 100644
--- a/nbconvert/tests/test_nbconvertapp.py
+++ b/nbconvert/tests/test_nbconvertapp.py
@@ -632,3 +632,37 @@ class TestNbConvertApp(TestsBase):
                     text = f.read()
                     assert '<script type="application/vnd.jupyter.widget-view+json">' in text
                     assert '<script type="application/vnd.jupyter.widget-state+json">' in text
+
+    def test_output_base(self):
+        """
+        Check various configurations of output_base (--output)
+        """
+        notebook_names = [
+            "notebook1",
+            "notebook2",
+        ]
+        with self.create_temp_cwd([x + ".ipynb" for x in notebook_names]):
+            self.nbconvert("*.ipynb --output '{notebook_name}_test_addition.asd' --to markdown")
+
+            for nbn in notebook_names:
+                assert os.path.isfile(f"{nbn}_test_addition.asd.md")
+
+        with self.create_temp_cwd([x + ".ipynb" for x in notebook_names]):
+            self.nbconvert("*.ipynb --to markdown")
+
+            for nbn in notebook_names:
+                assert os.path.isfile(f"{nbn}.md")
+
+        with pytest.raises(OSError), self.create_temp_cwd([x + ".ipynb" for x in notebook_names]):
+            self.nbconvert("*.ipynb --output notebook_test_name --to markdown")
+
+        # Test single output with static output name
+        with self.create_temp_cwd([notebook_names[0] + ".ipynb"]):
+            self.nbconvert("*.ipynb --output notebook_test_name --to markdown")
+            assert os.path.isfile("notebook_test_name.md")
+
+        # Test double extension fix
+        with self.create_temp_cwd([notebook_names[0] + ".ipynb"]):
+            self.nbconvert("*.ipynb --output notebook_test_name.md --to markdown")
+            assert os.path.isfile("notebook_test_name.md")
+            assert not os.path.isfile("notebook_test_name.md.md")