1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# based on pylintgui.py by Pierre Raybaut
5#
6# Licensed under the terms of the MIT License
7# (see spyder/__init__.py for details)
8
9"""
10Profiler widget
11
12See the official documentation on python profiling:
13http://docs.python.org/library/profile.html
14"""
15
16# Standard library imports
17from __future__ import with_statement
18import os
19import os.path as osp
20from itertools import islice
21import sys
22import time
23import re
24
25# Third party imports
26from qtpy.compat import getopenfilename, getsavefilename
27from qtpy.QtCore import (QByteArray, QProcess, QProcessEnvironment, QTextCodec,
28                         Qt, Signal)
29from qtpy.QtGui import QColor
30from qtpy.QtWidgets import (QApplication, QHBoxLayout, QLabel, QMessageBox,
31                            QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget)
32
33# Local imports
34from spyder.config.base import get_conf_path, get_translation, debug_print
35from spyder.py3compat import to_text_string
36from spyder.utils import icon_manager as ima
37from spyder.utils.qthelpers import (create_toolbutton, get_item_user_text,
38                                    set_item_user_text)
39from spyder.utils.programs import shell_split
40from spyder.widgets.comboboxes import PythonModulesComboBox
41from spyder.utils.misc import add_pathlist_to_PYTHONPATH, getcwd_or_home
42from spyder.widgets.variableexplorer.texteditor import TextEditor
43
44# This is needed for testing this module as a stand alone script
45try:
46    _ = get_translation("profiler", "spyder_profiler")
47except KeyError as error:
48    import gettext
49    _ = gettext.gettext
50
51
52locale_codec = QTextCodec.codecForLocale()
53
54
55def is_profiler_installed():
56    from spyder.utils.programs import is_module_installed
57    return is_module_installed('cProfile') and is_module_installed('pstats')
58
59
60class ProfilerWidget(QWidget):
61    """
62    Profiler widget
63    """
64    DATAPATH = get_conf_path('profiler.results')
65    VERSION = '0.0.1'
66    redirect_stdio = Signal(bool)
67
68    def __init__(self, parent, max_entries=100):
69        QWidget.__init__(self, parent)
70
71        self.setWindowTitle("Profiler")
72
73        self.output = None
74        self.error_output = None
75
76        self._last_wdir = None
77        self._last_args = None
78        self._last_pythonpath = None
79
80        self.filecombo = PythonModulesComboBox(self)
81
82        self.start_button = create_toolbutton(self, icon=ima.icon('run'),
83                                    text=_("Profile"),
84                                    tip=_("Run profiler"),
85                                    triggered=lambda : self.start(),
86                                    text_beside_icon=True)
87        self.stop_button = create_toolbutton(self,
88                                             icon=ima.icon('stop'),
89                                             text=_("Stop"),
90                                             tip=_("Stop current profiling"),
91                                             text_beside_icon=True)
92        self.filecombo.valid.connect(self.start_button.setEnabled)
93        #self.connect(self.filecombo, SIGNAL('valid(bool)'), self.show_data)
94        # FIXME: The combobox emits this signal on almost any event
95        #        triggering show_data() too early, too often.
96
97        browse_button = create_toolbutton(self, icon=ima.icon('fileopen'),
98                                          tip=_('Select Python script'),
99                                          triggered=self.select_file)
100
101        self.datelabel = QLabel()
102
103        self.log_button = create_toolbutton(self, icon=ima.icon('log'),
104                                            text=_("Output"),
105                                            text_beside_icon=True,
106                                            tip=_("Show program's output"),
107                                            triggered=self.show_log)
108
109        self.datatree = ProfilerDataTree(self)
110
111        self.collapse_button = create_toolbutton(self,
112                                                 icon=ima.icon('collapse'),
113                                                 triggered=lambda dD:
114                                                 self.datatree.change_view(-1),
115                                                 tip=_('Collapse one level up'))
116        self.expand_button = create_toolbutton(self,
117                                               icon=ima.icon('expand'),
118                                               triggered=lambda dD:
119                                               self.datatree.change_view(1),
120                                               tip=_('Expand one level down'))
121
122        self.save_button = create_toolbutton(self, text_beside_icon=True,
123                                             text=_("Save data"),
124                                             icon=ima.icon('filesave'),
125                                             triggered=self.save_data,
126                                             tip=_('Save profiling data'))
127        self.load_button = create_toolbutton(self, text_beside_icon=True,
128                            text=_("Load data"),
129                            icon=ima.icon('fileimport'),
130                            triggered=self.compare,
131                            tip=_('Load profiling data for comparison'))
132        self.clear_button = create_toolbutton(self, text_beside_icon=True,
133                                              text=_("Clear comparison"),
134                                              icon=ima.icon('editdelete'),
135                                              triggered=self.clear)
136
137        hlayout1 = QHBoxLayout()
138        hlayout1.addWidget(self.filecombo)
139        hlayout1.addWidget(browse_button)
140        hlayout1.addWidget(self.start_button)
141        hlayout1.addWidget(self.stop_button)
142
143        hlayout2 = QHBoxLayout()
144        hlayout2.addWidget(self.collapse_button)
145        hlayout2.addWidget(self.expand_button)
146        hlayout2.addStretch()
147        hlayout2.addWidget(self.datelabel)
148        hlayout2.addStretch()
149        hlayout2.addWidget(self.log_button)
150        hlayout2.addWidget(self.save_button)
151        hlayout2.addWidget(self.load_button)
152        hlayout2.addWidget(self.clear_button)
153
154        layout = QVBoxLayout()
155        layout.addLayout(hlayout1)
156        layout.addLayout(hlayout2)
157        layout.addWidget(self.datatree)
158        self.setLayout(layout)
159
160        self.process = None
161        self.set_running_state(False)
162        self.start_button.setEnabled(False)
163        self.clear_button.setEnabled(False)
164
165        if not is_profiler_installed():
166            # This should happen only on certain GNU/Linux distributions
167            # or when this a home-made Python build because the Python
168            # profilers are included in the Python standard library
169            for widget in (self.datatree, self.filecombo,
170                           self.start_button, self.stop_button):
171                widget.setDisabled(True)
172            url = 'http://docs.python.org/library/profile.html'
173            text = '%s <a href=%s>%s</a>' % (_('Please install'), url,
174                                             _("the Python profiler modules"))
175            self.datelabel.setText(text)
176        else:
177            pass # self.show_data()
178
179    def save_data(self):
180        """Save data"""
181        title = _( "Save profiler result")
182        filename, _selfilter = getsavefilename(
183                self, title, getcwd_or_home(),
184                _("Profiler result")+" (*.Result)")
185        if filename:
186            self.datatree.save_data(filename)
187
188    def compare(self):
189        filename, _selfilter = getopenfilename(
190                self, _("Select script to compare"),
191                getcwd_or_home(), _("Profiler result")+" (*.Result)")
192        if filename:
193            self.datatree.compare(filename)
194            self.show_data()
195            self.clear_button.setEnabled(True)
196
197    def clear(self):
198        self.datatree.compare(None)
199        self.datatree.hide_diff_cols(True)
200        self.show_data()
201        self.clear_button.setEnabled(False)
202
203    def analyze(self, filename, wdir=None, args=None, pythonpath=None):
204        if not is_profiler_installed():
205            return
206        self.kill_if_running()
207        #index, _data = self.get_data(filename)
208        index = None # FIXME: storing data is not implemented yet
209        if index is None:
210            self.filecombo.addItem(filename)
211            self.filecombo.setCurrentIndex(self.filecombo.count()-1)
212        else:
213            self.filecombo.setCurrentIndex(self.filecombo.findText(filename))
214        self.filecombo.selected()
215        if self.filecombo.is_valid():
216            if wdir is None:
217                wdir = osp.dirname(filename)
218            self.start(wdir, args, pythonpath)
219
220    def select_file(self):
221        self.redirect_stdio.emit(False)
222        filename, _selfilter = getopenfilename(
223                self, _("Select Python script"),
224                getcwd_or_home(), _("Python scripts")+" (*.py ; *.pyw)")
225        self.redirect_stdio.emit(True)
226        if filename:
227            self.analyze(filename)
228
229    def show_log(self):
230        if self.output:
231            TextEditor(self.output, title=_("Profiler output"),
232                       readonly=True, size=(700, 500)).exec_()
233
234    def show_errorlog(self):
235        if self.error_output:
236            TextEditor(self.error_output, title=_("Profiler output"),
237                       readonly=True, size=(700, 500)).exec_()
238
239    def start(self, wdir=None, args=None, pythonpath=None):
240        filename = to_text_string(self.filecombo.currentText())
241        if wdir is None:
242            wdir = self._last_wdir
243            if wdir is None:
244                wdir = osp.basename(filename)
245        if args is None:
246            args = self._last_args
247            if args is None:
248                args = []
249        if pythonpath is None:
250            pythonpath = self._last_pythonpath
251        self._last_wdir = wdir
252        self._last_args = args
253        self._last_pythonpath = pythonpath
254
255        self.datelabel.setText(_('Profiling, please wait...'))
256
257        self.process = QProcess(self)
258        self.process.setProcessChannelMode(QProcess.SeparateChannels)
259        self.process.setWorkingDirectory(wdir)
260        self.process.readyReadStandardOutput.connect(self.read_output)
261        self.process.readyReadStandardError.connect(
262                                          lambda: self.read_output(error=True))
263        self.process.finished.connect(lambda ec, es=QProcess.ExitStatus:
264                                      self.finished(ec, es))
265        self.stop_button.clicked.connect(self.kill)
266
267        if pythonpath is not None:
268            env = [to_text_string(_pth)
269                   for _pth in self.process.systemEnvironment()]
270            add_pathlist_to_PYTHONPATH(env, pythonpath)
271            processEnvironment = QProcessEnvironment()
272            for envItem in env:
273                envName, separator, envValue = envItem.partition('=')
274                processEnvironment.insert(envName, envValue)
275            self.process.setProcessEnvironment(processEnvironment)
276
277        self.output = ''
278        self.error_output = ''
279        self.stopped = False
280
281        p_args = ['-m', 'cProfile', '-o', self.DATAPATH]
282        if os.name == 'nt':
283            # On Windows, one has to replace backslashes by slashes to avoid
284            # confusion with escape characters (otherwise, for example, '\t'
285            # will be interpreted as a tabulation):
286            p_args.append(osp.normpath(filename).replace(os.sep, '/'))
287        else:
288            p_args.append(filename)
289        if args:
290            p_args.extend(shell_split(args))
291        executable = sys.executable
292        if executable.endswith("spyder.exe"):
293            # py2exe distribution
294            executable = "python.exe"
295        self.process.start(executable, p_args)
296
297        running = self.process.waitForStarted()
298        self.set_running_state(running)
299        if not running:
300            QMessageBox.critical(self, _("Error"),
301                                 _("Process failed to start"))
302
303    def kill(self):
304        """Stop button pressed."""
305        self.process.kill()
306        self.stopped = True
307
308    def set_running_state(self, state=True):
309        self.start_button.setEnabled(not state)
310        self.stop_button.setEnabled(state)
311
312    def read_output(self, error=False):
313        if error:
314            self.process.setReadChannel(QProcess.StandardError)
315        else:
316            self.process.setReadChannel(QProcess.StandardOutput)
317        qba = QByteArray()
318        while self.process.bytesAvailable():
319            if error:
320                qba += self.process.readAllStandardError()
321            else:
322                qba += self.process.readAllStandardOutput()
323        text = to_text_string( locale_codec.toUnicode(qba.data()) )
324        if error:
325            self.error_output += text
326        else:
327            self.output += text
328
329    def finished(self, exit_code, exit_status):
330        self.set_running_state(False)
331        self.show_errorlog()  # If errors occurred, show them.
332        self.output = self.error_output + self.output
333        # FIXME: figure out if show_data should be called here or
334        #        as a signal from the combobox
335        self.show_data(justanalyzed=True)
336
337    def kill_if_running(self):
338        if self.process is not None:
339            if self.process.state() == QProcess.Running:
340                self.process.kill()
341                self.process.waitForFinished()
342
343    def show_data(self, justanalyzed=False):
344        if not justanalyzed:
345            self.output = None
346        self.log_button.setEnabled(self.output is not None \
347                                   and len(self.output) > 0)
348        self.kill_if_running()
349        filename = to_text_string(self.filecombo.currentText())
350        if not filename:
351            return
352
353        if self.stopped:
354            self.datelabel.setText(_('Run stopped by user.'))
355            self.datatree.initialize_view()
356            return
357
358        self.datelabel.setText(_('Sorting data, please wait...'))
359        QApplication.processEvents()
360
361        self.datatree.load_data(self.DATAPATH)
362        self.datatree.show_tree()
363
364        text_style = "<span style=\'color: #444444\'><b>%s </b></span>"
365        date_text = text_style % time.strftime("%d %b %Y %H:%M",
366                                               time.localtime())
367        self.datelabel.setText(date_text)
368
369def gettime_s(text):
370    """Parse text and returns a time in seconds
371
372    The text is of the format 0h : 0.min:0.0s:0 ms:0us:0 ns.
373    Spaces are not taken into account and any of the specifiers can be ignored"""
374    pattern = r'([+-]?\d+\.?\d*) ?([munsecinh]+)'
375    matches = re.findall(pattern, text)
376    if len(matches) == 0:
377        return None
378    time = 0.
379    for res in matches:
380        tmp = float(res[0])
381        if res[1] == 'ns':
382            tmp *= 1e-9
383        elif res[1] == 'us':
384            tmp *= 1e-6
385        elif res[1] == 'ms':
386            tmp *= 1e-3
387        elif res[1] == 'min':
388            tmp *= 60
389        elif res[1] == 'h':
390            tmp *= 3600
391        time += tmp
392    return time
393
394class TreeWidgetItem( QTreeWidgetItem ):
395    def __init__(self, parent=None):
396        QTreeWidgetItem.__init__(self, parent)
397
398    def __lt__(self, otherItem):
399        column = self.treeWidget().sortColumn()
400        try:
401            if column == 1 or column == 3: #TODO: Hardcoded Column
402                t0 = gettime_s(self.text(column))
403                t1 = gettime_s(otherItem.text(column))
404                if t0 is not None and t1 is not None:
405                    return t0 > t1
406
407            return float( self.text(column) ) > float( otherItem.text(column) )
408        except ValueError:
409            return self.text(column) > otherItem.text(column)
410
411class ProfilerDataTree(QTreeWidget):
412    """
413    Convenience tree widget (with built-in model)
414    to store and view profiler data.
415
416    The quantities calculated by the profiler are as follows
417    (from profile.Profile):
418    [0] = The number of times this function was called, not counting direct
419          or indirect recursion,
420    [1] = Number of times this function appears on the stack, minus one
421    [2] = Total time spent internal to this function
422    [3] = Cumulative time that this function was present on the stack.  In
423          non-recursive functions, this is the total execution time from start
424          to finish of each invocation of a function, including time spent in
425          all subfunctions.
426    [4] = A dictionary indicating for each function name, the number of times
427          it was called by us.
428    """
429    SEP = r"<[=]>"  # separator between filename and linenumber
430    # (must be improbable as a filename to avoid splitting the filename itself)
431    def __init__(self, parent=None):
432        QTreeWidget.__init__(self, parent)
433        self.header_list = [_('Function/Module'), _('Total Time'), _('Diff'),
434                            _('Local Time'), _('Diff'), _('Calls'), _('Diff'),
435                            _('File:line')]
436        self.icon_list = {'module': ima.icon('python'),
437                         'function': ima.icon('function'),
438                         'builtin': ima.icon('python_t'),
439                         'constructor': ima.icon('class')}
440        self.profdata = None   # To be filled by self.load_data()
441        self.stats = None      # To be filled by self.load_data()
442        self.item_depth = None
443        self.item_list = None
444        self.items_to_be_shown = None
445        self.current_view_depth = None
446        self.compare_file = None
447        self.setColumnCount(len(self.header_list))
448        self.setHeaderLabels(self.header_list)
449        self.initialize_view()
450        self.itemActivated.connect(self.item_activated)
451        self.itemExpanded.connect(self.item_expanded)
452
453    def set_item_data(self, item, filename, line_number):
454        """Set tree item user data: filename (string) and line_number (int)"""
455        set_item_user_text(item, '%s%s%d' % (filename, self.SEP, line_number))
456
457    def get_item_data(self, item):
458        """Get tree item user data: (filename, line_number)"""
459        filename, line_number_str = get_item_user_text(item).split(self.SEP)
460        return filename, int(line_number_str)
461
462    def initialize_view(self):
463        """Clean the tree and view parameters"""
464        self.clear()
465        self.item_depth = 0   # To be use for collapsing/expanding one level
466        self.item_list = []  # To be use for collapsing/expanding one level
467        self.items_to_be_shown = {}
468        self.current_view_depth = 0
469
470    def load_data(self, profdatafile):
471        """Load profiler data saved by profile/cProfile module"""
472        import pstats
473        try:
474            stats_indi = [pstats.Stats(profdatafile), ]
475        except (OSError, IOError):
476            return
477        self.profdata = stats_indi[0]
478
479        if self.compare_file is not None:
480            try:
481                stats_indi.append(pstats.Stats(self.compare_file))
482            except (OSError, IOError) as e:
483                QMessageBox.critical(
484                    self, _("Error"),
485                    _("Error when trying to load profiler results"))
486                debug_print("Error when calling pstats, {}".format(e))
487                self.compare_file = None
488        map(lambda x: x.calc_callees(), stats_indi)
489        self.profdata.calc_callees()
490        self.stats1 = stats_indi
491        self.stats = stats_indi[0].stats
492
493    def compare(self,filename):
494        self.hide_diff_cols(False)
495        self.compare_file = filename
496
497    def hide_diff_cols(self, hide):
498        for i in (2,4,6):
499            self.setColumnHidden(i, hide)
500
501    def save_data(self, filename):
502        """"""
503        self.stats1[0].dump_stats(filename)
504
505    def find_root(self):
506        """Find a function without a caller"""
507        self.profdata.sort_stats("cumulative")
508        for func in self.profdata.fcn_list:
509            if ('~', 0) != func[0:2] and not func[2].startswith(
510                    '<built-in method exec>'):
511                # This skips the profiler function at the top of the list
512                # it does only occur in Python 3
513                return func
514
515    def find_callees(self, parent):
516        """Find all functions called by (parent) function."""
517        # FIXME: This implementation is very inneficient, because it
518        #        traverses all the data to find children nodes (callees)
519        return self.profdata.all_callees[parent]
520
521    def show_tree(self):
522        """Populate the tree with profiler data and display it."""
523        self.initialize_view() # Clear before re-populating
524        self.setItemsExpandable(True)
525        self.setSortingEnabled(False)
526        rootkey = self.find_root()  # This root contains profiler overhead
527        if rootkey:
528            self.populate_tree(self, self.find_callees(rootkey))
529            self.resizeColumnToContents(0)
530            self.setSortingEnabled(True)
531            self.sortItems(1, Qt.AscendingOrder) # FIXME: hardcoded index
532            self.change_view(1)
533
534    def function_info(self, functionKey):
535        """Returns processed information about the function's name and file."""
536        node_type = 'function'
537        filename, line_number, function_name = functionKey
538        if function_name == '<module>':
539            modulePath, moduleName = osp.split(filename)
540            node_type = 'module'
541            if moduleName == '__init__.py':
542                modulePath, moduleName = osp.split(modulePath)
543            function_name = '<' + moduleName + '>'
544        if not filename or filename == '~':
545            file_and_line = '(built-in)'
546            node_type = 'builtin'
547        else:
548            if function_name == '__init__':
549                node_type = 'constructor'
550            file_and_line = '%s : %d' % (filename, line_number)
551        return filename, line_number, function_name, file_and_line, node_type
552
553    @staticmethod
554    def format_measure(measure):
555        """Get format and units for data coming from profiler task."""
556        # Convert to a positive value.
557        measure = abs(measure)
558
559        # For number of calls
560        if isinstance(measure, int):
561            return to_text_string(measure)
562
563        # For time measurements
564        if 1.e-9 < measure <= 1.e-6:
565            measure = u"{0:.2f} ns".format(measure / 1.e-9)
566        elif 1.e-6 < measure <= 1.e-3:
567            measure = u"{0:.2f} us".format(measure / 1.e-6)
568        elif 1.e-3 < measure <= 1:
569            measure = u"{0:.2f} ms".format(measure / 1.e-3)
570        elif 1 < measure <= 60:
571            measure = u"{0:.2f} sec".format(measure)
572        elif 60 < measure <= 3600:
573            m, s = divmod(measure, 3600)
574            if s > 60:
575                m, s = divmod(measure, 60)
576                s = to_text_string(s).split(".")[-1]
577            measure = u"{0:.0f}.{1:.2s} min".format(m, s)
578        else:
579            h, m = divmod(measure, 3600)
580            if m > 60:
581                m /= 60
582            measure = u"{0:.0f}h:{1:.0f}min".format(h, m)
583        return measure
584
585    def color_string(self, x):
586        """Return a string formatted delta for the values in x.
587
588        Args:
589            x: 2-item list of integers (representing number of calls) or
590               2-item list of floats (representing seconds of runtime).
591
592        Returns:
593            A list with [formatted x[0], [color, formatted delta]], where
594            color reflects whether x[1] is lower, greater, or the same as
595            x[0].
596        """
597        diff_str = ""
598        color = "black"
599
600        if len(x) == 2 and self.compare_file is not None:
601            difference = x[0] - x[1]
602            if difference:
603                color, sign = ('green', '-') if difference < 0 else ('red', '+')
604                diff_str = '{}{}'.format(sign, self.format_measure(difference))
605        return [self.format_measure(x[0]), [diff_str, color]]
606
607    def format_output(self, child_key):
608        """ Formats the data.
609
610        self.stats1 contains a list of one or two pstat.Stats() instances, with
611        the first being the current run and the second, the saved run, if it
612        exists.  Each Stats instance is a dictionary mapping a function to
613        5 data points - cumulative calls, number of calls, total time,
614        cumulative time, and callers.
615
616        format_output() converts the number of calls, total time, and
617        cumulative time to a string format for the child_key parameter.
618        """
619        data = [x.stats.get(child_key, [0, 0, 0, 0, {}]) for x in self.stats1]
620        return (map(self.color_string, islice(zip(*data), 1, 4)))
621
622    def populate_tree(self, parentItem, children_list):
623        """Recursive method to create each item (and associated data) in the tree."""
624        for child_key in children_list:
625            self.item_depth += 1
626            (filename, line_number, function_name, file_and_line, node_type
627             ) = self.function_info(child_key)
628
629            ((total_calls, total_calls_dif), (loc_time, loc_time_dif), (cum_time,
630             cum_time_dif)) = self.format_output(child_key)
631
632            child_item = TreeWidgetItem(parentItem)
633            self.item_list.append(child_item)
634            self.set_item_data(child_item, filename, line_number)
635
636            # FIXME: indexes to data should be defined by a dictionary on init
637            child_item.setToolTip(0, _('Function or module name'))
638            child_item.setData(0, Qt.DisplayRole, function_name)
639            child_item.setIcon(0, self.icon_list[node_type])
640
641            child_item.setToolTip(1, _('Time in function '\
642                                       '(including sub-functions)'))
643            child_item.setData(1, Qt.DisplayRole, cum_time)
644            child_item.setTextAlignment(1, Qt.AlignRight)
645
646            child_item.setData(2, Qt.DisplayRole, cum_time_dif[0])
647            child_item.setForeground(2, QColor(cum_time_dif[1]))
648            child_item.setTextAlignment(2, Qt.AlignLeft)
649
650            child_item.setToolTip(3, _('Local time in function '\
651                                      '(not in sub-functions)'))
652
653            child_item.setData(3, Qt.DisplayRole, loc_time)
654            child_item.setTextAlignment(3, Qt.AlignRight)
655
656            child_item.setData(4, Qt.DisplayRole, loc_time_dif[0])
657            child_item.setForeground(4, QColor(loc_time_dif[1]))
658            child_item.setTextAlignment(4, Qt.AlignLeft)
659
660            child_item.setToolTip(5, _('Total number of calls '\
661                                       '(including recursion)'))
662
663            child_item.setData(5, Qt.DisplayRole, total_calls)
664            child_item.setTextAlignment(5, Qt.AlignRight)
665
666            child_item.setData(6, Qt.DisplayRole, total_calls_dif[0])
667            child_item.setForeground(6, QColor(total_calls_dif[1]))
668            child_item.setTextAlignment(6, Qt.AlignLeft)
669
670            child_item.setToolTip(7, _('File:line '\
671                                       'where function is defined'))
672            child_item.setData(7, Qt.DisplayRole, file_and_line)
673            #child_item.setExpanded(True)
674            if self.is_recursive(child_item):
675                child_item.setData(7, Qt.DisplayRole, '(%s)' % _('recursion'))
676                child_item.setDisabled(True)
677            else:
678                callees = self.find_callees(child_key)
679                if self.item_depth < 3:
680                    self.populate_tree(child_item, callees)
681                elif callees:
682                    child_item.setChildIndicatorPolicy(child_item.ShowIndicator)
683                    self.items_to_be_shown[id(child_item)] = callees
684            self.item_depth -= 1
685
686    def item_activated(self, item):
687        filename, line_number = self.get_item_data(item)
688        self.parent().edit_goto.emit(filename, line_number, '')
689
690    def item_expanded(self, item):
691        if item.childCount() == 0 and id(item) in self.items_to_be_shown:
692            callees = self.items_to_be_shown[id(item)]
693            self.populate_tree(item, callees)
694
695    def is_recursive(self, child_item):
696        """Returns True is a function is a descendant of itself."""
697        ancestor = child_item.parent()
698        # FIXME: indexes to data should be defined by a dictionary on init
699        while ancestor:
700            if (child_item.data(0, Qt.DisplayRole
701                                ) == ancestor.data(0, Qt.DisplayRole) and
702                child_item.data(7, Qt.DisplayRole
703                                ) == ancestor.data(7, Qt.DisplayRole)):
704                return True
705            else:
706                ancestor = ancestor.parent()
707        return False
708
709    def get_top_level_items(self):
710        """Iterate over top level items"""
711        return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())]
712
713    def get_items(self, maxlevel):
714        """Return all items with a level <= `maxlevel`"""
715        itemlist = []
716        def add_to_itemlist(item, maxlevel, level=1):
717            level += 1
718            for index in range(item.childCount()):
719                citem = item.child(index)
720                itemlist.append(citem)
721                if level <= maxlevel:
722                    add_to_itemlist(citem, maxlevel, level)
723        for tlitem in self.get_top_level_items():
724            itemlist.append(tlitem)
725            if maxlevel > 0:
726                add_to_itemlist(tlitem, maxlevel=maxlevel)
727        return itemlist
728
729    def change_view(self, change_in_depth):
730        """Change the view depth by expand or collapsing all same-level nodes"""
731        self.current_view_depth += change_in_depth
732        if self.current_view_depth < 0:
733            self.current_view_depth = 0
734        self.collapseAll()
735        if self.current_view_depth > 0:
736            for item in self.get_items(maxlevel=self.current_view_depth-1):
737                item.setExpanded(True)
738
739
740#==============================================================================
741# Tests
742#==============================================================================
743def primes(n):
744    """
745    Simple test function
746    Taken from http://www.huyng.com/posts/python-performance-analysis/
747    """
748    if n==2:
749        return [2]
750    elif n<2:
751        return []
752    s=list(range(3,n+1,2))
753    mroot = n ** 0.5
754    half=(n+1)//2-1
755    i=0
756    m=3
757    while m <= mroot:
758        if s[i]:
759            j=(m*m-3)//2
760            s[j]=0
761            while j<half:
762                s[j]=0
763                j+=m
764        i=i+1
765        m=2*i+3
766    return [2]+[x for x in s if x]
767
768
769def test():
770    """Run widget test"""
771    import inspect
772    import tempfile
773    from spyder.utils.qthelpers import qapplication
774
775    primes_sc = inspect.getsource(primes)
776    fd, script = tempfile.mkstemp(suffix='.py')
777    with os.fdopen(fd, 'w') as f:
778        f.write("# -*- coding: utf-8 -*-" + "\n\n")
779        f.write(primes_sc + "\n\n")
780        f.write("primes(100000)")
781
782    app = qapplication(test_time=5)
783    widget = ProfilerWidget(None)
784    widget.resize(800, 600)
785    widget.show()
786    widget.analyze(script)
787    sys.exit(app.exec_())
788
789
790if __name__ == '__main__':
791    test()
792