Skip to content

Commit ea3be54

Browse files
authored
Merge pull request #13303 from anntzer/execcheck
TST/MNT: Unify checking of executable info.
2 parents 2658eef + 32ed5bd commit ea3be54

File tree

7 files changed

+198
-121
lines changed

7 files changed

+198
-121
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Deprecations
2+
````````````
3+
4+
``checkdep_dvipng``, ``checkdep_ghostscript``, ``checkdep_pdftops``, and
5+
``checkdep_inkscape`` are deprecated.

lib/matplotlib/__init__.py

Lines changed: 145 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@
115115
""")
116116

117117
import atexit
118+
from collections import namedtuple
118119
from collections.abc import MutableMapping
119120
import contextlib
120-
import distutils.version
121+
from distutils.version import LooseVersion
121122
import functools
122123
import importlib
123124
import inspect
@@ -178,9 +179,7 @@ def compare_versions(a, b):
178179
"3.0", message="compare_versions arguments should be strs.")
179180
b = b.decode('ascii')
180181
if a:
181-
a = distutils.version.LooseVersion(a)
182-
b = distutils.version.LooseVersion(b)
183-
return a >= b
182+
return LooseVersion(a) >= LooseVersion(b)
184183
else:
185184
return False
186185

@@ -194,7 +193,7 @@ def _check_versions():
194193
("pyparsing", "2.0.1"),
195194
]:
196195
module = importlib.import_module(modname)
197-
if distutils.version.LooseVersion(module.__version__) < minver:
196+
if LooseVersion(module.__version__) < minver:
198197
raise ImportError("Matplotlib requires {}>={}; you have {}"
199198
.format(modname, minver, module.__version__))
200199

@@ -276,6 +275,117 @@ def wrapper():
276275
return wrapper
277276

278277

278+
_ExecInfo = namedtuple("_ExecInfo", "executable version")
279+
280+
281+
@functools.lru_cache()
282+
def _get_executable_info(name):
283+
"""
284+
Get the version of some executable that Matplotlib optionally depends on.
285+
286+
.. warning:
287+
The list of executables that this function supports is set according to
288+
Matplotlib's internal needs, and may change without notice.
289+
290+
Parameters
291+
----------
292+
name : str
293+
The executable to query. The following values are currently supported:
294+
"dvipng", "gs", "inkscape", "magick", "pdftops". This list is subject
295+
to change without notice.
296+
297+
Returns
298+
-------
299+
If the executable is found, a namedtuple with fields ``executable`` (`str`)
300+
and ``version`` (`distutils.version.LooseVersion`, or ``None`` if the
301+
version cannot be determined).
302+
303+
Raises
304+
------
305+
FileNotFoundError
306+
If the executable is not found or older than the oldest version
307+
supported by Matplotlib.
308+
ValueError
309+
If the executable is not one that we know how to query.
310+
"""
311+
312+
def impl(args, regex, min_ver=None):
313+
# Execute the subprocess specified by args; capture stdout and stderr.
314+
# Search for a regex match in the output; if the match succeeds, the
315+
# first group of the match is the version.
316+
# Return an _ExecInfo if the executable exists, and has a version of
317+
# at least min_ver (if set); else, raise FileNotFoundError.
318+
output = subprocess.check_output(
319+
args, stderr=subprocess.STDOUT, universal_newlines=True)
320+
match = re.search(regex, output)
321+
if match:
322+
version = LooseVersion(match.group(1))
323+
if min_ver is not None and version < min_ver:
324+
raise FileNotFoundError(
325+
f"You have {args[0]} version {version} but the minimum "
326+
f"version supported by Matplotlib is {min_ver}.")
327+
return _ExecInfo(args[0], version)
328+
else:
329+
raise FileNotFoundError(
330+
f"Failed to determine the version of {args[0]} from "
331+
f"{' '.join(args)}, which output {output}")
332+
333+
if name == "dvipng":
334+
return impl(["dvipng", "-version"], "(?m)^dvipng .* (.+)", "1.6")
335+
elif name == "gs":
336+
execs = (["gswin32c", "gswin64c", "mgs", "gs"] # "mgs" for miktex.
337+
if sys.platform == "win32" else
338+
["gs"])
339+
for e in execs:
340+
try:
341+
return impl([e, "--version"], "(.*)", "9")
342+
except FileNotFoundError:
343+
pass
344+
raise FileNotFoundError("Failed to find a Ghostscript installation")
345+
elif name == "inkscape":
346+
return impl(["inkscape", "-V"], "^Inkscape ([^ ]*)")
347+
elif name == "magick":
348+
path = None
349+
if sys.platform == "win32":
350+
# Check the registry to avoid confusing ImageMagick's convert with
351+
# Windows's builtin convert.exe.
352+
import winreg
353+
binpath = ""
354+
for flag in [0, winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY]:
355+
try:
356+
with winreg.OpenKeyEx(
357+
winreg.HKEY_LOCAL_MACHINE,
358+
r"Software\Imagemagick\Current",
359+
0, winreg.KEY_QUERY_VALUE | flag) as hkey:
360+
binpath = winreg.QueryValueEx(hkey, "BinPath")[0]
361+
except OSError:
362+
pass
363+
if binpath:
364+
for name in ["convert.exe", "magick.exe"]:
365+
candidate = Path(binpath, name)
366+
if candidate.exists():
367+
path = candidate
368+
break
369+
else:
370+
path = "convert"
371+
if path is None:
372+
raise FileNotFoundError(
373+
"Failed to find an ImageMagick installation")
374+
return impl([path, "--version"], r"^Version: ImageMagick (\S*)")
375+
elif name == "pdftops":
376+
info = impl(["pdftops", "-v"], "^pdftops version (.*)")
377+
if info and not ("3.0" <= info.version
378+
# poppler version numbers.
379+
or "0.9" <= info.version <= "1.0"):
380+
raise FileNotFoundError(
381+
f"You have pdftops version {info.version} but the minimum "
382+
f"version supported by Matplotlib is 3.0.")
383+
return info
384+
else:
385+
raise ValueError("Unknown executable: {!r}".format(name))
386+
387+
388+
@cbook.deprecated("3.1")
279389
def checkdep_dvipng():
280390
try:
281391
s = subprocess.Popen(['dvipng', '-version'],
@@ -289,6 +399,7 @@ def checkdep_dvipng():
289399
return None
290400

291401

402+
@cbook.deprecated("3.1")
292403
def checkdep_ghostscript():
293404
if checkdep_ghostscript.executable is None:
294405
if sys.platform == 'win32':
@@ -314,6 +425,7 @@ def checkdep_ghostscript():
314425
checkdep_ghostscript.version = None
315426

316427

428+
@cbook.deprecated("3.1")
317429
def checkdep_pdftops():
318430
try:
319431
s = subprocess.Popen(['pdftops', '-v'], stdout=subprocess.PIPE,
@@ -328,6 +440,7 @@ def checkdep_pdftops():
328440
return None
329441

330442

443+
@cbook.deprecated("3.1")
331444
def checkdep_inkscape():
332445
if checkdep_inkscape.version is None:
333446
try:
@@ -350,64 +463,39 @@ def checkdep_inkscape():
350463
def checkdep_ps_distiller(s):
351464
if not s:
352465
return False
353-
354-
flag = True
355-
gs_exec, gs_v = checkdep_ghostscript()
356-
if not gs_exec:
357-
flag = False
358-
_log.warning('matplotlibrc ps.usedistiller option can not be used '
359-
'unless ghostscript 9.0 or later is installed on your '
360-
'system.')
361-
362-
if s == 'xpdf':
363-
pdftops_req = '3.0'
364-
pdftops_req_alt = '0.9' # poppler version numbers, ugh
365-
pdftops_v = checkdep_pdftops()
366-
if compare_versions(pdftops_v, pdftops_req):
367-
pass
368-
elif (compare_versions(pdftops_v, pdftops_req_alt) and not
369-
compare_versions(pdftops_v, '1.0')):
370-
pass
371-
else:
372-
flag = False
373-
_log.warning('matplotlibrc ps.usedistiller can not be set to xpdf '
374-
'unless xpdf-%s or later is installed on your '
375-
'system.', pdftops_req)
376-
377-
if flag:
378-
return s
379-
else:
466+
try:
467+
_get_executable_info("gs")
468+
except FileNotFoundError:
469+
_log.warning(
470+
"Setting rcParams['ps.usedistiller'] requires ghostscript.")
380471
return False
472+
if s == "xpdf":
473+
try:
474+
_get_executable_info("pdftops")
475+
except FileNotFoundError:
476+
_log.warning(
477+
"Setting rcParams['ps.usedistiller'] to 'xpdf' requires xpdf.")
478+
return False
479+
return s
381480

382481

383482
def checkdep_usetex(s):
384483
if not s:
385484
return False
386-
387-
gs_req = '9.00'
388-
dvipng_req = '1.6'
389-
flag = True
390-
391-
if shutil.which("tex") is None:
392-
flag = False
393-
_log.warning('matplotlibrc text.usetex option can not be used unless '
394-
'TeX is installed on your system.')
395-
396-
dvipng_v = checkdep_dvipng()
397-
if not compare_versions(dvipng_v, dvipng_req):
398-
flag = False
399-
_log.warning('matplotlibrc text.usetex can not be used with *Agg '
400-
'backend unless dvipng-%s or later is installed on '
401-
'your system.', dvipng_req)
402-
403-
gs_exec, gs_v = checkdep_ghostscript()
404-
if not compare_versions(gs_v, gs_req):
405-
flag = False
406-
_log.warning('matplotlibrc text.usetex can not be used unless '
407-
'ghostscript-%s or later is installed on your system.',
408-
gs_req)
409-
410-
return flag
485+
if not shutil.which("tex"):
486+
_log.warning("usetex mode requires TeX.")
487+
return False
488+
try:
489+
_get_executable_info("dvipng")
490+
except FileNotFoundError:
491+
_log.warning("usetex mode requires dvipng.")
492+
return False
493+
try:
494+
_get_executable_info("gs")
495+
except FileNotFoundError:
496+
_log.warning("usetex mode requires ghostscript.")
497+
return False
498+
return True
411499

412500

413501
@_logged_cached('$HOME=%s')

lib/matplotlib/animation.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import numpy as np
3535

36+
import matplotlib as mpl
3637
from matplotlib._animation_data import (
3738
DISPLAY_TEMPLATE, INCLUDED_FRAMES, JS_INCLUDE, STYLE_INCLUDE)
3839
from matplotlib import cbook, rcParams, rcParamsDefault, rc_context
@@ -709,29 +710,17 @@ def output_args(self):
709710
@classmethod
710711
def bin_path(cls):
711712
binpath = super().bin_path()
712-
if sys.platform == 'win32' and binpath == 'convert':
713-
# Check the registry to avoid confusing ImageMagick's convert with
714-
# Windows's builtin convert.exe.
715-
import winreg
716-
binpath = ''
717-
for flag in (0, winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY):
718-
try:
719-
with winreg.OpenKeyEx(
720-
winreg.HKEY_LOCAL_MACHINE,
721-
r'Software\Imagemagick\Current',
722-
0, winreg.KEY_QUERY_VALUE | flag) as hkey:
723-
parent = winreg.QueryValueEx(hkey, 'BinPath')[0]
724-
except OSError:
725-
pass
726-
if binpath:
727-
for exe in ('convert.exe', 'magick.exe'):
728-
candidate = os.path.join(parent, exe)
729-
if os.path.exists(candidate):
730-
binpath = candidate
731-
break
732-
rcParams[cls.exec_key] = rcParamsDefault[cls.exec_key] = binpath
713+
if binpath == 'convert':
714+
binpath = mpl._get_executable_info('magick').executable
733715
return binpath
734716

717+
@classmethod
718+
def isAvailable(cls):
719+
try:
720+
return super().isAvailable()
721+
except FileNotFoundError: # May be raised by get_executable_info.
722+
return False
723+
735724

736725
# Combine ImageMagick options with pipe-based writing
737726
@writers.register('imagemagick')

lib/matplotlib/backends/backend_pgf.py

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -145,41 +145,28 @@ def _font_properties_str(prop):
145145

146146

147147
def make_pdf_to_png_converter():
148-
"""
149-
Returns a function that converts a pdf file to a png file.
150-
"""
151-
152-
tools_available = []
153-
# check for pdftocairo
154-
try:
155-
subprocess.check_output(["pdftocairo", "-v"], stderr=subprocess.STDOUT)
156-
tools_available.append("pdftocairo")
157-
except OSError:
158-
pass
159-
# check for ghostscript
160-
gs, ver = mpl.checkdep_ghostscript()
161-
if gs:
162-
tools_available.append("gs")
163-
164-
# pick converter
165-
if "pdftocairo" in tools_available:
148+
"""Returns a function that converts a pdf file to a png file."""
149+
if shutil.which("pdftocairo"):
166150
def cairo_convert(pdffile, pngfile, dpi):
167151
cmd = ["pdftocairo", "-singlefile", "-png", "-r", "%d" % dpi,
168152
pdffile, os.path.splitext(pngfile)[0]]
169153
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
170154
return cairo_convert
171-
elif "gs" in tools_available:
155+
try:
156+
gs_info = mpl._get_executable_info("gs")
157+
except FileNotFoundError:
158+
pass
159+
else:
172160
def gs_convert(pdffile, pngfile, dpi):
173-
cmd = [gs,
161+
cmd = [gs_info.executable,
174162
'-dQUIET', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT',
175163
'-dUseCIEColor', '-dTextAlphaBits=4',
176164
'-dGraphicsAlphaBits=4', '-dDOINTERPOLATE',
177165
'-sDEVICE=png16m', '-sOutputFile=%s' % pngfile,
178166
'-r%d' % dpi, pdffile]
179167
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
180168
return gs_convert
181-
else:
182-
raise RuntimeError("No suitable pdf to png renderer found.")
169+
raise RuntimeError("No suitable pdf to png renderer found.")
183170

184171

185172
class LatexError(Exception):

lib/matplotlib/backends/backend_ps.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import numpy as np
1919

20+
import matplotlib as mpl
2021
from matplotlib import (
2122
cbook, _path, __version__, rcParams, checkdep_ghostscript)
2223
from matplotlib.backend_bases import (
@@ -1359,11 +1360,11 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False):
13591360
psfile = tmpfile + '.ps'
13601361
dpi = rcParams['ps.distiller.res']
13611362

1362-
gs_exe, gs_version = checkdep_ghostscript()
13631363
cbook._check_and_log_subprocess(
1364-
[gs_exe, "-dBATCH", "-dNOPAUSE", "-r%d" % dpi,
1365-
"-sDEVICE=ps2write", paper_option,
1366-
"-sOutputFile=%s" % psfile, tmpfile], _log)
1364+
[mpl._get_executable_info("gs").executable,
1365+
"-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write",
1366+
paper_option, "-sOutputFile=%s" % psfile, tmpfile],
1367+
_log)
13671368

13681369
os.remove(tmpfile)
13691370
shutil.move(psfile, tmpfile)

0 commit comments

Comments
 (0)