##// END OF EJS Templates
remove workaround for 2.6 support
Thomas Kluyver -
Show More
@@ -1,414 +1,410 b''
1 """Generic testing tools.
1 """Generic testing tools.
2
2
3 Authors
3 Authors
4 -------
4 -------
5 - Fernando Perez <Fernando.Perez@berkeley.edu>
5 - Fernando Perez <Fernando.Perez@berkeley.edu>
6 """
6 """
7
7
8 from __future__ import absolute_import
8 from __future__ import absolute_import
9
9
10 #-----------------------------------------------------------------------------
10 #-----------------------------------------------------------------------------
11 # Copyright (C) 2009-2011 The IPython Development Team
11 # Copyright (C) 2009-2011 The IPython Development Team
12 #
12 #
13 # Distributed under the terms of the BSD License. The full license is in
13 # Distributed under the terms of the BSD License. The full license is in
14 # the file COPYING, distributed as part of this software.
14 # the file COPYING, distributed as part of this software.
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 #-----------------------------------------------------------------------------
17 #-----------------------------------------------------------------------------
18 # Imports
18 # Imports
19 #-----------------------------------------------------------------------------
19 #-----------------------------------------------------------------------------
20
20
21 import os
21 import os
22 import re
22 import re
23 import sys
23 import sys
24 import tempfile
24 import tempfile
25
25
26 from contextlib import contextmanager
26 from contextlib import contextmanager
27 from io import StringIO
27 from io import StringIO
28 from subprocess import Popen, PIPE
28 from subprocess import Popen, PIPE
29
29
30 try:
30 try:
31 # These tools are used by parts of the runtime, so we make the nose
31 # These tools are used by parts of the runtime, so we make the nose
32 # dependency optional at this point. Nose is a hard dependency to run the
32 # dependency optional at this point. Nose is a hard dependency to run the
33 # test suite, but NOT to use ipython itself.
33 # test suite, but NOT to use ipython itself.
34 import nose.tools as nt
34 import nose.tools as nt
35 has_nose = True
35 has_nose = True
36 except ImportError:
36 except ImportError:
37 has_nose = False
37 has_nose = False
38
38
39 from IPython.config.loader import Config
39 from IPython.config.loader import Config
40 from IPython.utils.text import list_strings
40 from IPython.utils.text import list_strings
41 from IPython.utils.io import temp_pyfile, Tee
41 from IPython.utils.io import temp_pyfile, Tee
42 from IPython.utils import py3compat
42 from IPython.utils import py3compat
43 from IPython.utils.encoding import DEFAULT_ENCODING
43 from IPython.utils.encoding import DEFAULT_ENCODING
44
44
45 from . import decorators as dec
45 from . import decorators as dec
46 from . import skipdoctest
46 from . import skipdoctest
47
47
48 #-----------------------------------------------------------------------------
48 #-----------------------------------------------------------------------------
49 # Functions and classes
49 # Functions and classes
50 #-----------------------------------------------------------------------------
50 #-----------------------------------------------------------------------------
51
51
52 # The docstring for full_path doctests differently on win32 (different path
52 # The docstring for full_path doctests differently on win32 (different path
53 # separator) so just skip the doctest there. The example remains informative.
53 # separator) so just skip the doctest there. The example remains informative.
54 doctest_deco = skipdoctest.skip_doctest if sys.platform == 'win32' else dec.null_deco
54 doctest_deco = skipdoctest.skip_doctest if sys.platform == 'win32' else dec.null_deco
55
55
56 @doctest_deco
56 @doctest_deco
57 def full_path(startPath,files):
57 def full_path(startPath,files):
58 """Make full paths for all the listed files, based on startPath.
58 """Make full paths for all the listed files, based on startPath.
59
59
60 Only the base part of startPath is kept, since this routine is typically
60 Only the base part of startPath is kept, since this routine is typically
61 used with a script's __file__ variable as startPath. The base of startPath
61 used with a script's __file__ variable as startPath. The base of startPath
62 is then prepended to all the listed files, forming the output list.
62 is then prepended to all the listed files, forming the output list.
63
63
64 Parameters
64 Parameters
65 ----------
65 ----------
66 startPath : string
66 startPath : string
67 Initial path to use as the base for the results. This path is split
67 Initial path to use as the base for the results. This path is split
68 using os.path.split() and only its first component is kept.
68 using os.path.split() and only its first component is kept.
69
69
70 files : string or list
70 files : string or list
71 One or more files.
71 One or more files.
72
72
73 Examples
73 Examples
74 --------
74 --------
75
75
76 >>> full_path('/foo/bar.py',['a.txt','b.txt'])
76 >>> full_path('/foo/bar.py',['a.txt','b.txt'])
77 ['/foo/a.txt', '/foo/b.txt']
77 ['/foo/a.txt', '/foo/b.txt']
78
78
79 >>> full_path('/foo',['a.txt','b.txt'])
79 >>> full_path('/foo',['a.txt','b.txt'])
80 ['/a.txt', '/b.txt']
80 ['/a.txt', '/b.txt']
81
81
82 If a single file is given, the output is still a list:
82 If a single file is given, the output is still a list:
83 >>> full_path('/foo','a.txt')
83 >>> full_path('/foo','a.txt')
84 ['/a.txt']
84 ['/a.txt']
85 """
85 """
86
86
87 files = list_strings(files)
87 files = list_strings(files)
88 base = os.path.split(startPath)[0]
88 base = os.path.split(startPath)[0]
89 return [ os.path.join(base,f) for f in files ]
89 return [ os.path.join(base,f) for f in files ]
90
90
91
91
92 def parse_test_output(txt):
92 def parse_test_output(txt):
93 """Parse the output of a test run and return errors, failures.
93 """Parse the output of a test run and return errors, failures.
94
94
95 Parameters
95 Parameters
96 ----------
96 ----------
97 txt : str
97 txt : str
98 Text output of a test run, assumed to contain a line of one of the
98 Text output of a test run, assumed to contain a line of one of the
99 following forms::
99 following forms::
100
100
101 'FAILED (errors=1)'
101 'FAILED (errors=1)'
102 'FAILED (failures=1)'
102 'FAILED (failures=1)'
103 'FAILED (errors=1, failures=1)'
103 'FAILED (errors=1, failures=1)'
104
104
105 Returns
105 Returns
106 -------
106 -------
107 nerr, nfail: number of errors and failures.
107 nerr, nfail: number of errors and failures.
108 """
108 """
109
109
110 err_m = re.search(r'^FAILED \(errors=(\d+)\)', txt, re.MULTILINE)
110 err_m = re.search(r'^FAILED \(errors=(\d+)\)', txt, re.MULTILINE)
111 if err_m:
111 if err_m:
112 nerr = int(err_m.group(1))
112 nerr = int(err_m.group(1))
113 nfail = 0
113 nfail = 0
114 return nerr, nfail
114 return nerr, nfail
115
115
116 fail_m = re.search(r'^FAILED \(failures=(\d+)\)', txt, re.MULTILINE)
116 fail_m = re.search(r'^FAILED \(failures=(\d+)\)', txt, re.MULTILINE)
117 if fail_m:
117 if fail_m:
118 nerr = 0
118 nerr = 0
119 nfail = int(fail_m.group(1))
119 nfail = int(fail_m.group(1))
120 return nerr, nfail
120 return nerr, nfail
121
121
122 both_m = re.search(r'^FAILED \(errors=(\d+), failures=(\d+)\)', txt,
122 both_m = re.search(r'^FAILED \(errors=(\d+), failures=(\d+)\)', txt,
123 re.MULTILINE)
123 re.MULTILINE)
124 if both_m:
124 if both_m:
125 nerr = int(both_m.group(1))
125 nerr = int(both_m.group(1))
126 nfail = int(both_m.group(2))
126 nfail = int(both_m.group(2))
127 return nerr, nfail
127 return nerr, nfail
128
128
129 # If the input didn't match any of these forms, assume no error/failures
129 # If the input didn't match any of these forms, assume no error/failures
130 return 0, 0
130 return 0, 0
131
131
132
132
133 # So nose doesn't think this is a test
133 # So nose doesn't think this is a test
134 parse_test_output.__test__ = False
134 parse_test_output.__test__ = False
135
135
136
136
137 def default_argv():
137 def default_argv():
138 """Return a valid default argv for creating testing instances of ipython"""
138 """Return a valid default argv for creating testing instances of ipython"""
139
139
140 return ['--quick', # so no config file is loaded
140 return ['--quick', # so no config file is loaded
141 # Other defaults to minimize side effects on stdout
141 # Other defaults to minimize side effects on stdout
142 '--colors=NoColor', '--no-term-title','--no-banner',
142 '--colors=NoColor', '--no-term-title','--no-banner',
143 '--autocall=0']
143 '--autocall=0']
144
144
145
145
146 def default_config():
146 def default_config():
147 """Return a config object with good defaults for testing."""
147 """Return a config object with good defaults for testing."""
148 config = Config()
148 config = Config()
149 config.TerminalInteractiveShell.colors = 'NoColor'
149 config.TerminalInteractiveShell.colors = 'NoColor'
150 config.TerminalTerminalInteractiveShell.term_title = False,
150 config.TerminalTerminalInteractiveShell.term_title = False,
151 config.TerminalInteractiveShell.autocall = 0
151 config.TerminalInteractiveShell.autocall = 0
152 config.HistoryManager.hist_file = tempfile.mktemp(u'test_hist.sqlite')
152 config.HistoryManager.hist_file = tempfile.mktemp(u'test_hist.sqlite')
153 config.HistoryManager.db_cache_size = 10000
153 config.HistoryManager.db_cache_size = 10000
154 return config
154 return config
155
155
156
156
157 def get_ipython_cmd(as_string=False):
157 def get_ipython_cmd(as_string=False):
158 """
158 """
159 Return appropriate IPython command line name. By default, this will return
159 Return appropriate IPython command line name. By default, this will return
160 a list that can be used with subprocess.Popen, for example, but passing
160 a list that can be used with subprocess.Popen, for example, but passing
161 `as_string=True` allows for returning the IPython command as a string.
161 `as_string=True` allows for returning the IPython command as a string.
162
162
163 Parameters
163 Parameters
164 ----------
164 ----------
165 as_string: bool
165 as_string: bool
166 Flag to allow to return the command as a string.
166 Flag to allow to return the command as a string.
167 """
167 """
168 # FIXME: remove workaround for 2.6 support
168 ipython_cmd = [sys.executable, "-m", "IPython"]
169 if sys.version_info[:2] > (2,6):
170 ipython_cmd = [sys.executable, "-m", "IPython"]
171 else:
172 ipython_cmd = ["ipython"]
173
169
174 if as_string:
170 if as_string:
175 ipython_cmd = " ".join(ipython_cmd)
171 ipython_cmd = " ".join(ipython_cmd)
176
172
177 return ipython_cmd
173 return ipython_cmd
178
174
179 def ipexec(fname, options=None):
175 def ipexec(fname, options=None):
180 """Utility to call 'ipython filename'.
176 """Utility to call 'ipython filename'.
181
177
182 Starts IPython with a minimal and safe configuration to make startup as fast
178 Starts IPython with a minimal and safe configuration to make startup as fast
183 as possible.
179 as possible.
184
180
185 Note that this starts IPython in a subprocess!
181 Note that this starts IPython in a subprocess!
186
182
187 Parameters
183 Parameters
188 ----------
184 ----------
189 fname : str
185 fname : str
190 Name of file to be executed (should have .py or .ipy extension).
186 Name of file to be executed (should have .py or .ipy extension).
191
187
192 options : optional, list
188 options : optional, list
193 Extra command-line flags to be passed to IPython.
189 Extra command-line flags to be passed to IPython.
194
190
195 Returns
191 Returns
196 -------
192 -------
197 (stdout, stderr) of ipython subprocess.
193 (stdout, stderr) of ipython subprocess.
198 """
194 """
199 if options is None: options = []
195 if options is None: options = []
200
196
201 # For these subprocess calls, eliminate all prompt printing so we only see
197 # For these subprocess calls, eliminate all prompt printing so we only see
202 # output from script execution
198 # output from script execution
203 prompt_opts = [ '--PromptManager.in_template=""',
199 prompt_opts = [ '--PromptManager.in_template=""',
204 '--PromptManager.in2_template=""',
200 '--PromptManager.in2_template=""',
205 '--PromptManager.out_template=""'
201 '--PromptManager.out_template=""'
206 ]
202 ]
207 cmdargs = default_argv() + prompt_opts + options
203 cmdargs = default_argv() + prompt_opts + options
208
204
209 test_dir = os.path.dirname(__file__)
205 test_dir = os.path.dirname(__file__)
210
206
211 ipython_cmd = get_ipython_cmd()
207 ipython_cmd = get_ipython_cmd()
212 # Absolute path for filename
208 # Absolute path for filename
213 full_fname = os.path.join(test_dir, fname)
209 full_fname = os.path.join(test_dir, fname)
214 full_cmd = ipython_cmd + cmdargs + [full_fname]
210 full_cmd = ipython_cmd + cmdargs + [full_fname]
215 p = Popen(full_cmd, stdout=PIPE, stderr=PIPE)
211 p = Popen(full_cmd, stdout=PIPE, stderr=PIPE)
216 out, err = p.communicate()
212 out, err = p.communicate()
217 out, err = py3compat.bytes_to_str(out), py3compat.bytes_to_str(err)
213 out, err = py3compat.bytes_to_str(out), py3compat.bytes_to_str(err)
218 # `import readline` causes 'ESC[?1034h' to be output sometimes,
214 # `import readline` causes 'ESC[?1034h' to be output sometimes,
219 # so strip that out before doing comparisons
215 # so strip that out before doing comparisons
220 if out:
216 if out:
221 out = re.sub(r'\x1b\[[^h]+h', '', out)
217 out = re.sub(r'\x1b\[[^h]+h', '', out)
222 return out, err
218 return out, err
223
219
224
220
225 def ipexec_validate(fname, expected_out, expected_err='',
221 def ipexec_validate(fname, expected_out, expected_err='',
226 options=None):
222 options=None):
227 """Utility to call 'ipython filename' and validate output/error.
223 """Utility to call 'ipython filename' and validate output/error.
228
224
229 This function raises an AssertionError if the validation fails.
225 This function raises an AssertionError if the validation fails.
230
226
231 Note that this starts IPython in a subprocess!
227 Note that this starts IPython in a subprocess!
232
228
233 Parameters
229 Parameters
234 ----------
230 ----------
235 fname : str
231 fname : str
236 Name of the file to be executed (should have .py or .ipy extension).
232 Name of the file to be executed (should have .py or .ipy extension).
237
233
238 expected_out : str
234 expected_out : str
239 Expected stdout of the process.
235 Expected stdout of the process.
240
236
241 expected_err : optional, str
237 expected_err : optional, str
242 Expected stderr of the process.
238 Expected stderr of the process.
243
239
244 options : optional, list
240 options : optional, list
245 Extra command-line flags to be passed to IPython.
241 Extra command-line flags to be passed to IPython.
246
242
247 Returns
243 Returns
248 -------
244 -------
249 None
245 None
250 """
246 """
251
247
252 import nose.tools as nt
248 import nose.tools as nt
253
249
254 out, err = ipexec(fname, options)
250 out, err = ipexec(fname, options)
255 #print 'OUT', out # dbg
251 #print 'OUT', out # dbg
256 #print 'ERR', err # dbg
252 #print 'ERR', err # dbg
257 # If there are any errors, we must check those befor stdout, as they may be
253 # If there are any errors, we must check those befor stdout, as they may be
258 # more informative than simply having an empty stdout.
254 # more informative than simply having an empty stdout.
259 if err:
255 if err:
260 if expected_err:
256 if expected_err:
261 nt.assert_equal("\n".join(err.strip().splitlines()), "\n".join(expected_err.strip().splitlines()))
257 nt.assert_equal("\n".join(err.strip().splitlines()), "\n".join(expected_err.strip().splitlines()))
262 else:
258 else:
263 raise ValueError('Running file %r produced error: %r' %
259 raise ValueError('Running file %r produced error: %r' %
264 (fname, err))
260 (fname, err))
265 # If no errors or output on stderr was expected, match stdout
261 # If no errors or output on stderr was expected, match stdout
266 nt.assert_equal("\n".join(out.strip().splitlines()), "\n".join(expected_out.strip().splitlines()))
262 nt.assert_equal("\n".join(out.strip().splitlines()), "\n".join(expected_out.strip().splitlines()))
267
263
268
264
269 class TempFileMixin(object):
265 class TempFileMixin(object):
270 """Utility class to create temporary Python/IPython files.
266 """Utility class to create temporary Python/IPython files.
271
267
272 Meant as a mixin class for test cases."""
268 Meant as a mixin class for test cases."""
273
269
274 def mktmp(self, src, ext='.py'):
270 def mktmp(self, src, ext='.py'):
275 """Make a valid python temp file."""
271 """Make a valid python temp file."""
276 fname, f = temp_pyfile(src, ext)
272 fname, f = temp_pyfile(src, ext)
277 self.tmpfile = f
273 self.tmpfile = f
278 self.fname = fname
274 self.fname = fname
279
275
280 def tearDown(self):
276 def tearDown(self):
281 if hasattr(self, 'tmpfile'):
277 if hasattr(self, 'tmpfile'):
282 # If the tmpfile wasn't made because of skipped tests, like in
278 # If the tmpfile wasn't made because of skipped tests, like in
283 # win32, there's nothing to cleanup.
279 # win32, there's nothing to cleanup.
284 self.tmpfile.close()
280 self.tmpfile.close()
285 try:
281 try:
286 os.unlink(self.fname)
282 os.unlink(self.fname)
287 except:
283 except:
288 # On Windows, even though we close the file, we still can't
284 # On Windows, even though we close the file, we still can't
289 # delete it. I have no clue why
285 # delete it. I have no clue why
290 pass
286 pass
291
287
292 pair_fail_msg = ("Testing {0}\n\n"
288 pair_fail_msg = ("Testing {0}\n\n"
293 "In:\n"
289 "In:\n"
294 " {1!r}\n"
290 " {1!r}\n"
295 "Expected:\n"
291 "Expected:\n"
296 " {2!r}\n"
292 " {2!r}\n"
297 "Got:\n"
293 "Got:\n"
298 " {3!r}\n")
294 " {3!r}\n")
299 def check_pairs(func, pairs):
295 def check_pairs(func, pairs):
300 """Utility function for the common case of checking a function with a
296 """Utility function for the common case of checking a function with a
301 sequence of input/output pairs.
297 sequence of input/output pairs.
302
298
303 Parameters
299 Parameters
304 ----------
300 ----------
305 func : callable
301 func : callable
306 The function to be tested. Should accept a single argument.
302 The function to be tested. Should accept a single argument.
307 pairs : iterable
303 pairs : iterable
308 A list of (input, expected_output) tuples.
304 A list of (input, expected_output) tuples.
309
305
310 Returns
306 Returns
311 -------
307 -------
312 None. Raises an AssertionError if any output does not match the expected
308 None. Raises an AssertionError if any output does not match the expected
313 value.
309 value.
314 """
310 """
315 name = getattr(func, "func_name", getattr(func, "__name__", "<unknown>"))
311 name = getattr(func, "func_name", getattr(func, "__name__", "<unknown>"))
316 for inp, expected in pairs:
312 for inp, expected in pairs:
317 out = func(inp)
313 out = func(inp)
318 assert out == expected, pair_fail_msg.format(name, inp, expected, out)
314 assert out == expected, pair_fail_msg.format(name, inp, expected, out)
319
315
320
316
321 if py3compat.PY3:
317 if py3compat.PY3:
322 MyStringIO = StringIO
318 MyStringIO = StringIO
323 else:
319 else:
324 # In Python 2, stdout/stderr can have either bytes or unicode written to them,
320 # In Python 2, stdout/stderr can have either bytes or unicode written to them,
325 # so we need a class that can handle both.
321 # so we need a class that can handle both.
326 class MyStringIO(StringIO):
322 class MyStringIO(StringIO):
327 def write(self, s):
323 def write(self, s):
328 s = py3compat.cast_unicode(s, encoding=DEFAULT_ENCODING)
324 s = py3compat.cast_unicode(s, encoding=DEFAULT_ENCODING)
329 super(MyStringIO, self).write(s)
325 super(MyStringIO, self).write(s)
330
326
331 notprinted_msg = """Did not find {0!r} in printed output (on {1}):
327 notprinted_msg = """Did not find {0!r} in printed output (on {1}):
332 -------
328 -------
333 {2!s}
329 {2!s}
334 -------
330 -------
335 """
331 """
336
332
337 class AssertPrints(object):
333 class AssertPrints(object):
338 """Context manager for testing that code prints certain text.
334 """Context manager for testing that code prints certain text.
339
335
340 Examples
336 Examples
341 --------
337 --------
342 >>> with AssertPrints("abc", suppress=False):
338 >>> with AssertPrints("abc", suppress=False):
343 ... print "abcd"
339 ... print "abcd"
344 ... print "def"
340 ... print "def"
345 ...
341 ...
346 abcd
342 abcd
347 def
343 def
348 """
344 """
349 def __init__(self, s, channel='stdout', suppress=True):
345 def __init__(self, s, channel='stdout', suppress=True):
350 self.s = s
346 self.s = s
351 self.channel = channel
347 self.channel = channel
352 self.suppress = suppress
348 self.suppress = suppress
353
349
354 def __enter__(self):
350 def __enter__(self):
355 self.orig_stream = getattr(sys, self.channel)
351 self.orig_stream = getattr(sys, self.channel)
356 self.buffer = MyStringIO()
352 self.buffer = MyStringIO()
357 self.tee = Tee(self.buffer, channel=self.channel)
353 self.tee = Tee(self.buffer, channel=self.channel)
358 setattr(sys, self.channel, self.buffer if self.suppress else self.tee)
354 setattr(sys, self.channel, self.buffer if self.suppress else self.tee)
359
355
360 def __exit__(self, etype, value, traceback):
356 def __exit__(self, etype, value, traceback):
361 self.tee.flush()
357 self.tee.flush()
362 setattr(sys, self.channel, self.orig_stream)
358 setattr(sys, self.channel, self.orig_stream)
363 printed = self.buffer.getvalue()
359 printed = self.buffer.getvalue()
364 assert self.s in printed, notprinted_msg.format(self.s, self.channel, printed)
360 assert self.s in printed, notprinted_msg.format(self.s, self.channel, printed)
365 return False
361 return False
366
362
367 printed_msg = """Found {0!r} in printed output (on {1}):
363 printed_msg = """Found {0!r} in printed output (on {1}):
368 -------
364 -------
369 {2!s}
365 {2!s}
370 -------
366 -------
371 """
367 """
372
368
373 class AssertNotPrints(AssertPrints):
369 class AssertNotPrints(AssertPrints):
374 """Context manager for checking that certain output *isn't* produced.
370 """Context manager for checking that certain output *isn't* produced.
375
371
376 Counterpart of AssertPrints"""
372 Counterpart of AssertPrints"""
377 def __exit__(self, etype, value, traceback):
373 def __exit__(self, etype, value, traceback):
378 self.tee.flush()
374 self.tee.flush()
379 setattr(sys, self.channel, self.orig_stream)
375 setattr(sys, self.channel, self.orig_stream)
380 printed = self.buffer.getvalue()
376 printed = self.buffer.getvalue()
381 assert self.s not in printed, printed_msg.format(self.s, self.channel, printed)
377 assert self.s not in printed, printed_msg.format(self.s, self.channel, printed)
382 return False
378 return False
383
379
384 @contextmanager
380 @contextmanager
385 def mute_warn():
381 def mute_warn():
386 from IPython.utils import warn
382 from IPython.utils import warn
387 save_warn = warn.warn
383 save_warn = warn.warn
388 warn.warn = lambda *a, **kw: None
384 warn.warn = lambda *a, **kw: None
389 try:
385 try:
390 yield
386 yield
391 finally:
387 finally:
392 warn.warn = save_warn
388 warn.warn = save_warn
393
389
394 @contextmanager
390 @contextmanager
395 def make_tempfile(name):
391 def make_tempfile(name):
396 """ Create an empty, named, temporary file for the duration of the context.
392 """ Create an empty, named, temporary file for the duration of the context.
397 """
393 """
398 f = open(name, 'w')
394 f = open(name, 'w')
399 f.close()
395 f.close()
400 try:
396 try:
401 yield
397 yield
402 finally:
398 finally:
403 os.unlink(name)
399 os.unlink(name)
404
400
405
401
406 @contextmanager
402 @contextmanager
407 def monkeypatch(obj, name, attr):
403 def monkeypatch(obj, name, attr):
408 """
404 """
409 Context manager to replace attribute named `name` in `obj` with `attr`.
405 Context manager to replace attribute named `name` in `obj` with `attr`.
410 """
406 """
411 orig = getattr(obj, name)
407 orig = getattr(obj, name)
412 setattr(obj, name, attr)
408 setattr(obj, name, attr)
413 yield
409 yield
414 setattr(obj, name, orig)
410 setattr(obj, name, orig)
General Comments 0
You need to be logged in to leave comments. Login now