Skip to content

Commit 2502c74

Browse files
committed
Unify checking of executable info.
1 parent 010f2f4 commit 2502c74

File tree

7 files changed

+199
-121
lines changed

7 files changed

+199
-121
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Deprecations and new APIs
2+
`````````````````````````
3+
4+
``checkdep_dvipng``, ``checkdep_ghostscript``, ``checkdep_pdftops``, and
5+
``checkdep_inkscape`` are deprecated in favor of the new function
6+
`matplotlib.get_executable_info`.

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
@@ -179,9 +180,7 @@ def compare_versions(a, b):
179180
"3.0", message="compare_versions arguments should be strs.")
180181
b = b.decode('ascii')
181182
if a:
182-
a = distutils.version.LooseVersion(a)
183-
b = distutils.version.LooseVersion(b)
184-
return a >= b
183+
return LooseVersion(a) >= LooseVersion(b)
185184
else:
186185
return False
187186

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

@@ -282,6 +281,117 @@ def wrapper():
282281
return wrapper
283282

284283

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

297407

408+
@cbook.deprecated("3.1", alternative="get_executable_info('gs')")
298409
def checkdep_ghostscript():
299410
if checkdep_ghostscript.executable is None:
300411
if sys.platform == 'win32':
@@ -320,6 +431,7 @@ def checkdep_ghostscript():
320431
checkdep_ghostscript.version = None
321432

322433

434+
@cbook.deprecated("3.1", alternative="get_executable_info('pdftops')")
323435
def checkdep_pdftops():
324436
try:
325437
s = subprocess.Popen(['pdftops', '-v'], stdout=subprocess.PIPE,
@@ -334,6 +446,7 @@ def checkdep_pdftops():
334446
return None
335447

336448

449+
@cbook.deprecated("3.1", alternative="get_executable_info('inkscape')")
337450
def checkdep_inkscape():
338451
if checkdep_inkscape.version is None:
339452
try:
@@ -356,64 +469,39 @@ def checkdep_inkscape():
356469
def checkdep_ps_distiller(s):
357470
if not s:
358471
return False
359-
360-
flag = True
361-
gs_exec, gs_v = checkdep_ghostscript()
362-
if not gs_exec:
363-
flag = False
364-
_log.warning('matplotlibrc ps.usedistiller option can not be used '
365-
'unless ghostscript 9.0 or later is installed on your '
366-
'system.')
367-
368-
if s == 'xpdf':
369-
pdftops_req = '3.0'
370-
pdftops_req_alt = '0.9' # poppler version numbers, ugh
371-
pdftops_v = checkdep_pdftops()
372-
if compare_versions(pdftops_v, pdftops_req):
373-
pass
374-
elif (compare_versions(pdftops_v, pdftops_req_alt) and not
375-
compare_versions(pdftops_v, '1.0')):
376-
pass
377-
else:
378-
flag = False
379-
_log.warning('matplotlibrc ps.usedistiller can not be set to xpdf '
380-
'unless xpdf-%s or later is installed on your '
381-
'system.', pdftops_req)
382-
383-
if flag:
384-
return s
385-
else:
472+
try:
473+
get_executable_info("gs")
474+
except FileNotFoundError:
475+
_log.warning(
476+
"Setting rcParams['ps.usedistiller'] requires ghostscript.")
386477
return False
478+
if s == "xpdf":
479+
try:
480+
get_executable_info("pdftops")
481+
except FileNotFoundError:
482+
_log.warning(
483+
"Setting rcParams['ps.usedistiller'] to 'xpdf' requires xpdf.")
484+
return False
485+
return s
387486

388487

389488
def checkdep_usetex(s):
390489
if not s:
391490
return False
392-
393-
gs_req = '9.00'
394-
dvipng_req = '1.6'
395-
flag = True
396-
397-
if shutil.which("tex") is None:
398-
flag = False
399-
_log.warning('matplotlibrc text.usetex option can not be used unless '
400-
'TeX is installed on your system.')
401-
402-
dvipng_v = checkdep_dvipng()
403-
if not compare_versions(dvipng_v, dvipng_req):
404-
flag = False
405-
_log.warning('matplotlibrc text.usetex can not be used with *Agg '
406-
'backend unless dvipng-%s or later is installed on '
407-
'your system.', dvipng_req)
408-
409-
gs_exec, gs_v = checkdep_ghostscript()
410-
if not compare_versions(gs_v, gs_req):
411-
flag = False
412-
_log.warning('matplotlibrc text.usetex can not be used unless '
413-
'ghostscript-%s or later is installed on your system.',
414-
gs_req)
415-
416-
return flag
491+
if not shutil.which("tex"):
492+
_log.warning("usetex mode requires TeX.")
493+
return False
494+
try:
495+
get_executable_info("dvipng")
496+
except FileNotFoundError:
497+
_log.warning("usetex mode requires dvipng.")
498+
return False
499+
try:
500+
get_executable_info("gs")
501+
except FileNotFoundError:
502+
_log.warning("usetex mode requires ghostscript.")
503+
return False
504+
return True
417505

418506

419507
@_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 (
@@ -1362,11 +1363,11 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False):
13621363
psfile = tmpfile + '.ps'
13631364
dpi = rcParams['ps.distiller.res']
13641365

1365-
gs_exe, gs_version = checkdep_ghostscript()
13661366
cbook._check_and_log_subprocess(
1367-
[gs_exe, "-dBATCH", "-dNOPAUSE", "-r%d" % dpi,
1368-
"-sDEVICE=ps2write", paper_option,
1369-
"-sOutputFile=%s" % psfile, tmpfile], _log)
1367+
[mpl.get_executable_info("gs").executable,
1368+
"-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write",
1369+
paper_option, "-sOutputFile=%s" % psfile, tmpfile],
1370+
_log)
13701371

13711372
os.remove(tmpfile)
13721373
shutil.move(psfile, tmpfile)

0 commit comments

Comments
 (0)