|
| 1 | +# This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | +# License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | +# file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| 4 | + |
| 5 | +from __future__ import absolute_import, print_function, unicode_literals |
| 6 | + |
| 7 | +import pytest |
| 8 | + |
| 9 | +import cProfile |
| 10 | +import cgi |
| 11 | + |
| 12 | +import gprof2dot |
| 13 | +import os |
| 14 | +import pstats |
| 15 | +import sys |
| 16 | + |
| 17 | +try: |
| 18 | + import pygraphviz |
| 19 | +except ImportError: |
| 20 | + pygraphviz = None |
| 21 | + |
| 22 | +try: |
| 23 | + from StringIO import StringIO |
| 24 | +except ImportError: |
| 25 | + from io import StringIO |
| 26 | + |
| 27 | +from xml.sax import saxutils |
| 28 | + |
| 29 | +import pytest_html_profiling.plugin as plugin |
| 30 | +from .plugin import HTMLReport |
| 31 | + |
| 32 | + |
| 33 | +def pytest_addhooks(pluginmanager): |
| 34 | + plugin.pytest_addhooks(pluginmanager) |
| 35 | + |
| 36 | + |
| 37 | +def pytest_addoption(parser): |
| 38 | + plugin.pytest_addoption(parser) |
| 39 | + |
| 40 | + group = parser.getgroup('terminal reporting') |
| 41 | + group.addoption("--html-profiling", action="store_true", default=False, |
| 42 | + help="Adds per-test profiling out put to the report HTML file.") |
| 43 | + |
| 44 | + if pygraphviz: |
| 45 | + group.addoption("--html-call-graph", action="store_true", default=False, |
| 46 | + help="Adds call graph visualizations based on the profiling to the " |
| 47 | + "HTML file for each test.") |
| 48 | + |
| 49 | + |
| 50 | +def pytest_configure(config): |
| 51 | + profiling = config.getoption('html_profiling') |
| 52 | + if profiling: |
| 53 | + plugin.HTMLReport = ProfilingHTMLReport |
| 54 | + |
| 55 | + plugin.pytest_configure(config) |
| 56 | + |
| 57 | + |
| 58 | +class ProfilingHTMLReport(HTMLReport): |
| 59 | + PROFILE_DIRNAME = 'results_profiles' |
| 60 | + STATS_FILENAME = 'test.cprof' |
| 61 | + DOT_SUFFIX = '.dot' |
| 62 | + GRAPH_SUFFIX = '.png' |
| 63 | + |
| 64 | + CUMULATIVE = 'cumulative' |
| 65 | + INTERNAL = 'time' |
| 66 | + |
| 67 | + PROFILE_HEADER = {CUMULATIVE: '--- PROFILE (SORTED BY CUMULATIVE TIME)---\n', |
| 68 | + INTERNAL: '--- PROFILE (SORTED BY INTERNAL TIME)---\n'} |
| 69 | + PROFILE_FOOTER = '--- END PROFILE ---' |
| 70 | + PROFILE_LINK = {CUMULATIVE: 'Profiling report (cumulative time)', |
| 71 | + INTERNAL: 'Profiling report (internal time)'} |
| 72 | + |
| 73 | + PRUNED_CUMULATIVE = 'pruned_cumulative' |
| 74 | + PRUNED_INTERNAL = 'pruned_internal' |
| 75 | + NON_PRUNED = 'non_pruned' |
| 76 | + |
| 77 | + CALLGRAPH_NAME = {PRUNED_CUMULATIVE: 'call_graph_pruned_cumulative', |
| 78 | + PRUNED_INTERNAL: 'call_graph_pruned_internal', |
| 79 | + NON_PRUNED: 'call_graph_non_pruned'} |
| 80 | + CALLGRAPH_TITLE = {PRUNED_CUMULATIVE: 'Call-graph (pruned, colored by cumulative time)', |
| 81 | + PRUNED_INTERNAL: 'Call-graph (pruned, colored by internal time)', |
| 82 | + NON_PRUNED: 'Call-graph (not pruned, colored by cumulative time)'} |
| 83 | + |
| 84 | + LINK_TEMPLATE = """ |
| 85 | +</pre> |
| 86 | +<a class ="popup_link" onfocus="this.blur();" href="javascript:showTestDetail('{0}')">{1}</a> |
| 87 | +<p> |
| 88 | +<div id='{0}' class="popup_window" style="background-color: #D9D9D9; margin-top: 10; margin-bottom: 10"> |
| 89 | + <div style='text-align: right; color:black;cursor:pointer'> |
| 90 | + <a onfocus='this.blur();' onclick="document.getElementById('{0}').style.display = 'none' " > |
| 91 | + [x]</a> |
| 92 | + </div> |
| 93 | + <pre>{2}</pre> |
| 94 | +</div> |
| 95 | +</p> |
| 96 | +<pre>""" # divId, linkText, content |
| 97 | + |
| 98 | + IMG_TEMPLATE = """ |
| 99 | +<img src="{0}"> |
| 100 | +""" # graph_filename |
| 101 | + |
| 102 | + TEMPERATURE_COLORMAP = gprof2dot.Theme( |
| 103 | + mincolor=(2.0 / 3.0, 0.80, 0.25), # dark blue |
| 104 | + maxcolor=(0.0, 1.0, 0.5), # satured red |
| 105 | + gamma=1.0, |
| 106 | + fontname='vera' |
| 107 | + ) |
| 108 | + |
| 109 | + def __init__(self, logfile, config): |
| 110 | + super(ProfilingHTMLReport, self).__init__(logfile, config) |
| 111 | + self._call_graph = config.getoption('html_call_graph') |
| 112 | + |
| 113 | + # |
| 114 | + # Temporarily copied from nose-html-profiler |
| 115 | + # |
| 116 | + def prepareTestCase(self, test): |
| 117 | + """Wrap test case run in :func:`prof.runcall`. |
| 118 | + """ |
| 119 | + test_profile_filename = self._get_test_profile_filename(test) |
| 120 | + test_profile_dir = os.path.dirname(test_profile_filename) |
| 121 | + |
| 122 | + if not os.path.exists(test_profile_dir): |
| 123 | + os.makedirs(test_profile_dir) |
| 124 | + |
| 125 | + def run_and_profile(result, test=test): |
| 126 | + cProfile.runctx("test.test(result)", globals(), locals(), |
| 127 | + filename=test_profile_filename, sort=1) |
| 128 | + |
| 129 | + return run_and_profile |
| 130 | + |
| 131 | + def _get_test_profile_dir(self, test): |
| 132 | + return os.path.join(self._profile_dir, self.startTime.strftime("%Y_%m_%d_%H_%M_%S"), |
| 133 | + test.id()) |
| 134 | + |
| 135 | + def _get_test_profile_filename(self, test): |
| 136 | + return os.path.join(self._get_test_profile_dir(test), self.STATS_FILENAME) |
| 137 | + |
| 138 | + def _get_test_dot_filename(self, test, prune): |
| 139 | + return os.path.join(self._get_test_profile_dir(test), |
| 140 | + self.CALLGRAPH_NAME[prune] + self.DOT_SUFFIX) |
| 141 | + |
| 142 | + def _get_test_graph_filename(self, test, prune): |
| 143 | + return os.path.join(self._get_test_profile_dir(test), |
| 144 | + self.CALLGRAPH_NAME[prune] + self.GRAPH_SUFFIX) |
| 145 | + |
| 146 | + def _generate_report_test(self, rows, cid, tid, n, t, o, e): |
| 147 | + o = saxutils.escape(o) |
| 148 | + |
| 149 | + o += self._get_profile_report_html(t, self.CUMULATIVE) |
| 150 | + o += self._get_profile_report_html(t, self.INTERNAL) |
| 151 | + |
| 152 | + if self._call_graph: |
| 153 | + o += self._get_callgraph_report_html(t, self.PRUNED_CUMULATIVE) |
| 154 | + o += self._get_callgraph_report_html(t, self.PRUNED_INTERNAL) |
| 155 | + o += self._get_callgraph_report_html(t, self.NON_PRUNED) |
| 156 | + |
| 157 | + super(ProfilingHTMLReport, self)._generate_report_test(rows, cid, tid, n, t, o, e) |
| 158 | + |
| 159 | + def _get_profile_report_html(self, test, type): |
| 160 | + report = self._get_profile_report(test, type) |
| 161 | + return self._link_to_report_html(test, type, self.PROFILE_LINK[type], report) |
| 162 | + |
| 163 | + def _link_to_report_html(self, test, label, title, report): |
| 164 | + return self.LINK_TEMPLATE.format(test.id() + '.' + label, title, report) |
| 165 | + |
| 166 | + def _get_profile_report(self, test, type): |
| 167 | + report = capture(self._print_profile_report, test, type) |
| 168 | + report = cgi.escape(report) |
| 169 | + return report |
| 170 | + |
| 171 | + def _print_profile_report(self, test, type): |
| 172 | + stats = pstats.Stats(self._get_test_profile_filename(test)) |
| 173 | + |
| 174 | + if stats: |
| 175 | + print(self.PROFILE_HEADER[type]) |
| 176 | + stats.sort_stats(type) |
| 177 | + stats.print_stats() |
| 178 | + print(self.PROFILE_FOOTER) |
| 179 | + |
| 180 | + def _get_callgraph_report_html(self, test, prune): |
| 181 | + report = self._get_callgraph_report(test, prune) |
| 182 | + return self._link_to_report_html(test, self.CALLGRAPH_NAME[prune], |
| 183 | + self.CALLGRAPH_TITLE[prune], report) |
| 184 | + |
| 185 | + def _get_callgraph_report(self, test, prune): |
| 186 | + self._write_dot_graph(test, prune) |
| 187 | + self._render_graph(test, prune) |
| 188 | + rel_graph_filename = os.path.relpath(self._get_test_graph_filename(test, prune), |
| 189 | + os.path.dirname(self.html_file)) |
| 190 | + return self.IMG_TEMPLATE.format(rel_graph_filename) |
| 191 | + |
| 192 | + def _write_dot_graph(self, test, prune=False): |
| 193 | + parser = gprof2dot.PstatsParser(self._get_test_profile_filename(test)) |
| 194 | + profile = parser.parse() |
| 195 | + |
| 196 | + funcId = self._find_func_id_for_test_case(profile, test) |
| 197 | + if funcId: |
| 198 | + profile.prune_root(funcId) |
| 199 | + |
| 200 | + if prune == self.PRUNED_CUMULATIVE: |
| 201 | + profile.prune(0.005, 0.001, False) |
| 202 | + elif prune == self.PRUNED_INTERNAL: |
| 203 | + profile.prune(0.005, 0.001, True) |
| 204 | + else: |
| 205 | + profile.prune(0, 0, False) |
| 206 | + |
| 207 | + with open(self._get_test_dot_filename(test, prune), 'wt') as f: |
| 208 | + dot = gprof2dot.DotWriter(f) |
| 209 | + dot.graph(profile, self.TEMPERATURE_COLORMAP) |
| 210 | + |
| 211 | + def _find_func_id_for_test_case(self, profile, test): |
| 212 | + testName = test.id().split('.')[-1] |
| 213 | + funcIds = [func.id for func in profile.functions.values() if func.name.endswith(testName)] |
| 214 | + |
| 215 | + if len(funcIds) == 1: |
| 216 | + return funcIds[0] |
| 217 | + |
| 218 | + def _render_graph(self, test, prune): |
| 219 | + graph = pygraphviz.AGraph(self._get_test_dot_filename(test, prune)) |
| 220 | + graph.layout('dot') |
| 221 | + graph.draw(self._get_test_graph_filename(test, prune)) |
| 222 | + |
| 223 | + def finalize(self, result): |
| 224 | + if not self.available(): |
| 225 | + return |
| 226 | + |
| 227 | + |
| 228 | +def capture(func, *args, **kwArgs): |
| 229 | + out = StringIO() |
| 230 | + old_stdout = sys.stdout |
| 231 | + sys.stdout = out |
| 232 | + func(*args, **kwArgs) |
| 233 | + sys.stdout = old_stdout |
| 234 | + return out.getvalue() |
0 commit comments