22Provides a collection of utilities for comparing (image) results.
33
44"""
5- from __future__ import absolute_import , division , print_function
6-
7- import six
85
96import atexit
107import functools
118import hashlib
12- import itertools
139import os
1410from pathlib import Path
1511import re
@@ -141,38 +137,81 @@ def _shlex_quote_bytes(b):
141137 else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
142138
143139
144- class _SVGConverter (object ):
140+ class _ConverterError (Exception ):
141+ pass
142+
143+
144+ class _Converter (object ):
145145 def __init__ (self ):
146146 self ._proc = None
147- # We cannot rely on the GC to trigger `__del__` at exit because
148- # other modules (e.g. `subprocess`) may already have their globals
149- # set to `None`, which make `proc.communicate` or `proc.terminate`
150- # fail. By relying on `atexit` we ensure the destructor runs before
151- # `None`-setting occurs.
147+ # Explicitly register deletion from an atexit handler because if we
148+ # wait until the object is GC'd (which occurs later), then some module
149+ # globals (e.g. signal.SIGKILL) has already been set to None, and
150+ # kill() doesn't work anymore...
152151 atexit .register (self .__del__ )
153152
154- def _read_to_prompt (self ):
155- """Did Inkscape reach the prompt without crashing?
156- """
157- stream = iter (functools .partial (self ._proc .stdout .read , 1 ), b"" )
158- prompt = (b"\n " , b">" )
159- n = len (prompt )
160- its = itertools .tee (stream , n )
161- for i , it in enumerate (its ):
162- next (itertools .islice (it , i , i ), None ) # Advance `it` by `i`.
153+ def __del__ (self ):
154+ if self ._proc :
155+ self ._proc .kill ()
156+ self ._proc .wait ()
157+ for stream in filter (None , [self ._proc .stdin ,
158+ self ._proc .stdout ,
159+ self ._proc .stderr ]):
160+ stream .close ()
161+ self ._proc = None
162+
163+ def _read_until (self , terminator ):
164+ """Read until the prompt is reached."""
165+ buf = bytearray ()
163166 while True :
164- window = tuple (map (next , its ))
165- if len (window ) != n :
166- # Ran out of data -- one of the `next(it)` raised
167- # StopIteration, so the tuple is shorter.
168- return False
169- if self ._proc .poll () is not None :
170- # Inkscape exited.
171- return False
172- if window == prompt :
173- # Successfully read until prompt.
174- return True
167+ c = self ._proc .stdout .read (1 )
168+ if not c :
169+ raise _ConverterError
170+ buf .extend (c )
171+ if buf .endswith (terminator ):
172+ return bytes (buf [:- len (terminator )])
175173
174+
175+ class _GSConverter (_Converter ):
176+ def __call__ (self , orig , dest ):
177+ if not self ._proc :
178+ self ._stdout = TemporaryFile ()
179+ self ._proc = subprocess .Popen (
180+ [matplotlib .checkdep_ghostscript .executable ,
181+ "-dNOPAUSE" , "-sDEVICE=png16m" ],
182+ # As far as I can see, ghostscript never outputs to stderr.
183+ stdin = subprocess .PIPE , stdout = subprocess .PIPE )
184+ try :
185+ self ._read_until (b"\n GS" )
186+ except _ConverterError :
187+ raise OSError ("Failed to start Ghostscript" )
188+
189+ def encode_and_escape (name ):
190+ return (os .fsencode (name )
191+ .replace (b"\\ " , b"\\ \\ " )
192+ .replace (b"(" , br"\(" )
193+ .replace (b")" , br"\)" ))
194+
195+ self ._proc .stdin .write (
196+ b"<< /OutputFile ("
197+ + encode_and_escape (dest )
198+ + b") >> setpagedevice ("
199+ + encode_and_escape (orig )
200+ + b") run flush\n " )
201+ self ._proc .stdin .flush ()
202+ # GS> if nothing left on the stack; GS<n> if n items left on the stack.
203+ err = self ._read_until (b"GS" )
204+ stack = self ._read_until (b">" )
205+ if stack or not os .path .exists (dest ):
206+ stack_size = int (stack [1 :]) if stack else 0
207+ self ._proc .stdin .write (b"pop\n " * stack_size )
208+ # Using the systemencoding should at least get the filenames right.
209+ raise ImageComparisonFailure (
210+ (err + b"GS" + stack + b">" )
211+ .decode (sys .getfilesystemencoding (), "replace" ))
212+
213+
214+ class _SVGConverter (_Converter ):
176215 def __call__ (self , orig , dest ):
177216 if (not self ._proc # First run.
178217 or self ._proc .poll () is not None ): # Inkscape terminated.
@@ -190,23 +229,22 @@ def __call__(self, orig, dest):
190229 # seem to sometimes deadlock when stderr is redirected to a pipe,
191230 # so we redirect it to a temporary file instead. This is not
192231 # necessary anymore as of Inkscape 0.92.1.
193- self . _stderr = TemporaryFile ()
232+ stderr = TemporaryFile ()
194233 self ._proc = subprocess .Popen (
195234 [str ("inkscape" ), "--without-gui" , "--shell" ],
196235 stdin = subprocess .PIPE , stdout = subprocess .PIPE ,
197- stderr = self ._stderr , env = env )
198- if not self ._read_to_prompt ():
199- raise OSError ("Failed to start Inkscape" )
200-
201- try :
202- fsencode = os .fsencode
203- except AttributeError : # Py2.
204- def fsencode (s ):
205- return s .encode (sys .getfilesystemencoding ())
236+ stderr = stderr , env = env )
237+ # Slight abuse, but makes shutdown handling easier.
238+ self ._proc .stderr = stderr
239+ try :
240+ self ._read_until (b"\n >" )
241+ except _ConverterError :
242+ raise OSError ("Failed to start Inkscape in interactive mode" )
206243
207244 # Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
208245 # behavior across platforms, so we can just use `shlex.quote`.
209- orig_b , dest_b = map (_shlex_quote_bytes , map (fsencode , [orig , dest ]))
246+ orig_b , dest_b = map (_shlex_quote_bytes ,
247+ map (os .fsencode , [orig , dest ]))
210248 if b"\n " in orig_b or b"\n " in dest_b :
211249 # Who knows whether the current folder name has a newline, or if
212250 # our encoding is even ASCII compatible... Just fall back on the
@@ -216,35 +254,22 @@ def fsencode(s):
216254 str ('inkscape' ), '-z' , old , '--export-png' , new ])(orig , dest )
217255 self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
218256 self ._proc .stdin .flush ()
219- if not self ._read_to_prompt ():
220- # Inkscape's output is not localized but gtk's is, so the
221- # output stream probably has a mixed encoding. Using
222- # `getfilesystemencoding` should at least get the filenames
223- # right...
257+ try :
258+ self ._read_until (b"\n >" )
259+ except _ConverterError :
260+ # Inkscape's output is not localized but gtk's is, so the output
261+ # stream probably has a mixed encoding. Using the filesystem
262+ # encoding should at least get the filenames right...
224263 self ._stderr .seek (0 )
225264 raise ImageComparisonFailure (
226265 self ._stderr .read ().decode (
227266 sys .getfilesystemencoding (), "replace" ))
228267
229- def __del__ (self ):
230- if self ._proc :
231- if self ._proc .poll () is None : # Not exited yet.
232- self ._proc .communicate (b"quit\n " )
233- self ._proc .wait ()
234- self ._proc .stdin .close ()
235- self ._proc .stdout .close ()
236- self ._stderr .close ()
237-
238268
239269def _update_converter ():
240270 gs , gs_v = matplotlib .checkdep_ghostscript ()
241271 if gs_v is not None :
242- def cmd (old , new ):
243- return [str (gs ), '-q' , '-sDEVICE=png16m' , '-dNOPAUSE' , '-dBATCH' ,
244- '-sOutputFile=' + new , old ]
245- converter ['pdf' ] = make_external_conversion_command (cmd )
246- converter ['eps' ] = make_external_conversion_command (cmd )
247-
272+ converter ['pdf' ] = converter ['eps' ] = _GSConverter ()
248273 if matplotlib .checkdep_inkscape () is not None :
249274 converter ['svg' ] = _SVGConverter ()
250275
0 commit comments