diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 03bc155c63a6..c2b2cbac7a69 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -38,7 +38,8 @@ import contextlib import tempfile import warnings -from matplotlib.cbook import iterable, is_string_like, deprecated +from matplotlib.cbook import (iterable, is_string_like, deprecated, + fspath_no_except) from matplotlib.compat import subprocess from matplotlib import verbose from matplotlib import rcParams, rcParamsDefault, rc_context @@ -349,6 +350,7 @@ def _run(self): output = sys.stdout else: output = subprocess.PIPE + command = [fspath_no_except(cmd) for cmd in command] verbose.report('MovieWriter.run: running command: %s' % ' '.join(command)) self._proc = subprocess.Popen(command, shell=False, diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 8f3bce4396b5..67ed5d8b8804 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -31,7 +31,8 @@ from matplotlib import verbose, rcParams, __version__ from matplotlib.backend_bases import (RendererBase, FigureManagerBase, FigureCanvasBase) -from matplotlib.cbook import is_string_like, maxdict, restrict_dict +from matplotlib.cbook import (is_string_like, maxdict, restrict_dict, + fspath_no_except, to_filehandle) from matplotlib.figure import Figure from matplotlib.font_manager import findfont, get_font from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING, @@ -563,8 +564,9 @@ def print_png(self, filename_or_obj, *args, **kwargs): metadata.update(user_metadata) try: - _png.write_png(renderer._renderer, filename_or_obj, - self.figure.dpi, metadata=metadata) + _png.write_png( + renderer._renderer, fspath_no_except(filename_or_obj), + self.figure.dpi, metadata=metadata) finally: if close: filename_or_obj.close() @@ -620,7 +622,8 @@ def print_jpg(self, filename_or_obj, *args, **kwargs): if 'quality' not in options: options['quality'] = rcParams['savefig.jpeg_quality'] - return background.save(filename_or_obj, format='jpeg', **options) + return background.save(to_filehandle(filename_or_obj), format='jpeg', + **options) print_jpeg = print_jpg # add TIFF support @@ -630,7 +633,7 @@ def print_tif(self, filename_or_obj, *args, **kwargs): return image = Image.frombuffer('RGBA', size, buf, 'raw', 'RGBA', 0, 1) dpi = (self.figure.dpi, self.figure.dpi) - return image.save(filename_or_obj, format='tiff', + return image.save(to_filehandle(filename_or_obj), format='tiff', dpi=dpi) print_tiff = print_tif diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index abe502ab8b25..3476f30a58d5 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -35,7 +35,7 @@ FigureManagerBase, FigureCanvasBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.cbook import (Bunch, is_string_like, get_realpath_and_stat, - is_writable_file_like, maxdict) + is_writable_file_like, maxdict, fspath_no_except) from matplotlib.figure import Figure from matplotlib.font_manager import findfont, is_opentype_cff_font, get_font from matplotlib.afm import AFM @@ -434,6 +434,7 @@ def __init__(self, filename, metadata=None): self.passed_in_file_object = False self.original_file_like = None self.tell_base = 0 + filename = fspath_no_except(filename) if is_string_like(filename): fh = open(filename, 'wb') elif is_writable_file_like(filename): diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index 2bec9f2be6a4..012cc639464a 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -560,6 +560,7 @@ def to_filehandle(fname, flag='rU', return_opened=False): files is automatic, if the filename ends in .gz. *flag* is a read/write flag for :func:`file` """ + fname = fspath_no_except(fname) if is_string_like(fname): if fname.endswith('.gz'): # get rid of 'U' in flag for gzipped files. @@ -619,6 +620,7 @@ def get_sample_data(fname, asfileobj=True): root = matplotlib.rcParams['examples.directory'] else: root = os.path.join(matplotlib._get_data_path(), 'sample_data') + fname = fspath_no_except(fname) path = os.path.join(root, fname) if asfileobj: @@ -827,6 +829,7 @@ def __init__(self): self._cache = {} def __call__(self, path): + path = fspath_no_except(path) result = self._cache.get(path) if result is None: realpath = os.path.realpath(path) @@ -987,7 +990,7 @@ def listFiles(root, patterns='*', recurse=1, return_folders=0): pattern_list = patterns.split(';') results = [] - for dirname, dirs, files in os.walk(root): + for dirname, dirs, files in os.walk(fspath_no_except(root)): # Append to results all relevant files (and perhaps folders) for name in files: fullname = os.path.normpath(os.path.join(dirname, name)) @@ -1011,10 +1014,10 @@ def get_recursive_filelist(args): files = [] for arg in args: - if os.path.isfile(arg): + if os.path.isfile(fspath_no_except(arg)): files.append(arg) continue - if os.path.isdir(arg): + if os.path.isdir(fspath_no_except(arg)): newfiles = listFiles(arg, recurse=1, return_folders=1) files.extend(newfiles) @@ -1543,6 +1546,7 @@ def simple_linear_interpolation(a, steps): @deprecated('2.1', alternative='shutil.rmtree') def recursive_remove(path): + path = fspath_no_except(path) if os.path.isdir(path): for fname in (glob.glob(os.path.join(path, '*')) + glob.glob(os.path.join(path, '.*'))): @@ -2412,7 +2416,7 @@ class TimeoutError(RuntimeError): pass def __init__(self, path): - self.path = path + self.path = fspath_no_except(path) self.end = "-" + str(os.getpid()) self.lock_path = os.path.join(self.path, self.LOCKFN + self.end) self.pattern = os.path.join(self.path, self.LOCKFN + '-*') @@ -2703,3 +2707,45 @@ def _get_key_params(self): (params, str_func)) return str_func, params + + +try: + from os import fspath +except ImportError: + def fspath(path): + """ + Return the string representation of the path. + If str or bytes is passed in, it is returned unchanged. + This code comes from PEP 519, modified to support earlier versions of + python. + + This is required for python < 3.6. + """ + if isinstance(path, (six.text_type, six.binary_type)): + return path + + # Work from the object's type to match method resolution of other magic + # methods. + path_type = type(path) + try: + return path_type.__fspath__(path) + except AttributeError: + if hasattr(path_type, '__fspath__'): + raise + try: + import pathlib + except ImportError: + pass + else: + if isinstance(path, pathlib.PurePath): + return six.text_type(path) + + raise TypeError("expected str, bytes or os.PathLike object, not " + + path_type.__name__) + + +def fspath_no_except(path): + try: + return fspath(path) + except TypeError: + return path diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index aa62c700fa9c..15c56aa896fa 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -574,7 +574,7 @@ def write_png(self, fname): """Write the image to png file with fname""" im = self.to_rgba(self._A[::-1] if self.origin == 'lower' else self._A, bytes=True, norm=True) - _png.write_png(im, fname) + _png.write_png(im, cbook.fspath_no_except(fname)) def set_data(self, A): """ diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 4cdfe4bd8b9e..ecb1c3ad979f 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -4,6 +4,8 @@ import inspect import warnings from contextlib import contextmanager +import shutil +import tempfile import matplotlib from matplotlib.cbook import is_string_like, iterable @@ -144,3 +146,21 @@ def setup(): set_font_settings_for_testing() set_reproducibility_for_testing() + + +@contextmanager +def closed_tempfile(suffix='', text=None): + """ + Context manager which yields the path to a closed temporary file with the + suffix `suffix`. The file will be deleted on exiting the context. An + additional argument `text` can be provided to have the file contain `text`. + """ + with tempfile.NamedTemporaryFile( + 'w+t', suffix=suffix, delete=False + ) as test_file: + file_name = test_file.name + if text is not None: + test_file.write(text) + test_file.flush() + yield file_name + shutil.rmtree(file_name, ignore_errors=True) diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index 017727016fe0..4898d55d3d6d 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -3,9 +3,7 @@ import six -import os import sys -import tempfile import numpy as np import pytest @@ -13,6 +11,7 @@ import matplotlib as mpl from matplotlib import pyplot as plt from matplotlib import animation +from ..testing import closed_tempfile class NullMovieWriter(animation.AbstractMovieWriter): @@ -125,20 +124,7 @@ def isAvailable(self): ] -# Smoke test for saving animations. In the future, we should probably -# design more sophisticated tests which compare resulting frames a-la -# matplotlib.testing.image_comparison -@pytest.mark.parametrize('writer, extension', WRITER_OUTPUT) -def test_save_animation_smoketest(tmpdir, writer, extension): - try: - # for ImageMagick the rcparams must be patched to account for - # 'convert' being a built in MS tool, not the imagemagick - # tool. - writer._init_from_registry() - except AttributeError: - pass - if not animation.writers.is_available(writer): - pytest.skip("writer '%s' not available on this system" % writer) +def _inner_animation(writer): fig, ax = plt.subplots() line, = ax.plot([], []) @@ -162,17 +148,64 @@ def animate(i): y = np.sin(x + i) line.set_data(x, y) return line, + anim = animation.FuncAnimation(fig, animate, init_func=init, frames=5) + return fig, anim, dpi, codec + + +# Smoke test for saving animations. In the future, we should probably +# design more sophisticated tests which compare resulting frames a-la +# matplotlib.testing.image_comparison +@pytest.mark.parametrize('writer, extension', WRITER_OUTPUT) +def test_save_animation_smoketest(tmpdir, writer, extension): + try: + # for ImageMagick the rcparams must be patched to account for + # 'convert' being a built in MS tool, not the imagemagick + # tool. + writer._init_from_registry() + except AttributeError: + pass + if not animation.writers.is_available(writer): + pytest.skip("writer '%s' not available on this system" % writer) + + fig, anim, dpi, codec = _inner_animation(writer) # Use temporary directory for the file-based writers, which produce a file # per frame with known names. - with tmpdir.as_cwd(): - anim = animation.FuncAnimation(fig, animate, init_func=init, frames=5) - try: - anim.save('movie.' + extension, fps=30, writer=writer, bitrate=500, - dpi=dpi, codec=codec) - except UnicodeDecodeError: - pytest.xfail("There can be errors in the numpy import stack, " - "see issues #1891 and #2679") + + with closed_tempfile(suffix='.' + extension) as fname: + anim.save(fname, fps=30, writer=writer, bitrate=500) + + +@pytest.mark.parametrize('writer, extension', WRITER_OUTPUT) +def test_save_animation_pep_519(writer, extension='mp4'): + class FakeFSPathClass(object): + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + if not animation.writers.is_available(writer): + pytest.skip("writer '%s' not available on this system" % writer) + + fig, anim, dpi, codec = _inner_animation(writer) + with closed_tempfile(suffix='.' + extension) as fname: + anim.save(FakeFSPathClass(fname), fps=30, writer=writer, bitrate=500, + dpi=dpi, codec=codec) + + +@pytest.mark.parametrize('writer, extension', WRITER_OUTPUT) +def test_save_animation_pathlib(writer, extension='mp4'): + try: + from pathlib import Path + except ImportError: + raise pytest.skip("pathlib not installed") + if not animation.writers.is_available(writer): + pytest.skip("writer '%s' not available on this system" % writer) + + fig, anim, dpi, codec = _inner_animation(writer) + with closed_tempfile(suffix='.' + extension) as fname: + anim.save(Path(fname), fps=30, writer=writer, bitrate=500, + dpi=dpi, codec=codec) def test_no_length_frames(): diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 3529ea8541db..ae4a6e49b624 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -19,7 +19,7 @@ _determinism_check) from matplotlib.testing.decorators import image_comparison from matplotlib import dviread - +from matplotlib.testing import closed_tempfile needs_tex = pytest.mark.xfail( not checkdep_tex(), @@ -71,14 +71,12 @@ def test_multipage_pagecount(): def test_multipage_keep_empty(): from matplotlib.backends.backend_pdf import PdfPages - from tempfile import NamedTemporaryFile # test empty pdf files # test that an empty pdf is left behind with keep_empty=True (default) - with NamedTemporaryFile(delete=False) as tmp: + with closed_tempfile(".pdf") as tmp: with PdfPages(tmp) as pdf: filename = pdf._file.fh.name assert os.path.exists(filename) - os.remove(filename) # test if an empty pdf is deleting itself afterwards with keep_empty=False with PdfPages(filename, keep_empty=False) as pdf: pass @@ -88,19 +86,17 @@ def test_multipage_keep_empty(): ax = fig.add_subplot(111) ax.plot([1, 2, 3]) # test that a non-empty pdf is left behind with keep_empty=True (default) - with NamedTemporaryFile(delete=False) as tmp: + with closed_tempfile(".pdf") as tmp: with PdfPages(tmp) as pdf: filename = pdf._file.fh.name pdf.savefig() assert os.path.exists(filename) - os.remove(filename) # test that a non-empty pdf is left behind with keep_empty=False - with NamedTemporaryFile(delete=False) as tmp: + with closed_tempfile(".pdf") as tmp: with PdfPages(tmp, keep_empty=False) as pdf: filename = pdf._file.fh.name pdf.savefig() assert os.path.exists(filename) - os.remove(filename) def test_composite_image(): @@ -191,3 +187,32 @@ def psfont(*args, **kwargs): ax.text(0.5, 0.5, 'hello') with tempfile.TemporaryFile() as tmpfile, pytest.raises(ValueError): fig.savefig(tmpfile, format='pdf') + + +def test_pdfpages_accept_pep_519(): + class FakeFSPathClass(object): + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + with closed_tempfile(suffix='.pdf') as fname: + with PdfPages(FakeFSPathClass(fname)) as pdf: + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + pdf.savefig(fig) + + +def test_savefig_accept_pathlib(): + try: + from pathlib import Path + except ImportError: + raise pytest.skip("pathlib not installed") + + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + with closed_tempfile(suffix='.pdf') as fname: + with PdfPages(Path(fname)) as pdf: + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + pdf.savefig(fig) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 12a79639a3a8..cc4825cdf971 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -13,6 +13,7 @@ from numpy.testing.utils import (assert_array_equal, assert_approx_equal, assert_array_almost_equal) import pytest +from matplotlib.testing import closed_tempfile import matplotlib.cbook as cbook import matplotlib.colors as mcolors @@ -533,3 +534,27 @@ def test_bounded(self, string, bounded): func_parser = cbook._StringFuncParser(string) b = func_parser.is_bounded_0_1 assert_array_equal(b, bounded) + + +def test_to_filehandle_accept_pep_519(): + class FakeFSPathClass(object): + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + + with closed_tempfile() as tmpfile: + pep519_path = FakeFSPathClass(tmpfile) + cbook.to_filehandle(pep519_path) + + +def test_to_filehandle_accept_pathlib(): + try: + from pathlib import Path + except ImportError: + raise pytest.skip("pathlib not installed") + + with closed_tempfile() as tmpfile: + path = Path(tmpfile) + cbook.to_filehandle(path) diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index f072fa04364c..6d44e819a521 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -4,6 +4,7 @@ from numpy.testing import assert_equal from matplotlib import rcParams from matplotlib.testing.decorators import image_comparison +from matplotlib.testing import skip, closed_tempfile from matplotlib.axes import Axes from matplotlib.ticker import AutoMinorLocator, FixedFormatter import matplotlib.pyplot as plt @@ -273,3 +274,31 @@ def test_autofmt_xdate(which): if which in ('both', 'minor'): for label in fig.axes[0].get_xticklabels(True, 'minor'): assert int(label.get_rotation()) == angle + + +@pytest.mark.parametrize('ext', ['png', 'svg', 'pdf']) +def test_savefig_accept_pep_519(ext): + class FakeFSPathClass(object): + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + with closed_tempfile(suffix='.{}'.format(ext)) as fname: + fig.savefig(FakeFSPathClass(fname)) + + +@pytest.mark.parametrize('ext', ['png', 'svg', 'pdf']) +def test_savefig_accept_pathlib(ext): + try: + from pathlib import Path + except ImportError: + skip("pathlib not installed") + + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + with closed_tempfile(suffix='.{}'.format(ext)) as fname: + fig.savefig(Path(fname)) diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 5e7a63790667..274c93a25795 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -8,7 +8,7 @@ import warnings import pytest - +from matplotlib.testing import closed_tempfile from matplotlib.font_manager import ( findfont, FontProperties, fontManager, json_dump, json_load, get_font, get_fontconfig_fonts, is_opentype_cff_font, fontManager as fm) @@ -41,15 +41,9 @@ def test_font_priority(): def test_json_serialization(): # on windows, we can't open a file twice, so save the name and unlink # manually... - try: - name = None - with tempfile.NamedTemporaryFile(delete=False) as temp: - name = temp.name - json_dump(fontManager, name) - copy = json_load(name) - finally: - if name and os.path.exists(name): - os.remove(name) + with closed_tempfile(".json") as temp: + json_dump(fontManager, temp) + copy = json_load(temp) with warnings.catch_warnings(): warnings.filterwarnings('ignore', 'findfont: Font family.*not found') for prop in ({'family': 'STIXGeneral'}, diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index c94772924a75..1acd377d25cc 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -12,6 +12,7 @@ from matplotlib.testing.decorators import image_comparison from matplotlib.image import (AxesImage, BboxImage, FigureImage, NonUniformImage, PcolorImage) +from matplotlib.testing import closed_tempfile from matplotlib.transforms import Bbox, Affine2D, TransformedBbox from matplotlib import rcParams, rc_context from matplotlib import patches @@ -783,3 +784,29 @@ def test_empty_imshow(): def test_imshow_float128(): fig, ax = plt.subplots() ax.imshow(np.zeros((3, 3), dtype=np.longdouble)) + + +def test_imsave_accept_pep_519(): + class FakeFSPathClass(object): + def __init__(self, path): + self._path = path + + def __fspath__(self): + return self._path + + a = np.array([[1, 2], [3, 4]]) + with closed_tempfile(suffix='.pdf') as fname: + pep519_path = FakeFSPathClass(fname) + plt.imsave(pep519_path, a) + + +def test_imsave_accept_pathlib(): + try: + from pathlib import Path + except ImportError: + raise pytest.skip("pathlib not installed") + + a = np.array([[1, 2], [3, 4]]) + with closed_tempfile(suffix='.pdf') as fname: + path = Path(fname) + plt.imsave(path, a) diff --git a/lib/matplotlib/tests/test_testing.py b/lib/matplotlib/tests/test_testing.py new file mode 100644 index 000000000000..17d37fd6ba56 --- /dev/null +++ b/lib/matplotlib/tests/test_testing.py @@ -0,0 +1,21 @@ +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import os.path + +from matplotlib.testing import closed_tempfile + + +def test_closed_tempfile(): + with closed_tempfile(".txt") as fname: + assert os.path.exists(fname) + assert fname.endswith(".txt") + name = fname + assert os.path.exists(name) + + +def test_closed_tempfile_text(): + text = "This is a test" + with closed_tempfile(".txt", text=text) as f: + with open(f, "rt") as g: + assert text == g.read()