From 31d7393b18b83ecd4aaaac35745e8e583018bfe7 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 23 Feb 2019 13:22:03 -0500 Subject: [PATCH] Backport PR #13303: Unify checking of executable info. --- doc/api/next_api_changes/2018-01-27-AL.rst | 5 + lib/matplotlib/__init__.py | 202 +++++++++++++++------ lib/matplotlib/animation.py | 31 +--- lib/matplotlib/backends/backend_pgf.py | 31 +--- lib/matplotlib/backends/backend_ps.py | 9 +- lib/matplotlib/testing/compare.py | 27 ++- lib/matplotlib/tests/test_backend_ps.py | 14 +- 7 files changed, 198 insertions(+), 121 deletions(-) create mode 100644 doc/api/next_api_changes/2018-01-27-AL.rst diff --git a/doc/api/next_api_changes/2018-01-27-AL.rst b/doc/api/next_api_changes/2018-01-27-AL.rst new file mode 100644 index 000000000000..2c163bee7f62 --- /dev/null +++ b/doc/api/next_api_changes/2018-01-27-AL.rst @@ -0,0 +1,5 @@ +Deprecations +```````````` + +``checkdep_dvipng``, ``checkdep_ghostscript``, ``checkdep_pdftops``, and +``checkdep_inkscape`` are deprecated. diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 566cc14832aa..cf73206cdc77 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -115,9 +115,10 @@ """) import atexit +from collections import namedtuple from collections.abc import MutableMapping import contextlib -import distutils.version +from distutils.version import LooseVersion import functools import importlib import inspect @@ -178,9 +179,7 @@ def compare_versions(a, b): "3.0", message="compare_versions arguments should be strs.") b = b.decode('ascii') if a: - a = distutils.version.LooseVersion(a) - b = distutils.version.LooseVersion(b) - return a >= b + return LooseVersion(a) >= LooseVersion(b) else: return False @@ -194,7 +193,7 @@ def _check_versions(): ("pyparsing", "2.0.1"), ]: module = importlib.import_module(modname) - if distutils.version.LooseVersion(module.__version__) < minver: + if LooseVersion(module.__version__) < minver: raise ImportError("Matplotlib requires {}>={}; you have {}" .format(modname, minver, module.__version__)) @@ -276,6 +275,117 @@ def wrapper(): return wrapper +_ExecInfo = namedtuple("_ExecInfo", "executable version") + + +@functools.lru_cache() +def _get_executable_info(name): + """ + Get the version of some executable that Matplotlib optionally depends on. + + .. warning: + The list of executables that this function supports is set according to + Matplotlib's internal needs, and may change without notice. + + Parameters + ---------- + name : str + The executable to query. The following values are currently supported: + "dvipng", "gs", "inkscape", "magick", "pdftops". This list is subject + to change without notice. + + Returns + ------- + If the executable is found, a namedtuple with fields ``executable`` (`str`) + and ``version`` (`distutils.version.LooseVersion`, or ``None`` if the + version cannot be determined). + + Raises + ------ + FileNotFoundError + If the executable is not found or older than the oldest version + supported by Matplotlib. + ValueError + If the executable is not one that we know how to query. + """ + + def impl(args, regex, min_ver=None): + # Execute the subprocess specified by args; capture stdout and stderr. + # Search for a regex match in the output; if the match succeeds, the + # first group of the match is the version. + # Return an _ExecInfo if the executable exists, and has a version of + # at least min_ver (if set); else, raise FileNotFoundError. + output = subprocess.check_output( + args, stderr=subprocess.STDOUT, universal_newlines=True) + match = re.search(regex, output) + if match: + version = LooseVersion(match.group(1)) + if min_ver is not None and version < min_ver: + raise FileNotFoundError( + f"You have {args[0]} version {version} but the minimum " + f"version supported by Matplotlib is {min_ver}.") + return _ExecInfo(args[0], version) + else: + raise FileNotFoundError( + f"Failed to determine the version of {args[0]} from " + f"{' '.join(args)}, which output {output}") + + if name == "dvipng": + return impl(["dvipng", "-version"], "(?m)^dvipng .* (.+)", "1.6") + elif name == "gs": + execs = (["gswin32c", "gswin64c", "mgs", "gs"] # "mgs" for miktex. + if sys.platform == "win32" else + ["gs"]) + for e in execs: + try: + return impl([e, "--version"], "(.*)", "9") + except FileNotFoundError: + pass + raise FileNotFoundError("Failed to find a Ghostscript installation") + elif name == "inkscape": + return impl(["inkscape", "-V"], "^Inkscape ([^ ]*)") + elif name == "magick": + path = None + if sys.platform == "win32": + # Check the registry to avoid confusing ImageMagick's convert with + # Windows's builtin convert.exe. + import winreg + binpath = "" + for flag in [0, winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY]: + try: + with winreg.OpenKeyEx( + winreg.HKEY_LOCAL_MACHINE, + r"Software\Imagemagick\Current", + 0, winreg.KEY_QUERY_VALUE | flag) as hkey: + binpath = winreg.QueryValueEx(hkey, "BinPath")[0] + except OSError: + pass + if binpath: + for name in ["convert.exe", "magick.exe"]: + candidate = Path(binpath, name) + if candidate.exists(): + path = candidate + break + else: + path = "convert" + if path is None: + raise FileNotFoundError( + "Failed to find an ImageMagick installation") + return impl([path, "--version"], r"^Version: ImageMagick (\S*)") + elif name == "pdftops": + info = impl(["pdftops", "-v"], "^pdftops version (.*)") + if info and not ("3.0" <= info.version + # poppler version numbers. + or "0.9" <= info.version <= "1.0"): + raise FileNotFoundError( + f"You have pdftops version {info.version} but the minimum " + f"version supported by Matplotlib is 3.0.") + return info + else: + raise ValueError("Unknown executable: {!r}".format(name)) + + +@cbook.deprecated("3.1") def checkdep_dvipng(): try: s = subprocess.Popen(['dvipng', '-version'], @@ -289,6 +399,7 @@ def checkdep_dvipng(): return None +@cbook.deprecated("3.1") def checkdep_ghostscript(): if checkdep_ghostscript.executable is None: if sys.platform == 'win32': @@ -314,6 +425,7 @@ def checkdep_ghostscript(): checkdep_ghostscript.version = None +@cbook.deprecated("3.1") def checkdep_pdftops(): try: s = subprocess.Popen(['pdftops', '-v'], stdout=subprocess.PIPE, @@ -328,6 +440,7 @@ def checkdep_pdftops(): return None +@cbook.deprecated("3.1") def checkdep_inkscape(): if checkdep_inkscape.version is None: try: @@ -350,64 +463,39 @@ def checkdep_inkscape(): def checkdep_ps_distiller(s): if not s: return False - - flag = True - gs_exec, gs_v = checkdep_ghostscript() - if not gs_exec: - flag = False - _log.warning('matplotlibrc ps.usedistiller option can not be used ' - 'unless ghostscript 9.0 or later is installed on your ' - 'system.') - - if s == 'xpdf': - pdftops_req = '3.0' - pdftops_req_alt = '0.9' # poppler version numbers, ugh - pdftops_v = checkdep_pdftops() - if compare_versions(pdftops_v, pdftops_req): - pass - elif (compare_versions(pdftops_v, pdftops_req_alt) and not - compare_versions(pdftops_v, '1.0')): - pass - else: - flag = False - _log.warning('matplotlibrc ps.usedistiller can not be set to xpdf ' - 'unless xpdf-%s or later is installed on your ' - 'system.', pdftops_req) - - if flag: - return s - else: + try: + _get_executable_info("gs") + except FileNotFoundError: + _log.warning( + "Setting rcParams['ps.usedistiller'] requires ghostscript.") return False + if s == "xpdf": + try: + _get_executable_info("pdftops") + except FileNotFoundError: + _log.warning( + "Setting rcParams['ps.usedistiller'] to 'xpdf' requires xpdf.") + return False + return s def checkdep_usetex(s): if not s: return False - - gs_req = '9.00' - dvipng_req = '1.6' - flag = True - - if shutil.which("tex") is None: - flag = False - _log.warning('matplotlibrc text.usetex option can not be used unless ' - 'TeX is installed on your system.') - - dvipng_v = checkdep_dvipng() - if not compare_versions(dvipng_v, dvipng_req): - flag = False - _log.warning('matplotlibrc text.usetex can not be used with *Agg ' - 'backend unless dvipng-%s or later is installed on ' - 'your system.', dvipng_req) - - gs_exec, gs_v = checkdep_ghostscript() - if not compare_versions(gs_v, gs_req): - flag = False - _log.warning('matplotlibrc text.usetex can not be used unless ' - 'ghostscript-%s or later is installed on your system.', - gs_req) - - return flag + if not shutil.which("tex"): + _log.warning("usetex mode requires TeX.") + return False + try: + _get_executable_info("dvipng") + except FileNotFoundError: + _log.warning("usetex mode requires dvipng.") + return False + try: + _get_executable_info("gs") + except FileNotFoundError: + _log.warning("usetex mode requires ghostscript.") + return False + return True @_logged_cached('$HOME=%s') diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index f6376ed9c051..3ab6f0bd2338 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -33,6 +33,7 @@ import numpy as np +import matplotlib as mpl from matplotlib._animation_data import ( DISPLAY_TEMPLATE, INCLUDED_FRAMES, JS_INCLUDE, STYLE_INCLUDE) from matplotlib import cbook, rcParams, rcParamsDefault, rc_context @@ -709,29 +710,17 @@ def output_args(self): @classmethod def bin_path(cls): binpath = super().bin_path() - if sys.platform == 'win32' and binpath == 'convert': - # Check the registry to avoid confusing ImageMagick's convert with - # Windows's builtin convert.exe. - import winreg - binpath = '' - for flag in (0, winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY): - try: - with winreg.OpenKeyEx( - winreg.HKEY_LOCAL_MACHINE, - r'Software\Imagemagick\Current', - 0, winreg.KEY_QUERY_VALUE | flag) as hkey: - parent = winreg.QueryValueEx(hkey, 'BinPath')[0] - except OSError: - pass - if binpath: - for exe in ('convert.exe', 'magick.exe'): - candidate = os.path.join(parent, exe) - if os.path.exists(candidate): - binpath = candidate - break - rcParams[cls.exec_key] = rcParamsDefault[cls.exec_key] = binpath + if binpath == 'convert': + binpath = mpl._get_executable_info('magick').executable return binpath + @classmethod + def isAvailable(cls): + try: + return super().isAvailable() + except FileNotFoundError: # May be raised by get_executable_info. + return False + # Combine ImageMagick options with pipe-based writing @writers.register('imagemagick') diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 1c4a61fa2796..fa5e520b617f 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -145,32 +145,20 @@ def _font_properties_str(prop): def make_pdf_to_png_converter(): - """ - Returns a function that converts a pdf file to a png file. - """ - - tools_available = [] - # check for pdftocairo - try: - subprocess.check_output(["pdftocairo", "-v"], stderr=subprocess.STDOUT) - tools_available.append("pdftocairo") - except OSError: - pass - # check for ghostscript - gs, ver = mpl.checkdep_ghostscript() - if gs: - tools_available.append("gs") - - # pick converter - if "pdftocairo" in tools_available: + """Returns a function that converts a pdf file to a png file.""" + if shutil.which("pdftocairo"): def cairo_convert(pdffile, pngfile, dpi): cmd = ["pdftocairo", "-singlefile", "-png", "-r", "%d" % dpi, pdffile, os.path.splitext(pngfile)[0]] subprocess.check_output(cmd, stderr=subprocess.STDOUT) return cairo_convert - elif "gs" in tools_available: + try: + gs_info = mpl._get_executable_info("gs") + except FileNotFoundError: + pass + else: def gs_convert(pdffile, pngfile, dpi): - cmd = [gs, + cmd = [gs_info.executable, '-dQUIET', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT', '-dUseCIEColor', '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', '-dDOINTERPOLATE', @@ -178,8 +166,7 @@ def gs_convert(pdffile, pngfile, dpi): '-r%d' % dpi, pdffile] subprocess.check_output(cmd, stderr=subprocess.STDOUT) return gs_convert - else: - raise RuntimeError("No suitable pdf to png renderer found.") + raise RuntimeError("No suitable pdf to png renderer found.") class LatexError(Exception): diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 529bed5f41c1..549ad84141a4 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -17,6 +17,7 @@ import numpy as np +import matplotlib as mpl from matplotlib import ( cbook, _path, __version__, rcParams, checkdep_ghostscript) from matplotlib.backend_bases import ( @@ -1359,11 +1360,11 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): psfile = tmpfile + '.ps' dpi = rcParams['ps.distiller.res'] - gs_exe, gs_version = checkdep_ghostscript() cbook._check_and_log_subprocess( - [gs_exe, "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, - "-sDEVICE=ps2write", paper_option, - "-sOutputFile=%s" % psfile, tmpfile], _log) + [mpl._get_executable_info("gs").executable, + "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write", + paper_option, "-sOutputFile=%s" % psfile, tmpfile], + _log) os.remove(tmpfile) shutil.move(psfile, tmpfile) diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index 9bab7e02c4d6..66409dbe8701 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -15,7 +15,7 @@ import numpy as np -import matplotlib +import matplotlib as mpl from matplotlib.testing.exceptions import ImageComparisonFailure from matplotlib import _png, cbook @@ -76,7 +76,7 @@ def compare_float(expected, actual, relTol=None, absTol=None): def get_cache_dir(): - cachedir = matplotlib.get_cachedir() + cachedir = mpl.get_cachedir() if cachedir is None: raise RuntimeError('Could not find a suitable configuration directory') cache_dir = os.path.join(cachedir, 'test_cache') @@ -99,11 +99,11 @@ def get_file_hash(path, block_size=2 ** 20): md5.update(data) if path.endswith('.pdf'): - from matplotlib import checkdep_ghostscript - md5.update(checkdep_ghostscript()[1].encode('utf-8')) + md5.update(str(mpl._get_executable_info("gs").version) + .encode('utf-8')) elif path.endswith('.svg'): - from matplotlib import checkdep_inkscape - md5.update(checkdep_inkscape().encode('utf-8')) + md5.update(str(mpl._get_executable_info("inkscape").version) + .encode('utf-8')) return md5.hexdigest() @@ -175,7 +175,7 @@ def __call__(self, orig, dest): if not self._proc: self._stdout = TemporaryFile() self._proc = subprocess.Popen( - [matplotlib.checkdep_ghostscript.executable, + [mpl._get_executable_info("gs").executable, "-dNOPAUSE", "-sDEVICE=png16m"], # As far as I can see, ghostscript never outputs to stderr. stdin=subprocess.PIPE, stdout=subprocess.PIPE) @@ -264,10 +264,17 @@ def __call__(self, orig, dest): def _update_converter(): - gs, gs_v = matplotlib.checkdep_ghostscript() - if gs_v is not None: + try: + mpl._get_executable_info("gs") + except FileNotFoundError: + pass + else: converter['pdf'] = converter['eps'] = _GSConverter() - if matplotlib.checkdep_inkscape() is not None: + try: + mpl._get_executable_info("inkscape") + except FileNotFoundError: + pass + else: converter['svg'] = _SVGConverter() diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index a8237f77f094..ba8730cc0718 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -7,7 +7,7 @@ import pytest -import matplotlib +import matplotlib as mpl import matplotlib.pyplot as plt from matplotlib import cbook, patheffects from matplotlib.testing.decorators import image_comparison @@ -18,10 +18,10 @@ with warnings.catch_warnings(): warnings.simplefilter('ignore') needs_ghostscript = pytest.mark.skipif( - matplotlib.checkdep_ghostscript()[0] is None, + "eps" not in mpl.testing.compare.converter, reason="This test needs a ghostscript installation") needs_usetex = pytest.mark.skipif( - not matplotlib.checkdep_usetex(True), + not mpl.checkdep_usetex(True), reason="This test needs a TeX installation") @@ -46,7 +46,7 @@ 'eps with usetex' ]) def test_savefig_to_stringio(format, use_log, rcParams): - matplotlib.rcParams.update(rcParams) + mpl.rcParams.update(rcParams) fig, ax = plt.subplots() @@ -71,8 +71,8 @@ def test_savefig_to_stringio(format, use_log, rcParams): def test_patheffects(): - with matplotlib.rc_context(): - matplotlib.rcParams['path.effects'] = [ + with mpl.rc_context(): + mpl.rcParams['path.effects'] = [ patheffects.withStroke(linewidth=4, foreground='w')] fig, ax = plt.subplots() ax.plot([1, 2, 3]) @@ -135,7 +135,7 @@ def test_failing_latex(tmpdir): """Test failing latex subprocess call""" path = str(tmpdir.join("tmpoutput.ps")) - matplotlib.rcParams['text.usetex'] = True + mpl.rcParams['text.usetex'] = True # This fails with "Double subscript" plt.xlabel("$22_2_2$")