77
88import six
99
10+ import atexit
11+ import functools
1012import hashlib
13+ import itertools
1114import os
15+ import re
1216import shutil
17+ import sys
18+ from tempfile import TemporaryFile
1319
1420import numpy as np
1521
1925from matplotlib import _png
2026from matplotlib import _get_cachedir
2127from matplotlib import cbook
22- from distutils import version
2328
2429__all__ = ['compare_float' , 'compare_images' , 'comparable_formats' ]
2530
@@ -128,6 +133,110 @@ def convert(old, new):
128133 return convert
129134
130135
136+ # Modified from https://bugs.python.org/issue25567.
137+ _find_unsafe_bytes = re .compile (br'[^a-zA-Z0-9_@%+=:,./-]' ).search
138+
139+
140+ def _shlex_quote_bytes (b ):
141+ return (b if _find_unsafe_bytes (b ) is None
142+ else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
143+
144+
145+ class _SVGConverter (object ):
146+ def __init__ (self ):
147+ self ._proc = None
148+ # We cannot rely on the GC to trigger `__del__` at exit because
149+ # other modules (e.g. `subprocess`) may already have their globals
150+ # set to `None`, which make `proc.communicate` or `proc.terminate`
151+ # fail. By relying on `atexit` we ensure the destructor runs before
152+ # `None`-setting occurs.
153+ atexit .register (self .__del__ )
154+
155+ def _read_to_prompt (self ):
156+ """Did Inkscape reach the prompt without crashing?
157+ """
158+ stream = iter (functools .partial (self ._proc .stdout .read , 1 ), b"" )
159+ prompt = (b"\n " , b">" )
160+ n = len (prompt )
161+ its = itertools .tee (stream , n )
162+ for i , it in enumerate (its ):
163+ next (itertools .islice (it , i , i ), None ) # Advance `it` by `i`.
164+ while True :
165+ window = tuple (map (next , its ))
166+ if len (window ) != n :
167+ # Ran out of data -- one of the `next(it)` raised
168+ # StopIteration, so the tuple is shorter.
169+ return False
170+ if self ._proc .poll () is not None :
171+ # Inkscape exited.
172+ return False
173+ if window == prompt :
174+ # Successfully read until prompt.
175+ return True
176+
177+ def __call__ (self , orig , dest ):
178+ if (not self ._proc # First run.
179+ or self ._proc .poll () is not None ): # Inkscape terminated.
180+ env = os .environ .copy ()
181+ # If one passes e.g. a png file to Inkscape, it will try to
182+ # query the user for conversion options via a GUI (even with
183+ # `--without-gui`). Unsetting `DISPLAY` prevents this (and causes
184+ # GTK to crash and Inkscape to terminate, but that'll just be
185+ # reported as a regular exception below).
186+ env .pop ("DISPLAY" , None ) # May already be unset.
187+ # Do not load any user options.
188+ # `os.environ` needs native strings on Py2+Windows.
189+ env [str ("INKSCAPE_PROFILE_DIR" )] = os .devnull
190+ # Old versions of Inkscape (0.48.3.1, used on Travis as of now)
191+ # seem to sometimes deadlock when stderr is redirected to a pipe,
192+ # so we redirect it to a temporary file instead. This is not
193+ # necessary anymore as of Inkscape 0.92.1.
194+ self ._stderr = TemporaryFile ()
195+ self ._proc = subprocess .Popen (
196+ [str ("inkscape" ), "--without-gui" , "--shell" ],
197+ stdin = subprocess .PIPE , stdout = subprocess .PIPE ,
198+ stderr = self ._stderr , env = env )
199+ if not self ._read_to_prompt ():
200+ raise OSError ("Failed to start Inkscape" )
201+
202+ try :
203+ fsencode = os .fsencode
204+ except AttributeError : # Py2.
205+ def fsencode (s ):
206+ return s .encode (sys .getfilesystemencoding ())
207+
208+ # Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
209+ # behavior across platforms, so we can just use `shlex.quote`.
210+ orig_b , dest_b = map (_shlex_quote_bytes , map (fsencode , [orig , dest ]))
211+ if b"\n " in orig_b or b"\n " in dest_b :
212+ # Who knows whether the current folder name has a newline, or if
213+ # our encoding is even ASCII compatible... Just fall back on the
214+ # slow solution (Inkscape uses `fgets` so it will always stop at a
215+ # newline).
216+ return make_external_conversion_command (lambda old , new : [
217+ str ('inkscape' ), '-z' , old , '--export-png' , new ])(orig , dest )
218+ self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
219+ self ._proc .stdin .flush ()
220+ if not self ._read_to_prompt ():
221+ # Inkscape's output is not localized but gtk's is, so the
222+ # output stream probably has a mixed encoding. Using
223+ # `getfilesystemencoding` should at least get the filenames
224+ # right...
225+ self ._stderr .seek (0 )
226+ raise ImageComparisonFailure (
227+ self ._stderr .read ().decode (
228+ sys .getfilesystemencoding (), "replace" ))
229+
230+ def __del__ (self ):
231+ if self ._proc :
232+ if self ._proc .poll () is None : # Not exited yet.
233+ self ._proc .communicate (b"quit\n " )
234+ self ._proc .wait ()
235+ self ._proc .stdin .close ()
236+ self ._proc .stdout .close ()
237+ self ._stderr .close ()
238+
239+
131240def _update_converter ():
132241 gs , gs_v = matplotlib .checkdep_ghostscript ()
133242 if gs_v is not None :
@@ -138,9 +247,7 @@ def cmd(old, new):
138247 converter ['eps' ] = make_external_conversion_command (cmd )
139248
140249 if matplotlib .checkdep_inkscape () is not None :
141- def cmd (old , new ):
142- return [str ('inkscape' ), '-z' , old , '--export-png' , new ]
143- converter ['svg' ] = make_external_conversion_command (cmd )
250+ converter ['svg' ] = _SVGConverter ()
144251
145252
146253#: A dictionary that maps filename extensions to functions which
@@ -363,9 +470,8 @@ def save_diff_image(expected, actual, output):
363470 actual , actualImage , expected , expectedImage )
364471 expectedImage = np .array (expectedImage ).astype (float )
365472 actualImage = np .array (actualImage ).astype (float )
366- assert expectedImage .ndim == actualImage .ndim
367473 assert expectedImage .shape == actualImage .shape
368- absDiffImage = abs (expectedImage - actualImage )
474+ absDiffImage = np . abs (expectedImage - actualImage )
369475
370476 # expand differences in luminance domain
371477 absDiffImage *= 255 * 10
0 commit comments