diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 934d848e470a..8023146a11d9 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -444,7 +444,7 @@ def checkdep_ghostscript(): for gs_exec in gs_execs: try: s = subprocess.Popen( - [str(gs_exec), '--version'], stdout=subprocess.PIPE, + [gs_exec, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = s.communicate() if s.returncode == 0: diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index 218ba33297fa..ea8c4fb90e0e 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -2,14 +2,10 @@ Provides a collection of utilities for comparing (image) results. """ -from __future__ import absolute_import, division, print_function - -import six import atexit import functools import hashlib -import itertools import os from pathlib import Path import re @@ -141,38 +137,81 @@ def _shlex_quote_bytes(b): else b"'" + b.replace(b"'", b"'\"'\"'") + b"'") -class _SVGConverter(object): +class _ConverterError(Exception): + pass + + +class _Converter(object): def __init__(self): self._proc = None - # We cannot rely on the GC to trigger `__del__` at exit because - # other modules (e.g. `subprocess`) may already have their globals - # set to `None`, which make `proc.communicate` or `proc.terminate` - # fail. By relying on `atexit` we ensure the destructor runs before - # `None`-setting occurs. + # Explicitly register deletion from an atexit handler because if we + # wait until the object is GC'd (which occurs later), then some module + # globals (e.g. signal.SIGKILL) has already been set to None, and + # kill() doesn't work anymore... atexit.register(self.__del__) - def _read_to_prompt(self): - """Did Inkscape reach the prompt without crashing? - """ - stream = iter(functools.partial(self._proc.stdout.read, 1), b"") - prompt = (b"\n", b">") - n = len(prompt) - its = itertools.tee(stream, n) - for i, it in enumerate(its): - next(itertools.islice(it, i, i), None) # Advance `it` by `i`. + def __del__(self): + if self._proc: + self._proc.kill() + self._proc.wait() + for stream in filter(None, [self._proc.stdin, + self._proc.stdout, + self._proc.stderr]): + stream.close() + self._proc = None + + def _read_until(self, terminator): + """Read until the prompt is reached.""" + buf = bytearray() while True: - window = tuple(map(next, its)) - if len(window) != n: - # Ran out of data -- one of the `next(it)` raised - # StopIteration, so the tuple is shorter. - return False - if self._proc.poll() is not None: - # Inkscape exited. - return False - if window == prompt: - # Successfully read until prompt. - return True + c = self._proc.stdout.read(1) + if not c: + raise _ConverterError + buf.extend(c) + if buf.endswith(terminator): + return bytes(buf[:-len(terminator)]) + +class _GSConverter(_Converter): + def __call__(self, orig, dest): + if not self._proc: + self._stdout = TemporaryFile() + self._proc = subprocess.Popen( + [matplotlib.checkdep_ghostscript.executable, + "-dNOPAUSE", "-sDEVICE=png16m"], + # As far as I can see, ghostscript never outputs to stderr. + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + try: + self._read_until(b"\nGS") + except _ConverterError: + raise OSError("Failed to start Ghostscript") + + def encode_and_escape(name): + return (os.fsencode(name) + .replace(b"\\", b"\\\\") + .replace(b"(", br"\(") + .replace(b")", br"\)")) + + self._proc.stdin.write( + b"<< /OutputFile (" + + encode_and_escape(dest) + + b") >> setpagedevice (" + + encode_and_escape(orig) + + b") run flush\n") + self._proc.stdin.flush() + # GS> if nothing left on the stack; GS if n items left on the stack. + err = self._read_until(b"GS") + stack = self._read_until(b">") + if stack or not os.path.exists(dest): + stack_size = int(stack[1:]) if stack else 0 + self._proc.stdin.write(b"pop\n" * stack_size) + # Using the systemencoding should at least get the filenames right. + raise ImageComparisonFailure( + (err + b"GS" + stack + b">") + .decode(sys.getfilesystemencoding(), "replace")) + + +class _SVGConverter(_Converter): def __call__(self, orig, dest): if (not self._proc # First run. or self._proc.poll() is not None): # Inkscape terminated. @@ -190,23 +229,22 @@ def __call__(self, orig, dest): # seem to sometimes deadlock when stderr is redirected to a pipe, # so we redirect it to a temporary file instead. This is not # necessary anymore as of Inkscape 0.92.1. - self._stderr = TemporaryFile() + stderr = TemporaryFile() self._proc = subprocess.Popen( [str("inkscape"), "--without-gui", "--shell"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=self._stderr, env=env) - if not self._read_to_prompt(): - raise OSError("Failed to start Inkscape") - - try: - fsencode = os.fsencode - except AttributeError: # Py2. - def fsencode(s): - return s.encode(sys.getfilesystemencoding()) + stderr=stderr, env=env) + # Slight abuse, but makes shutdown handling easier. + self._proc.stderr = stderr + try: + self._read_until(b"\n>") + except _ConverterError: + raise OSError("Failed to start Inkscape in interactive mode") # Inkscape uses glib's `g_shell_parse_argv`, which has a consistent # behavior across platforms, so we can just use `shlex.quote`. - orig_b, dest_b = map(_shlex_quote_bytes, map(fsencode, [orig, dest])) + orig_b, dest_b = map(_shlex_quote_bytes, + map(os.fsencode, [orig, dest])) if b"\n" in orig_b or b"\n" in dest_b: # Who knows whether the current folder name has a newline, or if # our encoding is even ASCII compatible... Just fall back on the @@ -216,35 +254,22 @@ def fsencode(s): str('inkscape'), '-z', old, '--export-png', new])(orig, dest) self._proc.stdin.write(orig_b + b" --export-png=" + dest_b + b"\n") self._proc.stdin.flush() - if not self._read_to_prompt(): - # Inkscape's output is not localized but gtk's is, so the - # output stream probably has a mixed encoding. Using - # `getfilesystemencoding` should at least get the filenames - # right... + try: + self._read_until(b"\n>") + except _ConverterError: + # Inkscape's output is not localized but gtk's is, so the output + # stream probably has a mixed encoding. Using the filesystem + # encoding should at least get the filenames right... self._stderr.seek(0) raise ImageComparisonFailure( self._stderr.read().decode( sys.getfilesystemencoding(), "replace")) - def __del__(self): - if self._proc: - if self._proc.poll() is None: # Not exited yet. - self._proc.communicate(b"quit\n") - self._proc.wait() - self._proc.stdin.close() - self._proc.stdout.close() - self._stderr.close() - def _update_converter(): gs, gs_v = matplotlib.checkdep_ghostscript() if gs_v is not None: - def cmd(old, new): - return [str(gs), '-q', '-sDEVICE=png16m', '-dNOPAUSE', '-dBATCH', - '-sOutputFile=' + new, old] - converter['pdf'] = make_external_conversion_command(cmd) - converter['eps'] = make_external_conversion_command(cmd) - + converter['pdf'] = converter['eps'] = _GSConverter() if matplotlib.checkdep_inkscape() is not None: converter['svg'] = _SVGConverter()