tools.py 13 KB

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