Repository: jupyter/nbconvert (python)

Issue:
Exporter default_config merging
In exporters `templateexporter, html, latex, markdown,rst` and `asciidoc`, the following is present
```python
@property
def default_config(self):
    c = Config( some stuff here )
    c.merge(super().default_config)
    return c
```
where in the merging, the configuration of the exporter can be overwritten by the parent configuration. In other words, if `self` and `super()` contain different values for the same setting, the resulting config will contain the `super()`s setting.

I am a bit confused about why this is the case. My expectation is that the reverse should take place: a subclass should inherit its parent's configuration and modify it according to its own needs.

As a quick check, I replaced the line that is merging with
```python
if super().default_config:
    c2 = super().default_config.copy()
    c2.merge(c)
    c = c2
```
and the tests are still passing (except for some skipped ones due to not installed programs).

Is there a reason why the former is preferable to the latter style of merging configs?

Pull request:
Tweak exporter default_config merging behavior
Re #1980, changes `Config` merging so that `child` configuration overwrites `parent` configuration as opposed to the other way around.

Closes #1980 

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d2b49700..445f09fe 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -36,7 +36,7 @@ repos:
       - id: black
 
   - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: v0.0.260
+    rev: v0.0.263
     hooks:
       - id: ruff
         args: ["--fix"]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 6bc1cf12..7e5512c5 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -10,13 +10,14 @@ sphinx:
 
 formats: all
 
-build:
-  image: latest
-
 python:
-  version: 3.8
   install:
     - method: pip
       path: .
       extra_requirements:
         - docs
+
+build:
+  os: ubuntu-22.04
+  tools:
+     python: "3.11"
diff --git a/nbconvert/exporters/asciidoc.py b/nbconvert/exporters/asciidoc.py
index 33df3642..f86449fc 100644
--- a/nbconvert/exporters/asciidoc.py
+++ b/nbconvert/exporters/asciidoc.py
@@ -48,5 +48,8 @@ class ASCIIDocExporter(TemplateExporter):
                 "HighlightMagicsPreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
diff --git a/nbconvert/exporters/exporter.py b/nbconvert/exporters/exporter.py
index 5023d59b..f833a344 100644
--- a/nbconvert/exporters/exporter.py
+++ b/nbconvert/exporters/exporter.py
@@ -95,6 +95,7 @@ class Exporter(LoggingConfigurable):
             "nbconvert.preprocessors.LatexPreprocessor",
             "nbconvert.preprocessors.HighlightMagicsPreprocessor",
             "nbconvert.preprocessors.ExtractOutputPreprocessor",
+            "nbconvert.preprocessors.ExtractAttachmentsPreprocessor",
             "nbconvert.preprocessors.ClearMetadataPreprocessor",
         ],
         help="""List of preprocessors available by default, by name, namespace,
diff --git a/nbconvert/exporters/html.py b/nbconvert/exporters/html.py
index 3e43d1c9..03d1462d 100644
--- a/nbconvert/exporters/html.py
+++ b/nbconvert/exporters/html.py
@@ -187,7 +187,10 @@ class HTMLExporter(TemplateExporter):
                 "HighlightMagicsPreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
 
     @contextfilter
diff --git a/nbconvert/exporters/latex.py b/nbconvert/exporters/latex.py
index 681323f4..d3ae76ef 100644
--- a/nbconvert/exporters/latex.py
+++ b/nbconvert/exporters/latex.py
@@ -54,6 +54,7 @@ class LatexExporter(TemplateExporter):
                         "text/plain",
                     ]
                 },
+                "ExtractAttachmentsPreprocessor": {"enabled": True},
                 "ExtractOutputPreprocessor": {"enabled": True},
                 "SVG2PDFPreprocessor": {"enabled": True},
                 "LatexPreprocessor": {"enabled": True},
@@ -61,7 +62,10 @@ class LatexExporter(TemplateExporter):
                 "HighlightMagicsPreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
 
     def from_notebook_node(self, nb, resources=None, **kw):
diff --git a/nbconvert/exporters/markdown.py b/nbconvert/exporters/markdown.py
index 28041077..bba1a324 100644
--- a/nbconvert/exporters/markdown.py
+++ b/nbconvert/exporters/markdown.py
@@ -34,6 +34,7 @@ class MarkdownExporter(TemplateExporter):
     def default_config(self):
         c = Config(
             {
+                "ExtractAttachmentsPreprocessor": {"enabled": True},
                 "ExtractOutputPreprocessor": {"enabled": True},
                 "NbConvertBase": {
                     "display_data_priority": [
@@ -49,5 +50,8 @@ class MarkdownExporter(TemplateExporter):
                 "HighlightMagicsPreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
diff --git a/nbconvert/exporters/pdf.py b/nbconvert/exporters/pdf.py
index 7e4a6482..db5f120d 100644
--- a/nbconvert/exporters/pdf.py
+++ b/nbconvert/exporters/pdf.py
@@ -140,7 +140,7 @@ class PDFExporter(LatexExporter):
                     stdout=stdout,
                     stderr=subprocess.STDOUT,
                     stdin=null,
-                    shell=shell,
+                    shell=shell,  # noqa
                     env=env,
                 )
                 out, _ = p.communicate()
@@ -210,8 +210,9 @@ class PDFExporter(LatexExporter):
         # convert output extension to pdf
         # the writer above required it to be tex
         resources["output_extension"] = ".pdf"
-        # clear figure outputs, extracted by latex export,
+        # clear figure outputs and attachments, extracted by latex export,
         # so we don't claim to be a multi-file export.
         resources.pop("outputs", None)
+        resources.pop("attachments", None)
 
         return pdf_data, resources
diff --git a/nbconvert/exporters/rst.py b/nbconvert/exporters/rst.py
index acac8703..1763e6fb 100644
--- a/nbconvert/exporters/rst.py
+++ b/nbconvert/exporters/rst.py
@@ -33,5 +33,8 @@ class RSTExporter(TemplateExporter):
                 "HighlightMagicsPreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
diff --git a/nbconvert/exporters/templateexporter.py b/nbconvert/exporters/templateexporter.py
index 21603b53..787031ef 100644
--- a/nbconvert/exporters/templateexporter.py
+++ b/nbconvert/exporters/templateexporter.py
@@ -183,7 +183,10 @@ class TemplateExporter(Exporter):
                 "TagRemovePreprocessor": {"enabled": True},
             }
         )
-        c.merge(super().default_config)
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
         return c
 
     template_name = Unicode(help="Name of the template to use").tag(
@@ -645,20 +648,16 @@ class TemplateExporter(Exporter):
                         break
                 if not found_at_least_one:
                     paths = "\n\t".join(root_dirs)
-                    raise ValueError(
-                        "No template sub-directory with name %r found in the following paths:\n\t%s"
-                        % (base_template, paths)
-                    )
+                    msg = f"No template sub-directory with name {base_template!r} found in the following paths:\n\t{paths}"
+                    raise ValueError(msg)
             merged_conf = recursive_update(dict(conf), merged_conf)
             base_template = conf.get("base_template")
         conf = merged_conf
         mimetypes = [mimetype for mimetype, enabled in conf.get("mimetypes", {}).items() if enabled]
         if self.output_mimetype and self.output_mimetype not in mimetypes and mimetypes:
             supported_mimetypes = "\n\t".join(mimetypes)
-            raise ValueError(
-                "Unsupported mimetype %r for template %r, mimetypes supported are: \n\t%s"
-                % (self.output_mimetype, self.template_name, supported_mimetypes)
-            )
+            msg = f"Unsupported mimetype {self.output_mimetype!r} for template {self.template_name!r}, mimetypes supported are: \n\t{supported_mimetypes}"
+            raise ValueError(msg)
         return template_names
 
     def get_prefix_root_dirs(self):
diff --git a/nbconvert/nbconvertapp.py b/nbconvert/nbconvertapp.py
index e179c125..d56bb68b 100755
--- a/nbconvert/nbconvertapp.py
+++ b/nbconvert/nbconvertapp.py
@@ -218,7 +218,7 @@ class NbConvertApp(JupyterApp):
     output_base = Unicode(
         "{notebook_name}",
         help="""Overwrite base name use for output files.
-            Supports pattern replacements '{notebook_name}' and '{notebook_filename}'.
+            Supports pattern replacements '{notebook_name}'.
             """,
     ).tag(config=True)
 
@@ -420,9 +420,7 @@ class NbConvertApp(JupyterApp):
         """
         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
-        )
+        notebook_name = self.output_base.format(notebook_name=notebook_name)
 
         return notebook_name
 
@@ -513,7 +511,7 @@ class NbConvertApp(JupyterApp):
             raise KeyError(msg)
 
         notebook_name = resources["unique_key"]
-        if self.use_output_suffix:
+        if self.use_output_suffix and self.output_base == "{notebook_name}":
             notebook_name += resources.get("output_suffix", "")
 
         write_results = self.writer.write(output, resources, notebook_name=notebook_name)
@@ -585,18 +583,6 @@ class NbConvertApp(JupyterApp):
             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:
diff --git a/nbconvert/preprocessors/__init__.py b/nbconvert/preprocessors/__init__.py
index 5f3d8894..a13443d9 100644
--- a/nbconvert/preprocessors/__init__.py
+++ b/nbconvert/preprocessors/__init__.py
@@ -11,6 +11,7 @@ from .coalescestreams import coalesce_streams
 from .convertfigures import ConvertFiguresPreprocessor
 from .csshtmlheader import CSSHTMLHeaderPreprocessor
 from .execute import ExecutePreprocessor
+from .extractattachments import ExtractAttachmentsPreprocessor
 from .extractoutput import ExtractOutputPreprocessor
 from .highlightmagics import HighlightMagicsPreprocessor
 from .latex import LatexPreprocessor
diff --git a/nbconvert/preprocessors/extractattachments.py b/nbconvert/preprocessors/extractattachments.py
new file mode 100644
index 00000000..763e614f
--- /dev/null
+++ b/nbconvert/preprocessors/extractattachments.py
@@ -0,0 +1,108 @@
+"""
+Module that extracts attachments from notebooks into their own files
+"""
+
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import os
+from base64 import b64decode
+
+from traitlets import Bool, Unicode
+
+from .base import Preprocessor
+
+
+class ExtractAttachmentsPreprocessor(Preprocessor):
+    """
+    Extracts attachments from all (markdown and raw) cells in a notebook.
+    The extracted attachments are stored in a directory ('attachments' by default).
+    https://nbformat.readthedocs.io/en/latest/format_description.html#cell-attachments
+    """
+
+    attachments_directory_template = Unicode(
+        "{notebook_name}_attachments",
+        help="Directory to place attachments if use_separate_dir is True",
+    ).tag(config=True)
+
+    use_separate_dir = Bool(
+        False,
+        help="Whether to use output_files_dir (which ExtractOutput also uses) or "
+        "create a separate directory for attachments",
+    ).tag(config=True)
+
+    def __init__(self, **kw):
+        """
+        Public constructor
+        """
+        super().__init__(**kw)
+        # directory path,
+        self.path_name = ""  # will be set in self.preprocess, needs resources
+        # Where extracted attachments are stored in resources
+        self.resources_item_key = (
+            "attachments"  # Here as a default, in case someone doesn't want to call preprocess
+        )
+
+    # Add condition and configurability here
+    def preprocess(self, nb, resources):
+        """
+        Determine some settings and apply preprocessor to notebook
+        """
+        if self.use_separate_dir:
+            self.path_name = self.attachments_directory_template.format(
+                notebook_name=resources["unique_key"]
+            )
+            # Initialize resources for attachments
+            resources["attachment_files_dir"] = self.path_name
+            resources["attachments"] = {}
+            self.resources_item_key = "attachments"
+        else:
+            # Use same resources as ExtractOutput
+            self.path_name = resources["output_files_dir"]
+            self.resources_item_key = "outputs"
+
+        # Make sure key exists
+        if not isinstance(resources[self.resources_item_key], dict):
+            resources[self.resources_item_key] = {}
+
+        nb, resources = super().preprocess(nb, resources)
+        return nb, resources
+
+    def preprocess_cell(self, cell, resources, index):
+        """
+        Extract attachments to individual files and
+        change references to them.
+        E.g.
+        '![image.png](attachment:021fdd80.png)'
+        becomes
+        '![image.png]({path_name}/021fdd80.png)'
+        Assumes self.path_name and self.resources_item_key is set properly (usually in preprocess).
+        """
+        if "attachments" in cell:
+            for fname in cell.attachments:
+                self.log.debug(f"Encountered attachment {fname}")
+
+                # Add file for writer
+
+                # Right now I don't know of a situation where there would be multiple
+                # mime types under same filename, and I can't index into it without the mimetype.
+                # So I only read the first one.
+                for mimetype in cell.attachments[fname]:
+                    # convert to bytes and decode
+                    data = cell.attachments[fname][mimetype].encode("utf-8")
+                    decoded = b64decode(data)
+                    break
+
+                # FilesWriter wants path to be in attachment filename here
+                new_filename = os.path.join(self.path_name, fname)
+                resources[self.resources_item_key][new_filename] = decoded
+
+                # Edit the reference to the attachment
+
+                # os.path.join on windows uses "\\" separator,
+                # but files like markdown still want "/"
+                if os.path.sep != "/":
+                    new_filename = new_filename.replace(os.path.sep, "/")
+                cell.source = cell.source.replace("attachment:" + fname, new_filename)
+
+        return cell, resources
diff --git a/nbconvert/preprocessors/svg2pdf.py b/nbconvert/preprocessors/svg2pdf.py
index 28e8ed21..85c8749f 100644
--- a/nbconvert/preprocessors/svg2pdf.py
+++ b/nbconvert/preprocessors/svg2pdf.py
@@ -54,7 +54,7 @@ class SVG2PDFPreprocessor(ConvertFiguresPreprocessor):
     @default("inkscape_version")
     def _inkscape_version_default(self):
         p = subprocess.Popen(
-            [self.inkscape, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
+            [self.inkscape, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE  # noqa
         )
         output, _ = p.communicate()
         if p.returncode != 0:
@@ -143,7 +143,7 @@ class SVG2PDFPreprocessor(ConvertFiguresPreprocessor):
                 # For backwards compatibility with specifying strings
                 # Okay-ish, since the string is trusted
                 full_cmd = self.command.format(*template_vars)
-            subprocess.call(full_cmd, shell=isinstance(full_cmd, str))
+            subprocess.call(full_cmd, shell=isinstance(full_cmd, str))  # noqa
 
             # Read output from drive
             # return value expects a filename
diff --git a/nbconvert/utils/pandoc.py b/nbconvert/utils/pandoc.py
index a883ec5f..a59c532d 100644
--- a/nbconvert/utils/pandoc.py
+++ b/nbconvert/utils/pandoc.py
@@ -51,7 +51,7 @@ def pandoc(source, fmt, to, extra_args=None, encoding="utf-8"):
     check_pandoc_version()
 
     # we can safely continue
-    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)  # noqa
     out, _ = p.communicate(source.encode())
     out_str = TextIOWrapper(BytesIO(out), encoding, "replace").read()
     return out_str.rstrip("\n")
@@ -75,7 +75,7 @@ def get_pandoc_version():
         if not shutil.which("pandoc"):
             raise PandocMissing()
 
-        out = subprocess.check_output(["pandoc", "-v"])
+        out = subprocess.check_output(["pandoc", "-v"])  # noqa
         out_lines = out.splitlines()
         version_pattern = re.compile(r"^\d+(\.\d+){1,}$")
         for tok in out_lines[0].decode("ascii", "replace").split():
diff --git a/nbconvert/writers/files.py b/nbconvert/writers/files.py
index fb63b028..852496c1 100644
--- a/nbconvert/writers/files.py
+++ b/nbconvert/writers/files.py
@@ -51,6 +51,19 @@ class FilesWriter(WriterBase):
             self.log.info("Making directory %s", path)
             ensure_dir_exists(path)
 
+    def _write_items(self, items, build_dir):
+        """Write a dict containing filename->binary data"""
+        for filename, data in items:
+            # Determine where to write the file to
+            dest = os.path.join(build_dir, filename)
+            path = os.path.dirname(dest)
+            self._makedir(path)
+
+            # Write file
+            self.log.debug("Writing %i bytes to %s", len(data), dest)
+            with open(dest, "wb") as f:
+                f.write(data)
+
     def write(self, output, resources, notebook_name=None, **kw):
         """
         Consume and write Jinja output to the file system.  Output directory
@@ -73,7 +86,7 @@ class FilesWriter(WriterBase):
         relpath = self.relpath or resource_path
         build_directory = self.build_directory or resource_path
 
-        # Write all of the extracted resources to the destination directory.
+        # Write the extracted outputs to the destination directory.
         # NOTE: WE WRITE EVERYTHING AS-IF IT'S BINARY.  THE EXTRACT FIG
         # PREPROCESSOR SHOULD HANDLE UNIX/WINDOWS LINE ENDINGS...
 
@@ -83,16 +96,17 @@ class FilesWriter(WriterBase):
                 "Support files will be in %s",
                 os.path.join(resources.get("output_files_dir", ""), ""),
             )
-        for filename, data in items:
-            # Determine where to write the file to
-            dest = os.path.join(build_directory, filename)
-            path = os.path.dirname(dest)
-            self._makedir(path)
+            self._write_items(items, build_directory)
 
-            # Write file
-            self.log.debug("Writing %i bytes to support file %s", len(data), dest)
-            with open(dest, "wb") as f:
-                f.write(data)
+        # Write the extracted attachments
+        # if ExtractAttachmentsOutput specified a separate directory
+        attachs = resources.get("attachments", {}).items()
+        if attachs:
+            self.log.info(
+                "Attachments will be in %s",
+                os.path.join(resources.get("attachment_files_dir", ""), ""),
+            )
+            self._write_items(attachs, build_directory)
 
         # Copy referenced files to output directory
         if build_directory:
diff --git a/pyproject.toml b/pyproject.toml
index 33fac7da..996a713d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -129,7 +129,7 @@ dependencies = [
   "black[jupyter]==23.3.0",
   "mdformat>0.7",
   "mdformat-gfm>=0.3.5",
-  "ruff==0.0.260"
+  "ruff==0.0.263"
 ]
 detached = true
 [tool.hatch.envs.lint.scripts]


Test patch:
diff --git a/nbconvert/exporters/tests/test_exporter.py b/nbconvert/exporters/tests/test_exporter.py
index 9b8c9133..ce021c8b 100644
--- a/nbconvert/exporters/tests/test_exporter.py
+++ b/nbconvert/exporters/tests/test_exporter.py
@@ -19,8 +19,8 @@ from unittest.mock import patch
 from traitlets.config import Config
 
 from ...preprocessors.base import Preprocessor
+from .. import Exporter, TemplateExporter
 from ..base import get_export_names
-from ..exporter import Exporter
 from .base import ExportersTestsBase
 
 # -----------------------------------------------------------------------------
@@ -38,6 +38,21 @@ class PizzaPreprocessor(Preprocessor):
         return nb, resources
 
 
+class DummyExporter(TemplateExporter):
+    """
+    Dummy exporter to check that parent default_config gets overwritten properly
+    """
+
+    @property
+    def default_config(self):
+        c = Config({"TagRemovePreprocessor": {"enabled": False}})
+        if super().default_config:
+            c2 = super().default_config.copy()
+            c2.merge(c)
+            c = c2
+        return c
+
+
 class TestExporter(ExportersTestsBase):
     """Contains test functions for exporter.py"""
 
@@ -87,3 +102,12 @@ class TestExporter(ExportersTestsBase):
         del os.environ["NBCONVERT_DISABLE_CONFIG_EXPORTERS"]
         export_names = get_export_names(config=config)
         self.assertEqual(export_names, ["notebook"])
+
+    def test_default_config_merge(self):
+        """
+        Do default_configs merge properly?
+        Class config should overwrite parent config
+        """
+        e = DummyExporter()
+        self.assertFalse(e.default_config["TagRemovePreprocessor"]["enabled"])
+        self.assertTrue(e.default_config["RegexRemovePreprocessor"]["enabled"])
diff --git a/nbconvert/preprocessors/tests/base.py b/nbconvert/preprocessors/tests/base.py
index d2bcc776..ba0b6351 100644
--- a/nbconvert/preprocessors/tests/base.py
+++ b/nbconvert/preprocessors/tests/base.py
@@ -3,6 +3,8 @@
 # Copyright (c) IPython Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+from base64 import b64encode
+
 from nbformat import v4 as nbformat
 
 from ...exporters.exporter import ResourcesDict
@@ -12,7 +14,7 @@ from ...tests.base import TestsBase
 class PreprocessorTestsBase(TestsBase):
     """Contains test functions preprocessor tests"""
 
-    def build_notebook(self, with_json_outputs=False):
+    def build_notebook(self, with_json_outputs=False, with_attachment=False):
         """Build a notebook in memory for use with preprocessor tests"""
 
         outputs = [
@@ -42,6 +44,19 @@ class PreprocessorTestsBase(TestsBase):
             nbformat.new_markdown_cell(source="$ e $"),
         ]
 
+        if with_attachment:
+            data = b"test"
+            encoded_data = b64encode(data)
+            # this is conversion of bytes to string, not base64 decoding
+            attachments = {"image.png": {"image/png": encoded_data.decode()}}
+            cells.extend(
+                [
+                    nbformat.new_markdown_cell(
+                        source="![image.png](attachment:image.png)", attachments=attachments
+                    )
+                ]
+            )
+
         return nbformat.new_notebook(cells=cells)
 
     def build_resources(self):
diff --git a/nbconvert/preprocessors/tests/test_extractattachments.py b/nbconvert/preprocessors/tests/test_extractattachments.py
new file mode 100644
index 00000000..e709b9ef
--- /dev/null
+++ b/nbconvert/preprocessors/tests/test_extractattachments.py
@@ -0,0 +1,87 @@
+"""Tests for the ExtractAttachments preprocessor"""
+
+# Copyright (c) IPython Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import os
+from base64 import b64decode
+
+from ..extractattachments import ExtractAttachmentsPreprocessor
+from .base import PreprocessorTestsBase
+
+
+class TestExtractAttachments(PreprocessorTestsBase):
+    """Contains test functions for extractattachments.py"""
+
+    def build_preprocessor(self):
+        """Make an instance of a preprocessor"""
+        preprocessor = ExtractAttachmentsPreprocessor()
+        preprocessor.enabled = True
+        return preprocessor
+
+    def test_constructor(self):
+        """Can a ExtractAttachmentsPreprocessor be constructed?"""
+        self.build_preprocessor()
+
+    def test_attachment(self):
+        """Test the output of the ExtractAttachmentsPreprocessor"""
+        nb = self.build_notebook(with_attachment=True)
+        res = self.build_resources()
+        preprocessor = self.build_preprocessor()
+        nb, res = preprocessor(nb, res)
+
+        # Check if attachment was extracted.
+        attachments = nb.cells[-1].attachments
+        self.assertIn("image.png", attachments)
+        self.assertIn("image/png", attachments["image.png"])
+        data = attachments["image.png"]["image/png"]
+        # convert to bytes, b64 decode, convert to str
+        data = b64decode(data.encode("utf-8"))
+        self.assertEqual(data, b"test")
+
+        # Verify attachment
+        self.assertIn("image.png", res["outputs"])
+        self.assertEqual(res["outputs"]["image.png"], b"test")
+
+        # Verify cell source changed appropriately
+        src = nb.cells[-1].source
+        self.assertEqual(src, "![image.png](image.png)")
+
+    def test_attachment_with_directory(self):
+        """Test that cell source modifications are correct when files are put in a directory"""
+        nb = self.build_notebook(with_attachment=True)
+        res = self.build_resources()
+        output_dir = "outputs"
+        res["output_files_dir"] = output_dir
+        preprocessor = self.build_preprocessor()
+        nb, res = preprocessor(nb, res)
+
+        # Verify attachment
+        # This can have "\\" separator on Windows
+        file_path = os.path.join("outputs", "image.png")
+        self.assertIn(file_path, res["outputs"])
+
+        # Verify cell source changed appropriately
+        src = nb.cells[-1].source
+        # This shouldn't change on Windows
+        self.assertEqual(src, "![image.png](outputs/image.png)")
+
+    def test_use_separate_dir_config(self):
+        """Test that use_separate_dir and attachment_directory_template work properly"""
+        nb = self.build_notebook(with_attachment=True)
+        res = self.build_resources()
+        res["unique_key"] = "notebook1"  # add notebook name for the folder
+        preprocessor = self.build_preprocessor()
+        preprocessor.use_separate_dir = True
+        preprocessor.attachments_directory_template = "{notebook_name}_custom"
+        nb, res = preprocessor(nb, res)
+
+        # Verify attachment
+        # This can have "\\" separator on Windows
+        file_path = os.path.join("notebook1_custom", "image.png")
+        self.assertIn(file_path, res["attachments"])
+
+        # Verify cell source changed appropriately
+        src = nb.cells[-1].source
+        # This shouldn't change on Windows
+        self.assertEqual(src, "![image.png](notebook1_custom/image.png)")
diff --git a/nbconvert/tests/base.py b/nbconvert/tests/base.py
index 6f437310..9077faa9 100644
--- a/nbconvert/tests/base.py
+++ b/nbconvert/tests/base.py
@@ -161,7 +161,7 @@ class TestsBase(unittest.TestCase):
             if isinstance(parameters, (str,)):
                 parameters = shlex.split(parameters)
             cmd += parameters
-        p = Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE)
+        p = Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE)  # noqa
         stdout, stderr = p.communicate(input=stdin)
         if not (p.returncode == 0 or ignore_return_code):
             raise OSError(stderr.decode("utf8", "replace"))
diff --git a/nbconvert/tests/test_nbconvertapp.py b/nbconvert/tests/test_nbconvertapp.py
index a05d5926..45deb10f 100644
--- a/nbconvert/tests/test_nbconvertapp.py
+++ b/nbconvert/tests/test_nbconvertapp.py
@@ -654,16 +654,31 @@ class TestNbConvertApp(TestsBase):
             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")
+        nbname = notebook_names[0]
+        with self.create_temp_cwd([nbname + ".ipynb"]):
+            self.nbconvert(f"{nbname}.ipynb --output notebook_test_name --to markdown")
             assert os.path.isfile("notebook_test_name.md")
 
+            self.nbconvert(f"{nbname}.ipynb --to notebook")
+            assert os.path.isfile("notebook1.nbconvert.ipynb")
+
+            self.nbconvert(f"{nbname}.ipynb --to notebook --output out.ipynb")
+            assert os.path.isfile("out.ipynb")
+
         # 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")
+
+    def test_same_filename_different_dir(self):
+        """
+        Check if files with same name in different directories pose a problem
+        """
+        with self.create_temp_cwd() as temp_wd:
+            self.copy_files_to(["notebook1.ipynb"], temp_wd + "/dir1")
+            self.copy_files_to(["notebook1.ipynb"], temp_wd + "/dir2")
+            self.nbconvert("dir1/notebook1.ipynb dir2/notebook1.ipynb --to markdown")
+            assert os.path.isfile("dir1/notebook1.md")
+            assert os.path.isfile("dir2/notebook1.md")
diff --git a/nbconvert/writers/tests/test_files.py b/nbconvert/writers/tests/test_files.py
index fcc0bf15..bca0e59b 100644
--- a/nbconvert/writers/tests/test_files.py
+++ b/nbconvert/writers/tests/test_files.py
@@ -19,7 +19,7 @@ class Testfiles(TestsBase):
 
         # Work in a temporary directory.
         with self.create_temp_cwd():
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res: dict = {}
 
             # Create files writer, test output
@@ -54,8 +54,11 @@ class Testfiles(TestsBase):
 
         # Work in a temporary directory.
         with self.create_temp_cwd():
-            # Create the resoruces dictionary
-            res = {"outputs": {os.path.join("z_files", "a"): b"b"}}
+            # Create the resources dictionary
+            res = {
+                "outputs": {os.path.join("z_files", "a"): b"b"},
+                "attachments": {os.path.join("z_attachments", "c"): b"d"},
+            }
 
             # Create files writer, test output
             writer = FilesWriter()
@@ -73,12 +76,18 @@ class Testfiles(TestsBase):
                 output = f.read()
                 self.assertEqual(output, "b")
 
+            attachment_file_dest = os.path.join("z_attachments", "c")
+            assert os.path.isfile(attachment_file_dest)
+            with open(attachment_file_dest) as f:
+                content = f.read()
+                self.assertEqual(content, "d")
+
     def test_build_dir(self):
         """Can FilesWriter write to a build dir correctly?"""
 
         # Work in a temporary directory.
         with self.create_temp_cwd():
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res = {"outputs": {os.path.join("z_files", "a"): b"b"}}
 
             # Create files writer, test output
@@ -122,7 +131,7 @@ class Testfiles(TestsBase):
             with open(os.path.join("sub", "c"), "w") as f:
                 f.write("d")
 
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res: dict = {}
 
             # Create files writer, test output
@@ -195,7 +204,7 @@ class Testfiles(TestsBase):
             with open(os.path.join("sub", "c"), "w") as f:
                 f.write("d")
 
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res: dict = {}
 
             # Create files writer, test output
@@ -229,7 +238,7 @@ class Testfiles(TestsBase):
             with open(os.path.join("sub", "c"), "w") as f:
                 f.write("d")
 
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res = {"metadata": {"path": "sub"}}
 
             # Create files writer, test output
@@ -262,7 +271,7 @@ class Testfiles(TestsBase):
             with open(os.path.join("sub", "c"), "w") as f:
                 f.write("d")
 
-            # Create the resoruces dictionary
+            # Create the resources dictionary
             res = {"metadata": {"path": "other_sub"}}
 
             # Create files writer, test output