| # (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 |
| """ |
| Exception-catching middleware that allows interactive debugging. |
| |
| This middleware catches all unexpected exceptions. A normal |
| traceback, like produced by |
| ``paste.exceptions.errormiddleware.ErrorMiddleware`` is given, plus |
| controls to see local variables and evaluate expressions in a local |
| context. |
| |
| This can only be used in single-process environments, because |
| subsequent requests must go back to the same process that the |
| exception originally occurred in. Threaded or non-concurrent |
| environments both work. |
| |
| This shouldn't be used in production in any way. That would just be |
| silly. |
| |
| If calling from an XMLHttpRequest call, if the GET variable ``_`` is |
| given then it will make the response more compact (and less |
| Javascripty), since if you use innerHTML it'll kill your browser. You |
| can look for the header X-Debug-URL in your 500 responses if you want |
| to see the full debuggable traceback. Also, this URL is printed to |
| ``wsgi.errors``, so you can open it up in another browser window. |
| """ |
| |
| from __future__ import print_function |
| |
| import sys |
| import os |
| import cgi |
| import traceback |
| import six |
| from six.moves import cStringIO as StringIO |
| import pprint |
| import itertools |
| import time |
| import re |
| from paste.exceptions import errormiddleware, formatter, collector |
| from paste import wsgilib |
| from paste import urlparser |
| from paste import httpexceptions |
| from paste import registry |
| from paste import request |
| from paste import response |
| from paste.evalexception import evalcontext |
| |
| limit = 200 |
| |
| def html_quote(v): |
| """ |
| Escape HTML characters, plus translate None to '' |
| """ |
| if v is None: |
| return '' |
| return cgi.escape(str(v), 1) |
| |
| def preserve_whitespace(v, quote=True): |
| """ |
| Quote a value for HTML, preserving whitespace (translating |
| newlines to ``<br>`` and multiple spaces to use `` ``). |
| |
| If ``quote`` is true, then the value will be HTML quoted first. |
| """ |
| if quote: |
| v = html_quote(v) |
| v = v.replace('\n', '<br>\n') |
| v = re.sub(r'()( +)', _repl_nbsp, v) |
| v = re.sub(r'(\n)( +)', _repl_nbsp, v) |
| v = re.sub(r'^()( +)', _repl_nbsp, v) |
| return '<code>%s</code>' % v |
| |
| def _repl_nbsp(match): |
| if len(match.group(2)) == 1: |
| return ' ' |
| return match.group(1) + ' ' * (len(match.group(2))-1) + ' ' |
| |
| def simplecatcher(application): |
| """ |
| A simple middleware that catches errors and turns them into simple |
| tracebacks. |
| """ |
| def simplecatcher_app(environ, start_response): |
| try: |
| return application(environ, start_response) |
| except: |
| out = StringIO() |
| traceback.print_exc(file=out) |
| start_response('500 Server Error', |
| [('content-type', 'text/html')], |
| sys.exc_info()) |
| res = out.getvalue() |
| return ['<h3>Error</h3><pre>%s</pre>' |
| % html_quote(res)] |
| return simplecatcher_app |
| |
| def wsgiapp(): |
| """ |
| Turns a function or method into a WSGI application. |
| """ |
| def decorator(func): |
| def wsgiapp_wrapper(*args): |
| # we get 3 args when this is a method, two when it is |
| # a function :( |
| if len(args) == 3: |
| environ = args[1] |
| start_response = args[2] |
| args = [args[0]] |
| else: |
| environ, start_response = args |
| args = [] |
| def application(environ, start_response): |
| form = wsgilib.parse_formvars(environ, |
| include_get_vars=True) |
| headers = response.HeaderDict( |
| {'content-type': 'text/html', |
| 'status': '200 OK'}) |
| form['environ'] = environ |
| form['headers'] = headers |
| res = func(*args, **form.mixed()) |
| status = headers.pop('status') |
| start_response(status, headers.headeritems()) |
| return [res] |
| app = httpexceptions.make_middleware(application) |
| app = simplecatcher(app) |
| return app(environ, start_response) |
| wsgiapp_wrapper.exposed = True |
| return wsgiapp_wrapper |
| return decorator |
| |
| def get_debug_info(func): |
| """ |
| A decorator (meant to be used under ``wsgiapp()``) that resolves |
| the ``debugcount`` variable to a ``DebugInfo`` object (or gives an |
| error if it can't be found). |
| """ |
| def debug_info_replacement(self, **form): |
| try: |
| if 'debugcount' not in form: |
| raise ValueError('You must provide a debugcount parameter') |
| debugcount = form.pop('debugcount') |
| try: |
| debugcount = int(debugcount) |
| except ValueError: |
| raise ValueError('Bad value for debugcount') |
| if debugcount not in self.debug_infos: |
| raise ValueError( |
| 'Debug %s no longer found (maybe it has expired?)' |
| % debugcount) |
| debug_info = self.debug_infos[debugcount] |
| return func(self, debug_info=debug_info, **form) |
| except ValueError as e: |
| form['headers']['status'] = '500 Server Error' |
| return '<html>There was an error: %s</html>' % html_quote(e) |
| return debug_info_replacement |
| |
| debug_counter = itertools.count(int(time.time())) |
| def get_debug_count(environ): |
| """ |
| Return the unique debug count for the current request |
| """ |
| if 'paste.evalexception.debug_count' in environ: |
| return environ['paste.evalexception.debug_count'] |
| else: |
| environ['paste.evalexception.debug_count'] = next = six.next(debug_counter) |
| return next |
| |
| class EvalException(object): |
| |
| def __init__(self, application, global_conf=None, |
| xmlhttp_key=None): |
| self.application = application |
| self.debug_infos = {} |
| if xmlhttp_key is None: |
| if global_conf is None: |
| xmlhttp_key = '_' |
| else: |
| xmlhttp_key = global_conf.get('xmlhttp_key', '_') |
| self.xmlhttp_key = xmlhttp_key |
| |
| def __call__(self, environ, start_response): |
| assert not environ['wsgi.multiprocess'], ( |
| "The EvalException middleware is not usable in a " |
| "multi-process environment") |
| environ['paste.evalexception'] = self |
| if environ.get('PATH_INFO', '').startswith('/_debug/'): |
| return self.debug(environ, start_response) |
| else: |
| return self.respond(environ, start_response) |
| |
| def debug(self, environ, start_response): |
| assert request.path_info_pop(environ) == '_debug' |
| next_part = request.path_info_pop(environ) |
| method = getattr(self, next_part, None) |
| if not method: |
| exc = httpexceptions.HTTPNotFound( |
| '%r not found when parsing %r' |
| % (next_part, wsgilib.construct_url(environ))) |
| return exc.wsgi_application(environ, start_response) |
| if not getattr(method, 'exposed', False): |
| exc = httpexceptions.HTTPForbidden( |
| '%r not allowed' % next_part) |
| return exc.wsgi_application(environ, start_response) |
| return method(environ, start_response) |
| |
| def media(self, environ, start_response): |
| """ |
| Static path where images and other files live |
| """ |
| app = urlparser.StaticURLParser( |
| os.path.join(os.path.dirname(__file__), 'media')) |
| return app(environ, start_response) |
| media.exposed = True |
| |
| def mochikit(self, environ, start_response): |
| """ |
| Static path where MochiKit lives |
| """ |
| app = urlparser.StaticURLParser( |
| os.path.join(os.path.dirname(__file__), 'mochikit')) |
| return app(environ, start_response) |
| mochikit.exposed = True |
| |
| def summary(self, environ, start_response): |
| """ |
| Returns a JSON-format summary of all the cached |
| exception reports |
| """ |
| start_response('200 OK', [('Content-type', 'text/x-json')]) |
| data = []; |
| items = self.debug_infos.values() |
| items.sort(lambda a, b: cmp(a.created, b.created)) |
| data = [item.json() for item in items] |
| return [repr(data)] |
| summary.exposed = True |
| |
| def view(self, environ, start_response): |
| """ |
| View old exception reports |
| """ |
| id = int(request.path_info_pop(environ)) |
| if id not in self.debug_infos: |
| start_response( |
| '500 Server Error', |
| [('Content-type', 'text/html')]) |
| return [ |
| "Traceback by id %s does not exist (maybe " |
| "the server has been restarted?)" |
| % id] |
| debug_info = self.debug_infos[id] |
| return debug_info.wsgi_application(environ, start_response) |
| view.exposed = True |
| |
| def make_view_url(self, environ, base_path, count): |
| return base_path + '/_debug/view/%s' % count |
| |
| #@wsgiapp() |
| #@get_debug_info |
| def show_frame(self, tbid, debug_info, **kw): |
| frame = debug_info.frame(int(tbid)) |
| vars = frame.tb_frame.f_locals |
| if vars: |
| registry.restorer.restoration_begin(debug_info.counter) |
| local_vars = make_table(vars) |
| registry.restorer.restoration_end() |
| else: |
| local_vars = 'No local vars' |
| return input_form(tbid, debug_info) + local_vars |
| |
| show_frame = wsgiapp()(get_debug_info(show_frame)) |
| |
| #@wsgiapp() |
| #@get_debug_info |
| def exec_input(self, tbid, debug_info, input, **kw): |
| if not input.strip(): |
| return '' |
| input = input.rstrip() + '\n' |
| frame = debug_info.frame(int(tbid)) |
| vars = frame.tb_frame.f_locals |
| glob_vars = frame.tb_frame.f_globals |
| context = evalcontext.EvalContext(vars, glob_vars) |
| registry.restorer.restoration_begin(debug_info.counter) |
| output = context.exec_expr(input) |
| registry.restorer.restoration_end() |
| input_html = formatter.str2html(input) |
| return ('<code style="color: #060">>>></code> ' |
| '<code>%s</code><br>\n%s' |
| % (preserve_whitespace(input_html, quote=False), |
| preserve_whitespace(output))) |
| |
| exec_input = wsgiapp()(get_debug_info(exec_input)) |
| |
| def respond(self, environ, start_response): |
| if environ.get('paste.throw_errors'): |
| return self.application(environ, start_response) |
| base_path = request.construct_url(environ, with_path_info=False, |
| with_query_string=False) |
| environ['paste.throw_errors'] = True |
| started = [] |
| def detect_start_response(status, headers, exc_info=None): |
| try: |
| return start_response(status, headers, exc_info) |
| except: |
| raise |
| else: |
| started.append(True) |
| try: |
| __traceback_supplement__ = errormiddleware.Supplement, self, environ |
| app_iter = self.application(environ, detect_start_response) |
| try: |
| return_iter = list(app_iter) |
| return return_iter |
| finally: |
| if hasattr(app_iter, 'close'): |
| app_iter.close() |
| except: |
| exc_info = sys.exc_info() |
| for expected in environ.get('paste.expected_exceptions', []): |
| if isinstance(exc_info[1], expected): |
| raise |
| |
| # Tell the Registry to save its StackedObjectProxies current state |
| # for later restoration |
| registry.restorer.save_registry_state(environ) |
| |
| count = get_debug_count(environ) |
| view_uri = self.make_view_url(environ, base_path, count) |
| if not started: |
| headers = [('content-type', 'text/html')] |
| headers.append(('X-Debug-URL', view_uri)) |
| start_response('500 Internal Server Error', |
| headers, |
| exc_info) |
| msg = 'Debug at: %s\n' % view_uri |
| if six.PY3: |
| msg = msg.encode('utf8') |
| environ['wsgi.errors'].write(msg) |
| |
| exc_data = collector.collect_exception(*exc_info) |
| debug_info = DebugInfo(count, exc_info, exc_data, base_path, |
| environ, view_uri) |
| assert count not in self.debug_infos |
| self.debug_infos[count] = debug_info |
| |
| if self.xmlhttp_key: |
| get_vars = request.parse_querystring(environ) |
| if dict(get_vars).get(self.xmlhttp_key): |
| exc_data = collector.collect_exception(*exc_info) |
| html = formatter.format_html( |
| exc_data, include_hidden_frames=False, |
| include_reusable=False, show_extra_data=False) |
| return [html] |
| |
| # @@: it would be nice to deal with bad content types here |
| return debug_info.content() |
| |
| def exception_handler(self, exc_info, environ): |
| simple_html_error = False |
| if self.xmlhttp_key: |
| get_vars = request.parse_querystring(environ) |
| if dict(get_vars).get(self.xmlhttp_key): |
| simple_html_error = True |
| return errormiddleware.handle_exception( |
| exc_info, environ['wsgi.errors'], |
| html=True, |
| debug_mode=True, |
| simple_html_error=simple_html_error) |
| |
| class DebugInfo(object): |
| |
| def __init__(self, counter, exc_info, exc_data, base_path, |
| environ, view_uri): |
| self.counter = counter |
| self.exc_data = exc_data |
| self.base_path = base_path |
| self.environ = environ |
| self.view_uri = view_uri |
| self.created = time.time() |
| self.exc_type, self.exc_value, self.tb = exc_info |
| __exception_formatter__ = 1 |
| self.frames = [] |
| n = 0 |
| tb = self.tb |
| while tb is not None and (limit is None or n < limit): |
| if tb.tb_frame.f_locals.get('__exception_formatter__'): |
| # Stop recursion. @@: should make a fake ExceptionFrame |
| break |
| self.frames.append(tb) |
| tb = tb.tb_next |
| n += 1 |
| |
| def json(self): |
| """Return the JSON-able representation of this object""" |
| return { |
| 'uri': self.view_uri, |
| 'created': time.strftime('%c', time.gmtime(self.created)), |
| 'created_timestamp': self.created, |
| 'exception_type': str(self.exc_type), |
| 'exception': str(self.exc_value), |
| } |
| |
| def frame(self, tbid): |
| for frame in self.frames: |
| if id(frame) == tbid: |
| return frame |
| else: |
| raise ValueError("No frame by id %s found from %r" % (tbid, self.frames)) |
| |
| def wsgi_application(self, environ, start_response): |
| start_response('200 OK', [('content-type', 'text/html')]) |
| return self.content() |
| |
| def content(self): |
| html = format_eval_html(self.exc_data, self.base_path, self.counter) |
| head_html = (formatter.error_css + formatter.hide_display_js) |
| head_html += self.eval_javascript() |
| repost_button = make_repost_button(self.environ) |
| page = error_template % { |
| 'repost_button': repost_button or '', |
| 'head_html': head_html, |
| 'body': html} |
| if six.PY3: |
| page = page.encode('utf8') |
| return [page] |
| |
| def eval_javascript(self): |
| base_path = self.base_path + '/_debug' |
| return ( |
| '<script type="text/javascript" src="%s/media/MochiKit.packed.js">' |
| '</script>\n' |
| '<script type="text/javascript" src="%s/media/debug.js">' |
| '</script>\n' |
| '<script type="text/javascript">\n' |
| 'debug_base = %r;\n' |
| 'debug_count = %r;\n' |
| '</script>\n' |
| % (base_path, base_path, base_path, self.counter)) |
| |
| class EvalHTMLFormatter(formatter.HTMLFormatter): |
| |
| def __init__(self, base_path, counter, **kw): |
| super(EvalHTMLFormatter, self).__init__(**kw) |
| self.base_path = base_path |
| self.counter = counter |
| |
| def format_source_line(self, filename, frame): |
| line = formatter.HTMLFormatter.format_source_line( |
| self, filename, frame) |
| return (line + |
| ' <a href="#" class="switch_source" ' |
| 'tbid="%s" onClick="return showFrame(this)"> ' |
| '<img src="%s/_debug/media/plus.jpg" border=0 width=9 ' |
| 'height=9> </a>' |
| % (frame.tbid, self.base_path)) |
| |
| def make_table(items): |
| if isinstance(items, dict): |
| items = items.items() |
| items.sort() |
| rows = [] |
| i = 0 |
| for name, value in items: |
| i += 1 |
| out = StringIO() |
| try: |
| pprint.pprint(value, out) |
| except Exception as e: |
| print('Error: %s' % e, file=out) |
| value = html_quote(out.getvalue()) |
| if len(value) > 100: |
| # @@: This can actually break the HTML :( |
| # should I truncate before quoting? |
| orig_value = value |
| value = value[:100] |
| value += '<a class="switch_source" style="background-color: #999" href="#" onclick="return expandLong(this)">...</a>' |
| value += '<span style="display: none">%s</span>' % orig_value[100:] |
| value = formatter.make_wrappable(value) |
| if i % 2: |
| attr = ' class="even"' |
| else: |
| attr = ' class="odd"' |
| rows.append('<tr%s style="vertical-align: top;"><td>' |
| '<b>%s</b></td><td style="overflow: auto">%s<td></tr>' |
| % (attr, html_quote(name), |
| preserve_whitespace(value, quote=False))) |
| return '<table>%s</table>' % ( |
| '\n'.join(rows)) |
| |
| def format_eval_html(exc_data, base_path, counter): |
| short_formatter = EvalHTMLFormatter( |
| base_path=base_path, |
| counter=counter, |
| include_reusable=False) |
| short_er = short_formatter.format_collected_data(exc_data) |
| long_formatter = EvalHTMLFormatter( |
| base_path=base_path, |
| counter=counter, |
| show_hidden_frames=True, |
| show_extra_data=False, |
| include_reusable=False) |
| long_er = long_formatter.format_collected_data(exc_data) |
| text_er = formatter.format_text(exc_data, show_hidden_frames=True) |
| if short_formatter.filter_frames(exc_data.frames) != \ |
| long_formatter.filter_frames(exc_data.frames): |
| # Only display the full traceback when it differs from the |
| # short version |
| full_traceback_html = """ |
| <br> |
| <script type="text/javascript"> |
| show_button('full_traceback', 'full traceback') |
| </script> |
| <div id="full_traceback" class="hidden-data"> |
| %s |
| </div> |
| """ % long_er |
| else: |
| full_traceback_html = '' |
| |
| return """ |
| %s |
| %s |
| <br> |
| <script type="text/javascript"> |
| show_button('text_version', 'text version') |
| </script> |
| <div id="text_version" class="hidden-data"> |
| <textarea style="width: 100%%" rows=10 cols=60>%s</textarea> |
| </div> |
| """ % (short_er, full_traceback_html, cgi.escape(text_er)) |
| |
| def make_repost_button(environ): |
| url = request.construct_url(environ) |
| if environ['REQUEST_METHOD'] == 'GET': |
| return ('<button onclick="window.location.href=%r">' |
| 'Re-GET Page</button><br>' % url) |
| else: |
| # @@: I'd like to reconstruct this, but I can't because |
| # the POST body is probably lost at this point, and |
| # I can't get it back :( |
| return None |
| # @@: Use or lose the following code block |
| """ |
| fields = [] |
| for name, value in wsgilib.parse_formvars( |
| environ, include_get_vars=False).items(): |
| if hasattr(value, 'filename'): |
| # @@: Arg, we'll just submit the body, and leave out |
| # the filename :( |
| value = value.value |
| fields.append( |
| '<input type="hidden" name="%s" value="%s">' |
| % (html_quote(name), html_quote(value))) |
| return ''' |
| <form action="%s" method="POST"> |
| %s |
| <input type="submit" value="Re-POST Page"> |
| </form>''' % (url, '\n'.join(fields)) |
| """ |
| |
| |
| def input_form(tbid, debug_info): |
| return ''' |
| <form action="#" method="POST" |
| onsubmit="return submitInput($(\'submit_%(tbid)s\'), %(tbid)s)"> |
| <div id="exec-output-%(tbid)s" style="width: 95%%; |
| padding: 5px; margin: 5px; border: 2px solid #000; |
| display: none"></div> |
| <input type="text" name="input" id="debug_input_%(tbid)s" |
| style="width: 100%%" |
| autocomplete="off" onkeypress="upArrow(this, event)"><br> |
| <input type="submit" value="Execute" name="submitbutton" |
| onclick="return submitInput(this, %(tbid)s)" |
| id="submit_%(tbid)s" |
| input-from="debug_input_%(tbid)s" |
| output-to="exec-output-%(tbid)s"> |
| <input type="submit" value="Expand" |
| onclick="return expandInput(this)"> |
| </form> |
| ''' % {'tbid': tbid} |
| |
| error_template = ''' |
| <html> |
| <head> |
| <title>Server Error</title> |
| %(head_html)s |
| </head> |
| <body> |
| |
| <div id="error-area" style="display: none; background-color: #600; color: #fff; border: 2px solid black"> |
| <div id="error-container"></div> |
| <button onclick="return clearError()">clear this</button> |
| </div> |
| |
| %(repost_button)s |
| |
| %(body)s |
| |
| </body> |
| </html> |
| ''' |
| |
| def make_eval_exception(app, global_conf, xmlhttp_key=None): |
| """ |
| Wraps the application in an interactive debugger. |
| |
| This debugger is a major security hole, and should only be |
| used during development. |
| |
| xmlhttp_key is a string that, if present in QUERY_STRING, |
| indicates that the request is an XMLHttp request, and the |
| Javascript/interactive debugger should not be returned. (If you |
| try to put the debugger somewhere with innerHTML, you will often |
| crash the browser) |
| """ |
| if xmlhttp_key is None: |
| xmlhttp_key = global_conf.get('xmlhttp_key', '_') |
| return EvalException(app, xmlhttp_key=xmlhttp_key) |