# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php #!/usr/bin/env python2.4 # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php """ These are functions for use when doctest-testing a document. """ import subprocess import doctest import os import sys import shutil import re import cgi import rfc822 from cStringIO import StringIO from paste.util import PySourceColor here = os.path.abspath(__file__) paste_parent = os.path.dirname( os.path.dirname(os.path.dirname(here))) def run(command): data = run_raw(command) if data: print(data) def run_raw(command): """ Runs the string command, returns any output. """ proc = subprocess.Popen(command, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, env=_make_env()) data = proc.stdout.read() proc.wait() while data.endswith('\n') or data.endswith('\r'): data = data[:-1] if data: data = '\n'.join( [l for l in data.splitlines() if l]) return data else: return '' def run_command(command, name, and_print=False): output = run_raw(command) data = '$ %s\n%s' % (command, output) show_file('shell-command', name, description='shell transcript', data=data) if and_print and output: print(output) def _make_env(): env = os.environ.copy() env['PATH'] = (env.get('PATH', '') + ':' + os.path.join(paste_parent, 'scripts') + ':' + os.path.join(paste_parent, 'paste', '3rd-party', 'sqlobject-files', 'scripts')) env['PYTHONPATH'] = (env.get('PYTHONPATH', '') + ':' + paste_parent) return env def clear_dir(dir): """ Clears (deletes) the given directory """ shutil.rmtree(dir, True) def ls(dir=None, recurse=False, indent=0): """ Show a directory listing """ dir = dir or os.getcwd() fns = os.listdir(dir) fns.sort() for fn in fns: full = os.path.join(dir, fn) if os.path.isdir(full): fn = fn + '/' print(' '*indent + fn) if os.path.isdir(full) and recurse: ls(dir=full, recurse=True, indent=indent+2) default_app = None default_url = None def set_default_app(app, url): global default_app global default_url default_app = app default_url = url def resource_filename(fn): """ Returns the filename of the resource -- generally in the directory resources/DocumentName/fn """ return os.path.join( os.path.dirname(sys.testing_document_filename), 'resources', os.path.splitext(os.path.basename(sys.testing_document_filename))[0], fn) def show(path_info, example_name): fn = resource_filename(example_name + '.html') out = StringIO() assert default_app is not None, ( "No default_app set") url = default_url + path_info out.write('<span class="doctest-url"><a href="%s">%s</a></span><br>\n' % (url, url)) out.write('<div class="doctest-example">\n') proc = subprocess.Popen( ['paster', 'serve' '--server=console', '--no-verbose', '--url=' + path_info], stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=_make_env()) stdout, errors = proc.communicate() stdout = StringIO(stdout) headers = rfc822.Message(stdout) content = stdout.read() for header, value in headers.items(): if header.lower() == 'status' and int(value.split()[0]) == 200: continue if header.lower() in ('content-type', 'content-length'): continue if (header.lower() == 'set-cookie' and value.startswith('_SID_')): continue out.write('<span class="doctest-header">%s: %s</span><br>\n' % (header, value)) lines = [l for l in content.splitlines() if l.strip()] for line in lines: out.write(line + '\n') if errors: out.write('<pre class="doctest-errors">%s</pre>' % errors) out.write('</div>\n') result = out.getvalue() if not os.path.exists(fn): f = open(fn, 'wb') f.write(result) f.close() else: f = open(fn, 'rb') expected = f.read() f.close() if not html_matches(expected, result): print('Pages did not match. Expected from %s:' % fn) print('-'*60) print(expected) print('='*60) print('Actual output:') print('-'*60) print(result) def html_matches(pattern, text): regex = re.escape(pattern) regex = regex.replace(r'\.\.\.', '.*') regex = re.sub(r'0x[0-9a-f]+', '.*', regex) regex = '^%s$' % regex return re.search(regex, text) def convert_docstring_string(data): if data.startswith('\n'): data = data[1:] lines = data.splitlines() new_lines = [] for line in lines: if line.rstrip() == '.': new_lines.append('') else: new_lines.append(line) data = '\n'.join(new_lines) + '\n' return data def create_file(path, version, data): data = convert_docstring_string(data) write_data(path, data) show_file(path, version) def append_to_file(path, version, data): data = convert_docstring_string(data) f = open(path, 'a') f.write(data) f.close() # I think these appends can happen so quickly (in less than a second) # that the .pyc file doesn't appear to be expired, even though it # is after we've made this change; so we have to get rid of the .pyc # file: if path.endswith('.py'): pyc_file = path + 'c' if os.path.exists(pyc_file): os.unlink(pyc_file) show_file(path, version, description='added to %s' % path, data=data) def show_file(path, version, description=None, data=None): ext = os.path.splitext(path)[1] if data is None: f = open(path, 'rb') data = f.read() f.close() if ext == '.py': html = ('<div class="source-code">%s</div>' % PySourceColor.str2html(data, PySourceColor.dark)) else: html = '<pre class="source-code">%s</pre>' % cgi.escape(data, 1) html = '<span class="source-filename">%s</span><br>%s' % ( description or path, html) write_data(resource_filename('%s.%s.gen.html' % (path, version)), html) def call_source_highlight(input, format): proc = subprocess.Popen(['source-highlight', '--out-format=html', '--no-doc', '--css=none', '--src-lang=%s' % format], shell=False, stdout=subprocess.PIPE) stdout, stderr = proc.communicate(input) result = stdout proc.wait() return result def write_data(path, data): dir = os.path.dirname(os.path.abspath(path)) if not os.path.exists(dir): os.makedirs(dir) f = open(path, 'wb') f.write(data) f.close() def change_file(path, changes): f = open(os.path.abspath(path), 'rb') lines = f.readlines() f.close() for change_type, line, text in changes: if change_type == 'insert': lines[line:line] = [text] elif change_type == 'delete': lines[line:text] = [] else: assert 0, ( "Unknown change_type: %r" % change_type) f = open(path, 'wb') f.write(''.join(lines)) f.close() class LongFormDocTestParser(doctest.DocTestParser): """ This parser recognizes some reST comments as commands, without prompts or expected output, like: .. run: do_this(... ...) """ _EXAMPLE_RE = re.compile(r""" # Source consists of a PS1 line followed by zero or more PS2 lines. (?: (?P<source> (?:^(?P<indent> [ ]*) >>> .*) # PS1 line (?:\n [ ]* \.\.\. .*)*) # PS2 lines \n? # Want consists of any non-blank lines that do not start with PS1. (?P<want> (?:(?![ ]*$) # Not a blank line (?![ ]*>>>) # Not a line starting with PS1 .*$\n? # But any other line )*)) | (?: # This is for longer commands that are prefixed with a reST # comment like '.. run:' (two colons makes that a directive). # These commands cannot have any output. (?:^\.\.[ ]*(?P<run>run):[ ]*\n) # Leading command/command (?:[ ]*\n)? # Blank line following (?P<runsource> (?:(?P<runindent> [ ]+)[^ ].*$) (?:\n [ ]+ .*)*) ) | (?: # This is for shell commands (?P<shellsource> (?:^(P<shellindent> [ ]*) [$] .*) # Shell line (?:\n [ ]* [>] .*)*) # Continuation \n? # Want consists of any non-blank lines that do not start with $ (?P<shellwant> (?:(?![ ]*$) (?![ ]*[$]$) .*$\n? )*)) """, re.MULTILINE | re.VERBOSE) def _parse_example(self, m, name, lineno): r""" 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. >>> def parseit(s): ... p = LongFormDocTestParser() ... return p._parse_example(p._EXAMPLE_RE.search(s), '<string>', 1) >>> parseit('>>> 1\n1') ('1', {}, '1', None) >>> parseit('>>> (1\n... +1)\n2') ('(1\n+1)', {}, '2', None) >>> parseit('.. run:\n\n test1\n test2\n') ('test1\ntest2', {}, '', None) """ # Get the example's indentation level. runner = m.group('run') or '' indent = len(m.group('%sindent' % runner)) # Divide source into lines; check that they're properly # indented; and then strip their indentation & prompts. source_lines = m.group('%ssource' % runner).split('\n') if runner: self._check_prefix(source_lines[1:], ' '*indent, name, lineno) else: self._check_prompt_blank(source_lines, indent, name, lineno) self._check_prefix(source_lines[2:], ' '*indent + '.', name, lineno) if runner: source = '\n'.join([sl[indent:] for sl in source_lines]) else: source = '\n'.join([sl[indent+4:] for sl in source_lines]) if runner: want = '' exc_msg = None else: # 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)) 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 parse(self, string, name='<string>'): """ 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. """ 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 # Find all doctest examples in the string: for m in self._EXAMPLE_RE.finditer(string): # 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) # Create an Example, and add it to the list. if not self._IS_BLANK_OR_COMMENT(source): # @@: Erg, this is the only line I need to change... output.append(doctest.Example( source, want, exc_msg, lineno=lineno, indent=min_indent+len(m.group('indent') or m.group('runindent')), 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 if __name__ == '__main__': if sys.argv[1:] and sys.argv[1] == 'doctest': doctest.testmod() sys.exit() if not paste_parent in sys.path: sys.path.append(paste_parent) for fn in sys.argv[1:]: fn = os.path.abspath(fn) # @@: OK, ick; but this module gets loaded twice sys.testing_document_filename = fn doctest.testfile( fn, module_relative=False, optionflags=doctest.ELLIPSIS|doctest.REPORT_ONLY_FIRST_FAILURE, parser=LongFormDocTestParser()) new = os.path.splitext(fn)[0] + '.html' assert new != fn os.system('rst2html.py %s > %s' % (fn, new))