├── .gitignore ├── .travis.yml ├── CONTRIBUTORS.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pyprof2calltree.py ├── setup.cfg ├── setup.py ├── test ├── __init__.py ├── profile_code.py └── test_integration.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .tox/ 4 | __pycache__/ 5 | build/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | 4 | matrix: 5 | include: 6 | - env: TOXENV=flake8 7 | - env: TOXENV=isort 8 | - python: "2.7" 9 | env: TOXENV=py27 10 | - python: "3.4" 11 | env: TOXENV=py34 12 | - python: "3.5" 13 | env: TOXENV=py35 14 | - python: "3.6" 15 | env: TOXENV=py36 16 | - python: "3.7" 17 | env: TOXENV=py37 18 | - python: "3.8" 19 | env: TOXENV=py38 20 | 21 | install: pip install tox 22 | script: tox 23 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | # Contributions to the pyprof2calltree project 2 | 3 | ## Creators 4 | 5 | * David Allouche 6 | * Jp Calderone 7 | * Itamar Shtull-Trauring 8 | * Johan Dahlin 9 | 10 | ## Maintainer 11 | 12 | * Peter Waller 13 | 14 | ## Contributors 15 | 16 | In chronological order: 17 | 18 | * Olivier Grisel 19 | * Repackaging and pstats support 20 | 21 | * David Glick 22 | * Fix in conversion algorithm 23 | 24 | * Peter Waller 25 | * Taking over PyPI maintainance 26 | 27 | * Steven Maude 28 | * Breaking things, documentation 29 | 30 | * Lukas Graf 31 | * Python 3.x compatibility 32 | 33 | * Jamie Wong 34 | * qcachegrind support 35 | 36 | * Yury V. Zaytsev 37 | * Bugfixes 38 | 39 | * Michael Droettboom 40 | * Source code display support 41 | 42 | * Zev Benjamin 43 | * Support for multiple functions with the same name 44 | * Tests 45 | 46 | * Jon Dufresne 47 | * A huge number of small fixes and consistency 48 | improvements across code, docs and setup.py alike. 49 | 50 | * Meesha <44530786+meesha7@users.noreply.github.com> 51 | * Support for multiple time units 52 | 53 | * [Your name or handle] <[email or website]> 54 | * [Brief summary of your changes] 55 | 56 | ## Thanks 57 | 58 | * Jon Dufresne for prompting 59 | a new release and many tidy up fixes! 60 | * Uwe L. Korn for pointing out mismatch in licensing 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2017 David Allouche, Jp Calderone, Itamar Shtull-Trauring, Johan Dahlin, Peter Waller and people listed in CONTRIBUTORS.txt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CONTRIBUTORS.txt 3 | recursive-include test *.py 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | Script to help visualize profiling data collected with the cProfile 5 | Python module with the kcachegrind_ (screenshots_) graphical calltree 6 | analyser. 7 | 8 | This is a rebranding of the venerable 9 | http://www.gnome.org/~johan/lsprofcalltree.py script by David Allouche 10 | et Al. It aims at making it easier to distribute (e.g. through PyPI) 11 | and behave more like the scripts of the debian kcachegrind-converters_ 12 | package. The final goal is to make it part of the official upstream 13 | kdesdk_ package. 14 | 15 | .. _kcachegrind: http://kcachegrind.sourceforge.net 16 | .. _kcachegrind-converters: https://packages.debian.org/en/stable/kcachegrind-converters 17 | .. _kdesdk: http://websvn.kde.org/trunk/KDE/kdesdk/kcachegrind/converters/ 18 | .. _screenshots: http://images.google.fr/images?q=kcachegrind 19 | 20 | Installation 21 | ============ 22 | 23 | On Debian ≥ 11, or derivatives such as Ubuntu ≥ 20.04, `sudo apt 24 | install kcachegrind pyprof2calltree`. 25 | 26 | Command line usage 27 | ================== 28 | 29 | Upon installation you should have a `pyprof2calltree` script in your path:: 30 | 31 | $ pyprof2calltree --help 32 | usage: pyprof2calltree [-h] [-o output_file_path] [-i input_file_path] [-k] 33 | [-r scriptfile [args ...]] 34 | 35 | optional arguments: 36 | -h, --help show this help message and exit 37 | -o output_file_path, --outfile output_file_path 38 | Save calltree stats to 39 | -i input_file_path, --infile input_file_path 40 | Read Python stats from 41 | -k, --kcachegrind Run the kcachegrind tool on the converted data 42 | -r scriptfile [args ...], --run-script scriptfile [args ...] 43 | Name of the Python script to run to collect profiling 44 | data 45 | -s {s,ms,us,ns}, --scale {s,ms,us,ns} 46 | Time scale 47 | 48 | 49 | Python shell usage 50 | ================== 51 | 52 | `pyprof2calltree` is also best used from an interactive Python shell such as 53 | the default shell. For instance let us profile XML parsing:: 54 | 55 | >>> from xml.etree import ElementTree 56 | >>> from cProfile import Profile 57 | >>> xml_content = '\n' + '\ttext\n' * 100 + '' 58 | >>> profiler = Profile() 59 | >>> profiler.runctx( 60 | ... "ElementTree.fromstring(xml_content)", 61 | ... locals(), globals()) 62 | 63 | >>> from pyprof2calltree import convert, visualize 64 | >>> visualize(profiler.getstats()) # run kcachegrind 65 | >>> convert(profiler.getstats(), 'profiling_results.kgrind') # save for later 66 | 67 | or with the ipython_:: 68 | 69 | In [1]: %doctest_mode 70 | Exception reporting mode: Plain 71 | Doctest mode is: ON 72 | 73 | >>> from xml.etree import ElementTree 74 | >>> xml_content = '\n' + '\ttext\n' * 100 + '' 75 | >>> %prun -D out.stats ElementTree.fromstring(xml_content) 76 | 77 | *** Profile stats marshalled to file 'out.stats' 78 | 79 | >>> from pyprof2calltree import convert, visualize 80 | >>> visualize('out.stats') 81 | >>> convert('out.stats', 'out.kgrind') 82 | 83 | >>> results = %prun -r ElementTree.fromstring(xml_content) 84 | >>> visualize(results) 85 | 86 | .. _ipython: https://ipython.org/ 87 | 88 | 89 | Change log 90 | ========== 91 | 92 | - 1.4.4 - 2018-10-19: Numerous small improvements, drop support for EOL python versions 93 | - 1.4.3 - 2017-07-28: Windows support (fixed is_installed check - #21) 94 | - 1.4.2 - 2017-07-19: No feature or bug fixes, just license clarification (#20) 95 | - 1.4.1 - 2017-05-20: No feature or bug fixes, just test distribution (#17) 96 | - 1.4.0 - 2016-09-03: Support multiple functions with the same name, tick unit from millis to nanos, tests added (#15) 97 | - 1.3.2 - 2014-07-05: Bugfix: correct source file paths (#12) 98 | - 1.3.1 - 2013-11-27: Bugfix for broken output writing on Python 3 (#8) 99 | - 1.3.0 - 2013-11-19: qcachegrind support 100 | - 1.2.0 - 2013-11-09: Python 3 support 101 | - 1.1.1 - 2013-09-25: Miscellaneous bugfixes 102 | - 1.1.0 - 2008-12-21: integrate fix in conversion by David Glick 103 | - 1.0.3 - 2008-10-16: fix typos in 1.0 release 104 | - 1.0 - 2008-10-16: initial release under the pyprof2calltree name 105 | -------------------------------------------------------------------------------- /pyprof2calltree.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2006-2008, David Allouche, Jp Calderone, Itamar Shtull-Trauring, 3 | # Johan Dahlin, Olivier Grisel 4 | # 5 | # Send maintenance requests needing new PyPI packages to: 6 | # Peter Waller 7 | # https://github.com/pwaller/pyprof2calltree 8 | # 9 | # See CONTRIBUTORS.txt. 10 | # 11 | # All rights reserved. 12 | # 13 | # Permission is hereby granted, free of charge, to any person obtaining a copy 14 | # of this software and associated documentation files (the "Software"), to deal 15 | # in the Software without restriction, including without limitation the rights 16 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | # copies of the Software, and to permit persons to whom the Software is 18 | # furnished to do so, subject to the following conditions: 19 | # 20 | # The above copyright notice and this permission notice shall be included in 21 | # all copies or substantial portions of the Software. 22 | # 23 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 29 | # THE SOFTWARE. 30 | """pyprof2calltree: profiling output which is readable by kcachegrind 31 | 32 | This script can either take raw cProfile.Profile.getstats() log entries or 33 | take a previously recorded instance of the pstats.Stats class. 34 | """ 35 | 36 | from __future__ import unicode_literals 37 | 38 | import argparse 39 | import cProfile 40 | import errno 41 | import io 42 | import os 43 | import pstats 44 | import subprocess 45 | import sys 46 | import tempfile 47 | from collections import defaultdict 48 | 49 | __all__ = ['convert', 'visualize', 'CalltreeConverter'] 50 | 51 | 52 | class Scale(object): 53 | def __init__(self, unit): 54 | SCALES = { 55 | 's': (1, 's', 'Seconds'), 56 | 'ms': (1e3, 'ms', 'Milliseconds'), 57 | 'us': (1e6, 'us', 'Microseconds'), 58 | 'ns': (1e9, 'ns', 'Nanoseconds') 59 | } 60 | 61 | self.scale, self.unit, self.name = SCALES[unit] 62 | 63 | 64 | class Code(object): 65 | def __init__(self, filename, firstlineno, name): 66 | self.co_filename = filename 67 | self.co_firstlineno = firstlineno 68 | self.co_name = name 69 | 70 | def __repr__(self): 71 | return '' % (self.co_filename, self.co_firstlineno, 72 | self.co_name) 73 | 74 | 75 | class Entry(object): 76 | def __init__(self, code, callcount, reccallcount, inlinetime, totaltime, calls): 77 | self.code = code 78 | self.callcount = callcount 79 | self.reccallcount = reccallcount 80 | self.inlinetime = inlinetime 81 | self.totaltime = totaltime 82 | self.calls = calls 83 | 84 | def __repr__(self): 85 | return '' % ( 86 | self.code, self.callcount, self.reccallcount, self.inlinetime, 87 | self.totaltime, self.calls 88 | ) 89 | 90 | 91 | class Subentry(object): 92 | def __init__(self, code, callcount, reccallcount, inlinetime, totaltime): 93 | self.code = code 94 | self.callcount = callcount 95 | self.reccallcount = reccallcount 96 | self.inlinetime = inlinetime 97 | self.totaltime = totaltime 98 | 99 | def __repr__(self): 100 | return '' % ( 101 | self.code, self.callcount, self.reccallcount, self.inlinetime, 102 | self.totaltime 103 | ) 104 | 105 | 106 | def is_basestring(s): 107 | try: 108 | unicode 109 | # Python 2.x 110 | return isinstance(s, basestring) 111 | except NameError: 112 | # Python 3.x 113 | return isinstance(s, (str, bytes)) 114 | 115 | 116 | def pstats2entries(data): 117 | """Helper to convert serialized pstats back to a list of raw entries. 118 | 119 | Converse operation of cProfile.Profile.snapshot_stats() 120 | """ 121 | # Each entry's key is a tuple of (filename, line number, function name) 122 | entries = {} 123 | allcallers = {} 124 | 125 | # first pass over stats to build the list of entry instances 126 | for code_info, call_info in data.stats.items(): 127 | # build a fake code object 128 | code = Code(*code_info) 129 | 130 | # build a fake entry object. entry.calls will be filled during the 131 | # second pass over stats 132 | cc, nc, tt, ct, callers = call_info 133 | entry = Entry(code, callcount=cc, reccallcount=nc - cc, inlinetime=tt, 134 | totaltime=ct, calls=[]) 135 | 136 | # collect the new entry 137 | entries[code_info] = entry 138 | allcallers[code_info] = list(callers.items()) 139 | 140 | # second pass of stats to plug callees into callers 141 | for entry in entries.values(): 142 | entry_label = cProfile.label(entry.code) 143 | entry_callers = allcallers.get(entry_label, []) 144 | for entry_caller, call_info in entry_callers: 145 | cc, nc, tt, ct = call_info 146 | subentry = Subentry(entry.code, callcount=cc, reccallcount=nc - cc, 147 | inlinetime=tt, totaltime=ct) 148 | # entry_caller has the same form as code_info 149 | entries[entry_caller].calls.append(subentry) 150 | 151 | return list(entries.values()) 152 | 153 | 154 | def is_installed(prog): 155 | """Return whether or not a given executable is installed on the machine.""" 156 | with open(os.devnull, 'w') as devnull: 157 | try: 158 | if os.name == 'nt': 159 | retcode = subprocess.call(['where', prog], stdout=devnull) 160 | else: 161 | retcode = subprocess.call(['which', prog], stdout=devnull) 162 | except OSError as e: 163 | # If where or which doesn't exist, a "ENOENT" error will occur (The 164 | # FileNotFoundError subclass on Python 3). 165 | if e.errno != errno.ENOENT: 166 | raise 167 | retcode = 1 168 | 169 | return retcode == 0 170 | 171 | 172 | def _entry_sort_key(entry): 173 | return cProfile.label(entry.code) 174 | 175 | 176 | KCACHEGRIND_EXECUTABLES = ["kcachegrind", "qcachegrind"] 177 | 178 | 179 | class CalltreeConverter(object): 180 | """Convert raw cProfile or pstats data to the calltree format""" 181 | 182 | def __init__(self, profiling_data, scale=None): 183 | if is_basestring(profiling_data): 184 | # treat profiling_data as a filename of pstats serialized data 185 | self.entries = pstats2entries(pstats.Stats(profiling_data)) 186 | elif isinstance(profiling_data, pstats.Stats): 187 | # convert pstats data to cProfile list of entries 188 | self.entries = pstats2entries(profiling_data) 189 | else: 190 | # assume this are direct cProfile entries 191 | self.entries = profiling_data 192 | self.out_file = None 193 | self.scale = scale 194 | 195 | if not scale: 196 | self.scale = Scale('ns') 197 | 198 | self._code_by_position = defaultdict(set) 199 | self._populate_code_by_position() 200 | 201 | def _populate_code_by_position(self): 202 | for entry in self.entries: 203 | self._add_code_by_position(entry.code) 204 | if not entry.calls: 205 | continue 206 | for subentry in entry.calls: 207 | self._add_code_by_position(subentry.code) 208 | 209 | def _add_code_by_position(self, code): 210 | co_filename, _, co_name = cProfile.label(code) 211 | self._code_by_position[(co_filename, co_name)].add(code) 212 | 213 | def munged_function_name(self, code): 214 | co_filename, co_firstlineno, co_name = cProfile.label(code) 215 | if len(self._code_by_position[(co_filename, co_name)]) == 1: 216 | return co_name 217 | return "%s:%d" % (co_name, co_firstlineno) 218 | 219 | def output(self, out_file): 220 | """Write the converted entries to out_file""" 221 | self.out_file = out_file 222 | out_file.write('event: {} : {}\n'.format(self.scale.unit, self.scale.name)) 223 | out_file.write('events: {}\n'.format(self.scale.unit)) 224 | self._output_summary() 225 | for entry in sorted(self.entries, key=_entry_sort_key): 226 | self._output_entry(entry) 227 | 228 | def visualize(self): 229 | """Launch kcachegrind on the converted entries. 230 | 231 | One of the executables listed in KCACHEGRIND_EXECUTABLES 232 | must be present in the system path. 233 | """ 234 | 235 | available_cmd = None 236 | for cmd in KCACHEGRIND_EXECUTABLES: 237 | if is_installed(cmd): 238 | available_cmd = cmd 239 | break 240 | 241 | if available_cmd is None: 242 | sys.stderr.write("Could not find kcachegrind. Tried: %s\n" % 243 | ", ".join(KCACHEGRIND_EXECUTABLES)) 244 | return 245 | 246 | if self.out_file is None: 247 | fd, outfile = tempfile.mkstemp(".log", "pyprof2calltree") 248 | use_temp_file = True 249 | else: 250 | outfile = self.out_file.name 251 | use_temp_file = False 252 | 253 | try: 254 | if use_temp_file: 255 | with io.open(fd, "w") as f: 256 | self.output(f) 257 | subprocess.call([available_cmd, outfile]) 258 | finally: 259 | # clean the temporary file 260 | if use_temp_file: 261 | os.remove(outfile) 262 | self.out_file = None 263 | 264 | def _output_summary(self): 265 | max_cost = 0 266 | for entry in self.entries: 267 | totaltime = int(entry.totaltime * self.scale.scale) 268 | max_cost = max(max_cost, totaltime) 269 | # Version 0.7.4 of kcachegrind appears to ignore the summary line and 270 | # calculate the total cost by summing the exclusive cost of all 271 | # functions, but it doesn't hurt to output it anyway. 272 | self.out_file.write('summary: %d\n' % (max_cost,)) 273 | 274 | def _output_entry(self, entry): 275 | out_file = self.out_file 276 | 277 | code = entry.code 278 | 279 | co_filename, co_firstlineno, co_name = cProfile.label(code) 280 | munged_name = self.munged_function_name(code) 281 | out_file.write('fl=%s\nfn=%s\n' % (co_filename, munged_name)) 282 | 283 | inlinetime = int(entry.inlinetime * self.scale.scale) 284 | out_file.write('%d %d\n' % (co_firstlineno, inlinetime)) 285 | 286 | # recursive calls are counted in entry.calls 287 | if entry.calls: 288 | for subentry in sorted(entry.calls, key=_entry_sort_key): 289 | self._output_subentry(co_firstlineno, subentry.code, 290 | subentry.callcount, 291 | int(subentry.totaltime * self.scale.scale)) 292 | 293 | out_file.write('\n') 294 | 295 | def _output_subentry(self, lineno, code, callcount, totaltime): 296 | out_file = self.out_file 297 | co_filename, co_firstlineno, co_name = cProfile.label(code) 298 | munged_name = self.munged_function_name(code) 299 | out_file.write('cfl=%s\ncfn=%s\n' % (co_filename, munged_name)) 300 | out_file.write('calls=%d %d\n' % (callcount, co_firstlineno)) 301 | out_file.write('%d %d\n' % (lineno, totaltime)) 302 | 303 | 304 | def main(): 305 | """Execute the converter using parameters provided on the command line""" 306 | 307 | parser = argparse.ArgumentParser() 308 | parser.add_argument('-o', '--outfile', metavar='output_file_path', 309 | help="Save calltree stats to ") 310 | parser.add_argument('-i', '--infile', metavar='input_file_path', 311 | help="Read Python stats from ") 312 | parser.add_argument('-k', '--kcachegrind', 313 | help="Run the kcachegrind tool on the converted data", 314 | action="store_true") 315 | parser.add_argument('-r', '--run-script', 316 | nargs=argparse.REMAINDER, 317 | metavar=('scriptfile', 'args'), 318 | dest='script', 319 | help="Name of the Python script to run to collect" 320 | " profiling data") 321 | parser.add_argument('-s', '--scale', choices=['s', 'ms', 'us', 'ns'], 322 | default='ns', 323 | help='Time scale') 324 | args = parser.parse_args() 325 | 326 | outfile = args.outfile 327 | scale = Scale(args.scale) 328 | 329 | if args.script is not None: 330 | # collect profiling data by running the given script 331 | if not args.outfile: 332 | outfile = '%s.log' % os.path.basename(args.script[0]) 333 | 334 | fd, tmp_path = tempfile.mkstemp(suffix='.prof', prefix='pyprof2calltree') 335 | os.close(fd) 336 | try: 337 | cmd = [ 338 | sys.executable, 339 | '-m', 'cProfile', 340 | '-o', tmp_path, 341 | ] 342 | cmd.extend(args.script) 343 | subprocess.check_call(cmd) 344 | 345 | kg = CalltreeConverter(tmp_path, scale) 346 | finally: 347 | os.remove(tmp_path) 348 | 349 | elif args.infile is not None: 350 | # use the profiling data from some input file 351 | if not args.outfile: 352 | outfile = '%s.log' % os.path.basename(args.infile) 353 | 354 | if args.infile == outfile: 355 | # prevent name collisions by appending another extension 356 | outfile += ".log" 357 | 358 | kg = CalltreeConverter(pstats.Stats(args.infile), scale) 359 | 360 | else: 361 | # at least an input file or a script to run is required 362 | parser.print_usage() 363 | sys.exit(2) 364 | 365 | if args.outfile is not None or not args.kcachegrind: 366 | # user either explicitly required output file or requested by not 367 | # explicitly asking to launch kcachegrind 368 | sys.stderr.write("writing converted data to: %s\n" % outfile) 369 | with open(outfile, 'w') as f: 370 | kg.output(f) 371 | 372 | if args.kcachegrind: 373 | sys.stderr.write("launching kcachegrind\n") 374 | kg.visualize() 375 | 376 | 377 | def visualize(profiling_data): 378 | """launch the kcachegrind on `profiling_data` 379 | 380 | `profiling_data` can either be: 381 | - a pstats.Stats instance 382 | - the filename of a pstats.Stats dump 383 | - the result of a call to cProfile.Profile.getstats() 384 | """ 385 | converter = CalltreeConverter(profiling_data) 386 | converter.visualize() 387 | 388 | 389 | def convert(profiling_data, outputfile): 390 | """convert `profiling_data` to calltree format and dump it to `outputfile` 391 | 392 | `profiling_data` can either be: 393 | - a pstats.Stats instance 394 | - the filename of a pstats.Stats dump 395 | - the result of a call to cProfile.Profile.getstats() 396 | 397 | `outputfile` can either be: 398 | - a file() instance open in write mode 399 | - a filename 400 | """ 401 | converter = CalltreeConverter(profiling_data) 402 | if is_basestring(outputfile): 403 | with open(outputfile, "w") as f: 404 | converter.output(f) 405 | else: 406 | converter.output(outputfile) 407 | 408 | 409 | if __name__ == '__main__': 410 | sys.exit(main()) 411 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pyprof2calltree 3 | version = 1.4.4 4 | description = Help visualize profiling data from cProfile with kcachegrind and qcachegrind 5 | long_description = file: README.rst 6 | url = https://github.com/pwaller/pyprof2calltree/ 7 | author = Olivier Grisel 8 | author_email = olivier.grisel@ensta.org 9 | maintainer = Peter Waller 10 | maintainer_email = p@pwaller.net 11 | license = MIT 12 | keywords = 13 | kcachegrind 14 | kde 15 | profiler 16 | programming 17 | qcachegrind 18 | tool 19 | visualization 20 | classifiers = 21 | Development Status :: 5 - Production/Stable 22 | Environment :: Console 23 | Environment :: X11 Applications :: KDE 24 | License :: OSI Approved :: MIT License 25 | Operating System :: POSIX 26 | Operating System :: Unix 27 | Programming Language :: Python 28 | Programming Language :: Python :: 2 29 | Programming Language :: Python :: 2.7 30 | Programming Language :: Python :: 3 31 | Programming Language :: Python :: 3.4 32 | Programming Language :: Python :: 3.5 33 | Programming Language :: Python :: 3.6 34 | Programming Language :: Python :: 3.7 35 | Programming Language :: Python :: 3.8 36 | Topic :: Desktop Environment :: K Desktop Environment (KDE) 37 | Topic :: Software Development 38 | Topic :: Software Development :: Quality Assurance 39 | Topic :: System :: System Shells 40 | Topic :: Utilities 41 | 42 | [options] 43 | python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* 44 | py_modules = 45 | pyprof2calltree 46 | zip_safe = true 47 | 48 | [options.entry_points] 49 | console_scripts = 50 | pyprof2calltree = pyprof2calltree:main 51 | 52 | [bdist_wheel] 53 | universal = 1 54 | 55 | [flake8] 56 | max-line-length = 88 57 | 58 | [isort] 59 | combine_as_imports = True 60 | force_grid_wrap = 0 61 | include_trailing_comma = True 62 | line_length = 88 63 | multi_line_output = 3 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwaller/pyprof2calltree/366af4cac28b5316326ba7f512c04e154cf942a7/test/__init__.py -------------------------------------------------------------------------------- /test/profile_code.py: -------------------------------------------------------------------------------- 1 | # We're going to use a custom timer, so we don't actually have to do anything 2 | # in these functions. 3 | 4 | 5 | def top(): 6 | mid1() 7 | mid2() 8 | mid3(5) 9 | C1.samename() 10 | C2.samename() 11 | 12 | 13 | def mid1(): 14 | bot() 15 | for i in range(5): 16 | mid2() 17 | bot() 18 | 19 | 20 | def mid2(): 21 | bot() 22 | 23 | 24 | def bot(): 25 | pass 26 | 27 | 28 | def mid3(x): 29 | if x > 0: 30 | mid4(x) 31 | 32 | 33 | def mid4(x): 34 | mid3(x - 1) 35 | 36 | 37 | class C1(object): 38 | @staticmethod 39 | def samename(): 40 | pass 41 | 42 | 43 | class C2(object): 44 | @staticmethod 45 | def samename(): 46 | pass 47 | 48 | 49 | expected_output_py2 = """event: ns : Nanoseconds 50 | events: ns 51 | summary: 59000 52 | fl= 53 | fn=top 54 | 5 6000 55 | cfl= 56 | cfn=mid1 57 | calls=1 13 58 | 5 27000 59 | cfl= 60 | cfn=mid2 61 | calls=1 20 62 | 5 3000 63 | cfl= 64 | cfn=mid3 65 | calls=1 28 66 | 5 21000 67 | cfl= 68 | cfn=samename:38 69 | calls=1 38 70 | 5 1000 71 | cfl= 72 | cfn=samename:44 73 | calls=1 44 74 | 5 1000 75 | 76 | fl= 77 | fn=mid1 78 | 13 9000 79 | cfl= 80 | cfn=mid2 81 | calls=5 20 82 | 13 15000 83 | cfl= 84 | cfn=bot 85 | calls=2 24 86 | 13 2000 87 | cfl=~ 88 | cfn= 89 | calls=1 0 90 | 13 1000 91 | 92 | fl= 93 | fn=mid2 94 | 20 12000 95 | cfl= 96 | cfn=bot 97 | calls=6 24 98 | 20 6000 99 | 100 | fl= 101 | fn=bot 102 | 24 8000 103 | 104 | fl= 105 | fn=mid3 106 | 28 11000 107 | cfl= 108 | cfn=mid4 109 | calls=5 33 110 | 28 19000 111 | 112 | fl= 113 | fn=mid4 114 | 33 10000 115 | cfl= 116 | cfn=mid3 117 | calls=5 28 118 | 33 17000 119 | 120 | fl= 121 | fn=samename:38 122 | 38 1000 123 | 124 | fl= 125 | fn=samename:44 126 | 44 1000 127 | 128 | fl=~ 129 | fn= 130 | 0 1000 131 | 132 | fl=~ 133 | fn= 134 | 0 1000 135 | 136 | """.replace('', top.__code__.co_filename) 137 | 138 | expected_output_py3 = """event: ns : Nanoseconds 139 | events: ns 140 | summary: 57000 141 | fl= 142 | fn=top 143 | 5 6000 144 | cfl= 145 | cfn=mid1 146 | calls=1 13 147 | 5 25000 148 | cfl= 149 | cfn=mid2 150 | calls=1 20 151 | 5 3000 152 | cfl= 153 | cfn=mid3 154 | calls=1 28 155 | 5 21000 156 | cfl= 157 | cfn=samename:38 158 | calls=1 38 159 | 5 1000 160 | cfl= 161 | cfn=samename:44 162 | calls=1 44 163 | 5 1000 164 | 165 | fl= 166 | fn=mid1 167 | 13 8000 168 | cfl= 169 | cfn=mid2 170 | calls=5 20 171 | 13 15000 172 | cfl= 173 | cfn=bot 174 | calls=2 24 175 | 13 2000 176 | 177 | fl= 178 | fn=mid2 179 | 20 12000 180 | cfl= 181 | cfn=bot 182 | calls=6 24 183 | 20 6000 184 | 185 | fl= 186 | fn=bot 187 | 24 8000 188 | 189 | fl= 190 | fn=mid3 191 | 28 11000 192 | cfl= 193 | cfn=mid4 194 | calls=5 33 195 | 28 19000 196 | 197 | fl= 198 | fn=mid4 199 | 33 10000 200 | cfl= 201 | cfn=mid3 202 | calls=5 28 203 | 33 17000 204 | 205 | fl= 206 | fn=samename:38 207 | 38 1000 208 | 209 | fl= 210 | fn=samename:44 211 | 44 1000 212 | 213 | fl=~ 214 | fn= 215 | 0 1000 216 | 217 | """.replace('', __file__) 218 | -------------------------------------------------------------------------------- /test/test_integration.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import pstats 3 | import sys 4 | import unittest 5 | 6 | from pyprof2calltree import CalltreeConverter 7 | 8 | from .profile_code import expected_output_py2, expected_output_py3, top 9 | 10 | try: 11 | from cStringIO import StringIO 12 | except ImportError: 13 | from io import StringIO 14 | 15 | if sys.version_info < (3, 0): 16 | expected_output = expected_output_py2 17 | else: 18 | expected_output = expected_output_py3 19 | 20 | 21 | class MockTimeProfile(cProfile.Profile): 22 | def __init__(self): 23 | self._mock_time = 0 24 | super(MockTimeProfile, self).__init__(self._timer, 1e-9) 25 | 26 | def _timer(self): 27 | now = self._mock_time 28 | self._mock_time += 1000 29 | return now 30 | 31 | 32 | class TestIntegration(unittest.TestCase): 33 | def setUp(self): 34 | self.profile = MockTimeProfile() 35 | self.profile.enable() 36 | top() 37 | self.profile.disable() 38 | 39 | def test_direct_entries(self): 40 | entries = self.profile.getstats() 41 | converter = CalltreeConverter(entries) 42 | out_file = StringIO() 43 | 44 | converter.output(out_file) 45 | self.assertEqual(out_file.getvalue(), expected_output) 46 | 47 | def test_pstats_data(self): 48 | stats = pstats.Stats(self.profile) 49 | converter = CalltreeConverter(stats) 50 | out_file = StringIO() 51 | 52 | converter.output(out_file) 53 | self.assertEqual(out_file.getvalue(), expected_output) 54 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | isort 5 | py{27,34,35,36,37,38} 6 | 7 | [testenv] 8 | commands = python -m unittest discover 9 | 10 | [testenv:flake8] 11 | deps = flake8 12 | commands = flake8 13 | skip_install = true 14 | 15 | [testenv:isort] 16 | deps = isort >= 5.0.1 17 | commands = isort --check-only --diff . 18 | skip_install = true 19 | --------------------------------------------------------------------------------