10
10
import atexit
11
11
import functools
12
12
import hashlib
13
- import itertools
14
13
import os
15
14
import re
16
15
import shutil
@@ -133,6 +132,13 @@ def convert(old, new):
133
132
return convert
134
133
135
134
135
+ try :
136
+ _fsencode = os .fsencode
137
+ except AttributeError : # Py2.
138
+ def _fsencode (s ):
139
+ return s .encode (sys .getfilesystemencoding ())
140
+
141
+
136
142
# Modified from https://bugs.python.org/issue25567.
137
143
_find_unsafe_bytes = re .compile (br'[^a-zA-Z0-9_@%+=:,./-]' ).search
138
144
@@ -142,38 +148,82 @@ def _shlex_quote_bytes(b):
142
148
else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
143
149
144
150
145
- class _SVGConverter (object ):
151
+ class _ConverterError (Exception ):
152
+ pass
153
+
154
+
155
+ class _Converter (object ):
146
156
def __init__ (self ):
147
157
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.
158
+ # Explicitly register deletion from an atexit handler because if we
159
+ # wait until the object is GC'd (which occurs later), then some module
160
+ # globals (e.g. signal.SIGKILL) has already been set to None, and
161
+ # kill() doesn't work anymore...
153
162
atexit .register (self .__del__ )
154
163
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
+ def __del__ (self ):
165
+ if self ._proc :
166
+ self ._proc .kill ()
167
+ self ._proc .wait ()
168
+ for stream in filter (None , [self ._proc .stdin ,
169
+ self ._proc .stdout ,
170
+ self ._proc .stderr ]):
171
+ stream .close ()
172
+ self ._proc = None
173
+
174
+ def _read_until (self , terminator ):
175
+ """Read until the prompt is reached."""
176
+ terminator = [six .int2byte (c ) for c in six .iterbytes (terminator )]
177
+ buf = []
164
178
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
179
+ c = self ._proc .stdout .read (1 )
180
+ if not c :
181
+ raise _ConverterError
182
+ buf .append (c )
183
+ if buf [- len (terminator ):] == terminator :
184
+ return b"" .join (buf [:- len (terminator )])
185
+
186
+
187
+ class _GSConverter (_Converter ):
188
+ def __call__ (self , orig , dest ):
189
+ if not self ._proc :
190
+ self ._stdout = TemporaryFile ()
191
+ self ._proc = subprocess .Popen (
192
+ [matplotlib .checkdep_ghostscript .executable ,
193
+ "-dNOPAUSE" , "-sDEVICE=png16m" ],
194
+ # As far as I can see, ghostscript never outputs to stderr.
195
+ stdin = subprocess .PIPE , stdout = subprocess .PIPE )
196
+ try :
197
+ self ._read_until (b"\n GS" )
198
+ except _ConverterError :
199
+ raise OSError ("Failed to start Ghostscript" )
200
+
201
+ def encode_and_escape (name ):
202
+ return (_fsencode (name )
203
+ .replace (b"(" , br"\(" )
204
+ .replace (b")" , br"\)" )
205
+ .replace (b"\\ " , b"\\ \\ " ))
206
+
207
+ self ._proc .stdin .write (
208
+ b"<< /OutputFile ("
209
+ + encode_and_escape (dest )
210
+ + b") >> setpagedevice ("
211
+ + encode_and_escape (orig )
212
+ + b") run flush\n " )
213
+ self ._proc .stdin .flush ()
214
+ # GS> if nothing left on the stack; GS<n> if n items left on the stack.
215
+ err = self ._read_until (b"GS" )
216
+ stack = self ._read_until (b">" )
217
+ if stack or not os .path .exists (dest ):
218
+ if stack :
219
+ self ._proc .stdin .write (b"pop\n " * int (stack [1 :]))
220
+ # Using the systemencoding should at least get the filenames right.
221
+ raise ImageComparisonFailure (
222
+ (err + b"GS" + stack + b">" )
223
+ .decode (sys .getfilesystemencoding (), "replace" ))
224
+
176
225
226
+ class _SVGConverter (_Converter ):
177
227
def __call__ (self , orig , dest ):
178
228
if (not self ._proc # First run.
179
229
or self ._proc .poll () is not None ): # Inkscape terminated.
@@ -191,23 +241,21 @@ def __call__(self, orig, dest):
191
241
# seem to sometimes deadlock when stderr is redirected to a pipe,
192
242
# so we redirect it to a temporary file instead. This is not
193
243
# necessary anymore as of Inkscape 0.92.1.
194
- self . _stderr = TemporaryFile ()
244
+ stderr = TemporaryFile ()
195
245
self ._proc = subprocess .Popen (
196
246
[str ("inkscape" ), "--without-gui" , "--shell" ],
197
247
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 ())
248
+ stderr = stderr , env = env )
249
+ # Slight abuse, but makes shutdown handling easier.
250
+ self ._proc .stderr = stderr
251
+ try :
252
+ self ._read_until (b"\n >" )
253
+ except _ConverterError :
254
+ raise OSError ("Failed to start Inkscape in interactive mode" )
207
255
208
256
# Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
209
257
# behavior across platforms, so we can just use `shlex.quote`.
210
- orig_b , dest_b = map (_shlex_quote_bytes , map (fsencode , [orig , dest ]))
258
+ orig_b , dest_b = map (_shlex_quote_bytes , map (_fsencode , [orig , dest ]))
211
259
if b"\n " in orig_b or b"\n " in dest_b :
212
260
# Who knows whether the current folder name has a newline, or if
213
261
# our encoding is even ASCII compatible... Just fall back on the
@@ -217,35 +265,22 @@ def fsencode(s):
217
265
str ('inkscape' ), '-z' , old , '--export-png' , new ])(orig , dest )
218
266
self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
219
267
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...
268
+ try :
269
+ self ._read_until (b"\n >" )
270
+ except _ConverterError :
271
+ # Inkscape's output is not localized but gtk's is, so the output
272
+ # stream probably has a mixed encoding. Using the filesystem
273
+ # encoding should at least get the filenames right...
225
274
self ._stderr .seek (0 )
226
275
raise ImageComparisonFailure (
227
276
self ._stderr .read ().decode (
228
277
sys .getfilesystemencoding (), "replace" ))
229
278
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
279
240
280
def _update_converter ():
241
281
gs , gs_v = matplotlib .checkdep_ghostscript ()
242
282
if gs_v is not None :
243
- def cmd (old , new ):
244
- return [str (gs ), '-q' , '-sDEVICE=png16m' , '-dNOPAUSE' , '-dBATCH' ,
245
- '-sOutputFile=' + new , old ]
246
- converter ['pdf' ] = make_external_conversion_command (cmd )
247
- converter ['eps' ] = make_external_conversion_command (cmd )
248
-
283
+ converter ['pdf' ] = converter ['eps' ] = _GSConverter ()
249
284
if matplotlib .checkdep_inkscape () is not None :
250
285
converter ['svg' ] = _SVGConverter ()
251
286
0 commit comments