iptestcontroller.py 18 KB

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