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