Skip to content

Commit 410d576

Browse files
authoredSep 5, 2018
Merge pull request #3919 from fabioz/master
Improve import performance of assertion rewrite. Fixes #3918.
2 parents 15ede8a + eec7081 commit 410d576

File tree

6 files changed

+166
-24
lines changed

6 files changed

+166
-24
lines changed
 

‎.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ env/
3838
.ropeproject
3939
.idea
4040
.hypothesis
41+
.pydevproject
42+
.project
43+
.settings

‎AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Endre Galaczi
7272
Eric Hunsberger
7373
Eric Siegerman
7474
Erik M. Bray
75+
Fabio Zadrozny
7576
Feng Ma
7677
Florian Bruhin
7778
Floris Bruynooghe

‎changelog/3918.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve performance of assertion rewriting.

‎src/_pytest/assertion/rewrite.py

+64-7
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,24 @@ def __init__(self, config):
6767
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
6868
# which might result in infinite recursion (#3506)
6969
self._writing_pyc = False
70+
self._basenames_to_check_rewrite = {"conftest"}
71+
self._marked_for_rewrite_cache = {}
72+
self._session_paths_checked = False
7073

7174
def set_session(self, session):
7275
self.session = session
76+
self._session_paths_checked = False
77+
78+
def _imp_find_module(self, name, path=None):
79+
"""Indirection so we can mock calls to find_module originated from the hook during testing"""
80+
return imp.find_module(name, path)
7381

7482
def find_module(self, name, path=None):
7583
if self._writing_pyc:
7684
return None
7785
state = self.config._assertstate
86+
if self._early_rewrite_bailout(name, state):
87+
return None
7888
state.trace("find_module called for: %s" % name)
7989
names = name.rsplit(".", 1)
8090
lastname = names[-1]
@@ -87,7 +97,7 @@ def find_module(self, name, path=None):
8797
pth = path[0]
8898
if pth is None:
8999
try:
90-
fd, fn, desc = imp.find_module(lastname, path)
100+
fd, fn, desc = self._imp_find_module(lastname, path)
91101
except ImportError:
92102
return None
93103
if fd is not None:
@@ -166,6 +176,44 @@ def find_module(self, name, path=None):
166176
self.modules[name] = co, pyc
167177
return self
168178

179+
def _early_rewrite_bailout(self, name, state):
180+
"""
181+
This is a fast way to get out of rewriting modules. Profiling has
182+
shown that the call to imp.find_module (inside of the find_module
183+
from this class) is a major slowdown, so, this method tries to
184+
filter what we're sure won't be rewritten before getting to it.
185+
"""
186+
if self.session is not None and not self._session_paths_checked:
187+
self._session_paths_checked = True
188+
for path in self.session._initialpaths:
189+
# Make something as c:/projects/my_project/path.py ->
190+
# ['c:', 'projects', 'my_project', 'path.py']
191+
parts = str(path).split(os.path.sep)
192+
# add 'path' to basenames to be checked.
193+
self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0])
194+
195+
# Note: conftest already by default in _basenames_to_check_rewrite.
196+
parts = name.split(".")
197+
if parts[-1] in self._basenames_to_check_rewrite:
198+
return False
199+
200+
# For matching the name it must be as if it was a filename.
201+
parts[-1] = parts[-1] + ".py"
202+
fn_pypath = py.path.local(os.path.sep.join(parts))
203+
for pat in self.fnpats:
204+
# if the pattern contains subdirectories ("tests/**.py" for example) we can't bail out based
205+
# on the name alone because we need to match against the full path
206+
if os.path.dirname(pat):
207+
return False
208+
if fn_pypath.fnmatch(pat):
209+
return False
210+
211+
if self._is_marked_for_rewrite(name, state):
212+
return False
213+
214+
state.trace("early skip of rewriting module: %s" % (name,))
215+
return True
216+
169217
def _should_rewrite(self, name, fn_pypath, state):
170218
# always rewrite conftest files
171219
fn = str(fn_pypath)
@@ -185,12 +233,20 @@ def _should_rewrite(self, name, fn_pypath, state):
185233
state.trace("matched test file %r" % (fn,))
186234
return True
187235

188-
for marked in self._must_rewrite:
189-
if name == marked or name.startswith(marked + "."):
190-
state.trace("matched marked file %r (from %r)" % (name, marked))
191-
return True
236+
return self._is_marked_for_rewrite(name, state)
192237

193-
return False
238+
def _is_marked_for_rewrite(self, name, state):
239+
try:
240+
return self._marked_for_rewrite_cache[name]
241+
except KeyError:
242+
for marked in self._must_rewrite:
243+
if name == marked or name.startswith(marked + "."):
244+
state.trace("matched marked file %r (from %r)" % (name, marked))
245+
self._marked_for_rewrite_cache[name] = True
246+
return True
247+
248+
self._marked_for_rewrite_cache[name] = False
249+
return False
194250

195251
def mark_rewrite(self, *names):
196252
"""Mark import names as needing to be rewritten.
@@ -207,6 +263,7 @@ def mark_rewrite(self, *names):
207263
):
208264
self._warn_already_imported(name)
209265
self._must_rewrite.update(names)
266+
self._marked_for_rewrite_cache.clear()
210267

211268
def _warn_already_imported(self, name):
212269
self.config.warn(
@@ -241,7 +298,7 @@ def load_module(self, name):
241298

242299
def is_package(self, name):
243300
try:
244-
fd, fn, desc = imp.find_module(name)
301+
fd, fn, desc = self._imp_find_module(name)
245302
except ImportError:
246303
return False
247304
if fd is not None:

‎src/_pytest/main.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ def __init__(self, config):
383383
self.trace = config.trace.root.get("collection")
384384
self._norecursepatterns = config.getini("norecursedirs")
385385
self.startdir = py.path.local()
386+
self._initialpaths = frozenset()
386387
# Keep track of any collected nodes in here, so we don't duplicate fixtures
387388
self._node_cache = {}
388389

@@ -441,13 +442,14 @@ def _perform_collect(self, args, genitems):
441442
self.trace("perform_collect", self, args)
442443
self.trace.root.indent += 1
443444
self._notfound = []
444-
self._initialpaths = set()
445+
initialpaths = []
445446
self._initialparts = []
446447
self.items = items = []
447448
for arg in args:
448449
parts = self._parsearg(arg)
449450
self._initialparts.append(parts)
450-
self._initialpaths.add(parts[0])
451+
initialpaths.append(parts[0])
452+
self._initialpaths = frozenset(initialpaths)
451453
rep = collect_one_node(self)
452454
self.ihook.pytest_collectreport(report=rep)
453455
self.trace.root.indent -= 1
@@ -564,7 +566,6 @@ def _tryconvertpyarg(self, x):
564566
"""Convert a dotted module name to path.
565567
566568
"""
567-
568569
try:
569570
with _patched_find_module():
570571
loader = pkgutil.find_loader(x)

‎testing/test_assertrewrite.py

+93-14
Original file line numberDiff line numberDiff line change
@@ -1106,22 +1106,21 @@ def test_ternary_display():
11061106

11071107

11081108
class TestIssue2121:
1109-
def test_simple(self, testdir):
1110-
testdir.tmpdir.join("tests/file.py").ensure().write(
1111-
"""
1112-
def test_simple_failure():
1113-
assert 1 + 1 == 3
1114-
"""
1115-
)
1116-
testdir.tmpdir.join("pytest.ini").write(
1117-
textwrap.dedent(
1109+
def test_rewrite_python_files_contain_subdirs(self, testdir):
1110+
testdir.makepyfile(
1111+
**{
1112+
"tests/file.py": """
1113+
def test_simple_failure():
1114+
assert 1 + 1 == 3
11181115
"""
1119-
[pytest]
1120-
python_files = tests/**.py
1121-
"""
1122-
)
1116+
}
1117+
)
1118+
testdir.makeini(
1119+
"""
1120+
[pytest]
1121+
python_files = tests/**.py
1122+
"""
11231123
)
1124-
11251124
result = testdir.runpytest()
11261125
result.stdout.fnmatch_lines("*E*assert (1 + 1) == 3")
11271126

@@ -1153,3 +1152,83 @@ def spy_write_pyc(*args, **kwargs):
11531152
hook = AssertionRewritingHook(pytestconfig)
11541153
assert hook.find_module("test_foo") is not None
11551154
assert len(write_pyc_called) == 1
1155+
1156+
1157+
class TestEarlyRewriteBailout(object):
1158+
@pytest.fixture
1159+
def hook(self, pytestconfig, monkeypatch, testdir):
1160+
"""Returns a patched AssertionRewritingHook instance so we can configure its initial paths and track
1161+
if imp.find_module has been called.
1162+
"""
1163+
import imp
1164+
1165+
self.find_module_calls = []
1166+
self.initial_paths = set()
1167+
1168+
class StubSession(object):
1169+
_initialpaths = self.initial_paths
1170+
1171+
def isinitpath(self, p):
1172+
return p in self._initialpaths
1173+
1174+
def spy_imp_find_module(name, path):
1175+
self.find_module_calls.append(name)
1176+
return imp.find_module(name, path)
1177+
1178+
hook = AssertionRewritingHook(pytestconfig)
1179+
# use default patterns, otherwise we inherit pytest's testing config
1180+
hook.fnpats[:] = ["test_*.py", "*_test.py"]
1181+
monkeypatch.setattr(hook, "_imp_find_module", spy_imp_find_module)
1182+
hook.set_session(StubSession())
1183+
testdir.syspathinsert()
1184+
return hook
1185+
1186+
def test_basic(self, testdir, hook):
1187+
"""
1188+
Ensure we avoid calling imp.find_module when we know for sure a certain module will not be rewritten
1189+
to optimize assertion rewriting (#3918).
1190+
"""
1191+
testdir.makeconftest(
1192+
"""
1193+
import pytest
1194+
@pytest.fixture
1195+
def fix(): return 1
1196+
"""
1197+
)
1198+
testdir.makepyfile(test_foo="def test_foo(): pass")
1199+
testdir.makepyfile(bar="def bar(): pass")
1200+
foobar_path = testdir.makepyfile(foobar="def foobar(): pass")
1201+
self.initial_paths.add(foobar_path)
1202+
1203+
# conftest files should always be rewritten
1204+
assert hook.find_module("conftest") is not None
1205+
assert self.find_module_calls == ["conftest"]
1206+
1207+
# files matching "python_files" mask should always be rewritten
1208+
assert hook.find_module("test_foo") is not None
1209+
assert self.find_module_calls == ["conftest", "test_foo"]
1210+
1211+
# file does not match "python_files": early bailout
1212+
assert hook.find_module("bar") is None
1213+
assert self.find_module_calls == ["conftest", "test_foo"]
1214+
1215+
# file is an initial path (passed on the command-line): should be rewritten
1216+
assert hook.find_module("foobar") is not None
1217+
assert self.find_module_calls == ["conftest", "test_foo", "foobar"]
1218+
1219+
def test_pattern_contains_subdirectories(self, testdir, hook):
1220+
"""If one of the python_files patterns contain subdirectories ("tests/**.py") we can't bailout early
1221+
because we need to match with the full path, which can only be found by calling imp.find_module.
1222+
"""
1223+
p = testdir.makepyfile(
1224+
**{
1225+
"tests/file.py": """
1226+
def test_simple_failure():
1227+
assert 1 + 1 == 3
1228+
"""
1229+
}
1230+
)
1231+
testdir.syspathinsert(p.dirpath())
1232+
hook.fnpats[:] = ["tests/**.py"]
1233+
assert hook.find_module("file") is not None
1234+
assert self.find_module_calls == ["file"]

0 commit comments

Comments
 (0)
Please sign in to comment.