├── .hgignore ├── README.rst ├── TODO ├── tools.py ├── tracemalloc.py └── tracemallocqt.py /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | *.pyc 3 | *.pyo 4 | build/ 5 | dist/ 6 | MANIFEST 7 | *.pickle 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Qt GUI for tracemalloc. 2 | 3 | * tracemallocqt: https://github.com/vstinner/tracemallocqt 4 | * tracemalloc for Python 2.5-3.3: http://pytracemalloc.readthedocs.org/ 5 | * tracemalloc in Python 3.4: http://docs.python.org/dev/library/tracemalloc.html 6 | 7 | Author: Victor Stinner 8 | 9 | 10 | Usage 11 | ===== 12 | 13 | Run your application, enable tracemalloc and dump snapshots with:: 14 | 15 | import pickle, tracemalloc 16 | tracemalloc.start() 17 | # ... run your application ... 18 | snapshot = tracemalloc.take_snapshot() 19 | with open(filename, "wb") as fp: 20 | pickle.dump(snapshot, fp, 2) 21 | snapshot = None 22 | 23 | Then open a snapshot with:: 24 | 25 | python tracemallocqt.py snapshot.pickle 26 | 27 | Compare two snapshots with:: 28 | 29 | python tracemallocqt.py snapshot1.pickle snapshot2.pickle 30 | 31 | You can specify any number of snapshots on the command line. 32 | 33 | 34 | Dependencies 35 | ============ 36 | 37 | pytracemalloc works on Python 2 and Python 3. It supports PySide and PyQt4 (use 38 | your preferred Qt binding). If PySide and PyQt4 are available, PySide is used. 39 | 40 | * PyQt4: http://www.riverbankcomputing.co.uk/software/pyqt/intro 41 | * PySide: http://qt-project.org/wiki/Get-PySide 42 | 43 | - Fedora: sudo yum install python-pyside 44 | - Debian: sudo apt-get install python-pyside.qtgui 45 | 46 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO list 2 | ========= 3 | 4 | Important: 5 | 6 | - dialog to open a snapshot file 7 | - contextual menu: copy filename/line/traceback, open line in an editor 8 | - support "all_frames=False" filter (search a filename anywhere in the 9 | traceback, not only in the most recent frame) 10 | 11 | Minor: 12 | 13 | - configurable filters 14 | - display the traceback limit 15 | 16 | Later: 17 | 18 | - annotate the source code: how many bytes were allocated 19 | (with red color for largest allocations ;-)) 20 | - tracemalloc_wrapper.py: collect RSS memory and tracemalloc current/peak 21 | memory every 5 seconds, take snapshots every 60 seconds 22 | - graphics to show the evolution of (total) memory many snapshots 23 | - more charts? 24 | - "process bar" to display the "%Total" column to see immediatly the 25 | lines allocating the most memroy 26 | -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code taken from Lib/tokenize.py of Python 3.4 3 | """ 4 | import re 5 | 6 | from codecs import lookup, BOM_UTF8 7 | 8 | cookie_re = re.compile(r'^[ \t\f]*#.*coding[:=][ \t]*([-\w.]+)') 9 | blank_re = re.compile(br'^[ \t\f]*(?:[#\r\n]|$)') 10 | 11 | def _get_normal_name(orig_enc): 12 | """Imitates get_normal_name in tokenizer.c.""" 13 | # Only care about the first 12 characters. 14 | enc = orig_enc[:12].lower().replace("_", "-") 15 | if enc == "utf-8" or enc.startswith("utf-8-"): 16 | return "utf-8" 17 | if enc in ("latin-1", "iso-8859-1", "iso-latin-1") or \ 18 | enc.startswith(("latin-1-", "iso-8859-1-", "iso-latin-1-")): 19 | return "iso-8859-1" 20 | return orig_enc 21 | 22 | def detect_encoding(readline): 23 | """ 24 | The detect_encoding() function is used to detect the encoding that should 25 | be used to decode a Python source file. It requires one argument, readline, 26 | in the same way as the tokenize() generator. 27 | 28 | It will call readline a maximum of twice, and return the encoding used 29 | (as a string) and a list of any lines (left as bytes) it has read in. 30 | 31 | It detects the encoding from the presence of a utf-8 bom or an encoding 32 | cookie as specified in pep-0263. If both a bom and a cookie are present, 33 | but disagree, a SyntaxError will be raised. If the encoding cookie is an 34 | invalid charset, raise a SyntaxError. Note that if a utf-8 bom is found, 35 | 'utf-8-sig' is returned. 36 | 37 | If no encoding is specified, then the default of 'utf-8' will be returned. 38 | """ 39 | try: 40 | filename = readline.__self__.name 41 | except AttributeError: 42 | filename = None 43 | bom_found = False 44 | encoding = None 45 | # FIXME: the default is not UTF-8 in Python 2 46 | default = 'utf-8' 47 | def read_or_stop(): 48 | try: 49 | return readline() 50 | except StopIteration: 51 | return b'' 52 | 53 | def find_cookie(line): 54 | try: 55 | # Decode as UTF-8. Either the line is an encoding declaration, 56 | # in which case it should be pure ASCII, or it must be UTF-8 57 | # per default encoding. 58 | line_string = line.decode('utf-8') 59 | except UnicodeDecodeError: 60 | msg = "invalid or missing encoding declaration" 61 | if filename is not None: 62 | msg = '{} for {!r}'.format(msg, filename) 63 | raise SyntaxError(msg) 64 | 65 | match = cookie_re.match(line_string) 66 | if not match: 67 | return None 68 | encoding = _get_normal_name(match.group(1)) 69 | try: 70 | lookup(encoding) 71 | except LookupError: 72 | # This behaviour mimics the Python interpreter 73 | if filename is None: 74 | msg = "unknown encoding: " + encoding 75 | else: 76 | msg = "unknown encoding for {!r}: {}".format(filename, 77 | encoding) 78 | raise SyntaxError(msg) 79 | 80 | if bom_found: 81 | if encoding != 'utf-8': 82 | # This behaviour mimics the Python interpreter 83 | if filename is None: 84 | msg = 'encoding problem: utf-8' 85 | else: 86 | msg = 'encoding problem for {!r}: utf-8'.format(filename) 87 | raise SyntaxError(msg) 88 | encoding += '-sig' 89 | return encoding 90 | 91 | first = read_or_stop() 92 | if first.startswith(BOM_UTF8): 93 | bom_found = True 94 | first = first[3:] 95 | default = 'utf-8-sig' 96 | if not first: 97 | return default, [] 98 | 99 | encoding = find_cookie(first) 100 | if encoding: 101 | return encoding, [first] 102 | if not blank_re.match(first): 103 | return default, [first] 104 | 105 | second = read_or_stop() 106 | if not second: 107 | return default, [first] 108 | 109 | encoding = find_cookie(second) 110 | if encoding: 111 | return encoding, [first, second] 112 | 113 | return default, [first, second] 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /tracemalloc.py: -------------------------------------------------------------------------------- 1 | # Copy of tracemalloc.py from pytracemalloc without _tracemalloc, 2 | # to be able to read snapshot without having to install the _tracemaloc 3 | # module 4 | from collections import Sequence, Iterable 5 | import fnmatch 6 | import linecache 7 | import os.path 8 | import pickle 9 | 10 | 11 | try: 12 | from functools import total_ordering 13 | except ImportError: 14 | # Python 2.6 15 | def total_ordering(cls): 16 | # Function backported from Python 2.7 17 | convert = { 18 | '__lt__': [('__gt__', lambda self, other: _not_op_and_not_eq(self.__lt__, self, other)), 19 | ('__le__', lambda self, other: _op_or_eq(self.__lt__, self, other)), 20 | ('__ge__', lambda self, other: _not_op(self.__lt__, other))], 21 | '__le__': [('__ge__', lambda self, other: _not_op_or_eq(self.__le__, self, other)), 22 | ('__lt__', lambda self, other: _op_and_not_eq(self.__le__, self, other)), 23 | ('__gt__', lambda self, other: _not_op(self.__le__, other))], 24 | '__gt__': [('__lt__', lambda self, other: _not_op_and_not_eq(self.__gt__, self, other)), 25 | ('__ge__', lambda self, other: _op_or_eq(self.__gt__, self, other)), 26 | ('__le__', lambda self, other: _not_op(self.__gt__, other))], 27 | '__ge__': [('__le__', lambda self, other: _not_op_or_eq(self.__ge__, self, other)), 28 | ('__gt__', lambda self, other: _op_and_not_eq(self.__ge__, self, other)), 29 | ('__lt__', lambda self, other: _not_op(self.__ge__, other))] 30 | } 31 | roots = [op for op in convert if getattr(cls, op, None) is not getattr(object, op, None)] 32 | if not roots: 33 | raise ValueError('must define at least one ordering operation: < > <= >=') 34 | root = max(roots) 35 | for opname, opfunc in convert[root]: 36 | if opname not in roots: 37 | opfunc.__name__ = opname 38 | opfunc.__doc__ = getattr(int, opname).__doc__ 39 | setattr(cls, opname, opfunc) 40 | return cls 41 | 42 | 43 | def _format_size(size, sign): 44 | for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'): 45 | if abs(size) < 100 and unit != 'B': 46 | # 3 digits (xx.x UNIT) 47 | if sign: 48 | return "%+.1f %s" % (size, unit) 49 | else: 50 | return "%.1f %s" % (size, unit) 51 | if abs(size) < 10 * 1024 or unit == 'TiB': 52 | # 4 or 5 digits (xxxx UNIT) 53 | if sign: 54 | return "%+.0f %s" % (size, unit) 55 | else: 56 | return "%.0f %s" % (size, unit) 57 | size /= 1024 58 | 59 | 60 | class Statistic(object): 61 | """ 62 | Statistic difference on memory allocations between two Snapshot instance. 63 | """ 64 | 65 | __slots__ = ('traceback', 'size', 'count') 66 | 67 | def __init__(self, traceback, size, count): 68 | self.traceback = traceback 69 | self.size = size 70 | self.count = count 71 | 72 | def __hash__(self): 73 | return hash((self.traceback, self.size, self.count)) 74 | 75 | def __eq__(self, other): 76 | return (self.traceback == other.traceback 77 | and self.size == other.size 78 | and self.count == other.count) 79 | 80 | def __str__(self): 81 | text = ("%s: size=%s, count=%i" 82 | % (self.traceback, 83 | _format_size(self.size, False), 84 | self.count)) 85 | if self.count: 86 | average = self.size / self.count 87 | text += ", average=%s" % _format_size(average, False) 88 | return text 89 | 90 | def __repr__(self): 91 | return ('' 92 | % (self.traceback, self.size, self.count)) 93 | 94 | def _sort_key(self): 95 | return (self.size, self.count, self.traceback) 96 | 97 | 98 | class StatisticDiff(object): 99 | """ 100 | Statistic difference on memory allocations between an old and a new 101 | Snapshot instance. 102 | """ 103 | __slots__ = ('traceback', 'size', 'size_diff', 'count', 'count_diff') 104 | 105 | def __init__(self, traceback, size, size_diff, count, count_diff): 106 | self.traceback = traceback 107 | self.size = size 108 | self.size_diff = size_diff 109 | self.count = count 110 | self.count_diff = count_diff 111 | 112 | def __hash__(self): 113 | return hash((self.traceback, self.size, self.size_diff, 114 | self.count, self.count_diff)) 115 | 116 | def __eq__(self, other): 117 | return (self.traceback == other.traceback 118 | and self.size == other.size 119 | and self.size_diff == other.size_diff 120 | and self.count == other.count 121 | and self.count_diff == other.count_diff) 122 | 123 | def __str__(self): 124 | text = ("%s: size=%s (%s), count=%i (%+i)" 125 | % (self.traceback, 126 | _format_size(self.size, False), 127 | _format_size(self.size_diff, True), 128 | self.count, 129 | self.count_diff)) 130 | if self.count: 131 | average = self.size / self.count 132 | text += ", average=%s" % _format_size(average, False) 133 | return text 134 | 135 | def __repr__(self): 136 | return ('' 137 | % (self.traceback, self.size, self.size_diff, 138 | self.count, self.count_diff)) 139 | 140 | def _sort_key(self): 141 | return (abs(self.size_diff), self.size, 142 | abs(self.count_diff), self.count, 143 | self.traceback) 144 | 145 | 146 | def _compare_grouped_stats(old_group, new_group): 147 | statistics = [] 148 | for traceback, stat in new_group.items(): 149 | previous = old_group.pop(traceback, None) 150 | if previous is not None: 151 | stat = StatisticDiff(traceback, 152 | stat.size, stat.size - previous.size, 153 | stat.count, stat.count - previous.count) 154 | else: 155 | stat = StatisticDiff(traceback, 156 | stat.size, stat.size, 157 | stat.count, stat.count) 158 | statistics.append(stat) 159 | 160 | for traceback, stat in old_group.items(): 161 | stat = StatisticDiff(traceback, 0, -stat.size, 0, -stat.count) 162 | statistics.append(stat) 163 | return statistics 164 | 165 | 166 | @total_ordering 167 | class Frame(object): 168 | """ 169 | Frame of a traceback. 170 | """ 171 | __slots__ = ("_frame",) 172 | 173 | def __init__(self, frame): 174 | # frame is a tuple: (filename: str, lineno: int) 175 | self._frame = frame 176 | 177 | @property 178 | def filename(self): 179 | return self._frame[0] 180 | 181 | @property 182 | def lineno(self): 183 | return self._frame[1] 184 | 185 | def __eq__(self, other): 186 | return (self._frame == other._frame) 187 | 188 | def __lt__(self, other): 189 | return (self._frame < other._frame) 190 | 191 | def __hash__(self): 192 | return hash(self._frame) 193 | 194 | def __str__(self): 195 | return "%s:%s" % (self.filename, self.lineno) 196 | 197 | def __repr__(self): 198 | return "" % (self.filename, self.lineno) 199 | 200 | 201 | @total_ordering 202 | class Traceback(Sequence): 203 | """ 204 | Sequence of Frame instances sorted from the most recent frame 205 | to the oldest frame. 206 | """ 207 | __slots__ = ("_frames",) 208 | 209 | def __init__(self, frames): 210 | Sequence.__init__(self) 211 | # frames is a tuple of frame tuples: see Frame constructor for the 212 | # format of a frame tuple 213 | self._frames = frames 214 | 215 | def __len__(self): 216 | return len(self._frames) 217 | 218 | def __getitem__(self, index): 219 | if isinstance(index, slice): 220 | return tuple(Frame(trace) for trace in self._frames[index]) 221 | else: 222 | return Frame(self._frames[index]) 223 | 224 | def __contains__(self, frame): 225 | return frame._frame in self._frames 226 | 227 | def __hash__(self): 228 | return hash(self._frames) 229 | 230 | def __eq__(self, other): 231 | return (self._frames == other._frames) 232 | 233 | def __lt__(self, other): 234 | return (self._frames < other._frames) 235 | 236 | def __str__(self): 237 | return str(self[0]) 238 | 239 | def __repr__(self): 240 | return "" % (tuple(self),) 241 | 242 | def format(self, limit=None): 243 | lines = [] 244 | if limit is not None and limit < 0: 245 | return lines 246 | for frame in self[:limit]: 247 | lines.append(' File "%s", line %s' 248 | % (frame.filename, frame.lineno)) 249 | line = linecache.getline(frame.filename, frame.lineno).strip() 250 | if line: 251 | lines.append(' %s' % line) 252 | return lines 253 | 254 | 255 | class Trace(object): 256 | """ 257 | Trace of a memory block. 258 | """ 259 | __slots__ = ("_trace",) 260 | 261 | def __init__(self, trace): 262 | # trace is a tuple: (size, traceback), see Traceback constructor 263 | # for the format of the traceback tuple 264 | self._trace = trace 265 | 266 | @property 267 | def size(self): 268 | return self._trace[0] 269 | 270 | @property 271 | def traceback(self): 272 | return Traceback(self._trace[1]) 273 | 274 | def __eq__(self, other): 275 | return (self._trace == other._trace) 276 | 277 | def __hash__(self): 278 | return hash(self._trace) 279 | 280 | def __str__(self): 281 | return "%s: %s" % (self.traceback, _format_size(self.size, False)) 282 | 283 | def __repr__(self): 284 | return ("" 285 | % (_format_size(self.size, False), self.traceback)) 286 | 287 | 288 | class _Traces(Sequence): 289 | def __init__(self, traces): 290 | Sequence.__init__(self) 291 | # traces is a tuple of trace tuples: see Trace constructor 292 | self._traces = traces 293 | 294 | def __len__(self): 295 | return len(self._traces) 296 | 297 | def __getitem__(self, index): 298 | if isinstance(index, slice): 299 | return tuple(Trace(trace) for trace in self._traces[index]) 300 | else: 301 | return Trace(self._traces[index]) 302 | 303 | def __contains__(self, trace): 304 | return trace._trace in self._traces 305 | 306 | def __eq__(self, other): 307 | return (self._traces == other._traces) 308 | 309 | def __repr__(self): 310 | return "" % len(self) 311 | 312 | 313 | def _normalize_filename(filename): 314 | filename = os.path.normcase(filename) 315 | if filename.endswith(('.pyc', '.pyo')): 316 | filename = filename[:-1] 317 | return filename 318 | 319 | 320 | class Filter(object): 321 | def __init__(self, inclusive, filename_pattern, 322 | lineno=None, all_frames=False): 323 | self.inclusive = inclusive 324 | self._filename_pattern = _normalize_filename(filename_pattern) 325 | self.lineno = lineno 326 | self.all_frames = all_frames 327 | 328 | @property 329 | def filename_pattern(self): 330 | return self._filename_pattern 331 | 332 | def __match_frame(self, filename, lineno): 333 | filename = _normalize_filename(filename) 334 | if not fnmatch.fnmatch(filename, self._filename_pattern): 335 | return False 336 | if self.lineno is None: 337 | return True 338 | else: 339 | return (lineno == self.lineno) 340 | 341 | def _match_frame(self, filename, lineno): 342 | return self.__match_frame(filename, lineno) ^ (not self.inclusive) 343 | 344 | def _match_traceback(self, traceback): 345 | if self.all_frames: 346 | if any(self.__match_frame(filename, lineno) 347 | for filename, lineno in traceback): 348 | return self.inclusive 349 | else: 350 | return (not self.inclusive) 351 | else: 352 | filename, lineno = traceback[0] 353 | return self._match_frame(filename, lineno) 354 | 355 | 356 | class Snapshot(object): 357 | """ 358 | Snapshot of traces of memory blocks allocated by Python. 359 | """ 360 | 361 | def __init__(self, traces, traceback_limit): 362 | # traces is a tuple of trace tuples: see _Traces constructor for 363 | # the exact format 364 | self.traces = _Traces(traces) 365 | self.traceback_limit = traceback_limit 366 | 367 | def dump(self, filename): 368 | """ 369 | Write the snapshot into a file. 370 | """ 371 | with open(filename, "wb") as fp: 372 | pickle.dump(self, fp, pickle.HIGHEST_PROTOCOL) 373 | 374 | @staticmethod 375 | def load(filename): 376 | """ 377 | Load a snapshot from a file. 378 | """ 379 | with open(filename, "rb") as fp: 380 | return pickle.load(fp) 381 | 382 | def _filter_trace(self, include_filters, exclude_filters, trace): 383 | traceback = trace[1] 384 | if include_filters: 385 | if not any(trace_filter._match_traceback(traceback) 386 | for trace_filter in include_filters): 387 | return False 388 | if exclude_filters: 389 | if any(not trace_filter._match_traceback(traceback) 390 | for trace_filter in exclude_filters): 391 | return False 392 | return True 393 | 394 | def filter_traces(self, filters): 395 | """ 396 | Create a new Snapshot instance with a filtered traces sequence, filters 397 | is a list of Filter instances. If filters is an empty list, return a 398 | new Snapshot instance with a copy of the traces. 399 | """ 400 | if not isinstance(filters, Iterable): 401 | raise TypeError("filters must be a list of filters, not %s" 402 | % type(filters).__name__) 403 | if filters: 404 | include_filters = [] 405 | exclude_filters = [] 406 | for trace_filter in filters: 407 | if trace_filter.inclusive: 408 | include_filters.append(trace_filter) 409 | else: 410 | exclude_filters.append(trace_filter) 411 | new_traces = [trace for trace in self.traces._traces 412 | if self._filter_trace(include_filters, 413 | exclude_filters, 414 | trace)] 415 | else: 416 | new_traces = self.traces._traces[:] 417 | return Snapshot(new_traces, self.traceback_limit) 418 | 419 | def _group_by(self, key_type, cumulative): 420 | if key_type not in ('traceback', 'filename', 'lineno'): 421 | raise ValueError("unknown key_type: %r" % (key_type,)) 422 | if cumulative and key_type not in ('lineno', 'filename'): 423 | raise ValueError("cumulative mode cannot by used " 424 | "with key type %r" % key_type) 425 | 426 | stats = {} 427 | tracebacks = {} 428 | if not cumulative: 429 | for trace in self.traces._traces: 430 | size, trace_traceback = trace 431 | try: 432 | traceback = tracebacks[trace_traceback] 433 | except KeyError: 434 | if key_type == 'traceback': 435 | frames = trace_traceback 436 | elif key_type == 'lineno': 437 | frames = trace_traceback[:1] 438 | else: # key_type == 'filename': 439 | frames = ((trace_traceback[0][0], 0),) 440 | traceback = Traceback(frames) 441 | tracebacks[trace_traceback] = traceback 442 | try: 443 | stat = stats[traceback] 444 | stat.size += size 445 | stat.count += 1 446 | except KeyError: 447 | stats[traceback] = Statistic(traceback, size, 1) 448 | else: 449 | # cumulative statistics 450 | for trace in self.traces._traces: 451 | size, trace_traceback = trace 452 | for frame in trace_traceback: 453 | try: 454 | traceback = tracebacks[frame] 455 | except KeyError: 456 | if key_type == 'lineno': 457 | frames = (frame,) 458 | else: # key_type == 'filename': 459 | frames = ((frame[0], 0),) 460 | traceback = Traceback(frames) 461 | tracebacks[frame] = traceback 462 | try: 463 | stat = stats[traceback] 464 | stat.size += size 465 | stat.count += 1 466 | except KeyError: 467 | stats[traceback] = Statistic(traceback, size, 1) 468 | return stats 469 | 470 | def statistics(self, key_type, cumulative=False): 471 | """ 472 | Group statistics by key_type. Return a sorted list of Statistic 473 | instances. 474 | """ 475 | grouped = self._group_by(key_type, cumulative) 476 | statistics = list(grouped.values()) 477 | statistics.sort(reverse=True, key=Statistic._sort_key) 478 | return statistics 479 | 480 | def compare_to(self, old_snapshot, key_type, cumulative=False): 481 | """ 482 | Compute the differences with an old snapshot old_snapshot. Get 483 | statistics as a sorted list of StatisticDiff instances, grouped by 484 | group_by. 485 | """ 486 | new_group = self._group_by(key_type, cumulative) 487 | old_group = old_snapshot._group_by(key_type, cumulative) 488 | statistics = _compare_grouped_stats(old_group, new_group) 489 | statistics.sort(reverse=True, key=StatisticDiff._sort_key) 490 | return statistics 491 | -------------------------------------------------------------------------------- /tracemallocqt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | unicode 5 | except NameError: 6 | # Python 3 7 | unicode = str 8 | try: 9 | from PySide import QtCore, QtGui 10 | from PySide.QtCore import Qt 11 | def fmt(x, *args): 12 | return x % args 13 | print("Qt binding: PySide") 14 | except ImportError: 15 | from PyQt4 import QtCore, QtGui 16 | from PyQt4.QtCore import Qt 17 | print("Qt binding: PyQt4") 18 | def fmt(x, *args): 19 | # QString in PyQt4 doesn't support the % operation 20 | return unicode(x) % args 21 | import datetime 22 | import functools 23 | import io 24 | import linecache 25 | import os.path 26 | import pickle 27 | import sys 28 | import tracemalloc 29 | import xml.sax.saxutils 30 | 31 | from tools import detect_encoding 32 | 33 | SORT_ROLE = Qt.UserRole 34 | MORE_TEXT = '...' 35 | 36 | def escape_html(text): 37 | return xml.sax.saxutils.escape(text) 38 | 39 | # FIXME 40 | def tr(text): 41 | return text 42 | 43 | class StatsModel(QtCore.QAbstractTableModel): 44 | def __init__(self, manager): 45 | QtCore.QAbstractTableModel.__init__(self, manager.window) 46 | self.manager = manager 47 | self.show_frames = 3 48 | self.tooltip_frames = 25 49 | self.set_stats(None, None, 'filename', False) 50 | 51 | def set_stats(self, snapshot1, snapshot2, group_by, cumulative): 52 | self.emit(QtCore.SIGNAL("layoutAboutToBeChanged()")) 53 | 54 | if snapshot1 is not None: 55 | if snapshot2 is not None: 56 | stats = snapshot2.compare_to(snapshot1, group_by, cumulative) 57 | else: 58 | stats = snapshot1.statistics(group_by, cumulative) 59 | self.stats = stats 60 | self.diff = isinstance(stats[0], tracemalloc.StatisticDiff) 61 | self.total = sum(stat.size for stat in self.stats) 62 | self.total_text = tracemalloc._format_size(self.total, False) 63 | if snapshot2 is not None: 64 | total1 = sum(trace.size for trace in snapshot1.traces) 65 | total2 = self.total 66 | self.total_text += ' (%s)' % tracemalloc._format_size(total2 - total1, True) 67 | else: 68 | self.stats = () 69 | self.diff = False 70 | self.total = 0 71 | self.total_text = tracemalloc._format_size(0, False) 72 | 73 | self.group_by = group_by 74 | if self.group_by == 'traceback': 75 | source = self.tr("Traceback") 76 | elif self.group_by == 'lineno': 77 | source = self.tr("Line") 78 | else: 79 | source = self.tr("Filename") 80 | self.headers = [source, self.tr("Size")] 81 | if self.diff: 82 | self.headers.append(self.tr("Size Diff")) 83 | self.headers.append(self.tr("Count")) 84 | if self.diff: 85 | self.headers.append(self.tr("Count Diff")) 86 | self.headers.extend([self.tr("Item Size"), self.tr("%Total")]) 87 | 88 | self.emit(QtCore.SIGNAL("layoutChanged()")) 89 | 90 | 91 | def get_default_sort_column(self): 92 | if self.diff: 93 | return 2 94 | else: 95 | return 1 96 | 97 | def rowCount(self, parent): 98 | return len(self.stats) 99 | 100 | def columnCount(self, parent): 101 | return len(self.headers) 102 | 103 | def get_stat(self, index): 104 | row = index.row() 105 | return self.stats[row] 106 | 107 | def format_size(self, role, size, diff): 108 | if role == SORT_ROLE: 109 | return size 110 | if role == Qt.ToolTipRole: 111 | if abs(size) < 10 * 1024: 112 | return None 113 | if diff: 114 | return "%+i" % size 115 | else: 116 | return str(size) 117 | return tracemalloc._format_size(size, diff) 118 | 119 | def format_frame(self, role, frame): 120 | filename = frame.filename 121 | 122 | if role == Qt.DisplayRole: 123 | filename = self.manager.format_filename(filename) 124 | elif role == Qt.ToolTipRole: 125 | filename = escape_html(filename) 126 | 127 | lineno = frame.lineno 128 | if self.group_by != "filename": 129 | if role == SORT_ROLE: 130 | return (filename, lineno) 131 | else: 132 | return "%s:%s" % (filename, lineno) 133 | else: 134 | return filename 135 | 136 | def _data(self, column, role, stat): 137 | if column == 0: 138 | if self.group_by == 'traceback': 139 | if role == Qt.ToolTipRole: 140 | max_frames = self.tooltip_frames 141 | else: 142 | max_frames = self.show_frames 143 | lines = [] 144 | if role == Qt.ToolTipRole: 145 | lines.append(self.tr("Traceback (most recent first):")) 146 | for frame in stat.traceback[:max_frames]: 147 | line = self.format_frame(role, frame) 148 | if role == Qt.ToolTipRole: 149 | lines.append(' ' * 2 + line) 150 | line = linecache.getline(frame.filename, frame.lineno).strip() 151 | if line: 152 | lines.append(' ' * 4 + '%s' % escape_html(line)) 153 | else: 154 | lines.append(line) 155 | if len(stat.traceback) > max_frames: 156 | lines.append(MORE_TEXT) 157 | if role == Qt.DisplayRole: 158 | return ' <= '.join(lines) 159 | elif role == Qt.ToolTipRole: 160 | return '
'.join(lines) 161 | else: # role == SORT_ROLE 162 | return lines 163 | else: 164 | frame = stat.traceback[0] 165 | if role == Qt.ToolTipRole: 166 | if frame.lineno: 167 | line = linecache.getline(frame.filename, frame.lineno).strip() 168 | if line: 169 | return line 170 | return None 171 | else: 172 | if role == Qt.ToolTipRole: 173 | return frame.filename 174 | return None 175 | else: 176 | return self.format_frame(role, frame) 177 | 178 | if column == 1: 179 | size = stat.size 180 | return self.format_size(role, size, False) 181 | 182 | if self.diff: 183 | if column == 2: 184 | size = stat.size_diff 185 | return self.format_size(role, size, True) 186 | if column == 3: 187 | if role == Qt.ToolTipRole: 188 | return None 189 | return stat.count 190 | if column == 4: 191 | if role == Qt.ToolTipRole: 192 | return None 193 | return "%+d" % stat.count_diff 194 | if column == 5: 195 | # Item Size 196 | if stat.count: 197 | size = stat.size / stat.count 198 | return self.format_size(role, size, False) 199 | else: 200 | return 0 201 | else: 202 | if column == 2: 203 | if role == Qt.ToolTipRole: 204 | return None 205 | return stat.count 206 | if column == 3: 207 | # Item Size 208 | if not stat.count: 209 | return 0 210 | size = stat.size / stat.count 211 | return self.format_size(role, size, False) 212 | 213 | # %Total 214 | if role == Qt.ToolTipRole: 215 | return None 216 | if not self.total: 217 | return 0 218 | percent = float(stat.size) / self.total 219 | if role == Qt.DisplayRole: 220 | return "%.1f %%" % (percent * 100.0) 221 | else: 222 | return percent 223 | 224 | def data(self, index, role): 225 | if not index.isValid(): 226 | return None 227 | if role not in (Qt.DisplayRole, Qt.ToolTipRole): 228 | return None 229 | stat = self.stats[index.row()] 230 | column = index.column() 231 | return self._data(column, role, stat) 232 | 233 | def headerData(self, col, orientation, role): 234 | if orientation == Qt.Horizontal and role == Qt.DisplayRole: 235 | return self.headers[col] 236 | return None 237 | 238 | def sort(self, col, order): 239 | """sort table by given column number col""" 240 | self.emit(QtCore.SIGNAL("layoutAboutToBeChanged()")) 241 | self.stats = sorted(self.stats, 242 | key=functools.partial(self._data, col, SORT_ROLE), 243 | reverse=(order == Qt.DescendingOrder)) 244 | self.emit(QtCore.SIGNAL("layoutChanged()")) 245 | 246 | 247 | class HistoryState: 248 | def __init__(self, group_by, filters, cumulative): 249 | self.group_by = group_by 250 | self.filters = filters 251 | self.cumulative = cumulative 252 | 253 | 254 | class History: 255 | def __init__(self, stats): 256 | self.stats = stats 257 | self.states = [] 258 | self.index = -1 259 | 260 | def clear(self): 261 | del self.states[:] 262 | self.index = -1 263 | 264 | def append(self, state): 265 | if self.index != len(self.states) - 1: 266 | del self.states[self.index+1:] 267 | self.states.append(state) 268 | self.index += 1 269 | 270 | def restore_state(self): 271 | state = self.states[self.index] 272 | self.stats.restore_state(state) 273 | 274 | def go_next(self): 275 | if self.index >= len(self.states) - 1: 276 | return 277 | self.index += 1 278 | self.restore_state() 279 | 280 | def go_previous(self): 281 | if self.index == 0: 282 | return 283 | self.index -= 1 284 | self.restore_state() 285 | 286 | 287 | class StatsManager: 288 | GROUP_BY = ['filename', 'lineno', 'traceback'] 289 | # index in the combo box 290 | GROUP_BY_FILENAME = 0 291 | GROUP_BY_LINENO = 1 292 | GROUP_BY_TRACEBACK = 2 293 | 294 | def __init__(self, window, app): 295 | self.app = app 296 | self.window = window 297 | self.snapshots = window.snapshots 298 | self.source = window.source 299 | self.filename_parts = 3 300 | self._auto_refresh = False 301 | 302 | self.filters = [] 303 | self.history = History(self) 304 | 305 | self.model = StatsModel(self) 306 | self.view = QtGui.QTableView(window) 307 | self.view.setModel(self.model) 308 | self.cumulative_checkbox = QtGui.QCheckBox(window.tr("Cumulative sizes"), window) 309 | self.group_by = QtGui.QComboBox(window) 310 | self.group_by.addItems([ 311 | window.tr("Filename"), 312 | window.tr("Line number"), 313 | window.tr("Traceback"), 314 | ]) 315 | 316 | self.filters_label = QtGui.QLabel(window) 317 | self.summary = QtGui.QLabel(window) 318 | self.view.verticalHeader().hide() 319 | self.view.resizeColumnsToContents() 320 | self.view.setSortingEnabled(True) 321 | 322 | window.connect(self.group_by, QtCore.SIGNAL("currentIndexChanged(int)"), self.group_by_changed) 323 | window.connect(self.view, QtCore.SIGNAL("doubleClicked(const QModelIndex&)"), self.double_clicked) 324 | window.connect(self.cumulative_checkbox, QtCore.SIGNAL("stateChanged(int)"), self.change_cumulative) 325 | window.connect(self.snapshots.load_button, QtCore.SIGNAL("clicked(bool)"), self.load_snapshots) 326 | window.connect(self.view.selectionModel(), QtCore.SIGNAL("selectionChanged(const QItemSelection&, const QItemSelection&)"), self.selection_changed) 327 | 328 | self.clear() 329 | self._auto_refresh = True 330 | 331 | def clear(self): 332 | del self.filters[:] 333 | # self.filters.append(tracemalloc.Filter(False, "")) 334 | self.cumulative_checkbox.setCheckState(Qt.Unchecked) 335 | self.group_by.setCurrentIndex(self.GROUP_BY_FILENAME) 336 | self.history.clear() 337 | self.append_history() 338 | self.refresh() 339 | 340 | def load_snapshots(self, checked): 341 | self.source.clear() 342 | self.clear() 343 | 344 | def append_history(self): 345 | group_by = self.group_by.currentIndex() 346 | filters = self.filters[:] 347 | cumulative = self.cumulative_checkbox.checkState() 348 | state = HistoryState(group_by, filters, cumulative) 349 | self.history.append(state) 350 | 351 | def restore_state(self, state): 352 | self.filters = state.filters[:] 353 | self._auto_refresh = False 354 | self.cumulative_checkbox.setCheckState(state.cumulative) 355 | self.group_by.setCurrentIndex(state.group_by) 356 | self._auto_refresh = True 357 | self.refresh() 358 | 359 | def format_filename(self, filename): 360 | parts = filename.split(os.path.sep) 361 | if len(parts) > self.filename_parts: 362 | parts = [MORE_TEXT] + parts[-self.filename_parts:] 363 | return os.path.join(*parts) 364 | 365 | def get_group_by(self): 366 | index = self.group_by.currentIndex() 367 | return self.GROUP_BY[index] 368 | 369 | def get_cumulative(self): 370 | return (self.cumulative_checkbox.checkState() == Qt.Checked) 371 | 372 | def refresh(self): 373 | group_by = self.get_group_by() 374 | if group_by != 'traceback': 375 | cumulative = self.get_cumulative() 376 | else: 377 | # FIXME: add visual feedback 378 | cumulative = False 379 | snapshot1, snapshot2 = self.snapshots.load_snapshots(self.filters) 380 | 381 | self.view.clearSelection() 382 | group_by = self.get_group_by() 383 | self.model.set_stats(snapshot1, snapshot2, group_by, cumulative) 384 | 385 | self.view.resizeColumnsToContents() 386 | self.view.sortByColumn(self.model.get_default_sort_column(), Qt.DescendingOrder) 387 | 388 | if self.filters: 389 | filters = [] 390 | for filter in self.filters: 391 | text = self.format_filename(filter.filename_pattern) 392 | if filter.lineno: 393 | text = "%s:%s" % (text, filter.lineno) 394 | if filter.all_frames: 395 | text += self.window.tr(" (any frame)") 396 | if filter.inclusive: 397 | text = fmt(self.window.tr("include %s"), text) 398 | else: 399 | text = fmt(self.window.tr("exclude %s"), text) 400 | filters.append(text) 401 | filters_text = ", ".join(filters) 402 | else: 403 | filters_text = self.window.tr("(none)") 404 | filters_text = fmt(self.window.tr("Filters: %s"), filters_text) 405 | self.filters_label.setText(filters_text) 406 | 407 | total = self.model.total_text 408 | lines = len(self.model.stats) 409 | if group_by == 'filename': 410 | lines = fmt(self.window.tr("Files: %s"), lines) 411 | elif group_by == 'lineno': 412 | lines = fmt(self.window.tr("Lines: %s"), lines) 413 | else: 414 | lines = fmt(self.window.tr("Tracebacks: %s"), lines) 415 | total = fmt(self.window.tr("%s - Total: %s"), lines, total) 416 | self.summary.setText(total) 417 | 418 | def selection_changed(self, selected, unselected): 419 | indexes = selected.indexes() 420 | if not indexes: 421 | return 422 | stat = self.model.get_stat(indexes[0]) 423 | if stat is None: 424 | return 425 | self.source.set_traceback(stat.traceback, 426 | self.get_group_by() != 'filename') 427 | self.source.show_frame(stat.traceback[0]) 428 | 429 | def double_clicked(self, index): 430 | stat = self.model.get_stat(index) 431 | if stat is None: 432 | return 433 | group_by = self.get_group_by() 434 | if group_by == 'filename': 435 | all_frames = self.get_cumulative() 436 | self.filters.append(tracemalloc.Filter(True, stat.traceback[0].filename, all_frames=all_frames)) 437 | self._auto_refresh = False 438 | self.group_by.setCurrentIndex(self.GROUP_BY_LINENO) 439 | self.append_history() 440 | self._auto_refresh = True 441 | self.refresh() 442 | elif group_by == 'lineno': 443 | # Replace filter by filename with filter by line 444 | new_filter = tracemalloc.Filter(True, stat.traceback[0].filename, stat.traceback[0].lineno, all_frames=False) 445 | if self.filters: 446 | old_filter = self.filters[-1] 447 | replace = (old_filter.inclusive == new_filter.inclusive 448 | and old_filter.filename_pattern == new_filter.filename_pattern 449 | and old_filter.lineno == None) 450 | else: 451 | replace = False 452 | if replace: 453 | self.filters[-1] = new_filter 454 | else: 455 | self.filters.append(new_filter) 456 | self._auto_refresh = False 457 | self.group_by.setCurrentIndex(self.GROUP_BY_TRACEBACK) 458 | self.append_history() 459 | self._auto_refresh = True 460 | self.refresh() 461 | 462 | def group_by_changed(self, index): 463 | if not self._auto_refresh: 464 | return 465 | self.append_history() 466 | self.refresh() 467 | 468 | def change_cumulative(self, state): 469 | if not self._auto_refresh: 470 | return 471 | self.append_history() 472 | self.refresh() 473 | 474 | 475 | class MySnapshot: 476 | def __init__(self, filename): 477 | self.filename = filename 478 | ts = int(os.stat(filename).st_mtime) 479 | self.timestamp = datetime.datetime.fromtimestamp(ts) 480 | self.snapshot = None 481 | self.ntraces = None 482 | self.total = None 483 | 484 | def load(self): 485 | if self.snapshot is None: 486 | print("Load snapshot %s" % self.filename) 487 | with open(self.filename, "rb") as fp: 488 | self.snapshot = pickle.load(fp) 489 | self.ntraces = len(self.snapshot.traces) 490 | self.total = sum(trace.size for trace in self.snapshot.traces) 491 | return self.snapshot 492 | 493 | def unload(self): 494 | self.snapshot = None 495 | 496 | def get_label(self): 497 | if self.ntraces is None: 498 | print("Process snapshot %s..." % self.filename) 499 | # fill ntraces and total 500 | self.load() 501 | self.unload() 502 | print("Process snapshot %s... done" % self.filename) 503 | 504 | name = os.path.basename(self.filename) 505 | infos = [ 506 | tracemalloc._format_size(self.total, False), 507 | fmt(tr("%s traces"), self.ntraces), 508 | str(self.timestamp), 509 | ] 510 | return "%s (%s)" % (name, ', '.join(infos)) 511 | 512 | 513 | class SnapshotManager: 514 | def __init__(self, parent): 515 | self.snapshots = [] 516 | self.combo1 = QtGui.QComboBox(parent) 517 | self.combo1.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum)) 518 | self.combo2 = QtGui.QComboBox(parent) 519 | self.combo2.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum)) 520 | self.load_button = QtGui.QPushButton(tr("Load"), parent) 521 | self.load_button.setEnabled(True) 522 | 523 | def set_filenames(self, filenames): 524 | self.snapshots = [MySnapshot(filename) for filename in filenames] 525 | self.snapshots.sort(key=lambda snapshot: snapshot.timestamp) 526 | 527 | self.snapshots[0].load() 528 | if len(self.snapshots) > 1: 529 | self.snapshots[1].load() 530 | 531 | items = [snapshot.get_label() for snapshot in self.snapshots] 532 | self.combo1.addItems(items) 533 | self.combo1.setCurrentIndex(0) 534 | 535 | items = ['(none)'] + items 536 | self.combo2.addItems(items) 537 | if len(self.snapshots) > 1: 538 | self.combo2.setCurrentIndex(2) 539 | else: 540 | self.combo2.setCurrentIndex(0) 541 | 542 | def load_snapshots(self, filters): 543 | index1 = self.combo1.currentIndex() 544 | index2 = self.combo2.currentIndex() 545 | snapshot1 = self.snapshots[index1].load() 546 | snapshot2 = None 547 | if index2: 548 | index2 -= 1 549 | if index2 != index1: 550 | snapshot2 = self.snapshots[index2].load() 551 | else: 552 | self.combo2.setCurrentIndex(0) 553 | # FIXME: incremental filter 554 | if filters: 555 | snapshot1 = snapshot1.filter_traces(filters) 556 | if snapshot2 is not None: 557 | if filters: 558 | snapshot2 = snapshot2.filter_traces(filters) 559 | return (snapshot1, snapshot2) 560 | 561 | 562 | class SourceCodeManager: 563 | def __init__(self, window): 564 | self.text_edit = QtGui.QTextEdit(window) 565 | self.text_edit.setReadOnly(True) 566 | # FIXME: write an optimized model 567 | self.traceback = None 568 | self.traceback_model = QtGui.QStringListModel() 569 | self.traceback_view = QtGui.QListView(window) 570 | self.traceback_view.setModel(self.traceback_model) 571 | self.traceback_view.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) 572 | window.connect(self.traceback_view.selectionModel(), QtCore.SIGNAL("selectionChanged(const QItemSelection&, const QItemSelection&)"), self.frame_selection_changed) 573 | # filename => (lines, mtime) 574 | self._file_cache = {} 575 | self.clear() 576 | 577 | def clear(self): 578 | self._current_file = None 579 | self._current_lineno = None 580 | self.traceback_model.setStringList([]) 581 | self.text_edit.setText('') 582 | self._file_cache.clear() 583 | 584 | def frame_selection_changed(self, selected, unselected): 585 | indexes = selected.indexes() 586 | if not indexes: 587 | return 588 | row = indexes[0].row() 589 | frame = self.traceback[row] 590 | self.show_frame(frame) 591 | 592 | def set_traceback(self, traceback, show_lineno): 593 | self.traceback = traceback 594 | if show_lineno: 595 | lines = ['%s:%s' % (frame.filename, frame.lineno) for frame in traceback] 596 | else: 597 | lines = [frame.filename for frame in traceback] 598 | self.traceback_model.setStringList(lines) 599 | 600 | def read_file(self, filename): 601 | try: 602 | mtime = os.stat(filename).st_mtime 603 | except OSError: 604 | return None 605 | 606 | if filename in self._file_cache: 607 | text, cache_mtime = self._file_cache[filename] 608 | if mtime == cache_mtime: 609 | return text 610 | 611 | print("Read %s content (mtime: %s)" % (filename, mtime)) 612 | with open(filename, 'rb') as fp: 613 | encoding, lines = detect_encoding(fp.readline) 614 | lineno = 1 615 | lines = [] 616 | with io.open(filename, 'r', encoding=encoding) as fp: 617 | for lineno, line in enumerate(fp, 1): 618 | lines.append('%d: %s' % (lineno, line.rstrip())) 619 | 620 | text = '\n'.join(lines) 621 | self._file_cache[filename] = (text, mtime) 622 | return text 623 | 624 | def load_file(self, filename): 625 | if filename.startswith("<") and filename.startswith(">"): 626 | return False 627 | if self._current_file == filename: 628 | return True 629 | text = self.read_file(filename) 630 | if text is None: 631 | return False 632 | self.text_edit.setText(text) 633 | self._current_file = filename 634 | self._current_lineno = None 635 | return True 636 | 637 | def set_line_number(self, lineno): 638 | if self._current_lineno == lineno: 639 | return 640 | self._current_lineno = lineno 641 | doc = self.text_edit.document() 642 | # FIXME: complexity in O(number of lines)? 643 | block = doc.findBlockByLineNumber(lineno - 1) 644 | cursor = QtGui.QTextCursor(block) 645 | cursor.select(QtGui.QTextCursor.BlockUnderCursor) 646 | # FIXME: complexity in O(number of lines)? 647 | self.text_edit.setTextCursor(cursor) 648 | 649 | def show_frame(self, frame): 650 | filename = frame.filename 651 | if not self.load_file(filename): 652 | self._current_file = None 653 | self.text_edit.setText('') 654 | return 655 | if frame.lineno > 0: 656 | self.set_line_number(frame.lineno) 657 | 658 | 659 | class MainWindow(QtGui.QMainWindow): 660 | def __init__(self, app, filenames): 661 | QtGui.QMainWindow.__init__(self) 662 | self.setGeometry(300, 200, 1300, 450) 663 | self.setWindowTitle("Tracemalloc") 664 | 665 | # actions 666 | action_previous = QtGui.QAction(self.tr("Previous"), self) 667 | self.connect(action_previous, QtCore.SIGNAL("triggered(bool)"), self.go_previous) 668 | action_next = QtGui.QAction(self.tr("Next"), self) 669 | self.connect(action_next, QtCore.SIGNAL("triggered(bool)"), self.go_next) 670 | 671 | # toolbar 672 | toolbar = self.addToolBar(self.tr("Navigation")) 673 | toolbar.addAction(action_previous) 674 | toolbar.addAction(action_next) 675 | 676 | # create classes 677 | self.snapshots = SnapshotManager(self) 678 | self.snapshots.set_filenames(filenames) 679 | self.source = SourceCodeManager(self) 680 | self.stats = StatsManager(self, app) 681 | self.history = self.stats.history 682 | 683 | # snapshots 684 | hbox1 = QtGui.QHBoxLayout() 685 | hbox1.addWidget(QtGui.QLabel(self.tr("Snapshot:"))) 686 | hbox1.addWidget(self.snapshots.combo1) 687 | hbox2 = QtGui.QHBoxLayout() 688 | hbox2.addWidget(QtGui.QLabel(self.tr("compared to:"))) 689 | hbox2.addWidget(self.snapshots.combo2) 690 | 691 | vbox = QtGui.QVBoxLayout() 692 | vbox.addLayout(hbox1) 693 | vbox.addLayout(hbox2) 694 | 695 | hbox3 = QtGui.QHBoxLayout() 696 | hbox3.addLayout(vbox) 697 | hbox3.addWidget(self.snapshots.load_button) 698 | snap_box = QtGui.QWidget() 699 | snap_box.setLayout(hbox3) 700 | 701 | # Group by 702 | hbox = QtGui.QHBoxLayout() 703 | hbox.addWidget(QtGui.QLabel(self.tr("Group by:"))) 704 | hbox.addWidget(self.stats.group_by) 705 | hbox.addWidget(self.stats.cumulative_checkbox) 706 | hbox.addWidget(self.stats.filters_label) 707 | self.stats.filters_label.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum)) 708 | group_by_box = QtGui.QWidget() 709 | group_by_box.setLayout(hbox) 710 | 711 | # Source 712 | source_splitter = QtGui.QSplitter() 713 | source_splitter.addWidget(self.source.traceback_view) 714 | self.source.traceback_view.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Minimum)) 715 | source_splitter.addWidget(self.source.text_edit) 716 | self.source.text_edit.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)) 717 | 718 | # Top widgets 719 | layout = QtGui.QVBoxLayout() 720 | layout.addWidget(snap_box) 721 | layout.addWidget(group_by_box) 722 | layout.addWidget(self.stats.view) 723 | layout.addWidget(self.stats.summary) 724 | top_widget = QtGui.QWidget(self) 725 | top_widget.setLayout(layout) 726 | 727 | # main splitter 728 | main_splitter = QtGui.QSplitter(Qt.Vertical) 729 | main_splitter.addWidget(top_widget) 730 | main_splitter.addWidget(source_splitter) 731 | self.setCentralWidget(main_splitter) 732 | 733 | def go_previous(self, checked): 734 | self.history.go_previous() 735 | 736 | def go_next(self, checked): 737 | self.history.go_next() 738 | 739 | 740 | class Application: 741 | def __init__(self): 742 | if len(sys.argv) >= 2: 743 | filenames = sys.argv[1:] 744 | else: 745 | print("usage: %s snapshot1.pickle [snapshot2.pickle snapshot3.pickle ...]") 746 | sys.exit(1) 747 | 748 | # Create a Qt application 749 | self.app = QtGui.QApplication(sys.argv) 750 | self.window = MainWindow(self, filenames) 751 | 752 | def main(self): 753 | self.window.show() 754 | self.app.exec_() 755 | sys.exit() 756 | 757 | 758 | if __name__ == "__main__": 759 | Application().main() 760 | --------------------------------------------------------------------------------