diff --git a/README.rst b/README.rst index a0ab440..97c86dd 100644 --- a/README.rst +++ b/README.rst @@ -84,9 +84,7 @@ Debugging Filters * Catch (optionally email) errors with extended tracebacks (using Zope/ZPT conventions) in ``paste.exceptions`` -* Catch errors presenting a `cgitb - `_-based - output, in ``paste.cgitb_catcher``. +* Catch errors presenting traceback in ``paste.cgitb_catcher``. * Profile each request and append profiling information to the HTML, in ``paste.debug.profile`` diff --git a/docs/developer-features.txt b/docs/developer-features.txt index 503d419..822b86e 100644 --- a/docs/developer-features.txt +++ b/docs/developer-features.txt @@ -111,9 +111,7 @@ Debugging Filters frame, including an interactive prompt that runs in the individual stack frames, in :mod:`paste.evalexception`. -* Catch errors presenting a `cgitb - `_-based - output, in :mod:`paste.cgitb_catcher`. +* Catch errors presenting traceback in ``paste.cgitb_catcher``. * Profile each request and append profiling information to the HTML, in :mod:`paste.debug.profile` diff --git a/paste/cgitb_catcher.py b/paste/cgitb_catcher.py index b8f7e87..11cf633 100644 --- a/paste/cgitb_catcher.py +++ b/paste/cgitb_catcher.py @@ -4,21 +4,21 @@ """ WSGI middleware -Captures any exceptions and prints a pretty report. See the `cgitb -documentation `_ -for more. +Captures any exceptions and prints a pretty report. """ -import cgitb from io import StringIO import sys from paste.util import converters +from paste.util.cgitb_hook import Hook -class NoDefault(object): - pass -class CgitbMiddleware(object): +class NoDefault: + ... + + +class CgitbMiddleware: def __init__(self, app, global_conf=None, @@ -42,7 +42,7 @@ def __call__(self, environ, start_response): try: app_iter = self.app(environ, start_response) return self.catching_iter(app_iter, environ) - except: + except Exception: exc_info = sys.exc_info() start_response('500 Internal Server Error', [('content-type', 'text/html')], @@ -61,7 +61,7 @@ def catching_iter(self, app_iter, environ): if hasattr(app_iter, 'close'): error_on_close = True app_iter.close() - except: + except Exception: response = self.exception_handler(sys.exc_info(), environ) if not error_on_close and hasattr(app_iter, 'close'): try: @@ -77,22 +77,24 @@ def catching_iter(self, app_iter, environ): def exception_handler(self, exc_info, environ): dummy_file = StringIO() - hook = cgitb.Hook(file=dummy_file, - display=self.display, - logdir=self.logdir, - context=self.context, - format=self.format) + hook = Hook( + file=dummy_file, + display=self.display, + logdir=self.logdir, + context=self.context, + format=self.format) hook(*exc_info) return dummy_file.getvalue() + def make_cgitb_middleware(app, global_conf, display=NoDefault, logdir=None, context=5, format='html'): """ - Wraps the application in the ``cgitb`` (standard library) - error catcher. + Wraps the application in an error catcher based on the + former ``cgitb`` module in the standard library. display: If true (or debug is set in the global configuration) diff --git a/paste/util/cgitb_hook.py b/paste/util/cgitb_hook.py new file mode 100644 index 0000000..959b31d --- /dev/null +++ b/paste/util/cgitb_hook.py @@ -0,0 +1,331 @@ +"""Hook class from the deprecated cgitb library.""" + +# Copyright © 2001-2023 Python Software Foundation; All Rights Reserved. + +import inspect +import keyword +import linecache +import os +import pydoc +import sys +import tempfile +import time +import tokenize +import traceback +from html import escape as html_escape + + +__all__ = ['Hook'] + + +def reset(): + """Return a string that resets the CGI and browser to a known state.""" + return ''' + --> --> + + ''' + + +__UNDEF__ = [] # a special sentinel object + + +def small(text): + if text: + return '' + text + '' + else: + return '' + + +def strong(text): + if text: + return '' + text + '' + else: + return '' + + +def grey(text): + if text: + return '' + text + '' + else: + return '' + + +def lookup(name, frame, locals): + """Find the value for a given name in the given environment.""" + if name in locals: + return 'local', locals[name] + if name in frame.f_globals: + return 'global', frame.f_globals[name] + if '__builtins__' in frame.f_globals: + builtins = frame.f_globals['__builtins__'] + if isinstance(builtins, dict): + if name in builtins: + return 'builtin', builtins[name] + else: + if hasattr(builtins, name): + return 'builtin', getattr(builtins, name) + return None, __UNDEF__ + + +def scanvars(reader, frame, locals): + """Scan one logical line of Python and look up values of variables used.""" + vars, lasttoken, parent, prefix, value = [], None, None, '', __UNDEF__ + for ttype, token, start, end, line in tokenize.generate_tokens(reader): + if ttype == tokenize.NEWLINE: + break + if ttype == tokenize.NAME and token not in keyword.kwlist: + if lasttoken == '.': + if parent is not __UNDEF__: + value = getattr(parent, token, __UNDEF__) + vars.append((prefix + token, prefix, value)) + else: + where, value = lookup(token, frame, locals) + vars.append((token, where, value)) + elif token == '.': + prefix += lasttoken + '.' + parent = value + else: + parent, prefix = None, '' + lasttoken = token + return vars + + +def html(einfo, context=5): + """Return a nice HTML document describing a given traceback.""" + etype, evalue, etb = einfo + if isinstance(etype, type): + etype = etype.__name__ + pyver = 'Python ' + sys.version.split()[0] + ': ' + sys.executable + date = time.ctime(time.time()) + head = f''' + + + + + +
 
+ 
+{html_escape(str(etype))}
+{pyver}
{date}
+

A problem occurred in a Python script. Here is the sequence of +function calls leading up to the error, in the order they occurred.

''' + + indent = '' + small(' ' * 5) + ' ' + frames = [] + records = inspect.getinnerframes(etb, context) + for frame, file, lnum, func, lines, index in records: + if file: + file = os.path.abspath(file) + link = f'{pydoc.html.escape(file)}' + else: + file = link = '?' + args, varargs, varkw, locals = inspect.getargvalues(frame) + call = '' + if func != '?': + call = 'in ' + strong(pydoc.html.escape(func)) + if func != "": + call += inspect.formatargvalues( + args, varargs, varkw, locals, + formatvalue=lambda value: '=' + pydoc.html.repr(value)) + + highlight = {} + + def reader(lnum=[lnum]): + highlight[lnum[0]] = 1 + try: + return linecache.getline(file, lnum[0]) + finally: + lnum[0] += 1 + + vars = scanvars(reader, frame, locals) + + rows = [' ' + f'{link} {call}'] + if index is not None: + i = lnum - index + for line in lines: + num = small(' ' * (5-len(str(i))) + str(i)) + ' ' + if i in highlight: + line = f'=>{num}{pydoc.html.preformat(line)}' + rows.append(f'{line}') + else: + line = f'  {num}{pydoc.html.preformat(line)}' + rows.append(f'{grey(line)}') + i += 1 + + done, dump = {}, [] + for name, where, value in vars: + if name in done: + continue + done[name] = 1 + if value is not __UNDEF__: + if where in ('global', 'builtin'): + name = f'{where} {strong(name)}' + elif where == 'local': + name = strong(name) + else: + name = where + strong(name.split('.')[-1]) + dump.append(f'{name} = {pydoc.html.repr(value)}') + else: + dump.append(name + ' undefined') + + rows.append('{}'.format( + small(grey(', '.join(dump))))) + frames.append(''' + +{}
'''.format('\n'.join(rows))) + + exception = [f'

{strong(pydoc.html.escape(str(etype)))}:' + f' {pydoc.html.escape(str(evalue))}'] + for name in dir(evalue): + if name[:1] == '_': + continue + value = pydoc.html.repr(getattr(evalue, name)) + exception.append(f'\n
{indent}{name} =\n{value}') + + return head + ''.join(frames) + ''.join(exception) + ''' + + + +'''.format(pydoc.html.escape(''.join( + traceback.format_exception(etype, evalue, etb)))) + + +def text(einfo, context=5): + """Return a plain text document describing a given traceback.""" + etype, evalue, etb = einfo + if isinstance(etype, type): + etype = etype.__name__ + pyver = 'Python ' + sys.version.split()[0] + ': ' + sys.executable + date = time.ctime(time.time()) + head = f"{etype}\n{pyver}\n{date}\n" + ''' +A problem occurred in a Python script. Here is the sequence of +function calls leading up to the error, in the order they occurred. +''' + + frames = [] + records = inspect.getinnerframes(etb, context) + for frame, file, lnum, func, lines, index in records: + file = file and os.path.abspath(file) or '?' + args, varargs, varkw, locals = inspect.getargvalues(frame) + call = '' + if func != '?': + call = 'in ' + func + if func != "": + call += inspect.formatargvalues( + args, varargs, varkw, locals, + formatvalue=lambda value: '=' + pydoc.text.repr(value)) + + highlight = {} + + def reader(lnum=[lnum]): + highlight[lnum[0]] = 1 + try: + return linecache.getline(file, lnum[0]) + finally: + lnum[0] += 1 + + vars = scanvars(reader, frame, locals) + + rows = [f' {file} {call}'] + if index is not None: + i = lnum - index + for line in lines: + num = f'{i:5d} ' + rows.append(num+line.rstrip()) + i += 1 + + done, dump = {}, [] + for name, where, value in vars: + if name in done: + continue + done[name] = 1 + if value is not __UNDEF__: + if where == 'global': + name = 'global ' + name + elif where != 'local': + name = where + name.split('.')[-1] + dump.append(f'{name} = {pydoc.text.repr(value)}') + else: + dump.append(name + ' undefined') + + rows.append('\n'.join(dump)) + frames.append('\n{}\n'.format('\n'.join(rows))) + + exception = [f'{etype}: {evalue}'] + for name in dir(evalue): + value = pydoc.text.repr(getattr(evalue, name)) + exception.append(f'\n {name} = {value}') + + return head + ''.join(frames) + ''.join(exception) + ''' + +The above is a description of an error in a Python program. Here is +the original traceback: + +{} +'''.format(''.join(traceback.format_exception(etype, evalue, etb))) + + +class Hook: + """A hook to replace sys.excepthook that shows tracebacks in HTML.""" + + def __init__(self, display=1, logdir=None, context=5, file=None, + format="html"): + self.display = display # send tracebacks to browser if true + self.logdir = logdir # log tracebacks to files if not None + self.context = context # number of source code lines per frame + self.file = file or sys.stdout # place to send the output + self.format = format + + def __call__(self, etype, evalue, etb): + self.handle((etype, evalue, etb)) + + def handle(self, info=None): + info = info or sys.exc_info() + if self.format == "html": + self.file.write(reset()) + + formatter = html if self.format == "html" else text + plain = False + try: + doc = formatter(info, self.context) + except Exception: # just in case something goes wrong + doc = ''.join(traceback.format_exception(*info)) + plain = True + + if self.display: + if plain: + doc = pydoc.html.escape(doc) + self.file.write(f'

{doc}
\n') + else: + self.file.write(doc + '\n') + else: + self.file.write('

A problem occurred in a Python script.\n') + + if self.logdir is not None: + suffix = '.html' if self.format == 'html' else '.txt' + fd, path = tempfile.mkstemp(suffix=suffix, dir=self.logdir) + + try: + with os.fdopen(fd, 'w') as file: + file.write(doc) + msg = f'{path} contains the description of this error.' + except Exception: + msg = f'Tried to save traceback to {path}, but failed.' + + if self.format == 'html': + self.file.write(f'

{msg}

\n') + else: + self.file.write(msg + '\n') + try: + self.file.flush() + except Exception: + pass diff --git a/tests/test_util/test_cgitb_hook.py b/tests/test_util/test_cgitb_hook.py new file mode 100644 index 0000000..f3b6ff7 --- /dev/null +++ b/tests/test_util/test_cgitb_hook.py @@ -0,0 +1,91 @@ +# Slightly modified tests from the original cgitb test module. + +# Copyright © 2001-2023 Python Software Foundation; All Rights Reserved. + +import sys + +import pytest + +from paste.util.cgitb_hook import small, strong, grey, html, text, Hook + + +def test_fonts(): + text = "Hello Robbie!" + assert small(text) == f'{text}' + assert strong(text) == f'{text}' + assert grey(text) == f'{text}' + + +def test_blanks(): + assert small('') == '' + assert strong('') == '' + assert grey('') == '' + + +def test_html(): + try: + raise ValueError("Hello World") + except ValueError as err: + # If the html was templated we could do a bit more here. + # At least check that we get details on what we just raised. + out = html(sys.exc_info()) + assert 'ValueError' in out + assert str(err) in out + + +def test_text(): + try: + raise ValueError("Hello World") + except ValueError: + out = text(sys.exc_info()) + assert 'ValueError' in out + assert 'Hello World' in out + + +def dummy_error(): + raise RuntimeError("Hello World") + + +@pytest.mark.parametrize('format', (None, 'html', 'text')) +def test_syshook_no_logdir_default_format(format, capsys, tmp_path): + excepthook = sys.excepthook + args = {'logdir': tmp_path} + if format: + args['format'] = format + hook = Hook(**args) + try: + dummy_error() + except RuntimeError as err: + hook(err.__class__, err, err.__traceback__) + finally: + sys.excepthook = excepthook + + log_files = list(tmp_path.glob('*.txt' if format == 'text' else '*.html')) + assert len(log_files) == 1 + log_file = log_files[0] + out = log_file.open('r').read() + log_file.unlink() + + assert 'A problem occurred in a Python script.' in out + assert 'RuntimeError' in out + assert 'Hello World' in out + assert 'test_syshook_no_logdir_default_format' in out + if format == 'text': + assert 'in dummy_error' in out + else: + assert 'in dummy_error' in out + assert '

' in out + assert '

' in out + + assert 'Content-Type: text/html' not in out + assert 'contains the description of this error' not in out + + captured = capsys.readouterr() + assert not captured.err + output = captured.out + + if format != 'text': + assert 'Content-Type: text/html' in output + assert out in output + assert log_file.name in output + assert 'contains the description of this error' in output