##// END OF EJS Templates
Pytest diagnostics improvement for `IPython.testing.tools`...
Nikita Kniazev -
Show More
@@ -1,69 +1,72 b''
1 1 import types
2 2 import sys
3 3 import builtins
4 4 import os
5 5 import pytest
6 6 import pathlib
7 7 import shutil
8 8
9 # Must register before it gets imported
10 pytest.register_assert_rewrite("IPython.testing.tools")
11
9 12 from .testing import tools
10 13
11 14
12 15 def get_ipython():
13 16 from .terminal.interactiveshell import TerminalInteractiveShell
14 17 if TerminalInteractiveShell._instance:
15 18 return TerminalInteractiveShell.instance()
16 19
17 20 config = tools.default_config()
18 21 config.TerminalInteractiveShell.simple_prompt = True
19 22
20 23 # Create and initialize our test-friendly IPython instance.
21 24 shell = TerminalInteractiveShell.instance(config=config)
22 25 return shell
23 26
24 27
25 28 @pytest.fixture(scope='session', autouse=True)
26 29 def work_path():
27 30 path = pathlib.Path("./tmp-ipython-pytest-profiledir")
28 31 os.environ["IPYTHONDIR"] = str(path.absolute())
29 32 if path.exists():
30 33 raise ValueError('IPython dir temporary path already exists ! Did previous test run exit successfully ?')
31 34 path.mkdir()
32 35 yield
33 36 shutil.rmtree(str(path.resolve()))
34 37
35 38
36 39 def nopage(strng, start=0, screen_lines=0, pager_cmd=None):
37 40 if isinstance(strng, dict):
38 41 strng = strng.get("text/plain", "")
39 42 print(strng)
40 43
41 44
42 45 def xsys(self, cmd):
43 46 """Replace the default system call with a capturing one for doctest.
44 47 """
45 48 # We use getoutput, but we need to strip it because pexpect captures
46 49 # the trailing newline differently from commands.getoutput
47 50 print(self.getoutput(cmd, split=False, depth=1).rstrip(), end="", file=sys.stdout)
48 51 sys.stdout.flush()
49 52
50 53
51 54 # for things to work correctly we would need this as a session fixture;
52 55 # unfortunately this will fail on some test that get executed as _collection_
53 56 # time (before the fixture run), in particular parametrized test that contain
54 57 # yields. so for now execute at import time.
55 58 #@pytest.fixture(autouse=True, scope='session')
56 59 def inject():
57 60
58 61 builtins.get_ipython = get_ipython
59 62 builtins._ip = get_ipython()
60 63 builtins.ip = get_ipython()
61 64 builtins.ip.system = types.MethodType(xsys, ip)
62 65 builtins.ip.builtin_trap.activate()
63 66 from .core import page
64 67
65 68 page.pager_page = nopage
66 69 # yield
67 70
68 71
69 72 inject()
@@ -1,463 +1,477 b''
1 1 """Generic testing tools.
2 2
3 3 Authors
4 4 -------
5 5 - Fernando Perez <Fernando.Perez@berkeley.edu>
6 6 """
7 7
8 8
9 9 # Copyright (c) IPython Development Team.
10 10 # Distributed under the terms of the Modified BSD License.
11 11
12 12 import os
13 13 from pathlib import Path
14 14 import re
15 15 import sys
16 16 import tempfile
17 17 import unittest
18 18
19 19 from contextlib import contextmanager
20 20 from io import StringIO
21 21 from subprocess import Popen, PIPE
22 22 from unittest.mock import patch
23 23
24 24 from traitlets.config.loader import Config
25 25 from IPython.utils.process import get_output_error_code
26 26 from IPython.utils.text import list_strings
27 27 from IPython.utils.io import temp_pyfile, Tee
28 28 from IPython.utils import py3compat
29 29
30 30 from . import decorators as dec
31 31 from . import skipdoctest
32 32
33 33
34 34 # The docstring for full_path doctests differently on win32 (different path
35 35 # separator) so just skip the doctest there. The example remains informative.
36 36 doctest_deco = skipdoctest.skip_doctest if sys.platform == 'win32' else dec.null_deco
37 37
38 38 @doctest_deco
39 39 def full_path(startPath,files):
40 40 """Make full paths for all the listed files, based on startPath.
41 41
42 42 Only the base part of startPath is kept, since this routine is typically
43 43 used with a script's ``__file__`` variable as startPath. The base of startPath
44 44 is then prepended to all the listed files, forming the output list.
45 45
46 46 Parameters
47 47 ----------
48 48 startPath : string
49 49 Initial path to use as the base for the results. This path is split
50 50 using os.path.split() and only its first component is kept.
51 51
52 52 files : string or list
53 53 One or more files.
54 54
55 55 Examples
56 56 --------
57 57
58 58 >>> full_path('/foo/bar.py',['a.txt','b.txt'])
59 59 ['/foo/a.txt', '/foo/b.txt']
60 60
61 61 >>> full_path('/foo',['a.txt','b.txt'])
62 62 ['/a.txt', '/b.txt']
63 63
64 64 If a single file is given, the output is still a list::
65 65
66 66 >>> full_path('/foo','a.txt')
67 67 ['/a.txt']
68 68 """
69 69
70 70 files = list_strings(files)
71 71 base = os.path.split(startPath)[0]
72 72 return [ os.path.join(base,f) for f in files ]
73 73
74 74
75 75 def parse_test_output(txt):
76 76 """Parse the output of a test run and return errors, failures.
77 77
78 78 Parameters
79 79 ----------
80 80 txt : str
81 81 Text output of a test run, assumed to contain a line of one of the
82 82 following forms::
83 83
84 84 'FAILED (errors=1)'
85 85 'FAILED (failures=1)'
86 86 'FAILED (errors=1, failures=1)'
87 87
88 88 Returns
89 89 -------
90 90 nerr, nfail
91 91 number of errors and failures.
92 92 """
93 93
94 94 err_m = re.search(r'^FAILED \(errors=(\d+)\)', txt, re.MULTILINE)
95 95 if err_m:
96 96 nerr = int(err_m.group(1))
97 97 nfail = 0
98 98 return nerr, nfail
99 99
100 100 fail_m = re.search(r'^FAILED \(failures=(\d+)\)', txt, re.MULTILINE)
101 101 if fail_m:
102 102 nerr = 0
103 103 nfail = int(fail_m.group(1))
104 104 return nerr, nfail
105 105
106 106 both_m = re.search(r'^FAILED \(errors=(\d+), failures=(\d+)\)', txt,
107 107 re.MULTILINE)
108 108 if both_m:
109 109 nerr = int(both_m.group(1))
110 110 nfail = int(both_m.group(2))
111 111 return nerr, nfail
112 112
113 113 # If the input didn't match any of these forms, assume no error/failures
114 114 return 0, 0
115 115
116 116
117 117 # So nose doesn't think this is a test
118 118 parse_test_output.__test__ = False
119 119
120 120
121 121 def default_argv():
122 122 """Return a valid default argv for creating testing instances of ipython"""
123 123
124 124 return ['--quick', # so no config file is loaded
125 125 # Other defaults to minimize side effects on stdout
126 126 '--colors=NoColor', '--no-term-title','--no-banner',
127 127 '--autocall=0']
128 128
129 129
130 130 def default_config():
131 131 """Return a config object with good defaults for testing."""
132 132 config = Config()
133 133 config.TerminalInteractiveShell.colors = 'NoColor'
134 134 config.TerminalTerminalInteractiveShell.term_title = False,
135 135 config.TerminalInteractiveShell.autocall = 0
136 136 f = tempfile.NamedTemporaryFile(suffix=u'test_hist.sqlite', delete=False)
137 137 config.HistoryManager.hist_file = Path(f.name)
138 138 f.close()
139 139 config.HistoryManager.db_cache_size = 10000
140 140 return config
141 141
142 142
143 143 def get_ipython_cmd(as_string=False):
144 144 """
145 145 Return appropriate IPython command line name. By default, this will return
146 146 a list that can be used with subprocess.Popen, for example, but passing
147 147 `as_string=True` allows for returning the IPython command as a string.
148 148
149 149 Parameters
150 150 ----------
151 151 as_string: bool
152 152 Flag to allow to return the command as a string.
153 153 """
154 154 ipython_cmd = [sys.executable, "-m", "IPython"]
155 155
156 156 if as_string:
157 157 ipython_cmd = " ".join(ipython_cmd)
158 158
159 159 return ipython_cmd
160 160
161 161 def ipexec(fname, options=None, commands=()):
162 162 """Utility to call 'ipython filename'.
163 163
164 164 Starts IPython with a minimal and safe configuration to make startup as fast
165 165 as possible.
166 166
167 167 Note that this starts IPython in a subprocess!
168 168
169 169 Parameters
170 170 ----------
171 171 fname : str, Path
172 172 Name of file to be executed (should have .py or .ipy extension).
173 173
174 174 options : optional, list
175 175 Extra command-line flags to be passed to IPython.
176 176
177 177 commands : optional, list
178 178 Commands to send in on stdin
179 179
180 180 Returns
181 181 -------
182 182 ``(stdout, stderr)`` of ipython subprocess.
183 183 """
184 if options is None: options = []
184 __tracebackhide__ = True
185
186 if options is None:
187 options = []
185 188
186 189 cmdargs = default_argv() + options
187 190
188 191 test_dir = os.path.dirname(__file__)
189 192
190 193 ipython_cmd = get_ipython_cmd()
191 194 # Absolute path for filename
192 195 full_fname = os.path.join(test_dir, fname)
193 196 full_cmd = ipython_cmd + cmdargs + ['--', full_fname]
194 197 env = os.environ.copy()
195 198 # FIXME: ignore all warnings in ipexec while we have shims
196 199 # should we keep suppressing warnings here, even after removing shims?
197 200 env['PYTHONWARNINGS'] = 'ignore'
198 201 # env.pop('PYTHONWARNINGS', None) # Avoid extraneous warnings appearing on stderr
199 202 # Prevent coloring under PyCharm ("\x1b[0m" at the end of the stdout)
200 203 env.pop("PYCHARM_HOSTED", None)
201 204 for k, v in env.items():
202 205 # Debug a bizarre failure we've seen on Windows:
203 206 # TypeError: environment can only contain strings
204 207 if not isinstance(v, str):
205 208 print(k, v)
206 209 p = Popen(full_cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE, env=env)
207 210 out, err = p.communicate(input=py3compat.encode('\n'.join(commands)) or None)
208 211 out, err = py3compat.decode(out), py3compat.decode(err)
209 212 # `import readline` causes 'ESC[?1034h' to be output sometimes,
210 213 # so strip that out before doing comparisons
211 214 if out:
212 215 out = re.sub(r'\x1b\[[^h]+h', '', out)
213 216 return out, err
214 217
215 218
216 219 def ipexec_validate(fname, expected_out, expected_err='',
217 220 options=None, commands=()):
218 221 """Utility to call 'ipython filename' and validate output/error.
219 222
220 223 This function raises an AssertionError if the validation fails.
221 224
222 225 Note that this starts IPython in a subprocess!
223 226
224 227 Parameters
225 228 ----------
226 229 fname : str, Path
227 230 Name of the file to be executed (should have .py or .ipy extension).
228 231
229 232 expected_out : str
230 233 Expected stdout of the process.
231 234
232 235 expected_err : optional, str
233 236 Expected stderr of the process.
234 237
235 238 options : optional, list
236 239 Extra command-line flags to be passed to IPython.
237 240
238 241 Returns
239 242 -------
240 243 None
241 244 """
245 __tracebackhide__ = True
242 246
243 247 out, err = ipexec(fname, options, commands)
244 248 #print 'OUT', out # dbg
245 249 #print 'ERR', err # dbg
246 250 # If there are any errors, we must check those before stdout, as they may be
247 251 # more informative than simply having an empty stdout.
248 252 if err:
249 253 if expected_err:
250 assert err.strip().splitlines() == expected_err.strip().splitlines()
254 assert "\n".join(err.strip().splitlines()) == "\n".join(
255 expected_err.strip().splitlines()
256 )
251 257 else:
252 258 raise ValueError('Running file %r produced error: %r' %
253 259 (fname, err))
254 260 # If no errors or output on stderr was expected, match stdout
255 assert out.strip().splitlines() == expected_out.strip().splitlines()
261 assert "\n".join(out.strip().splitlines()) == "\n".join(
262 expected_out.strip().splitlines()
263 )
256 264
257 265
258 266 class TempFileMixin(unittest.TestCase):
259 267 """Utility class to create temporary Python/IPython files.
260 268
261 269 Meant as a mixin class for test cases."""
262 270
263 271 def mktmp(self, src, ext='.py'):
264 272 """Make a valid python temp file."""
265 273 fname = temp_pyfile(src, ext)
266 274 if not hasattr(self, 'tmps'):
267 275 self.tmps=[]
268 276 self.tmps.append(fname)
269 277 self.fname = fname
270 278
271 279 def tearDown(self):
272 280 # If the tmpfile wasn't made because of skipped tests, like in
273 281 # win32, there's nothing to cleanup.
274 282 if hasattr(self, 'tmps'):
275 283 for fname in self.tmps:
276 284 # If the tmpfile wasn't made because of skipped tests, like in
277 285 # win32, there's nothing to cleanup.
278 286 try:
279 287 os.unlink(fname)
280 288 except:
281 289 # On Windows, even though we close the file, we still can't
282 290 # delete it. I have no clue why
283 291 pass
284 292
285 293 def __enter__(self):
286 294 return self
287 295
288 296 def __exit__(self, exc_type, exc_value, traceback):
289 297 self.tearDown()
290 298
291 299
292 300 pair_fail_msg = ("Testing {0}\n\n"
293 301 "In:\n"
294 302 " {1!r}\n"
295 303 "Expected:\n"
296 304 " {2!r}\n"
297 305 "Got:\n"
298 306 " {3!r}\n")
299 307 def check_pairs(func, pairs):
300 308 """Utility function for the common case of checking a function with a
301 309 sequence of input/output pairs.
302 310
303 311 Parameters
304 312 ----------
305 313 func : callable
306 314 The function to be tested. Should accept a single argument.
307 315 pairs : iterable
308 316 A list of (input, expected_output) tuples.
309 317
310 318 Returns
311 319 -------
312 320 None. Raises an AssertionError if any output does not match the expected
313 321 value.
314 322 """
323 __tracebackhide__ = True
324
315 325 name = getattr(func, "func_name", getattr(func, "__name__", "<unknown>"))
316 326 for inp, expected in pairs:
317 327 out = func(inp)
318 328 assert out == expected, pair_fail_msg.format(name, inp, expected, out)
319 329
320 330
321 331 MyStringIO = StringIO
322 332
323 333 _re_type = type(re.compile(r''))
324 334
325 335 notprinted_msg = """Did not find {0!r} in printed output (on {1}):
326 336 -------
327 337 {2!s}
328 338 -------
329 339 """
330 340
331 341 class AssertPrints(object):
332 342 """Context manager for testing that code prints certain text.
333 343
334 344 Examples
335 345 --------
336 346 >>> with AssertPrints("abc", suppress=False):
337 347 ... print("abcd")
338 348 ... print("def")
339 349 ...
340 350 abcd
341 351 def
342 352 """
343 353 def __init__(self, s, channel='stdout', suppress=True):
344 354 self.s = s
345 355 if isinstance(self.s, (str, _re_type)):
346 356 self.s = [self.s]
347 357 self.channel = channel
348 358 self.suppress = suppress
349 359
350 360 def __enter__(self):
351 361 self.orig_stream = getattr(sys, self.channel)
352 362 self.buffer = MyStringIO()
353 363 self.tee = Tee(self.buffer, channel=self.channel)
354 364 setattr(sys, self.channel, self.buffer if self.suppress else self.tee)
355 365
356 366 def __exit__(self, etype, value, traceback):
367 __tracebackhide__ = True
368
357 369 try:
358 370 if value is not None:
359 371 # If an error was raised, don't check anything else
360 372 return False
361 373 self.tee.flush()
362 374 setattr(sys, self.channel, self.orig_stream)
363 375 printed = self.buffer.getvalue()
364 376 for s in self.s:
365 377 if isinstance(s, _re_type):
366 378 assert s.search(printed), notprinted_msg.format(s.pattern, self.channel, printed)
367 379 else:
368 380 assert s in printed, notprinted_msg.format(s, self.channel, printed)
369 381 return False
370 382 finally:
371 383 self.tee.close()
372 384
373 385 printed_msg = """Found {0!r} in printed output (on {1}):
374 386 -------
375 387 {2!s}
376 388 -------
377 389 """
378 390
379 391 class AssertNotPrints(AssertPrints):
380 392 """Context manager for checking that certain output *isn't* produced.
381 393
382 394 Counterpart of AssertPrints"""
383 395 def __exit__(self, etype, value, traceback):
396 __tracebackhide__ = True
397
384 398 try:
385 399 if value is not None:
386 400 # If an error was raised, don't check anything else
387 401 self.tee.close()
388 402 return False
389 403 self.tee.flush()
390 404 setattr(sys, self.channel, self.orig_stream)
391 405 printed = self.buffer.getvalue()
392 406 for s in self.s:
393 407 if isinstance(s, _re_type):
394 408 assert not s.search(printed),printed_msg.format(
395 409 s.pattern, self.channel, printed)
396 410 else:
397 411 assert s not in printed, printed_msg.format(
398 412 s, self.channel, printed)
399 413 return False
400 414 finally:
401 415 self.tee.close()
402 416
403 417 @contextmanager
404 418 def mute_warn():
405 419 from IPython.utils import warn
406 420 save_warn = warn.warn
407 421 warn.warn = lambda *a, **kw: None
408 422 try:
409 423 yield
410 424 finally:
411 425 warn.warn = save_warn
412 426
413 427 @contextmanager
414 428 def make_tempfile(name):
415 429 """ Create an empty, named, temporary file for the duration of the context.
416 430 """
417 431 open(name, 'w').close()
418 432 try:
419 433 yield
420 434 finally:
421 435 os.unlink(name)
422 436
423 437 def fake_input(inputs):
424 438 """Temporarily replace the input() function to return the given values
425 439
426 440 Use as a context manager:
427 441
428 442 with fake_input(['result1', 'result2']):
429 443 ...
430 444
431 445 Values are returned in order. If input() is called again after the last value
432 446 was used, EOFError is raised.
433 447 """
434 448 it = iter(inputs)
435 449 def mock_input(prompt=''):
436 450 try:
437 451 return next(it)
438 452 except StopIteration as e:
439 453 raise EOFError('No more inputs given') from e
440 454
441 455 return patch('builtins.input', mock_input)
442 456
443 457 def help_output_test(subcommand=''):
444 458 """test that `ipython [subcommand] -h` works"""
445 459 cmd = get_ipython_cmd() + [subcommand, '-h']
446 460 out, err, rc = get_output_error_code(cmd)
447 461 assert rc == 0, err
448 462 assert "Traceback" not in err
449 463 assert "Options" in out
450 464 assert "--help-all" in out
451 465 return out, err
452 466
453 467
454 468 def help_all_output_test(subcommand=''):
455 469 """test that `ipython [subcommand] --help-all` works"""
456 470 cmd = get_ipython_cmd() + [subcommand, '--help-all']
457 471 out, err, rc = get_output_error_code(cmd)
458 472 assert rc == 0, err
459 473 assert "Traceback" not in err
460 474 assert "Options" in out
461 475 assert "Class" in out
462 476 return out, err
463 477
General Comments 0
You need to be logged in to leave comments. Login now