##// END OF EJS Templates
Add failing test for issue gh-1456
Thomas Kluyver -
Show More
@@ -0,0 +1,51 b''
1 """Tests for IPython.core.ultratb
2 """
3
4 import os.path
5 import unittest
6
7 from IPython.testing import tools as tt
8 from IPython.utils.syspathcontext import prepended_to_syspath
9 from IPython.utils.tempdir import TemporaryDirectory
10
11 ip = get_ipython()
12
13 file_1 = """1
14 2
15 3
16 def f():
17 1/0
18 """
19
20 file_2 = """def f():
21 1/0
22 """
23
24 class ChangedPyFileTest(unittest.TestCase):
25 def test_changing_py_file(self):
26 """Traceback produced if the line where the error occurred is missing?
27
28 https://github.com/ipython/ipython/issues/1456
29 """
30 with TemporaryDirectory() as td:
31 fname = os.path.join(td, "foo.py")
32 with open(fname, "w") as f:
33 f.write(file_1)
34
35 with prepended_to_syspath(td):
36 ip.run_cell("import foo")
37
38 with tt.AssertPrints("ZeroDivisionError"):
39 ip.run_cell("foo.f()")
40
41 # Make the file shorter, so the line of the error is missing.
42 with open(fname, "w") as f:
43 f.write(file_2)
44
45 # For some reason, this was failing on the *second* call after
46 # changing the file, so we call f() twice.
47 with tt.AssertNotPrints("Internal Python error", channel='stderr'):
48 with tt.AssertPrints("ZeroDivisionError"):
49 ip.run_cell("foo.f()")
50 with tt.AssertPrints("ZeroDivisionError"):
51 ip.run_cell("foo.f()")
@@ -1,382 +1,391 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
28
29 try:
29 try:
30 # These tools are used by parts of the runtime, so we make the nose
30 # These tools are used by parts of the runtime, so we make the nose
31 # dependency optional at this point. Nose is a hard dependency to run the
31 # dependency optional at this point. Nose is a hard dependency to run the
32 # test suite, but NOT to use ipython itself.
32 # test suite, but NOT to use ipython itself.
33 import nose.tools as nt
33 import nose.tools as nt
34 has_nose = True
34 has_nose = True
35 except ImportError:
35 except ImportError:
36 has_nose = False
36 has_nose = False
37
37
38 from IPython.config.loader import Config
38 from IPython.config.loader import Config
39 from IPython.utils.process import find_cmd, getoutputerror
39 from IPython.utils.process import find_cmd, getoutputerror
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 'FAILED (errors=1)'
100 'FAILED (errors=1)'
101 'FAILED (failures=1)'
101 'FAILED (failures=1)'
102 'FAILED (errors=1, failures=1)'
102 'FAILED (errors=1, failures=1)'
103
103
104 Returns
104 Returns
105 -------
105 -------
106 nerr, nfail: number of errors and failures.
106 nerr, nfail: number of errors and failures.
107 """
107 """
108
108
109 err_m = re.search(r'^FAILED \(errors=(\d+)\)', txt, re.MULTILINE)
109 err_m = re.search(r'^FAILED \(errors=(\d+)\)', txt, re.MULTILINE)
110 if err_m:
110 if err_m:
111 nerr = int(err_m.group(1))
111 nerr = int(err_m.group(1))
112 nfail = 0
112 nfail = 0
113 return nerr, nfail
113 return nerr, nfail
114
114
115 fail_m = re.search(r'^FAILED \(failures=(\d+)\)', txt, re.MULTILINE)
115 fail_m = re.search(r'^FAILED \(failures=(\d+)\)', txt, re.MULTILINE)
116 if fail_m:
116 if fail_m:
117 nerr = 0
117 nerr = 0
118 nfail = int(fail_m.group(1))
118 nfail = int(fail_m.group(1))
119 return nerr, nfail
119 return nerr, nfail
120
120
121 both_m = re.search(r'^FAILED \(errors=(\d+), failures=(\d+)\)', txt,
121 both_m = re.search(r'^FAILED \(errors=(\d+), failures=(\d+)\)', txt,
122 re.MULTILINE)
122 re.MULTILINE)
123 if both_m:
123 if both_m:
124 nerr = int(both_m.group(1))
124 nerr = int(both_m.group(1))
125 nfail = int(both_m.group(2))
125 nfail = int(both_m.group(2))
126 return nerr, nfail
126 return nerr, nfail
127
127
128 # If the input didn't match any of these forms, assume no error/failures
128 # If the input didn't match any of these forms, assume no error/failures
129 return 0, 0
129 return 0, 0
130
130
131
131
132 # So nose doesn't think this is a test
132 # So nose doesn't think this is a test
133 parse_test_output.__test__ = False
133 parse_test_output.__test__ = False
134
134
135
135
136 def default_argv():
136 def default_argv():
137 """Return a valid default argv for creating testing instances of ipython"""
137 """Return a valid default argv for creating testing instances of ipython"""
138
138
139 return ['--quick', # so no config file is loaded
139 return ['--quick', # so no config file is loaded
140 # Other defaults to minimize side effects on stdout
140 # Other defaults to minimize side effects on stdout
141 '--colors=NoColor', '--no-term-title','--no-banner',
141 '--colors=NoColor', '--no-term-title','--no-banner',
142 '--autocall=0']
142 '--autocall=0']
143
143
144
144
145 def default_config():
145 def default_config():
146 """Return a config object with good defaults for testing."""
146 """Return a config object with good defaults for testing."""
147 config = Config()
147 config = Config()
148 config.TerminalInteractiveShell.colors = 'NoColor'
148 config.TerminalInteractiveShell.colors = 'NoColor'
149 config.TerminalTerminalInteractiveShell.term_title = False,
149 config.TerminalTerminalInteractiveShell.term_title = False,
150 config.TerminalInteractiveShell.autocall = 0
150 config.TerminalInteractiveShell.autocall = 0
151 config.HistoryManager.hist_file = tempfile.mktemp(u'test_hist.sqlite')
151 config.HistoryManager.hist_file = tempfile.mktemp(u'test_hist.sqlite')
152 config.HistoryManager.db_cache_size = 10000
152 config.HistoryManager.db_cache_size = 10000
153 return config
153 return config
154
154
155
155
156 def ipexec(fname, options=None):
156 def ipexec(fname, options=None):
157 """Utility to call 'ipython filename'.
157 """Utility to call 'ipython filename'.
158
158
159 Starts IPython witha minimal and safe configuration to make startup as fast
159 Starts IPython witha minimal and safe configuration to make startup as fast
160 as possible.
160 as possible.
161
161
162 Note that this starts IPython in a subprocess!
162 Note that this starts IPython in a subprocess!
163
163
164 Parameters
164 Parameters
165 ----------
165 ----------
166 fname : str
166 fname : str
167 Name of file to be executed (should have .py or .ipy extension).
167 Name of file to be executed (should have .py or .ipy extension).
168
168
169 options : optional, list
169 options : optional, list
170 Extra command-line flags to be passed to IPython.
170 Extra command-line flags to be passed to IPython.
171
171
172 Returns
172 Returns
173 -------
173 -------
174 (stdout, stderr) of ipython subprocess.
174 (stdout, stderr) of ipython subprocess.
175 """
175 """
176 if options is None: options = []
176 if options is None: options = []
177
177
178 # For these subprocess calls, eliminate all prompt printing so we only see
178 # For these subprocess calls, eliminate all prompt printing so we only see
179 # output from script execution
179 # output from script execution
180 prompt_opts = [ '--PromptManager.in_template=""',
180 prompt_opts = [ '--PromptManager.in_template=""',
181 '--PromptManager.in2_template=""',
181 '--PromptManager.in2_template=""',
182 '--PromptManager.out_template=""'
182 '--PromptManager.out_template=""'
183 ]
183 ]
184 cmdargs = ' '.join(default_argv() + prompt_opts + options)
184 cmdargs = ' '.join(default_argv() + prompt_opts + options)
185
185
186 _ip = get_ipython()
186 _ip = get_ipython()
187 test_dir = os.path.dirname(__file__)
187 test_dir = os.path.dirname(__file__)
188
188
189 ipython_cmd = find_cmd('ipython3' if py3compat.PY3 else 'ipython')
189 ipython_cmd = find_cmd('ipython3' if py3compat.PY3 else 'ipython')
190 # Absolute path for filename
190 # Absolute path for filename
191 full_fname = os.path.join(test_dir, fname)
191 full_fname = os.path.join(test_dir, fname)
192 full_cmd = '%s %s %s' % (ipython_cmd, cmdargs, full_fname)
192 full_cmd = '%s %s %s' % (ipython_cmd, cmdargs, full_fname)
193 #print >> sys.stderr, 'FULL CMD:', full_cmd # dbg
193 #print >> sys.stderr, 'FULL CMD:', full_cmd # dbg
194 out, err = getoutputerror(full_cmd)
194 out, err = getoutputerror(full_cmd)
195 # `import readline` causes 'ESC[?1034h' to be output sometimes,
195 # `import readline` causes 'ESC[?1034h' to be output sometimes,
196 # so strip that out before doing comparisons
196 # so strip that out before doing comparisons
197 if out:
197 if out:
198 out = re.sub(r'\x1b\[[^h]+h', '', out)
198 out = re.sub(r'\x1b\[[^h]+h', '', out)
199 return out, err
199 return out, err
200
200
201
201
202 def ipexec_validate(fname, expected_out, expected_err='',
202 def ipexec_validate(fname, expected_out, expected_err='',
203 options=None):
203 options=None):
204 """Utility to call 'ipython filename' and validate output/error.
204 """Utility to call 'ipython filename' and validate output/error.
205
205
206 This function raises an AssertionError if the validation fails.
206 This function raises an AssertionError if the validation fails.
207
207
208 Note that this starts IPython in a subprocess!
208 Note that this starts IPython in a subprocess!
209
209
210 Parameters
210 Parameters
211 ----------
211 ----------
212 fname : str
212 fname : str
213 Name of the file to be executed (should have .py or .ipy extension).
213 Name of the file to be executed (should have .py or .ipy extension).
214
214
215 expected_out : str
215 expected_out : str
216 Expected stdout of the process.
216 Expected stdout of the process.
217
217
218 expected_err : optional, str
218 expected_err : optional, str
219 Expected stderr of the process.
219 Expected stderr of the process.
220
220
221 options : optional, list
221 options : optional, list
222 Extra command-line flags to be passed to IPython.
222 Extra command-line flags to be passed to IPython.
223
223
224 Returns
224 Returns
225 -------
225 -------
226 None
226 None
227 """
227 """
228
228
229 import nose.tools as nt
229 import nose.tools as nt
230
230
231 out, err = ipexec(fname, options)
231 out, err = ipexec(fname, options)
232 #print 'OUT', out # dbg
232 #print 'OUT', out # dbg
233 #print 'ERR', err # dbg
233 #print 'ERR', err # dbg
234 # If there are any errors, we must check those befor stdout, as they may be
234 # If there are any errors, we must check those befor stdout, as they may be
235 # more informative than simply having an empty stdout.
235 # more informative than simply having an empty stdout.
236 if err:
236 if err:
237 if expected_err:
237 if expected_err:
238 nt.assert_equal(err.strip(), expected_err.strip())
238 nt.assert_equal(err.strip(), expected_err.strip())
239 else:
239 else:
240 raise ValueError('Running file %r produced error: %r' %
240 raise ValueError('Running file %r produced error: %r' %
241 (fname, err))
241 (fname, err))
242 # If no errors or output on stderr was expected, match stdout
242 # If no errors or output on stderr was expected, match stdout
243 nt.assert_equal(out.strip(), expected_out.strip())
243 nt.assert_equal(out.strip(), expected_out.strip())
244
244
245
245
246 class TempFileMixin(object):
246 class TempFileMixin(object):
247 """Utility class to create temporary Python/IPython files.
247 """Utility class to create temporary Python/IPython files.
248
248
249 Meant as a mixin class for test cases."""
249 Meant as a mixin class for test cases."""
250
250
251 def mktmp(self, src, ext='.py'):
251 def mktmp(self, src, ext='.py'):
252 """Make a valid python temp file."""
252 """Make a valid python temp file."""
253 fname, f = temp_pyfile(src, ext)
253 fname, f = temp_pyfile(src, ext)
254 self.tmpfile = f
254 self.tmpfile = f
255 self.fname = fname
255 self.fname = fname
256
256
257 def tearDown(self):
257 def tearDown(self):
258 if hasattr(self, 'tmpfile'):
258 if hasattr(self, 'tmpfile'):
259 # If the tmpfile wasn't made because of skipped tests, like in
259 # If the tmpfile wasn't made because of skipped tests, like in
260 # win32, there's nothing to cleanup.
260 # win32, there's nothing to cleanup.
261 self.tmpfile.close()
261 self.tmpfile.close()
262 try:
262 try:
263 os.unlink(self.fname)
263 os.unlink(self.fname)
264 except:
264 except:
265 # On Windows, even though we close the file, we still can't
265 # On Windows, even though we close the file, we still can't
266 # delete it. I have no clue why
266 # delete it. I have no clue why
267 pass
267 pass
268
268
269 pair_fail_msg = ("Testing {0}\n\n"
269 pair_fail_msg = ("Testing {0}\n\n"
270 "In:\n"
270 "In:\n"
271 " {1!r}\n"
271 " {1!r}\n"
272 "Expected:\n"
272 "Expected:\n"
273 " {2!r}\n"
273 " {2!r}\n"
274 "Got:\n"
274 "Got:\n"
275 " {3!r}\n")
275 " {3!r}\n")
276 def check_pairs(func, pairs):
276 def check_pairs(func, pairs):
277 """Utility function for the common case of checking a function with a
277 """Utility function for the common case of checking a function with a
278 sequence of input/output pairs.
278 sequence of input/output pairs.
279
279
280 Parameters
280 Parameters
281 ----------
281 ----------
282 func : callable
282 func : callable
283 The function to be tested. Should accept a single argument.
283 The function to be tested. Should accept a single argument.
284 pairs : iterable
284 pairs : iterable
285 A list of (input, expected_output) tuples.
285 A list of (input, expected_output) tuples.
286
286
287 Returns
287 Returns
288 -------
288 -------
289 None. Raises an AssertionError if any output does not match the expected
289 None. Raises an AssertionError if any output does not match the expected
290 value.
290 value.
291 """
291 """
292 name = getattr(func, "func_name", getattr(func, "__name__", "<unknown>"))
292 name = getattr(func, "func_name", getattr(func, "__name__", "<unknown>"))
293 for inp, expected in pairs:
293 for inp, expected in pairs:
294 out = func(inp)
294 out = func(inp)
295 assert out == expected, pair_fail_msg.format(name, inp, expected, out)
295 assert out == expected, pair_fail_msg.format(name, inp, expected, out)
296
296
297
297
298 if py3compat.PY3:
298 if py3compat.PY3:
299 MyStringIO = StringIO
299 MyStringIO = StringIO
300 else:
300 else:
301 # In Python 2, stdout/stderr can have either bytes or unicode written to them,
301 # In Python 2, stdout/stderr can have either bytes or unicode written to them,
302 # so we need a class that can handle both.
302 # so we need a class that can handle both.
303 class MyStringIO(StringIO):
303 class MyStringIO(StringIO):
304 def write(self, s):
304 def write(self, s):
305 s = py3compat.cast_unicode(s, encoding=DEFAULT_ENCODING)
305 s = py3compat.cast_unicode(s, encoding=DEFAULT_ENCODING)
306 super(MyStringIO, self).write(s)
306 super(MyStringIO, self).write(s)
307
307
308 notprinted_msg = """Did not find {0!r} in printed output (on {1}):
308 notprinted_msg = """Did not find {0!r} in printed output (on {1}):
309 {2!r}"""
309 -------
310 {2!s}
311 -------
312 """
310
313
311 class AssertPrints(object):
314 class AssertPrints(object):
312 """Context manager for testing that code prints certain text.
315 """Context manager for testing that code prints certain text.
313
316
314 Examples
317 Examples
315 --------
318 --------
316 >>> with AssertPrints("abc", suppress=False):
319 >>> with AssertPrints("abc", suppress=False):
317 ... print "abcd"
320 ... print "abcd"
318 ... print "def"
321 ... print "def"
319 ...
322 ...
320 abcd
323 abcd
321 def
324 def
322 """
325 """
323 def __init__(self, s, channel='stdout', suppress=True):
326 def __init__(self, s, channel='stdout', suppress=True):
324 self.s = s
327 self.s = s
325 self.channel = channel
328 self.channel = channel
326 self.suppress = suppress
329 self.suppress = suppress
327
330
328 def __enter__(self):
331 def __enter__(self):
329 self.orig_stream = getattr(sys, self.channel)
332 self.orig_stream = getattr(sys, self.channel)
330 self.buffer = MyStringIO()
333 self.buffer = MyStringIO()
331 self.tee = Tee(self.buffer, channel=self.channel)
334 self.tee = Tee(self.buffer, channel=self.channel)
332 setattr(sys, self.channel, self.buffer if self.suppress else self.tee)
335 setattr(sys, self.channel, self.buffer if self.suppress else self.tee)
333
336
334 def __exit__(self, etype, value, traceback):
337 def __exit__(self, etype, value, traceback):
335 self.tee.flush()
338 self.tee.flush()
336 setattr(sys, self.channel, self.orig_stream)
339 setattr(sys, self.channel, self.orig_stream)
337 printed = self.buffer.getvalue()
340 printed = self.buffer.getvalue()
338 assert self.s in printed, notprinted_msg.format(self.s, self.channel, printed)
341 assert self.s in printed, notprinted_msg.format(self.s, self.channel, printed)
339 return False
342 return False
340
343
344 printed_msg = """Found {0!r} in printed output (on {1}):
345 -------
346 {2!s}
347 -------
348 """
349
341 class AssertNotPrints(AssertPrints):
350 class AssertNotPrints(AssertPrints):
342 """Context manager for checking that certain output *isn't* produced.
351 """Context manager for checking that certain output *isn't* produced.
343
352
344 Counterpart of AssertPrints"""
353 Counterpart of AssertPrints"""
345 def __exit__(self, etype, value, traceback):
354 def __exit__(self, etype, value, traceback):
346 self.tee.flush()
355 self.tee.flush()
347 setattr(sys, self.channel, self.orig_stream)
356 setattr(sys, self.channel, self.orig_stream)
348 printed = self.buffer.getvalue()
357 printed = self.buffer.getvalue()
349 assert self.s not in printed, notprinted_msg.format(self.s, self.channel, printed)
358 assert self.s not in printed, printed_msg.format(self.s, self.channel, printed)
350 return False
359 return False
351
360
352 @contextmanager
361 @contextmanager
353 def mute_warn():
362 def mute_warn():
354 from IPython.utils import warn
363 from IPython.utils import warn
355 save_warn = warn.warn
364 save_warn = warn.warn
356 warn.warn = lambda *a, **kw: None
365 warn.warn = lambda *a, **kw: None
357 try:
366 try:
358 yield
367 yield
359 finally:
368 finally:
360 warn.warn = save_warn
369 warn.warn = save_warn
361
370
362 @contextmanager
371 @contextmanager
363 def make_tempfile(name):
372 def make_tempfile(name):
364 """ Create an empty, named, temporary file for the duration of the context.
373 """ Create an empty, named, temporary file for the duration of the context.
365 """
374 """
366 f = open(name, 'w')
375 f = open(name, 'w')
367 f.close()
376 f.close()
368 try:
377 try:
369 yield
378 yield
370 finally:
379 finally:
371 os.unlink(name)
380 os.unlink(name)
372
381
373
382
374 @contextmanager
383 @contextmanager
375 def monkeypatch(obj, name, attr):
384 def monkeypatch(obj, name, attr):
376 """
385 """
377 Context manager to replace attribute named `name` in `obj` with `attr`.
386 Context manager to replace attribute named `name` in `obj` with `attr`.
378 """
387 """
379 orig = getattr(obj, name)
388 orig = getattr(obj, name)
380 setattr(obj, name, attr)
389 setattr(obj, name, attr)
381 yield
390 yield
382 setattr(obj, name, orig)
391 setattr(obj, name, orig)
General Comments 0
You need to be logged in to leave comments. Login now