├── docs ├── pstats_detail.png └── pstats_index.png ├── README.md ├── html ├── index.html └── function.html └── pstats_viewer.py /docs/pstats_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msherry/pstats_viewer/HEAD/docs/pstats_detail.png -------------------------------------------------------------------------------- /docs/pstats_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msherry/pstats_viewer/HEAD/docs/pstats_index.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pstats_viewer 2 | 3 | An interactive browser for Python's cProfile output in pstats format. 4 | 5 | Python's cProfile module is great for collecting profiling data on Python 6 | programs, but interpreting the output isn't easy. This tool allows for browsing 7 | this data in a simple web-based tool. 8 | 9 | Based on the 10 | [original](https://chadaustin.me/2008/05/open-sourced-our-pstats-viewer/) 11 | released by IMVU with some enhancements and fixes. 12 | 13 | ![screenshot of index](/docs/pstats_index.png?raw=true) 14 | ![screenshot of detail](/docs/pstats_detail.png?raw=true) 15 | 16 | ## Getting Started 17 | 18 | ### Prerequisities 19 | 20 | There are no prerequisites for running pstats_viewer other than Python itself. 21 | 22 | ### Running the viewer 23 | 24 | Running pstats_viewer on the cProfile output file of your choice will start a 25 | local web server on port 4040: 26 | 27 | ``` 28 | pstats_viewer.py 29 | ``` 30 | 31 | An alternate port number may also be provided: 32 | 33 | ``` 34 | pstats_viewer.py 35 | ``` 36 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 |

{filename}

17 |
    18 |
  • Total time: {total_time}
  • 19 |
20 |
21 | Regex filter:
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {table} 36 |
file:line:functionexclusive timeinclusive timeprimitive callstotal callsexclusive per callinclusive per call
37 | 38 | 39 | -------------------------------------------------------------------------------- /html/function.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Index 4 |

{func}

5 |
    6 |
  • Primitive calls: {primitive}
  • 7 |
  • Total calls: {total}
  • 8 |
  • Exclusive time: {exclusive:.04f}s
  • 9 |
  • Inclusive time: {inclusive:.04f}s
  • 10 |
11 |

Callers

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {callers} 23 |
FunctionExclusive timeInclusive timePrimitive callsTotal callsExclusive per callInclusive per call
24 |

Self

25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {self} 36 |
FunctionExclusive timeInclusive timePrimitive callsTotal callsExclusive per callInclusive per call
37 |

Callees

38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {callees} 49 |
FunctionExclusive timeInclusive timePrimitive callsTotal callsExclusive per callInclusive per call
50 | 51 | 52 | -------------------------------------------------------------------------------- /pstats_viewer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | 5 | try: 6 | # Python 2 7 | import urlparse 8 | from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer 9 | from StringIO import StringIO 10 | from urllib import unquote 11 | except ImportError: 12 | # Python 3 13 | from http.server import HTTPServer, BaseHTTPRequestHandler 14 | from io import StringIO 15 | import urllib.parse as urlparse 16 | from urllib.parse import unquote 17 | 18 | import os.path 19 | import pstats 20 | import re 21 | import sys 22 | import traceback 23 | from typing import TYPE_CHECKING 24 | 25 | if TYPE_CHECKING: 26 | from typing import Any, Callable, Dict, List, Match, Optional, Tuple 27 | FuncType = Tuple[str, str, str] 28 | 29 | 30 | PORT = 4040 31 | 32 | DIR = os.path.dirname(os.path.realpath(__file__)) 33 | 34 | INDEX_PAGE_HTML = open(os.path.join(DIR, 'html/index.html')).read() 35 | 36 | FUNCTION_PAGE_HTML = open(os.path.join(DIR, 'html/function.html')).read() 37 | 38 | 39 | def htmlquote(fn): 40 | return fn.replace('&', '&').replace('<', '<').replace('>', '>') 41 | 42 | 43 | def shrink(s): 44 | if len(s) < 40: 45 | return s 46 | return s[:20] + '...' + s[-20:] 47 | 48 | 49 | def formatfunc(func): 50 | # type: (FuncType) -> str 51 | file, line, func_name = func 52 | containing_dir = os.path.basename(os.path.dirname(file).rstrip('/')) 53 | return '%s:%s:%s' % (os.path.join(containing_dir, os.path.basename(file)), 54 | line, htmlquote(shrink(func_name))) 55 | 56 | 57 | def wrapTag(tag, body, **kwargs): 58 | # type: (str, str, **str) -> str 59 | attrs = '' 60 | if kwargs: 61 | attrs = ' ' + ' '.join('%s="%s"' % (key, value) 62 | for key, value in kwargs.items()) 63 | open_tag = '<%s%s>' % (tag, attrs) 64 | return '%s%s' % (open_tag, body, tag) 65 | 66 | 67 | def formatTime(dt): 68 | # type: (float) -> str 69 | return '%.2fs' % dt 70 | 71 | 72 | def formatTimeAndPercent(dt, total): 73 | # type: (float, float) -> str 74 | percent = '(%.1f%%)' % (100.0 * dt / total) 75 | if percent == '(0.0%)': 76 | percent = '' 77 | return '%s %s' % ( 78 | formatTime(dt), wrapTag('font', percent, color='#808080')) 79 | 80 | 81 | class MyHandler(BaseHTTPRequestHandler): 82 | def __init__(self, stats, *args, **kwargs): 83 | # type: (pstats.Stats, *Any, **Any) -> None 84 | self.stats = stats 85 | self.stats.calc_callees() 86 | self.total_time = self.stats.total_tt 87 | (self.filename,) = self.stats.files 88 | self.width, self.print_list = self.stats.get_print_list(()) 89 | 90 | self.func_to_id = {} 91 | self.id_to_func = {} 92 | 93 | for i, func in enumerate(self.print_list): 94 | self.id_to_func[i] = func 95 | self.func_to_id[func] = i 96 | 97 | self.routes = self.setup_routes() 98 | BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 99 | 100 | def setup_routes(self): 101 | # type: () -> Dict[str, Callable] 102 | routes = {} 103 | for method_name in dir(self): 104 | method = getattr(self, method_name) 105 | if method.__doc__ is None: 106 | continue 107 | if method.__doc__.startswith('handle:'): 108 | _handle, path_re = method.__doc__.split(':') 109 | path_re = path_re.strip() 110 | routes[path_re] = method 111 | return routes 112 | 113 | def _find_handler(self, path): 114 | # type: (str) -> Tuple[Optional[Callable], Optional[Match[str]]] 115 | for path_re, method in self.routes.items(): 116 | match_obj = re.match(path_re, path) 117 | if match_obj is None: 118 | print('did not handle %s with %s' % (path, path_re)) 119 | continue 120 | print('handling %s with %s (%s)' % ( 121 | path, path_re, match_obj.groups())) 122 | return method, match_obj 123 | 124 | # This can happen for things like /favicon.ico, so we can't just abort 125 | print('no handler for %s' % path) 126 | return None, None 127 | 128 | 129 | def _filter_exp_from_query(self): 130 | # type: () -> Optional[str] 131 | filter_exp = self.query.get('filter', None) 132 | if filter_exp: 133 | filter_exp = unquote(filter_exp) 134 | return filter_exp 135 | 136 | def _filter_query_from_exp(self, filter_exp): 137 | # type: (Optional[str]) -> str 138 | return '?filter={}'.format(filter_exp) if filter_exp else '' 139 | 140 | def get_function_link(self, func, filter_exp): 141 | # type: (FuncType, Optional[str]) -> str 142 | _file, _line, func_name = func 143 | title = func_name 144 | func_id = self.func_to_id[func] 145 | filter_query = self._filter_query_from_exp(filter_exp) 146 | 147 | return wrapTag( 148 | 'a', formatfunc(func), title=title, href='/func/{func_id}{filter_query}'.format( 149 | func_id=func_id, filter_query=filter_query)) 150 | 151 | def do_GET(self): 152 | # type: () -> None 153 | path, query = urlparse.urlsplit(self.path)[2:4] 154 | self.query = {} 155 | for elt in query.split('&'): 156 | if not elt: 157 | continue 158 | key, value = elt.split('=', 1) 159 | self.query[key] = value 160 | 161 | method, mo = self._find_handler(path) 162 | if not method: 163 | self.send_response(404) 164 | return 165 | 166 | assert mo is not None # mypy 167 | 168 | try: 169 | temp = StringIO() 170 | original_wfile = self.wfile 171 | self.wfile = temp 172 | try: 173 | method(*mo.groups()) 174 | finally: 175 | self.wfile = original_wfile 176 | 177 | self.send_response(200) 178 | self.send_header('Content-Type', 'text/html') 179 | self.send_header('Cache-Control', 'no-cache') 180 | self.end_headers() 181 | self.wfile.write(temp.getvalue().encode('utf8')) 182 | except Exception: 183 | self.send_response(500) 184 | self.send_header('Content-Type', 'text/plain') 185 | self.end_headers() 186 | self.wfile.write(traceback.format_exc().encode('utf8')) 187 | 188 | def index(self): 189 | # type: () -> None 190 | 'handle: /$' 191 | table = [] 192 | 193 | sort_index = ['cc', 'nc', 'tt', 'ct', 'epc', 'ipc'].index( 194 | self.query.get('sort', 'ct')) 195 | print('sort_index', sort_index) 196 | 197 | # EPC/IPC (exclusive/inclusive per call) are fake fields that need to 198 | # be calculated 199 | if sort_index < 4: 200 | self.print_list.sort( 201 | key=lambda func: self.stats.stats[func][sort_index], 202 | reverse=True) 203 | elif sort_index == 4: # EPC 204 | self.print_list.sort( 205 | key=lambda func: ( 206 | self.stats.stats[func][2] / self.stats.stats[func][0]), 207 | reverse=True 208 | ) 209 | elif sort_index == 5: # IPC 210 | self.print_list.sort( 211 | key=lambda func: ( 212 | self.stats.stats[func][3] / self.stats.stats[func][0]), 213 | reverse=True 214 | ) 215 | else: 216 | # Shouldn't get here 217 | raise ValueError('Invalid sort_index: {}'.format(sort_index)) 218 | 219 | 220 | filter_exp = self._filter_exp_from_query() 221 | if filter_exp: 222 | print('filter_exp:', filter_exp) 223 | for func in self.print_list: 224 | if filter_exp and not re.search(filter_exp, formatfunc(func)): 225 | continue 226 | (primitive_calls, total_calls, 227 | exclusive_time, inclusive_time, callers) = self.stats.stats[func] 228 | 229 | row = wrapTag('tr', ''.join( 230 | wrapTag('td', cell) for cell in ( 231 | self.get_function_link(func, filter_exp), 232 | formatTimeAndPercent(exclusive_time, self.total_time), 233 | formatTimeAndPercent(inclusive_time, self.total_time), 234 | primitive_calls, 235 | total_calls, 236 | formatTime(exclusive_time / (primitive_calls or 1)), 237 | formatTime(inclusive_time / (primitive_calls or 1))))) 238 | 239 | table.append(row) 240 | 241 | data = INDEX_PAGE_HTML.format( 242 | filename=self.filename, total_time=formatTime(self.total_time), 243 | filter_exp=filter_exp or '', 244 | filter_param=('&filter=%s' % filter_exp) if filter_exp else '', 245 | table='\n'.join(table)) 246 | self.wfile.write(data) 247 | 248 | def func(self, func_id_str): 249 | # type: (str) -> None 250 | 'handle: /func/(.*)$' 251 | # func_id_str may also include query params 252 | 253 | func_id = int(func_id_str) 254 | func = self.id_to_func[func_id] 255 | filter_exp = self._filter_exp_from_query() 256 | 257 | f_cc, f_nc, f_tt, f_ct, callers = self.stats.stats[func] 258 | callees = self.stats.all_callees[func] 259 | 260 | def sortedByInclusive(items): 261 | sortable = [(ct, (f, (cc, nc, tt, ct))) 262 | for f, (cc, nc, tt, ct) in items] 263 | return [y for x, y in sorted(sortable, reverse=True)] 264 | 265 | def build_function_table(items, filter_exp): 266 | # type: (List, Optional[str]) -> str 267 | callersTable = [] 268 | for caller, (cc, nc, tt, ct) in sortedByInclusive(items): 269 | tag = wrapTag( 270 | 'tr', ''.join( 271 | wrapTag('td', cell) 272 | for cell in ( 273 | self.get_function_link(caller, filter_exp), 274 | formatTimeAndPercent(tt, self.total_time), 275 | formatTimeAndPercent(ct, self.total_time), 276 | cc, 277 | nc, 278 | # ncalls shouldn't be 0, but I guess it can be 279 | formatTime(tt / (cc or 1)), 280 | formatTime(ct / (cc or 1))))) 281 | callersTable.append(tag) 282 | return '\n'.join(callersTable) 283 | 284 | caller_stats = [(c, self.stats.stats[c][:4]) for c in callers] 285 | callersTable = build_function_table(caller_stats, filter_exp) 286 | calleesTable = build_function_table(callees.items(), filter_exp) 287 | selfTable = build_function_table([(func, (f_cc, f_nc, f_tt, f_ct))], filter_exp) 288 | 289 | page = FUNCTION_PAGE_HTML.format( 290 | filter_query=self._filter_query_from_exp(filter_exp), 291 | func=formatfunc(func), primitive=f_cc, total=f_nc, 292 | exclusive=f_tt, inclusive=f_ct, callers=callersTable, self=selfTable, 293 | callees=calleesTable) 294 | 295 | self.wfile.write(page) 296 | 297 | 298 | def main(argv): 299 | # type: (List[str]) -> None 300 | statsfile = argv[1] 301 | port = argv[2:] 302 | if port == []: 303 | port = PORT 304 | else: 305 | port = int(port[0]) 306 | 307 | stats = pstats.Stats(statsfile) 308 | 309 | httpd = HTTPServer( 310 | ('', port), 311 | lambda *a, **kwargs: MyHandler(stats, *a, **kwargs)) 312 | httpd.serve_forever() 313 | 314 | 315 | if __name__ == '__main__': 316 | main(argv=sys.argv) 317 | --------------------------------------------------------------------------------