tools.py 14 KB

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