2
2
Provides a collection of utilities for comparing (image) results.
3
3
4
4
"""
5
- from __future__ import absolute_import , division , print_function
6
-
7
- import six
8
5
9
6
import atexit
10
7
import functools
11
8
import hashlib
12
- import itertools
13
9
import os
14
10
from pathlib import Path
15
11
import re
@@ -141,38 +137,81 @@ def _shlex_quote_bytes(b):
141
137
else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
142
138
143
139
144
- class _SVGConverter (object ):
140
+ class _ConverterError (Exception ):
141
+ pass
142
+
143
+
144
+ class _Converter (object ):
145
145
def __init__ (self ):
146
146
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...
152
151
atexit .register (self .__del__ )
153
152
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 ()
163
166
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 ):])
175
173
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"(" , br"\(" )
192
+ .replace (b")" , br"\)" )
193
+ .replace (b"\\ " , b"\\ \\ " ))
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 ):
176
215
def __call__ (self , orig , dest ):
177
216
if (not self ._proc # First run.
178
217
or self ._proc .poll () is not None ): # Inkscape terminated.
@@ -190,23 +229,22 @@ def __call__(self, orig, dest):
190
229
# seem to sometimes deadlock when stderr is redirected to a pipe,
191
230
# so we redirect it to a temporary file instead. This is not
192
231
# necessary anymore as of Inkscape 0.92.1.
193
- self . _stderr = TemporaryFile ()
232
+ stderr = TemporaryFile ()
194
233
self ._proc = subprocess .Popen (
195
234
[str ("inkscape" ), "--without-gui" , "--shell" ],
196
235
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" )
206
243
207
244
# Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
208
245
# 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 ]))
210
248
if b"\n " in orig_b or b"\n " in dest_b :
211
249
# Who knows whether the current folder name has a newline, or if
212
250
# our encoding is even ASCII compatible... Just fall back on the
@@ -216,35 +254,22 @@ def fsencode(s):
216
254
str ('inkscape' ), '-z' , old , '--export-png' , new ])(orig , dest )
217
255
self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
218
256
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...
224
263
self ._stderr .seek (0 )
225
264
raise ImageComparisonFailure (
226
265
self ._stderr .read ().decode (
227
266
sys .getfilesystemencoding (), "replace" ))
228
267
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
-
238
268
239
269
def _update_converter ():
240
270
gs , gs_v = matplotlib .checkdep_ghostscript ()
241
271
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 ()
248
273
if matplotlib .checkdep_inkscape () is not None :
249
274
converter ['svg' ] = _SVGConverter ()
250
275
0 commit comments