Repository: jupyter/nbconvert
Pull request #1723 (2022-03-07 12:56:13)

Drop dependency on testpath.
Instead, backport the tiny chdir contextmanager from CPython to replace TemporaryWorkingDirectory.

Followup to #1596

blink1073: I think it would be cleaner to add the `TemporaryWorkingDirectory` class directly to a local util rather than use a more complicated context manager.
blink1073: Fair enough, no strong objection.  Thanks!

Patch:
diff --git a/nbconvert/exporters/pdf.py b/nbconvert/exporters/pdf.py
index 3fbef9e3..193045cb 100644
--- a/nbconvert/exporters/pdf.py
+++ b/nbconvert/exporters/pdf.py
@@ -6,11 +6,13 @@
 import subprocess
 import os
 import sys
+from tempfile import TemporaryDirectory
 
 import shutil
 from traitlets import Integer, List, Bool, Instance, Unicode, default
-from testpath.tempdir import TemporaryWorkingDirectory
+from typing import Optional
 from .latex import LatexExporter
+from ..utils import _contextlib_chdir
 
 class LatexFailed(IOError):
     """Exception for failed latex run
@@ -174,7 +176,7 @@ class PDFExporter(LatexExporter):
             self.texinputs = os.getcwd()
 
         self._captured_outputs = []
-        with TemporaryWorkingDirectory():
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
             notebook_name = 'notebook'
             resources['output_extension'] = '.tex'
             tex_file = self.writer.write(latex, resources, notebook_name=notebook_name)
diff --git a/nbconvert/utils/_contextlib_chdir.py b/nbconvert/utils/_contextlib_chdir.py
new file mode 100644
index 00000000..70f1ee99
--- /dev/null
+++ b/nbconvert/utils/_contextlib_chdir.py
@@ -0,0 +1,20 @@
+"""Backport of Python 3.11's contextlib.chdir."""
+
+
+from contextlib import AbstractContextManager
+import os
+
+
+class chdir(AbstractContextManager):
+    """Non thread-safe context manager to change the current working directory."""
+
+    def __init__(self, path):
+        self.path = path
+        self._old_cwd = []
+
+    def __enter__(self):
+        self._old_cwd.append(os.getcwd())
+        os.chdir(self.path)
+
+    def __exit__(self, *excinfo):
+        os.chdir(self._old_cwd.pop())
diff --git a/setup.py b/setup.py
index dfb59d0c..d80532e5 100644
--- a/setup.py
+++ b/setup.py
@@ -222,7 +222,6 @@ setup_args['install_requires'] = [
     'entrypoints>=0.2.2',
     'bleach',
     'pandocfilters>=1.4.1',
-    'testpath',
     'defusedxml',
     'beautifulsoup4',
     'nbclient>=0.5.0,<0.6.0',


Test patch:
diff --git a/nbconvert/exporters/tests/test_templateexporter.py b/nbconvert/exporters/tests/test_templateexporter.py
index f0bbe1bb..da5585d8 100644
--- a/nbconvert/exporters/tests/test_templateexporter.py
+++ b/nbconvert/exporters/tests/test_templateexporter.py
@@ -14,6 +14,7 @@ from jinja2 import TemplateNotFound
 from nbformat import v4
 from unittest.mock import patch
 from concurrent.futures import ProcessPoolExecutor
+from tempfile import TemporaryDirectory
 
 from .base import ExportersTestsBase
 from .cheese import CheesePreprocessor
@@ -21,7 +22,7 @@ from ..templateexporter import TemplateExporter
 from ..rst import RSTExporter
 from ..html import HTMLExporter
 from ..markdown import MarkdownExporter
-from testpath import tempdir
+from ...utils import _contextlib_chdir
 
 import pytest
 
@@ -128,7 +129,7 @@ class TestExporter(ExportersTestsBase):
         assert len(output) > 0
 
     def test_absolute_template_file(self):
-        with tempdir.TemporaryDirectory() as td:
+        with TemporaryDirectory() as td:
             template = os.path.join(td, 'abstemplate.ext.j2')
             test_output = 'absolute!'
             with open(template, 'w') as f:
@@ -140,7 +141,7 @@ class TestExporter(ExportersTestsBase):
             assert os.path.dirname(template) in exporter.template_paths
 
     def test_relative_template_file(self):
-        with tempdir.TemporaryWorkingDirectory() as td:
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
             with patch('os.getcwd', return_value=os.path.abspath(td)):
                 template = os.path.join('relative', 'relative_template.ext.j2')
                 template_abs = os.path.abspath(os.path.join(td, template))
@@ -155,7 +156,7 @@ class TestExporter(ExportersTestsBase):
                 assert os.path.dirname(template_abs) in [os.path.abspath(d) for d in exporter.template_paths]
 
     def test_absolute_template_file_compatibility(self):
-        with tempdir.TemporaryDirectory() as td:
+        with TemporaryDirectory() as td:
             template = os.path.join(td, 'abstemplate.tpl')
             test_output = 'absolute!'
             with open(template, 'w') as f:
@@ -168,7 +169,7 @@ class TestExporter(ExportersTestsBase):
             assert os.path.dirname(template) in exporter.template_paths
 
     def test_relative_template_file_compatibility(self):
-        with tempdir.TemporaryWorkingDirectory() as td:
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
             with patch('os.getcwd', return_value=os.path.abspath(td)):
                 template = os.path.join('relative', 'relative_template.tpl')
                 template_abs = os.path.abspath(os.path.join(td, template))
@@ -184,7 +185,7 @@ class TestExporter(ExportersTestsBase):
                 assert os.path.dirname(template_abs) in [os.path.abspath(d) for d in exporter.template_paths]
 
     def test_absolute_template_name_tpl_compatibility(self):
-        with tempdir.TemporaryDirectory() as td:
+        with TemporaryDirectory() as td:
             template = os.path.join(td, 'abstemplate.tpl')
             test_output = 'absolute!'
             with open(template, 'w') as f:
@@ -218,7 +219,7 @@ class TestExporter(ExportersTestsBase):
 
     # Can't use @pytest.mark.parametrize without removing all self.assert calls in all tests... repeating some here
     def relative_template_test(self, template):
-        with tempdir.TemporaryWorkingDirectory() as td:
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
             with patch('os.getcwd', return_value=os.path.abspath(td)):
                 template_abs = os.path.abspath(os.path.join(td, template))
                 dirname = os.path.dirname(template_abs)
@@ -248,7 +249,7 @@ class TestExporter(ExportersTestsBase):
         self.relative_template_test(os.path.join('.', 'relative', 'relative_template.tpl'))
 
     def test_absolute_template_dir(self):
-        with tempdir.TemporaryDirectory() as td:
+        with TemporaryDirectory() as td:
             template = 'mytemplate'
             template_file = os.path.join(td, template, 'index.py.j2')
             template_dir = os.path.dirname(template_file)
@@ -265,7 +266,7 @@ class TestExporter(ExportersTestsBase):
             assert os.path.join(td, template) in exporter.template_paths
 
     def test_local_template_dir(self):
-        with tempdir.TemporaryWorkingDirectory() as td:
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
             with patch('os.getcwd', return_value=os.path.abspath(td)):
                 template = 'mytemplate'
                 template_file = os.path.join(template, 'index.py.j2')
diff --git a/nbconvert/tests/base.py b/nbconvert/tests/base.py
index 9f6d7a8e..c5bfe414 100644
--- a/nbconvert/tests/base.py
+++ b/nbconvert/tests/base.py
@@ -3,6 +3,7 @@
 # Copyright (c) IPython Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+import contextlib
 import io
 import os
 import glob
@@ -12,9 +13,11 @@ import sys
 import unittest
 import nbconvert
 from subprocess import Popen, PIPE
+from tempfile import TemporaryDirectory
 
 from nbformat import v4, write
-from testpath.tempdir import TemporaryWorkingDirectory
+
+from ..utils import _contextlib_chdir
 
 
 class TestsBase(unittest.TestCase):
@@ -86,15 +89,12 @@ class TestsBase(unittest.TestCase):
             text = text.replace(search, replacement)
         return text
 
+    @contextlib.contextmanager
     def create_temp_cwd(self, copy_filenames=None):
-        temp_dir = TemporaryWorkingDirectory()
-
-        #Copy the files if requested.
-        if copy_filenames is not None:
-            self.copy_files_to(copy_filenames, dest=temp_dir.name)
-
-        #Return directory handler
-        return temp_dir
+        with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
+            if copy_filenames is not None:  # Copy the files if requested.
+                self.copy_files_to(copy_filenames, dest=td)
+            yield td  # Return directory handler
 
     @classmethod
     def merge_dicts(cls, *dict_args):