1# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) 2# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php 3""" 4Exception-catching middleware that allows interactive debugging. 5 6This middleware catches all unexpected exceptions. A normal 7traceback, like produced by 8``paste.exceptions.errormiddleware.ErrorMiddleware`` is given, plus 9controls to see local variables and evaluate expressions in a local 10context. 11 12This can only be used in single-process environments, because 13subsequent requests must go back to the same process that the 14exception originally occurred in. Threaded or non-concurrent 15environments both work. 16 17This shouldn't be used in production in any way. That would just be 18silly. 19 20If calling from an XMLHttpRequest call, if the GET variable ``_`` is 21given then it will make the response more compact (and less 22Javascripty), since if you use innerHTML it'll kill your browser. You 23can look for the header X-Debug-URL in your 500 responses if you want 24to see the full debuggable traceback. Also, this URL is printed to 25``wsgi.errors``, so you can open it up in another browser window. 26""" 27 28from __future__ import print_function 29 30import sys 31import os 32import cgi 33import traceback 34import six 35from six.moves import cStringIO as StringIO 36import pprint 37import itertools 38import time 39import re 40from paste.exceptions import errormiddleware, formatter, collector 41from paste import wsgilib 42from paste import urlparser 43from paste import httpexceptions 44from paste import registry 45from paste import request 46from paste import response 47from paste.evalexception import evalcontext 48 49limit = 200 50 51def html_quote(v): 52 """ 53 Escape HTML characters, plus translate None to '' 54 """ 55 if v is None: 56 return '' 57 return cgi.escape(str(v), 1) 58 59def preserve_whitespace(v, quote=True): 60 """ 61 Quote a value for HTML, preserving whitespace (translating 62 newlines to ``<br>`` and multiple spaces to use `` ``). 63 64 If ``quote`` is true, then the value will be HTML quoted first. 65 """ 66 if quote: 67 v = html_quote(v) 68 v = v.replace('\n', '<br>\n') 69 v = re.sub(r'()( +)', _repl_nbsp, v) 70 v = re.sub(r'(\n)( +)', _repl_nbsp, v) 71 v = re.sub(r'^()( +)', _repl_nbsp, v) 72 return '<code>%s</code>' % v 73 74def _repl_nbsp(match): 75 if len(match.group(2)) == 1: 76 return ' ' 77 return match.group(1) + ' ' * (len(match.group(2))-1) + ' ' 78 79def simplecatcher(application): 80 """ 81 A simple middleware that catches errors and turns them into simple 82 tracebacks. 83 """ 84 def simplecatcher_app(environ, start_response): 85 try: 86 return application(environ, start_response) 87 except: 88 out = StringIO() 89 traceback.print_exc(file=out) 90 start_response('500 Server Error', 91 [('content-type', 'text/html')], 92 sys.exc_info()) 93 res = out.getvalue() 94 return ['<h3>Error</h3><pre>%s</pre>' 95 % html_quote(res)] 96 return simplecatcher_app 97 98def wsgiapp(): 99 """ 100 Turns a function or method into a WSGI application. 101 """ 102 def decorator(func): 103 def wsgiapp_wrapper(*args): 104 # we get 3 args when this is a method, two when it is 105 # a function :( 106 if len(args) == 3: 107 environ = args[1] 108 start_response = args[2] 109 args = [args[0]] 110 else: 111 environ, start_response = args 112 args = [] 113 def application(environ, start_response): 114 form = wsgilib.parse_formvars(environ, 115 include_get_vars=True) 116 headers = response.HeaderDict( 117 {'content-type': 'text/html', 118 'status': '200 OK'}) 119 form['environ'] = environ 120 form['headers'] = headers 121 res = func(*args, **form.mixed()) 122 status = headers.pop('status') 123 start_response(status, headers.headeritems()) 124 return [res] 125 app = httpexceptions.make_middleware(application) 126 app = simplecatcher(app) 127 return app(environ, start_response) 128 wsgiapp_wrapper.exposed = True 129 return wsgiapp_wrapper 130 return decorator 131 132def get_debug_info(func): 133 """ 134 A decorator (meant to be used under ``wsgiapp()``) that resolves 135 the ``debugcount`` variable to a ``DebugInfo`` object (or gives an 136 error if it can't be found). 137 """ 138 def debug_info_replacement(self, **form): 139 try: 140 if 'debugcount' not in form: 141 raise ValueError('You must provide a debugcount parameter') 142 debugcount = form.pop('debugcount') 143 try: 144 debugcount = int(debugcount) 145 except ValueError: 146 raise ValueError('Bad value for debugcount') 147 if debugcount not in self.debug_infos: 148 raise ValueError( 149 'Debug %s no longer found (maybe it has expired?)' 150 % debugcount) 151 debug_info = self.debug_infos[debugcount] 152 return func(self, debug_info=debug_info, **form) 153 except ValueError as e: 154 form['headers']['status'] = '500 Server Error' 155 return '<html>There was an error: %s</html>' % html_quote(e) 156 return debug_info_replacement 157 158debug_counter = itertools.count(int(time.time())) 159def get_debug_count(environ): 160 """ 161 Return the unique debug count for the current request 162 """ 163 if 'paste.evalexception.debug_count' in environ: 164 return environ['paste.evalexception.debug_count'] 165 else: 166 environ['paste.evalexception.debug_count'] = next = six.next(debug_counter) 167 return next 168 169class EvalException(object): 170 171 def __init__(self, application, global_conf=None, 172 xmlhttp_key=None): 173 self.application = application 174 self.debug_infos = {} 175 if xmlhttp_key is None: 176 if global_conf is None: 177 xmlhttp_key = '_' 178 else: 179 xmlhttp_key = global_conf.get('xmlhttp_key', '_') 180 self.xmlhttp_key = xmlhttp_key 181 182 def __call__(self, environ, start_response): 183 assert not environ['wsgi.multiprocess'], ( 184 "The EvalException middleware is not usable in a " 185 "multi-process environment") 186 environ['paste.evalexception'] = self 187 if environ.get('PATH_INFO', '').startswith('/_debug/'): 188 return self.debug(environ, start_response) 189 else: 190 return self.respond(environ, start_response) 191 192 def debug(self, environ, start_response): 193 assert request.path_info_pop(environ) == '_debug' 194 next_part = request.path_info_pop(environ) 195 method = getattr(self, next_part, None) 196 if not method: 197 exc = httpexceptions.HTTPNotFound( 198 '%r not found when parsing %r' 199 % (next_part, wsgilib.construct_url(environ))) 200 return exc.wsgi_application(environ, start_response) 201 if not getattr(method, 'exposed', False): 202 exc = httpexceptions.HTTPForbidden( 203 '%r not allowed' % next_part) 204 return exc.wsgi_application(environ, start_response) 205 return method(environ, start_response) 206 207 def media(self, environ, start_response): 208 """ 209 Static path where images and other files live 210 """ 211 app = urlparser.StaticURLParser( 212 os.path.join(os.path.dirname(__file__), 'media')) 213 return app(environ, start_response) 214 media.exposed = True 215 216 def mochikit(self, environ, start_response): 217 """ 218 Static path where MochiKit lives 219 """ 220 app = urlparser.StaticURLParser( 221 os.path.join(os.path.dirname(__file__), 'mochikit')) 222 return app(environ, start_response) 223 mochikit.exposed = True 224 225 def summary(self, environ, start_response): 226 """ 227 Returns a JSON-format summary of all the cached 228 exception reports 229 """ 230 start_response('200 OK', [('Content-type', 'text/x-json')]) 231 data = []; 232 items = self.debug_infos.values() 233 items.sort(lambda a, b: cmp(a.created, b.created)) 234 data = [item.json() for item in items] 235 return [repr(data)] 236 summary.exposed = True 237 238 def view(self, environ, start_response): 239 """ 240 View old exception reports 241 """ 242 id = int(request.path_info_pop(environ)) 243 if id not in self.debug_infos: 244 start_response( 245 '500 Server Error', 246 [('Content-type', 'text/html')]) 247 return [ 248 "Traceback by id %s does not exist (maybe " 249 "the server has been restarted?)" 250 % id] 251 debug_info = self.debug_infos[id] 252 return debug_info.wsgi_application(environ, start_response) 253 view.exposed = True 254 255 def make_view_url(self, environ, base_path, count): 256 return base_path + '/_debug/view/%s' % count 257 258 #@wsgiapp() 259 #@get_debug_info 260 def show_frame(self, tbid, debug_info, **kw): 261 frame = debug_info.frame(int(tbid)) 262 vars = frame.tb_frame.f_locals 263 if vars: 264 registry.restorer.restoration_begin(debug_info.counter) 265 local_vars = make_table(vars) 266 registry.restorer.restoration_end() 267 else: 268 local_vars = 'No local vars' 269 return input_form(tbid, debug_info) + local_vars 270 271 show_frame = wsgiapp()(get_debug_info(show_frame)) 272 273 #@wsgiapp() 274 #@get_debug_info 275 def exec_input(self, tbid, debug_info, input, **kw): 276 if not input.strip(): 277 return '' 278 input = input.rstrip() + '\n' 279 frame = debug_info.frame(int(tbid)) 280 vars = frame.tb_frame.f_locals 281 glob_vars = frame.tb_frame.f_globals 282 context = evalcontext.EvalContext(vars, glob_vars) 283 registry.restorer.restoration_begin(debug_info.counter) 284 output = context.exec_expr(input) 285 registry.restorer.restoration_end() 286 input_html = formatter.str2html(input) 287 return ('<code style="color: #060">>>></code> ' 288 '<code>%s</code><br>\n%s' 289 % (preserve_whitespace(input_html, quote=False), 290 preserve_whitespace(output))) 291 292 exec_input = wsgiapp()(get_debug_info(exec_input)) 293 294 def respond(self, environ, start_response): 295 if environ.get('paste.throw_errors'): 296 return self.application(environ, start_response) 297 base_path = request.construct_url(environ, with_path_info=False, 298 with_query_string=False) 299 environ['paste.throw_errors'] = True 300 started = [] 301 def detect_start_response(status, headers, exc_info=None): 302 try: 303 return start_response(status, headers, exc_info) 304 except: 305 raise 306 else: 307 started.append(True) 308 try: 309 __traceback_supplement__ = errormiddleware.Supplement, self, environ 310 app_iter = self.application(environ, detect_start_response) 311 try: 312 return_iter = list(app_iter) 313 return return_iter 314 finally: 315 if hasattr(app_iter, 'close'): 316 app_iter.close() 317 except: 318 exc_info = sys.exc_info() 319 for expected in environ.get('paste.expected_exceptions', []): 320 if isinstance(exc_info[1], expected): 321 raise 322 323 # Tell the Registry to save its StackedObjectProxies current state 324 # for later restoration 325 registry.restorer.save_registry_state(environ) 326 327 count = get_debug_count(environ) 328 view_uri = self.make_view_url(environ, base_path, count) 329 if not started: 330 headers = [('content-type', 'text/html')] 331 headers.append(('X-Debug-URL', view_uri)) 332 start_response('500 Internal Server Error', 333 headers, 334 exc_info) 335 msg = 'Debug at: %s\n' % view_uri 336 if six.PY3: 337 msg = msg.encode('utf8') 338 environ['wsgi.errors'].write(msg) 339 340 exc_data = collector.collect_exception(*exc_info) 341 debug_info = DebugInfo(count, exc_info, exc_data, base_path, 342 environ, view_uri) 343 assert count not in self.debug_infos 344 self.debug_infos[count] = debug_info 345 346 if self.xmlhttp_key: 347 get_vars = request.parse_querystring(environ) 348 if dict(get_vars).get(self.xmlhttp_key): 349 exc_data = collector.collect_exception(*exc_info) 350 html = formatter.format_html( 351 exc_data, include_hidden_frames=False, 352 include_reusable=False, show_extra_data=False) 353 return [html] 354 355 # @@: it would be nice to deal with bad content types here 356 return debug_info.content() 357 358 def exception_handler(self, exc_info, environ): 359 simple_html_error = False 360 if self.xmlhttp_key: 361 get_vars = request.parse_querystring(environ) 362 if dict(get_vars).get(self.xmlhttp_key): 363 simple_html_error = True 364 return errormiddleware.handle_exception( 365 exc_info, environ['wsgi.errors'], 366 html=True, 367 debug_mode=True, 368 simple_html_error=simple_html_error) 369 370class DebugInfo(object): 371 372 def __init__(self, counter, exc_info, exc_data, base_path, 373 environ, view_uri): 374 self.counter = counter 375 self.exc_data = exc_data 376 self.base_path = base_path 377 self.environ = environ 378 self.view_uri = view_uri 379 self.created = time.time() 380 self.exc_type, self.exc_value, self.tb = exc_info 381 __exception_formatter__ = 1 382 self.frames = [] 383 n = 0 384 tb = self.tb 385 while tb is not None and (limit is None or n < limit): 386 if tb.tb_frame.f_locals.get('__exception_formatter__'): 387 # Stop recursion. @@: should make a fake ExceptionFrame 388 break 389 self.frames.append(tb) 390 tb = tb.tb_next 391 n += 1 392 393 def json(self): 394 """Return the JSON-able representation of this object""" 395 return { 396 'uri': self.view_uri, 397 'created': time.strftime('%c', time.gmtime(self.created)), 398 'created_timestamp': self.created, 399 'exception_type': str(self.exc_type), 400 'exception': str(self.exc_value), 401 } 402 403 def frame(self, tbid): 404 for frame in self.frames: 405 if id(frame) == tbid: 406 return frame 407 else: 408 raise ValueError("No frame by id %s found from %r" % (tbid, self.frames)) 409 410 def wsgi_application(self, environ, start_response): 411 start_response('200 OK', [('content-type', 'text/html')]) 412 return self.content() 413 414 def content(self): 415 html = format_eval_html(self.exc_data, self.base_path, self.counter) 416 head_html = (formatter.error_css + formatter.hide_display_js) 417 head_html += self.eval_javascript() 418 repost_button = make_repost_button(self.environ) 419 page = error_template % { 420 'repost_button': repost_button or '', 421 'head_html': head_html, 422 'body': html} 423 if six.PY3: 424 page = page.encode('utf8') 425 return [page] 426 427 def eval_javascript(self): 428 base_path = self.base_path + '/_debug' 429 return ( 430 '<script type="text/javascript" src="%s/media/MochiKit.packed.js">' 431 '</script>\n' 432 '<script type="text/javascript" src="%s/media/debug.js">' 433 '</script>\n' 434 '<script type="text/javascript">\n' 435 'debug_base = %r;\n' 436 'debug_count = %r;\n' 437 '</script>\n' 438 % (base_path, base_path, base_path, self.counter)) 439 440class EvalHTMLFormatter(formatter.HTMLFormatter): 441 442 def __init__(self, base_path, counter, **kw): 443 super(EvalHTMLFormatter, self).__init__(**kw) 444 self.base_path = base_path 445 self.counter = counter 446 447 def format_source_line(self, filename, frame): 448 line = formatter.HTMLFormatter.format_source_line( 449 self, filename, frame) 450 return (line + 451 ' <a href="#" class="switch_source" ' 452 'tbid="%s" onClick="return showFrame(this)"> ' 453 '<img src="%s/_debug/media/plus.jpg" border=0 width=9 ' 454 'height=9> </a>' 455 % (frame.tbid, self.base_path)) 456 457def make_table(items): 458 if isinstance(items, dict): 459 items = items.items() 460 items.sort() 461 rows = [] 462 i = 0 463 for name, value in items: 464 i += 1 465 out = StringIO() 466 try: 467 pprint.pprint(value, out) 468 except Exception as e: 469 print('Error: %s' % e, file=out) 470 value = html_quote(out.getvalue()) 471 if len(value) > 100: 472 # @@: This can actually break the HTML :( 473 # should I truncate before quoting? 474 orig_value = value 475 value = value[:100] 476 value += '<a class="switch_source" style="background-color: #999" href="#" onclick="return expandLong(this)">...</a>' 477 value += '<span style="display: none">%s</span>' % orig_value[100:] 478 value = formatter.make_wrappable(value) 479 if i % 2: 480 attr = ' class="even"' 481 else: 482 attr = ' class="odd"' 483 rows.append('<tr%s style="vertical-align: top;"><td>' 484 '<b>%s</b></td><td style="overflow: auto">%s<td></tr>' 485 % (attr, html_quote(name), 486 preserve_whitespace(value, quote=False))) 487 return '<table>%s</table>' % ( 488 '\n'.join(rows)) 489 490def format_eval_html(exc_data, base_path, counter): 491 short_formatter = EvalHTMLFormatter( 492 base_path=base_path, 493 counter=counter, 494 include_reusable=False) 495 short_er = short_formatter.format_collected_data(exc_data) 496 long_formatter = EvalHTMLFormatter( 497 base_path=base_path, 498 counter=counter, 499 show_hidden_frames=True, 500 show_extra_data=False, 501 include_reusable=False) 502 long_er = long_formatter.format_collected_data(exc_data) 503 text_er = formatter.format_text(exc_data, show_hidden_frames=True) 504 if short_formatter.filter_frames(exc_data.frames) != \ 505 long_formatter.filter_frames(exc_data.frames): 506 # Only display the full traceback when it differs from the 507 # short version 508 full_traceback_html = """ 509 <br> 510 <script type="text/javascript"> 511 show_button('full_traceback', 'full traceback') 512 </script> 513 <div id="full_traceback" class="hidden-data"> 514 %s 515 </div> 516 """ % long_er 517 else: 518 full_traceback_html = '' 519 520 return """ 521 %s 522 %s 523 <br> 524 <script type="text/javascript"> 525 show_button('text_version', 'text version') 526 </script> 527 <div id="text_version" class="hidden-data"> 528 <textarea style="width: 100%%" rows=10 cols=60>%s</textarea> 529 </div> 530 """ % (short_er, full_traceback_html, cgi.escape(text_er)) 531 532def make_repost_button(environ): 533 url = request.construct_url(environ) 534 if environ['REQUEST_METHOD'] == 'GET': 535 return ('<button onclick="window.location.href=%r">' 536 'Re-GET Page</button><br>' % url) 537 else: 538 # @@: I'd like to reconstruct this, but I can't because 539 # the POST body is probably lost at this point, and 540 # I can't get it back :( 541 return None 542 # @@: Use or lose the following code block 543 """ 544 fields = [] 545 for name, value in wsgilib.parse_formvars( 546 environ, include_get_vars=False).items(): 547 if hasattr(value, 'filename'): 548 # @@: Arg, we'll just submit the body, and leave out 549 # the filename :( 550 value = value.value 551 fields.append( 552 '<input type="hidden" name="%s" value="%s">' 553 % (html_quote(name), html_quote(value))) 554 return ''' 555<form action="%s" method="POST"> 556%s 557<input type="submit" value="Re-POST Page"> 558</form>''' % (url, '\n'.join(fields)) 559""" 560 561 562def input_form(tbid, debug_info): 563 return ''' 564<form action="#" method="POST" 565 onsubmit="return submitInput($(\'submit_%(tbid)s\'), %(tbid)s)"> 566<div id="exec-output-%(tbid)s" style="width: 95%%; 567 padding: 5px; margin: 5px; border: 2px solid #000; 568 display: none"></div> 569<input type="text" name="input" id="debug_input_%(tbid)s" 570 style="width: 100%%" 571 autocomplete="off" onkeypress="upArrow(this, event)"><br> 572<input type="submit" value="Execute" name="submitbutton" 573 onclick="return submitInput(this, %(tbid)s)" 574 id="submit_%(tbid)s" 575 input-from="debug_input_%(tbid)s" 576 output-to="exec-output-%(tbid)s"> 577<input type="submit" value="Expand" 578 onclick="return expandInput(this)"> 579</form> 580 ''' % {'tbid': tbid} 581 582error_template = ''' 583<html> 584<head> 585 <title>Server Error</title> 586 %(head_html)s 587</head> 588<body> 589 590<div id="error-area" style="display: none; background-color: #600; color: #fff; border: 2px solid black"> 591<div id="error-container"></div> 592<button onclick="return clearError()">clear this</button> 593</div> 594 595%(repost_button)s 596 597%(body)s 598 599</body> 600</html> 601''' 602 603def make_eval_exception(app, global_conf, xmlhttp_key=None): 604 """ 605 Wraps the application in an interactive debugger. 606 607 This debugger is a major security hole, and should only be 608 used during development. 609 610 xmlhttp_key is a string that, if present in QUERY_STRING, 611 indicates that the request is an XMLHttp request, and the 612 Javascript/interactive debugger should not be returned. (If you 613 try to put the debugger somewhere with innerHTML, you will often 614 crash the browser) 615 """ 616 if xmlhttp_key is None: 617 xmlhttp_key = global_conf.get('xmlhttp_key', '_') 618 return EvalException(app, xmlhttp_key=xmlhttp_key) 619