Skip to content

Commit 0b4820d

Browse files
committed
Half-finished initial implementation of profiling_plugin
1 parent 2d2a2a6 commit 0b4820d

3 files changed

Lines changed: 240 additions & 5 deletions

File tree

pytest_html_profiling/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
__version__ = get_distribution(__name__).version
66
except DistributionNotFound:
77
# package is not installed
8-
__version__ = "unknown"
8+
__version__ = 'Please install this package with setup.py'
99

1010
__pypi_url__ = "https://pypi.python.org/pypi/pytest-html"
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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()

setup.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
setup(
44
name="pytest-html-profiling",
55
use_scm_version=True,
6-
description="pytest plugin for generating HTML reports",
6+
description="Pytest plugin for generating HTML reports with per-test profiling and "
7+
"optionally call graph visualizations. Based on pytest-html by Dave Hunt.",
78
long_description=open("README.rst").read(),
8-
author="Dave Hunt",
9-
author_email="dhunt@mozilla.com",
9+
author="Radmila Kompova and Sveinung Gundersen",
10+
author_email="sveinugu@gmail.com",
1011
url="https://github.com/hyperbrowser/pytest-html-profiling",
1112
packages=["pytest_html_profiling"],
1213
package_data={"pytest_html_profiling": ["resources/*"]},
13-
entry_points={"pytest11": ["html = pytest_html_profiling.plugin"]},
14+
entry_points={"pytest11": ["html = pytest_html_profiling.profiling_plugin"]},
1415
setup_requires=["setuptools_scm"],
1516
install_requires=["pytest>=3.0", "pytest-metadata"],
1617
license="Mozilla Public License 2.0 (MPL 2.0)",

0 commit comments

Comments
 (0)