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