##// END OF EJS Templates
try to fix str issue with nv onwindows
Matthias Bussonnier -
Show More
@@ -1,493 +1,496 b''
1 1 # -*- coding: utf-8 -*-
2 2 """IPython Test Process Controller
3 3
4 4 This module runs one or more subprocesses which will actually run the IPython
5 5 test suite.
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
13 13 import argparse
14 14 import multiprocessing.pool
15 15 import os
16 16 import stat
17 17 import shutil
18 18 import signal
19 19 import sys
20 20 import subprocess
21 21 import time
22 22
23 23 from .iptest import (
24 24 have, test_group_names as py_test_group_names, test_sections, StreamCapturer,
25 25 )
26 26 from IPython.utils.path import compress_user
27 27 from IPython.utils.py3compat import decode
28 28 from IPython.utils.sysinfo import get_sys_info
29 29 from IPython.utils.tempdir import TemporaryDirectory
30 30 from pathlib import Path
31 from typing import Dict
31 32
32 33 class TestController:
33 34 """Run tests in a subprocess
34 35 """
35 36 #: str, IPython test suite to be executed.
36 37 section = None
37 38 #: list, command line arguments to be executed
38 39 cmd = None
39 40 #: dict, extra environment variables to set for the subprocess
40 env = None
41 env: Dict[str, str] = {}
41 42 #: list, TemporaryDirectory instances to clear up when the process finishes
42 43 dirs = None
43 44 #: subprocess.Popen instance
44 45 process = None
45 46 #: str, process stdout+stderr
46 47 stdout = None
47 48
48 49 def __init__(self):
49 50 self.cmd = []
50 51 self.env = {}
51 52 self.dirs = []
52 53
53 54 def setUp(self):
54 55 """Create temporary directories etc.
55 56
56 57 This is only called when we know the test group will be run. Things
57 58 created here may be cleaned up by self.cleanup().
58 59 """
59 60 pass
60 61
61 62 def launch(self, buffer_output=False, capture_output=False):
62 63 # print('*** ENV:', self.env) # dbg
63 64 # print('*** CMD:', self.cmd) # dbg
64 65 env = os.environ.copy()
65 66 env.update(self.env)
66 67 if buffer_output:
67 68 capture_output = True
68 69 self.stdout_capturer = c = StreamCapturer(echo=not buffer_output)
69 70 c.start()
70 71 stdout = c.writefd if capture_output else None
71 72 stderr = subprocess.STDOUT if capture_output else None
73 for k, v in env.items():
74 assert isinstance(v, str), f"env[{repr(k)}] is not a str: {v}"
72 75 self.process = subprocess.Popen(self.cmd, stdout=stdout,
73 76 stderr=stderr, env=env)
74 77
75 78 def wait(self):
76 79 self.process.wait()
77 80 self.stdout_capturer.halt()
78 81 self.stdout = self.stdout_capturer.get_buffer()
79 82 return self.process.returncode
80 83
81 84 def cleanup_process(self):
82 85 """Cleanup on exit by killing any leftover processes."""
83 86 subp = self.process
84 87 if subp is None or (subp.poll() is not None):
85 88 return # Process doesn't exist, or is already dead.
86 89
87 90 try:
88 91 print('Cleaning up stale PID: %d' % subp.pid)
89 92 subp.kill()
90 93 except: # (OSError, WindowsError) ?
91 94 # This is just a best effort, if we fail or the process was
92 95 # really gone, ignore it.
93 96 pass
94 97 else:
95 98 for i in range(10):
96 99 if subp.poll() is None:
97 100 time.sleep(0.1)
98 101 else:
99 102 break
100 103
101 104 if subp.poll() is None:
102 105 # The process did not die...
103 106 print('... failed. Manual cleanup may be required.')
104 107
105 108 def cleanup(self):
106 109 "Kill process if it's still alive, and clean up temporary directories"
107 110 self.cleanup_process()
108 111 for td in self.dirs:
109 112 td.cleanup()
110 113
111 114 __del__ = cleanup
112 115
113 116
114 117 class PyTestController(TestController):
115 118 """Run Python tests using IPython.testing.iptest"""
116 119 #: str, Python command to execute in subprocess
117 120 pycmd = None
118 121
119 122 def __init__(self, section, options):
120 123 """Create new test runner."""
121 124 TestController.__init__(self)
122 125 self.section = section
123 126 # pycmd is put into cmd[2] in PyTestController.launch()
124 127 self.cmd = [sys.executable, '-c', None, section]
125 128 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
126 129 self.options = options
127 130
128 131 def setup(self):
129 132 ipydir = TemporaryDirectory()
130 133 self.dirs.append(ipydir)
131 134 self.env['IPYTHONDIR'] = ipydir.name
132 135 self.workingdir = workingdir = TemporaryDirectory()
133 136 self.dirs.append(workingdir)
134 137 self.env['IPTEST_WORKING_DIR'] = workingdir.name
135 138 # This means we won't get odd effects from our own matplotlib config
136 139 self.env['MPLCONFIGDIR'] = workingdir.name
137 140 # For security reasons (http://bugs.python.org/issue16202), use
138 141 # a temporary directory to which other users have no access.
139 142 self.env['TMPDIR'] = workingdir.name
140 143
141 144 # Add a non-accessible directory to PATH (see gh-7053)
142 145 noaccess = Path(self.workingdir.name) / "_no_access_"
143 146 self.noaccess = noaccess.resolve()
144 147
145 148 noaccess.mkdir(0, exist_ok=True)
146 149
147 150 PATH = os.environ.get('PATH', '')
148 151 if PATH:
149 152 PATH = noaccess / PATH
150 153 else:
151 154 PATH = noaccess
152 self.env['PATH'] = PATH
155 self.env["PATH"] = str(PATH)
153 156
154 157 # From options:
155 158 if self.options.xunit:
156 159 self.add_xunit()
157 160 if self.options.coverage:
158 161 self.add_coverage()
159 162 self.env['IPTEST_SUBPROC_STREAMS'] = self.options.subproc_streams
160 163 self.cmd.extend(self.options.extra_args)
161 164
162 165 def cleanup(self):
163 166 """
164 167 Make the non-accessible directory created in setup() accessible
165 168 again, otherwise deleting the workingdir will fail.
166 169 """
167 170 self.noaccess.chmod(stat.S_IRWXU)
168 171 TestController.cleanup(self)
169 172
170 173 @property
171 174 def will_run(self):
172 175 try:
173 176 return test_sections[self.section].will_run
174 177 except KeyError:
175 178 return True
176 179
177 180 def add_xunit(self):
178 181 xunit_file = Path(f"{self.section}.xunit.xml")
179 182 self.cmd.extend(["--with-xunit", "--xunit-file", xunit_file.absolute()])
180 183
181 184 def add_coverage(self):
182 185 try:
183 186 sources = test_sections[self.section].includes
184 187 except KeyError:
185 188 sources = ["IPython"]
186 189
187 190 coverage_rc = (
188 191 "[run]\n" "data_file = {data_file}\n" "source =\n" " {source}\n"
189 192 ).format(
190 193 data_file=Path(f".coverage.{self.section}").absolute(),
191 194 source="\n ".join(sources),
192 195 )
193 config_file = Path(self.workingdir.name) / ".coveragerc"
196 config_file: Path = Path(self.workingdir.name) / ".coveragerc"
194 197 config_file.touch(exist_ok=True)
195 198 config_file.write_text(coverage_rc)
196 199
197 self.env["COVERAGE_PROCESS_START"] = config_file.resolve()
200 self.env["COVERAGE_PROCESS_START"] = str(config_file.resolve())
198 201 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
199 202
200 203 def launch(self, buffer_output=False):
201 204 self.cmd[2] = self.pycmd
202 205 super(PyTestController, self).launch(buffer_output=buffer_output)
203 206
204 207
205 208 def prepare_controllers(options):
206 209 """Returns two lists of TestController instances, those to run, and those
207 210 not to run."""
208 211 testgroups = options.testgroups
209 212 if not testgroups:
210 213 testgroups = py_test_group_names
211 214
212 215 controllers = [PyTestController(name, options) for name in testgroups]
213 216
214 217 to_run = [c for c in controllers if c.will_run]
215 218 not_run = [c for c in controllers if not c.will_run]
216 219 return to_run, not_run
217 220
218 221 def do_run(controller, buffer_output=True):
219 222 """Setup and run a test controller.
220 223
221 224 If buffer_output is True, no output is displayed, to avoid it appearing
222 225 interleaved. In this case, the caller is responsible for displaying test
223 226 output on failure.
224 227
225 228 Returns
226 229 -------
227 230 controller : TestController
228 231 The same controller as passed in, as a convenience for using map() type
229 232 APIs.
230 233 exitcode : int
231 234 The exit code of the test subprocess. Non-zero indicates failure.
232 235 """
233 236 try:
234 237 try:
235 238 controller.setup()
236 239 controller.launch(buffer_output=buffer_output)
237 240 except Exception:
238 241 import traceback
239 242 traceback.print_exc()
240 243 return controller, 1 # signal failure
241 244
242 245 exitcode = controller.wait()
243 246 return controller, exitcode
244 247
245 248 except KeyboardInterrupt:
246 249 return controller, -signal.SIGINT
247 250 finally:
248 251 controller.cleanup()
249 252
250 253 def report():
251 254 """Return a string with a summary report of test-related variables."""
252 255 inf = get_sys_info()
253 256 out = []
254 257 def _add(name, value):
255 258 out.append((name, value))
256 259
257 260 _add('IPython version', inf['ipython_version'])
258 261 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
259 262 _add('IPython package', compress_user(inf['ipython_path']))
260 263 _add('Python version', inf['sys_version'].replace('\n',''))
261 264 _add('sys.executable', compress_user(inf['sys_executable']))
262 265 _add('Platform', inf['platform'])
263 266
264 267 width = max(len(n) for (n,v) in out)
265 268 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
266 269
267 270 avail = []
268 271 not_avail = []
269 272
270 273 for k, is_avail in have.items():
271 274 if is_avail:
272 275 avail.append(k)
273 276 else:
274 277 not_avail.append(k)
275 278
276 279 if avail:
277 280 out.append('\nTools and libraries available at test time:\n')
278 281 avail.sort()
279 282 out.append(' ' + ' '.join(avail)+'\n')
280 283
281 284 if not_avail:
282 285 out.append('\nTools and libraries NOT available at test time:\n')
283 286 not_avail.sort()
284 287 out.append(' ' + ' '.join(not_avail)+'\n')
285 288
286 289 return ''.join(out)
287 290
288 291 def run_iptestall(options):
289 292 """Run the entire IPython test suite by calling nose and trial.
290 293
291 294 This function constructs :class:`IPTester` instances for all IPython
292 295 modules and package and then runs each of them. This causes the modules
293 296 and packages of IPython to be tested each in their own subprocess using
294 297 nose.
295 298
296 299 Parameters
297 300 ----------
298 301
299 302 All parameters are passed as attributes of the options object.
300 303
301 304 testgroups : list of str
302 305 Run only these sections of the test suite. If empty, run all the available
303 306 sections.
304 307
305 308 fast : int or None
306 309 Run the test suite in parallel, using n simultaneous processes. If None
307 310 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
308 311
309 312 inc_slow : bool
310 313 Include slow tests. By default, these tests aren't run.
311 314
312 315 url : unicode
313 316 Address:port to use when running the JS tests.
314 317
315 318 xunit : bool
316 319 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
317 320
318 321 coverage : bool or str
319 322 Measure code coverage from tests. True will store the raw coverage data,
320 323 or pass 'html' or 'xml' to get reports.
321 324
322 325 extra_args : list
323 326 Extra arguments to pass to the test subprocesses, e.g. '-v'
324 327 """
325 328 to_run, not_run = prepare_controllers(options)
326 329
327 330 def justify(ltext, rtext, width=70, fill='-'):
328 331 ltext += ' '
329 332 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
330 333 return ltext + rtext
331 334
332 335 # Run all test runners, tracking execution time
333 336 failed = []
334 337 t_start = time.time()
335 338
336 339 print()
337 340 if options.fast == 1:
338 341 # This actually means sequential, i.e. with 1 job
339 342 for controller in to_run:
340 343 print('Test group:', controller.section)
341 344 sys.stdout.flush() # Show in correct order when output is piped
342 345 controller, res = do_run(controller, buffer_output=False)
343 346 if res:
344 347 failed.append(controller)
345 348 if res == -signal.SIGINT:
346 349 print("Interrupted")
347 350 break
348 351 print()
349 352
350 353 else:
351 354 # Run tests concurrently
352 355 try:
353 356 pool = multiprocessing.pool.ThreadPool(options.fast)
354 357 for (controller, res) in pool.imap_unordered(do_run, to_run):
355 358 res_string = 'OK' if res == 0 else 'FAILED'
356 359 print(justify('Test group: ' + controller.section, res_string))
357 360 if res:
358 361 print(decode(controller.stdout))
359 362 failed.append(controller)
360 363 if res == -signal.SIGINT:
361 364 print("Interrupted")
362 365 break
363 366 except KeyboardInterrupt:
364 367 return
365 368
366 369 for controller in not_run:
367 370 print(justify('Test group: ' + controller.section, 'NOT RUN'))
368 371
369 372 t_end = time.time()
370 373 t_tests = t_end - t_start
371 374 nrunners = len(to_run)
372 375 nfail = len(failed)
373 376 # summarize results
374 377 print('_'*70)
375 378 print('Test suite completed for system with the following information:')
376 379 print(report())
377 380 took = "Took %.3fs." % t_tests
378 381 print('Status: ', end='')
379 382 if not failed:
380 383 print('OK (%d test groups).' % nrunners, took)
381 384 else:
382 385 # If anything went wrong, point out what command to rerun manually to
383 386 # see the actual errors and individual summary
384 387 failed_sections = [c.section for c in failed]
385 388 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
386 389 nrunners, ', '.join(failed_sections)), took)
387 390 print()
388 391 print('You may wish to rerun these, with:')
389 392 print(' iptest', *failed_sections)
390 393 print()
391 394
392 395 if options.coverage:
393 396 from coverage import coverage, CoverageException
394 397 cov = coverage(data_file='.coverage')
395 398 cov.combine()
396 399 cov.save()
397 400
398 401 # Coverage HTML report
399 402 if options.coverage == 'html':
400 403 html_dir = 'ipy_htmlcov'
401 404 shutil.rmtree(html_dir, ignore_errors=True)
402 405 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
403 406 sys.stdout.flush()
404 407
405 408 # Custom HTML reporter to clean up module names.
406 409 from coverage.html import HtmlReporter
407 410 class CustomHtmlReporter(HtmlReporter):
408 411 def find_code_units(self, morfs):
409 412 super(CustomHtmlReporter, self).find_code_units(morfs)
410 413 for cu in self.code_units:
411 414 nameparts = cu.name.split(os.sep)
412 415 if 'IPython' not in nameparts:
413 416 continue
414 417 ix = nameparts.index('IPython')
415 418 cu.name = '.'.join(nameparts[ix:])
416 419
417 420 # Reimplement the html_report method with our custom reporter
418 421 cov.get_data()
419 422 cov.config.from_args(omit='*{0}tests{0}*'.format(os.sep), html_dir=html_dir,
420 423 html_title='IPython test coverage',
421 424 )
422 425 reporter = CustomHtmlReporter(cov, cov.config)
423 426 reporter.report(None)
424 427 print('done.')
425 428
426 429 # Coverage XML report
427 430 elif options.coverage == 'xml':
428 431 try:
429 432 cov.xml_report(outfile='ipy_coverage.xml')
430 433 except CoverageException as e:
431 434 print('Generating coverage report failed. Are you running javascript tests only?')
432 435 import traceback
433 436 traceback.print_exc()
434 437
435 438 if failed:
436 439 # Ensure that our exit code indicates failure
437 440 sys.exit(1)
438 441
439 442 argparser = argparse.ArgumentParser(description='Run IPython test suite')
440 443 argparser.add_argument('testgroups', nargs='*',
441 444 help='Run specified groups of tests. If omitted, run '
442 445 'all tests.')
443 446 argparser.add_argument('--all', action='store_true',
444 447 help='Include slow tests not run by default.')
445 448 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
446 449 help='Run test sections in parallel. This starts as many '
447 450 'processes as you have cores, or you can specify a number.')
448 451 argparser.add_argument('--xunit', action='store_true',
449 452 help='Produce Xunit XML results')
450 453 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
451 454 help="Measure test coverage. Specify 'html' or "
452 455 "'xml' to get reports.")
453 456 argparser.add_argument('--subproc-streams', default='capture',
454 457 help="What to do with stdout/stderr from subprocesses. "
455 458 "'capture' (default), 'show' and 'discard' are the options.")
456 459
457 460 def default_options():
458 461 """Get an argparse Namespace object with the default arguments, to pass to
459 462 :func:`run_iptestall`.
460 463 """
461 464 options = argparser.parse_args([])
462 465 options.extra_args = []
463 466 return options
464 467
465 468 def main():
466 469 # iptest doesn't work correctly if the working directory is the
467 470 # root of the IPython source tree. Tell the user to avoid
468 471 # frustration.
469 472 main_file = Path.cwd() / Path("IPython/testing/__main__.py")
470 473
471 474 if main_file.exists():
472 475 print("Don't run iptest from the IPython source directory", file=sys.stderr)
473 476 sys.exit(1)
474 477 # Arguments after -- should be passed through to nose. Argparse treats
475 478 # everything after -- as regular positional arguments, so we separate them
476 479 # first.
477 480 try:
478 481 ix = sys.argv.index('--')
479 482 except ValueError:
480 483 to_parse = sys.argv[1:]
481 484 extra_args = []
482 485 else:
483 486 to_parse = sys.argv[1:ix]
484 487 extra_args = sys.argv[ix+1:]
485 488
486 489 options = argparser.parse_args(to_parse)
487 490 options.extra_args = extra_args
488 491
489 492 run_iptestall(options)
490 493
491 494
492 495 if __name__ == '__main__':
493 496 main()
General Comments 0
You need to be logged in to leave comments. Login now