summaryrefslogtreecommitdiff
path: root/paste/debug/doctest_webapp.py
blob: f399ac38d8a21fcbe21b3133d057053e837fc6d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# (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.
"""

try:
    import subprocess
except ImportError:
    from paste.util import subprocess24 as 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))