forked from abalkin/pytest-leaks
/
pytest_leaks.py
257 lines (223 loc) · 8.42 KB
/
pytest_leaks.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# -*- coding: utf-8 -*-
"""
A pytest plugin to trace resource leaks
"""
from __future__ import print_function
import gc
import sys
import warnings
from collections import OrderedDict as odict
from inspect import isabstract
import pytest
_py3 = sys.version_info[0] >= 3
def pytest_addoption(parser):
group = parser.getgroup('leaks')
group.addoption(
'-R', '--leaks',
action='store',
dest='leaks',
help='''\
runs each test several times and examines sys.gettotalrefcount() to
see if the test appears to be leaking references. The argument should
be of the form stab:run:fname where 'stab' is the number of times the
test is run to let gettotalrefcount settle down, 'run' is the number
of times further it is run and 'fname' is the name of the file the
reports are written to. These parameters all have defaults (5, 4 and
"reflog.txt" respectively), and the minimal invocation is '-R :'.
'''
)
parser.addini('leaks_stab',
'the number of times the test is run to let '
'gettotalrefcount settle down', default=5)
parser.addini('leaks_run',
'the number of times the test is run', default=4)
def pytest_configure(config):
leaks = config.getvalue("leaks")
if leaks:
checker = LeakChecker(config)
config.pluginmanager.register(checker, 'leaks_checker')
@pytest.fixture
def leaks_checker(request):
return request.config.pluginmanager.get_plugin('leaks_checker')
class Leaks(odict):
pass
class LeakChecker(object):
def __init__(self, config):
self.stab = config.getini('leaks_stab')
self.run = config.getini('leaks_run')
leaks_option_parts = config.getvalue("leaks").split(':')
if len(leaks_option_parts) > 1:
try:
self.stab = int(leaks_option_parts[0])
except ValueError:
pass
try:
self.run = int(leaks_option_parts[1])
except ValueError:
pass
# TODO: warn about invalid -R values.
# pytest.set_trace()
# Get access to the builtin "runner" plugin.
self.runner = config.pluginmanager.get_plugin('runner')
self.leaks = {} # nodeid -> leaks
def hunt_leaks(self, func):
return hunt_leaks(func, self.stab, self.run)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(self, item, nextitem):
def run_test():
hook = item.ihook
hook.pytest_runtest_setup(item=item)
hook.pytest_runtest_call(item=item)
hook.pytest_runtest_teardown(item=item, nextitem=nextitem)
call = self.runner.CallInfo(lambda: self.hunt_leaks(run_test),
'leakshunt')
if call.excinfo is None and call.result:
self.leaks[item.nodeid] = call.result
yield
@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_report_teststatus(self, report):
outcome = yield
if report.when == 'call' and report.outcome == 'passed':
leaks = self.leaks.get(report.nodeid)
if leaks:
# cat, letter, word
outcome.force_result(('leaked', 'L', 'LEAKED'))
@pytest.hookimpl
def pytest_terminal_summary(self, terminalreporter, exitstatus):
tr = terminalreporter
leaked = tr.getreports('leaked')
if leaked:
tr.write_sep("=", 'leaks summary', cyan=True)
for rep in leaked:
tr.line("%s: %r" % (rep.nodeid, self.leaks[rep.nodeid]))
def hunt_leaks(func, nwarmup, ntracked):
"""Run a func multiple times, looking for leaks."""
# This code is hackish and inelegant, but it seems to do the job.
import copyreg
import collections.abc
# Save current values for cleanup() to restore.
fs = warnings.filters[:]
ps = copyreg.dispatch_table.copy()
pic = sys.path_importer_cache.copy()
try:
import zipimport
except ImportError:
zdc = None # Run unmodified on platforms without zipimport support
else:
zdc = zipimport._zip_directory_cache.copy()
abcs = {}
for abc in [getattr(collections.abc, a) for a in collections.abc.__all__]:
if not isabstract(abc):
continue
for obj in abc.__subclasses__() + [abc]:
abcs[obj] = obj._abc_registry.copy()
repcount = nwarmup + ntracked
rc_deltas = [0] * repcount
alloc_deltas = [0] * repcount
# initialize variables to make pyflakes quiet
rc_before = alloc_before = 0
for i in range(repcount):
func()
alloc_after, rc_after = cleanup(fs, ps, pic, zdc, abcs)
if i >= nwarmup:
rc_deltas[i] = rc_after - rc_before
alloc_deltas[i] = alloc_after - alloc_before
alloc_before = alloc_after
rc_before = rc_after
# These checkers return False on success, True on failure
def check_rc_deltas(deltas):
return any(deltas)
def check_alloc_deltas(deltas):
# At least 1/3rd of 0s
if 3 * deltas.count(0) < len(deltas):
return True
# Nothing else than 1s, 0s and -1s
if not set(deltas) <= {1, 0, -1}:
return True
return False
leaks = Leaks()
for deltas, item_name, checker in [
(rc_deltas, 'refs', check_rc_deltas),
(alloc_deltas, 'blocks', check_alloc_deltas)
]:
if checker(deltas):
leaks[item_name] = deltas[nwarmup:]
return leaks
# The following code is mostly copied from Python 2.7 / 3.5 dash_R_cleanup
if _py3:
def cleanup(warning_filters, copyreg_dispatch_table, path_importer_cache,
zip_directory_cache, abcs):
import copyreg
import re
import warnings
import _strptime
import linecache
import urllib.parse
import urllib.request
import mimetypes
import doctest
import struct
import filecmp
import collections.abc
from distutils.dir_util import _path_created
from weakref import WeakSet
# Clear the warnings registry, so they can be displayed again
for mod in sys.modules.values():
if hasattr(mod, '__warningregistry__'):
del mod.__warningregistry__
# Restore some original values.
warnings.filters[:] = warning_filters
copyreg.dispatch_table.clear()
copyreg.dispatch_table.update(copyreg_dispatch_table)
sys.path_importer_cache.clear()
sys.path_importer_cache.update(path_importer_cache)
try:
import zipimport
except ImportError:
pass # Run unmodified on platforms without zipimport support
else:
zipimport._zip_directory_cache.clear()
zipimport._zip_directory_cache.update(zip_directory_cache)
# clear type cache
sys._clear_type_cache()
# Clear ABC registries, restoring previously saved ABC registries.
for a in collections.abc.__all__:
abc = getattr(collections.abc, a)
if not isabstract(abc):
continue
for obj in abc.__subclasses__() + [abc]:
obj._abc_registry = abcs.get(obj, WeakSet()).copy()
obj._abc_cache.clear()
obj._abc_negative_cache.clear()
# Flush standard output, so that buffered data is sent to the OS and
# associated Python objects are reclaimed.
for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
if stream is not None:
stream.flush()
# Clear assorted module caches.
_path_created.clear()
re.purge()
_strptime._regex_cache.clear()
urllib.parse.clear_cache()
urllib.request.urlcleanup()
linecache.clearcache()
mimetypes._default_mime_types()
filecmp._cache.clear()
struct._clearcache()
doctest.master = None
try:
import ctypes
except ImportError:
# Don't worry about resetting the cache if ctypes is not supported
pass
else:
ctypes._reset_cache()
# Collect cyclic trash and read memory statistics immediately after.
func1 = sys.getallocatedblocks
func2 = sys.gettotalrefcount
gc.collect()
return func1(), func2()
else:
def cleanup(warning_filters, copyreg_dispatch_table, path_importer_cache,
zip_directory_cache, abcs):
pass