Skip to content

Commit d27247b

Browse files
danielbradburnavelis
authored andcommitted
jazzband#33 organise cprofile output as a sortable table (jazzband#200)
* add .venv* to .gitignore * made the profile output a sortable table with links to the appropriate source code * remove memoization of get_dot function since this was causing problem on python 2, and should probably be in a separate PR * removed python 3 only spitlines * fixed failing test due to PY3/2 differences in profile output * fixed failing test due difference in python 3.4 * fixed failing test due to floating point precision * fixed problem due to trying to import abs from math * reverted incorrect attempt to fix flaky test due to floating point precision
1 parent 69a2227 commit d27247b

10 files changed

+231
-39
lines changed

project/test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ factory-boy==2.8.1
1313
freezegun==0.3.5
1414
networkx==1.11
1515
pydotplus==2.0.2
16+
contextlib2==0.5.5

project/tests/test_code.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from collections import namedtuple
2+
from django.test import TestCase
3+
from silk.views.code import _code, _code_context, _code_context_from_request
4+
5+
6+
FILE_PATH = __file__
7+
LINE_NUM = 5
8+
END_LINE_NUM = 10
9+
10+
with open(__file__) as f:
11+
ACTUAL_LINES = [l + '\n' for l in f.read().split('\n')]
12+
13+
14+
class CodeTestCase(TestCase):
15+
16+
def assertActualLineEqual(self, actual_line, end_line_num=None):
17+
expected_actual_line = ACTUAL_LINES[LINE_NUM - 1:end_line_num or LINE_NUM]
18+
self.assertEqual(actual_line, expected_actual_line)
19+
20+
def assertCodeEqual(self, code):
21+
expected_code = [line.strip('\n') for line in ACTUAL_LINES[0:LINE_NUM + 10]] + ['']
22+
self.assertEqual(code, expected_code)
23+
24+
def test_code(self):
25+
for end_line_num in None, END_LINE_NUM:
26+
actual_line, code = _code(FILE_PATH, LINE_NUM, end_line_num)
27+
self.assertActualLineEqual(actual_line, end_line_num)
28+
self.assertCodeEqual(code)
29+
30+
def test_code_context(self):
31+
for end_line_num in None, END_LINE_NUM:
32+
for prefix in '', 'salchicha_':
33+
context = _code_context(FILE_PATH, LINE_NUM, end_line_num, prefix)
34+
self.assertActualLineEqual(context[prefix + 'actual_line'], end_line_num)
35+
self.assertCodeEqual(context[prefix + 'code'])
36+
self.assertEqual(context[prefix + 'file_path'], FILE_PATH)
37+
self.assertEqual(context[prefix + 'line_num'], LINE_NUM)
38+
39+
def test_code_context_from_request(self):
40+
for end_line_num in None, END_LINE_NUM:
41+
for prefix in '', 'salchicha_':
42+
request = namedtuple('Request', 'GET')(dict(file_path=FILE_PATH, line_num=LINE_NUM))
43+
context = _code_context_from_request(request, end_line_num, prefix)
44+
self.assertActualLineEqual(context[prefix + 'actual_line'], end_line_num)
45+
self.assertCodeEqual(context[prefix + 'code'])
46+
self.assertEqual(context[prefix + 'file_path'], FILE_PATH)
47+
self.assertEqual(context[prefix + 'line_num'], LINE_NUM)

project/tests/test_profile_parser.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# future
2+
from __future__ import print_function
3+
# std
4+
import cProfile
5+
import sys
6+
# 3rd party
7+
import contextlib2 as contextlib
8+
from six import StringIO, PY3
9+
from django.test import TestCase
10+
# silk
11+
from silk.utils.profile_parser import parse_profile
12+
13+
14+
class ProfileParserTestCase(TestCase):
15+
16+
def test_profile_parser(self):
17+
"""
18+
Verify that the function parse_profile produces the expected output.
19+
"""
20+
with contextlib.closing(StringIO()) as stream:
21+
with contextlib.redirect_stdout(stream):
22+
cProfile.run('print()')
23+
stream.seek(0)
24+
actual = list(parse_profile(stream))
25+
if PY3:
26+
if sys.version_info < (3,5):
27+
expected = [
28+
['ncalls', 'tottime', 'percall', 'cumtime', 'percall', 'filename:lineno(function)'],
29+
['1', '0.000', '0.000', '0.000', '0.000', '<string>:1(<module>)'],
30+
['1', '0.000', '0.000', '0.000', '0.000', '{built-in method exec}'],
31+
['1', '0.000', '0.000', '0.000', '0.000', '{built-in method print}'],
32+
['1', '0.000', '0.000', '0.000', '0.000', "{method 'disable' of '_lsprof.Profiler' objects}"],
33+
]
34+
else:
35+
expected = [
36+
['ncalls', 'tottime', 'percall', 'cumtime', 'percall', 'filename:lineno(function)'],
37+
['1', '0.000', '0.000', '0.000', '0.000', '<string>:1(<module>)'],
38+
['1', '0.000', '0.000', '0.000', '0.000', '{built-in method builtins.exec}'],
39+
['1', '0.000', '0.000', '0.000', '0.000', '{built-in method builtins.print}'],
40+
['1', '0.000', '0.000', '0.000', '0.000', "{method 'disable' of '_lsprof.Profiler' objects}"],
41+
]
42+
else:
43+
expected = [
44+
['ncalls', 'tottime', 'percall', 'cumtime', 'percall', 'filename:lineno(function)'],
45+
['1', '0.000', '0.000', '0.000', '0.000', '<string>:1(<module>)'],
46+
['2', '0.000', '0.000', '0.000', '0.000', 'StringIO.py:208(write)'],
47+
['2', '0.000', '0.000', '0.000', '0.000', 'StringIO.py:38(_complain_ifclosed)'],
48+
['2', '0.000', '0.000', '0.000', '0.000', '{isinstance}'],
49+
['2', '0.000', '0.000', '0.000', '0.000', '{len}'],
50+
['2', '0.000', '0.000', '0.000', '0.000', "{method 'append' of 'list' objects}"],
51+
['1', '0.000', '0.000', '0.000', '0.000', "{method 'disable' of '_lsprof.Profiler' objects}"]
52+
]
53+
54+
self.assertListEqual(actual, expected)

silk/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import base64
44
import random
5+
import re
56

67
from django.core.files.storage import FileSystemStorage
78
from django.db import models
@@ -14,7 +15,9 @@
1415
from django.db import transaction
1516
from uuid import uuid4
1617
import sqlparse
18+
from django.utils.safestring import mark_safe
1719

20+
from silk.utils.profile_parser import parse_profile
1821
from silk.config import SilkyConfig
1922

2023
# Django 1.8 removes commit_on_success, django 1.5 does not have atomic
@@ -85,6 +88,23 @@ class Request(models.Model):
8588
def total_meta_time(self):
8689
return (self.meta_time or 0) + (self.meta_time_spent_queries or 0)
8790

91+
@property
92+
def profile_table(self):
93+
for n, columns in enumerate(parse_profile(self.pyprofile)):
94+
location = columns[-1]
95+
if n and '{' not in location and '<' not in location:
96+
r = re.compile('(?P<src>.*\.py)\:(?P<num>[0-9]+).*')
97+
m = r.search(location)
98+
group = m.groupdict()
99+
src = group['src']
100+
num = group['num']
101+
name = 'c%d' % n
102+
fmt = '<a name={name} href="?pos={n}&file_path={src}&line_num={num}#{name}">{location}</a>'
103+
rep = fmt.format(**dict(group, **locals()))
104+
yield columns[:-1] + [mark_safe(rep)]
105+
else:
106+
yield columns
107+
88108
# defined in atomic transaction within SQLQuery save()/delete() as well
89109
# as in bulk_create of SQLQueryManager
90110
# TODO: This is probably a bad way to do this, .count() will prob do?

silk/templates/silk/profile_detail.html

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
{% block js %}
88
<script type="text/javascript" src="{% static 'silk/lib/viz-lite.js' %}"></script>
99
<script type="text/javascript" src="{% static 'silk/lib/svg-pan-zoom.min.js' %}"></script>
10+
<script type="text/javascript" src="{% static 'silk/lib/sortable.js' %}"></script>
1011
{{ block.super }}
1112
{% endblock %}
1213

@@ -67,6 +68,26 @@
6768
svg {
6869
display: block;
6970
}
71+
72+
.file-path {
73+
font-size: 13px;
74+
}
75+
76+
.file-path>a {
77+
color: black;
78+
}
79+
80+
.file-path>a:hover {
81+
color: #9dd0ff;
82+
}
83+
84+
.file-path>a:active {
85+
color: #594F4F;
86+
}
87+
88+
#pyprofile-table {
89+
margin-top: 25px;
90+
}
7091

7192
</style>
7293
{% endblock %}
@@ -143,13 +164,38 @@
143164
<div class="heading">
144165
<div class="inner-heading">Python Profiler</div>
145166
</div>
167+
146168
<div class="description">
147-
The below is a dump from the cPython profiler.
169+
Below is a dump from the cPython profiler.
170+
{% if silk_request.prof_file %}
171+
Click <a href="{% url 'silk:request_profile_download' request_id=silk_request.pk %}">here</a> to download profile.
172+
{% endif %}
148173
</div>
149-
{% if silk_request.prof_file %}
150-
Click <a href="{% url 'silk:request_profile_download' request_id=silk_request.pk %}">here</a> to download profile.
151-
{% endif %}
152-
<pre class="pyprofile">{{ silk_request.pyprofile }}</pre>
174+
175+
<table id='pyprofile-table' class="sortable">
176+
{% for row in silk_request.profile_table %}
177+
<tr>
178+
{% for column in row %}
179+
{% if forloop.parentloop.counter0 %}
180+
<td>
181+
{% if forloop.counter0 == file_column %}
182+
<div class="file-path">
183+
{{ column }}
184+
</div>
185+
{% if forloop.parentloop.counter0 == pos %}
186+
{% code pyprofile_code pyprofile_actual_line %}
187+
{% endif %}
188+
{% else %}
189+
{{ column }}
190+
{% endif %}
191+
</td>
192+
{% else %}
193+
<th>{{ column }}</th>
194+
{% endif %}
195+
{% endfor %}
196+
</tr>
197+
{% endfor %}
198+
</table>
153199
{% endif %}
154200
</div>
155201

silk/utils/profile_parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from six import text_type
2+
import re
3+
4+
5+
_pattern = re.compile(' +')
6+
7+
8+
def parse_profile(output):
9+
"""
10+
Parse the output of cProfile to a list of tuples.
11+
"""
12+
if isinstance(output, text_type):
13+
output = output.split('\n')
14+
for i, line in enumerate(output):
15+
# ignore n function calls, total time and ordered by and empty lines
16+
line = line.strip()
17+
if i > 3 and line:
18+
columns = _pattern.split(line)[0:]
19+
function = ' '.join(columns[5:])
20+
columns = columns[:5] + [function]
21+
yield columns

silk/views/code.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55

66
def _code(file_path, line_num, end_line_num=None):
7+
line_num = int(line_num)
78
if not end_line_num:
89
end_line_num = line_num
10+
end_line_num = int(end_line_num)
911
actual_line = []
1012
lines = ''
1113
with open(file_path, 'r') as f:
@@ -19,10 +21,23 @@ def _code(file_path, line_num, end_line_num=None):
1921
return actual_line, code
2022

2123

22-
def _code_context(file_path, line_num):
23-
actual_line, code = _code(file_path, line_num)
24-
context = {'code': code, 'file_path': file_path, 'line_num': line_num, 'actual_line': actual_line}
25-
return context
24+
def _code_context(file_path, line_num, end_line_num=None, prefix=''):
25+
actual_line, code = _code(file_path, line_num, end_line_num)
26+
return {
27+
prefix + 'code': code,
28+
prefix + 'file_path': file_path,
29+
prefix + 'line_num': line_num,
30+
prefix + 'actual_line': actual_line
31+
}
32+
33+
34+
def _code_context_from_request(request, end_line_num=None, prefix=''):
35+
file_path = request.GET.get('file_path')
36+
line_num = request.GET.get('line_num')
37+
result = {}
38+
if file_path is not None and line_num is not None:
39+
result = _code_context(file_path, line_num, end_line_num, prefix)
40+
return result
2641

2742

2843
def _should_display_file_name(file_name):

silk/views/profile_detail.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.views.generic import View
44
from silk.auth import login_possibly_required, permissions_possibly_required
55
from silk.models import Profile
6-
from silk.views.sql_detail import _code
6+
from silk.views.code import _code_context, _code_context_from_request
77

88

99
class ProfilingDetailView(View):
@@ -18,16 +18,21 @@ def get(self, request, *_, **kwargs):
1818
profile = Profile.objects.get(pk=profile_id)
1919
file_path = profile.file_path
2020
line_num = profile.line_num
21+
22+
context['pos'] = pos = int(request.GET.get('pos', 0))
23+
if pos:
24+
context.update(_code_context_from_request(request, prefix='pyprofile_'))
25+
2126
context['profile'] = profile
2227
context['line_num'] = file_path
2328
context['file_path'] = line_num
29+
context['file_column'] = 5
30+
2431
if profile.request:
2532
context['silk_request'] = profile.request
2633
if file_path and line_num:
2734
try:
28-
actual_line, code = _code(file_path, line_num, profile.end_line_num)
29-
context['code'] = code
30-
context['actual_line'] = actual_line
35+
context.update(_code_context(file_path, line_num, profile.end_line_num))
3136
except IOError as e:
3237
if e.errno == 2:
3338
context['code_error'] = e.filename + ' does not exist.'

silk/views/profile_dot.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@ def _create_dot(profile, cutoff):
5252
return fp.getvalue()
5353

5454

55+
def _get_dot(request_id, cutoff):
56+
silk_request = get_object_or_404(Request, pk=request_id, prof_file__isnull=False)
57+
profile = _create_profile(silk_request.prof_file)
58+
result = dict(dot=_create_dot(profile, cutoff))
59+
return HttpResponse(json.dumps(result).encode('utf-8'), content_type='application/json')
60+
61+
5562
class ProfileDotView(View):
5663

5764
@method_decorator(login_possibly_required)
5865
@method_decorator(permissions_possibly_required)
5966
def get(self, request, request_id):
60-
silk_request = get_object_or_404(Request, pk=request_id, prof_file__isnull=False)
6167
cutoff = float(request.GET.get('cutoff', '') or 5)
62-
profile = _create_profile(silk_request.prof_file)
63-
result = dict(dot=_create_dot(profile, cutoff))
64-
return HttpResponse(json.dumps(result).encode('utf-8'), content_type='application/json')
68+
return _get_dot(request_id, cutoff)

silk/views/sql_detail.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,7 @@
88

99
from silk.auth import login_possibly_required, permissions_possibly_required
1010
from silk.models import SQLQuery, Request, Profile
11-
12-
13-
def _code(file_path, line_num, end_line_num=None):
14-
if not end_line_num:
15-
end_line_num = line_num
16-
actual_line = []
17-
lines = ''
18-
with open(file_path, 'r') as f:
19-
r = range(max(0, line_num - 10), line_num + 10)
20-
for i, line in enumerate(f):
21-
if i in r:
22-
lines += line
23-
if i + 1 in range(line_num, end_line_num + 1):
24-
actual_line.append(line)
25-
code = lines.split('\n')
26-
return actual_line, code
27-
28-
29-
def _code_context(file_path, line_num):
30-
actual_line, code = _code(file_path, line_num)
31-
context = {'code': code, 'file_path': file_path, 'line_num': line_num, 'actual_line': actual_line}
32-
return context
11+
from silk.views.code import _code
3312

3413

3514
class SQLDetailView(View):

0 commit comments

Comments
 (0)