"""Nose Plugin that supports IPython doctests. Limitations: - When generating examples for use as doctests, make sure that you have pretty-printing OFF. This can be done either by setting the ``PlainTextFormatter.pprint`` option in your configuration file to False, or by interactively disabling it with %Pprint. This is required so that IPython output matches that of normal Python, which is used by doctest for internal execution. - Do not rely on specific prompt numbers for results (such as using '_34==True', for example). For IPython tests run via an external process the prompt numbers may be different, and IPython tests run as normal python code won't even have these special _NN variables set at all. """ #----------------------------------------------------------------------------- # Module imports # From the standard library import doctest import logging import os import re from testpath import modified_env #----------------------------------------------------------------------------- # Module globals and other constants #----------------------------------------------------------------------------- log = logging.getLogger(__name__) #----------------------------------------------------------------------------- # Classes and functions #----------------------------------------------------------------------------- class DocTestFinder(doctest.DocTestFinder): def _get_test(self, obj, name, module, globs, source_lines): test = super()._get_test(obj, name, module, globs, source_lines) if bool(getattr(obj, "__skip_doctest__", False)) and test is not None: for example in test.examples: example.options[doctest.SKIP] = True return test class IPDoctestOutputChecker(doctest.OutputChecker): """Second-chance checker with support for random tests. If the default comparison doesn't pass, this checker looks in the expected output string for flags that tell us to ignore the output. """ random_re = re.compile(r'#\s*random\s+') def check_output(self, want, got, optionflags): """Check output, accepting special markers embedded in the output. If the output didn't pass the default validation but the special string '#random' is included, we accept it.""" # Let the original tester verify first, in case people have valid tests # that happen to have a comment saying '#random' embedded in. ret = doctest.OutputChecker.check_output(self, want, got, optionflags) if not ret and self.random_re.search(want): #print >> sys.stderr, 'RANDOM OK:',want # dbg return True return ret # A simple subclassing of the original with a different class name, so we can # distinguish and treat differently IPython examples from pure python ones. class IPExample(doctest.Example): pass class IPDocTestParser(doctest.DocTestParser): """ A class used to parse strings containing doctest examples. Note: This is a version modified to properly recognize IPython input and convert any IPython examples into valid Python ones. """ # This regular expression is used to find doctest examples in a # string. It defines three groups: `source` is the source code # (including leading indentation and prompts); `indent` is the # indentation of the first (PS1) line of the source code; and # `want` is the expected output (including leading indentation). # Classic Python prompts or default IPython ones _PS1_PY = r'>>>' _PS2_PY = r'\.\.\.' _PS1_IP = r'In\ \[\d+\]:' _PS2_IP = r'\ \ \ \.\.\.+:' _RE_TPL = r''' # Source consists of a PS1 line followed by zero or more PS2 lines. (?P (?:^(?P [ ]*) (?P %s) .*) # PS1 line (?:\n [ ]* (?P %s) .*)*) # PS2 lines \n? # a newline # Want consists of any non-blank lines that do not start with PS1. (?P (?:(?![ ]*$) # Not a blank line (?![ ]*%s) # Not a line starting with PS1 (?![ ]*%s) # Not a line starting with PS2 .*$\n? # But any other line )*) ''' _EXAMPLE_RE_PY = re.compile( _RE_TPL % (_PS1_PY,_PS2_PY,_PS1_PY,_PS2_PY), re.MULTILINE | re.VERBOSE) _EXAMPLE_RE_IP = re.compile( _RE_TPL % (_PS1_IP,_PS2_IP,_PS1_IP,_PS2_IP), re.MULTILINE | re.VERBOSE) # Mark a test as being fully random. In this case, we simply append the # random marker ('#random') to each individual example's output. This way # we don't need to modify any other code. _RANDOM_TEST = re.compile(r'#\s*all-random\s+') def ip2py(self,source): """Convert input IPython source into valid Python.""" block = _ip.input_transformer_manager.transform_cell(source) if len(block.splitlines()) == 1: return _ip.prefilter(block) else: return block def parse(self, string, name=''): """ Divide the given string into examples and intervening text, and return them as a list of alternating Examples and strings. Line numbers for the Examples are 0-based. The optional argument `name` is a name identifying this string, and is only used for error messages. """ #print 'Parse string:\n',string # dbg string = string.expandtabs() # If all lines begin with the same indentation, then strip it. min_indent = self._min_indent(string) if min_indent > 0: string = '\n'.join([l[min_indent:] for l in string.split('\n')]) output = [] charno, lineno = 0, 0 # We make 'all random' tests by adding the '# random' mark to every # block of output in the test. if self._RANDOM_TEST.search(string): random_marker = '\n# random' else: random_marker = '' # Whether to convert the input from ipython to python syntax ip2py = False # Find all doctest examples in the string. First, try them as Python # examples, then as IPython ones terms = list(self._EXAMPLE_RE_PY.finditer(string)) if terms: # Normal Python example Example = doctest.Example else: # It's an ipython example. terms = list(self._EXAMPLE_RE_IP.finditer(string)) Example = IPExample ip2py = True for m in terms: # Add the pre-example text to `output`. output.append(string[charno:m.start()]) # Update lineno (lines before this example) lineno += string.count('\n', charno, m.start()) # Extract info from the regexp match. (source, options, want, exc_msg) = \ self._parse_example(m, name, lineno,ip2py) # Append the random-output marker (it defaults to empty in most # cases, it's only non-empty for 'all-random' tests): want += random_marker # Create an Example, and add it to the list. if not self._IS_BLANK_OR_COMMENT(source): output.append(Example(source, want, exc_msg, lineno=lineno, indent=min_indent+len(m.group('indent')), options=options)) # Update lineno (lines inside this example) lineno += string.count('\n', m.start(), m.end()) # Update charno. charno = m.end() # Add any remaining post-example text to `output`. output.append(string[charno:]) return output def _parse_example(self, m, name, lineno,ip2py=False): """ Given a regular expression match from `_EXAMPLE_RE` (`m`), return a pair `(source, want)`, where `source` is the matched example's source code (with prompts and indentation stripped); and `want` is the example's expected output (with indentation stripped). `name` is the string's name, and `lineno` is the line number where the example starts; both are used for error messages. Optional: `ip2py`: if true, filter the input via IPython to convert the syntax into valid python. """ # Get the example's indentation level. indent = len(m.group('indent')) # Divide source into lines; check that they're properly # indented; and then strip their indentation & prompts. source_lines = m.group('source').split('\n') # We're using variable-length input prompts ps1 = m.group('ps1') ps2 = m.group('ps2') ps1_len = len(ps1) self._check_prompt_blank(source_lines, indent, name, lineno,ps1_len) if ps2: self._check_prefix(source_lines[1:], ' '*indent + ps2, name, lineno) source = '\n'.join([sl[indent+ps1_len+1:] for sl in source_lines]) if ip2py: # Convert source input from IPython into valid Python syntax source = self.ip2py(source) # Divide want into lines; check that it's properly indented; and # then strip the indentation. Spaces before the last newline should # be preserved, so plain rstrip() isn't good enough. want = m.group('want') want_lines = want.split('\n') if len(want_lines) > 1 and re.match(r' *$', want_lines[-1]): del want_lines[-1] # forget final newline & spaces after it self._check_prefix(want_lines, ' '*indent, name, lineno + len(source_lines)) # Remove ipython output prompt that might be present in the first line want_lines[0] = re.sub(r'Out\[\d+\]: \s*?\n?','',want_lines[0]) want = '\n'.join([wl[indent:] for wl in want_lines]) # If `want` contains a traceback message, then extract it. m = self._EXCEPTION_RE.match(want) if m: exc_msg = m.group('msg') else: exc_msg = None # Extract options from the source. options = self._find_options(source, name, lineno) return source, options, want, exc_msg def _check_prompt_blank(self, lines, indent, name, lineno, ps1_len): """ Given the lines of a source string (including prompts and leading indentation), check to make sure that every prompt is followed by a space character. If any line is not followed by a space character, then raise ValueError. Note: IPython-modified version which takes the input prompt length as a parameter, so that prompts of variable length can be dealt with. """ space_idx = indent+ps1_len min_len = space_idx+1 for i, line in enumerate(lines): if len(line) >= min_len and line[space_idx] != ' ': raise ValueError('line %r of the docstring for %s ' 'lacks blank after %s: %r' % (lineno+i+1, name, line[indent:space_idx], line)) SKIP = doctest.register_optionflag('SKIP') class IPDocTestRunner(doctest.DocTestRunner,object): """Test runner that synchronizes the IPython namespace with test globals. """ def run(self, test, compileflags=None, out=None, clear_globs=True): # Override terminal size to standardise traceback format with modified_env({'COLUMNS': '80', 'LINES': '24'}): return super(IPDocTestRunner,self).run(test, compileflags,out,clear_globs)