Skip to content

Commit a98e3ce

Browse files
committed
Enable re-writing of setuptools-installed plugins
Hook up the PEP 302 import hook very early in pytest startup so that it gets installed before setuptools-installed plugins are imported. Also iterate over all installed plugins and mark them for rewriting. If an installed plugin is already imported then a warning is issued, we can not break since that might break existing plugins and the fallback will still be gracefull to plain asserts. Some existing tests are failing in this commit because of the new warning triggered by inline pytest runs due to the hypothesis plugin already being imported. The tests will be fixed in the next commit.
1 parent dd5ce96 commit a98e3ce

File tree

7 files changed

+233
-92
lines changed

7 files changed

+233
-92
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ time or change existing behaviors in order to make them less surprising/more use
143143

144144
**Changes**
145145

146+
* Plugins now benefit from assertion rewriting. Thanks
147+
`@sober7`_, `@nicoddemus`_ and `@flub`_ for the PR.
148+
146149
* Fixtures marked with ``@pytest.fixture`` can now use ``yield`` statements exactly like
147150
those marked with the ``@pytest.yield_fixture`` decorator. This change renders
148151
``@pytest.yield_fixture`` deprecated and makes ``@pytest.fixture`` with ``yield`` statements

_pytest/assertion/__init__.py

Lines changed: 22 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
import os
66
import sys
77

8-
from _pytest.config import hookimpl
9-
from _pytest.monkeypatch import MonkeyPatch
8+
from _pytest.monkeypatch import monkeypatch
109
from _pytest.assertion import util
1110

1211

@@ -35,10 +34,7 @@ def __init__(self, config, mode):
3534
self.trace = config.trace.root.get("assertion")
3635

3736

38-
@hookimpl(tryfirst=True)
39-
def pytest_load_initial_conftests(early_config, parser, args):
40-
ns, ns_unknown_args = parser.parse_known_and_unknown_args(args)
41-
mode = ns.assertmode
37+
def install_importhook(config, mode):
4238
if mode == "rewrite":
4339
try:
4440
import ast # noqa
@@ -51,37 +47,37 @@ def pytest_load_initial_conftests(early_config, parser, args):
5147
sys.version_info[:3] == (2, 6, 0)):
5248
mode = "reinterp"
5349

54-
early_config._assertstate = AssertionState(early_config, mode)
55-
warn_about_missing_assertion(mode, early_config.pluginmanager)
50+
config._assertstate = AssertionState(config, mode)
5651

57-
if mode != "plain":
58-
_load_modules(mode)
59-
m = MonkeyPatch()
60-
early_config._cleanup.append(m.undo)
61-
m.setattr(py.builtin.builtins, 'AssertionError',
62-
reinterpret.AssertionError) # noqa
52+
_load_modules(mode)
53+
m = monkeypatch()
54+
config._cleanup.append(m.undo)
55+
m.setattr(py.builtin.builtins, 'AssertionError',
56+
reinterpret.AssertionError) # noqa
6357

6458
hook = None
6559
if mode == "rewrite":
66-
hook = rewrite.AssertionRewritingHook(early_config) # noqa
60+
hook = rewrite.AssertionRewritingHook(config) # noqa
6761
sys.meta_path.insert(0, hook)
6862

69-
early_config._assertstate.hook = hook
70-
early_config._assertstate.trace("configured with mode set to %r" % (mode,))
63+
config._assertstate.hook = hook
64+
config._assertstate.trace("configured with mode set to %r" % (mode,))
7165
def undo():
72-
hook = early_config._assertstate.hook
66+
hook = config._assertstate.hook
7367
if hook is not None and hook in sys.meta_path:
7468
sys.meta_path.remove(hook)
75-
early_config.add_cleanup(undo)
69+
config.add_cleanup(undo)
70+
return hook
7671

7772

7873
def pytest_collection(session):
7974
# this hook is only called when test modules are collected
8075
# so for example not in the master process of pytest-xdist
8176
# (which does not collect test modules)
82-
hook = session.config._assertstate.hook
83-
if hook is not None:
84-
hook.set_session(session)
77+
assertstate = getattr(session.config, '_assertstate', None)
78+
if assertstate:
79+
if assertstate.hook is not None:
80+
assertstate.hook.set_session(session)
8581

8682

8783
def _running_on_ci():
@@ -138,9 +134,10 @@ def pytest_runtest_teardown(item):
138134

139135

140136
def pytest_sessionfinish(session):
141-
hook = session.config._assertstate.hook
142-
if hook is not None:
143-
hook.session = None
137+
assertstate = getattr(session.config, '_assertstate', None)
138+
if assertstate:
139+
if assertstate.hook is not None:
140+
assertstate.hook.set_session(None)
144141

145142

146143
def _load_modules(mode):
@@ -151,31 +148,5 @@ def _load_modules(mode):
151148
from _pytest.assertion import rewrite # noqa
152149

153150

154-
def warn_about_missing_assertion(mode, pluginmanager):
155-
try:
156-
assert False
157-
except AssertionError:
158-
pass
159-
else:
160-
if mode == "rewrite":
161-
specifically = ("assertions which are not in test modules "
162-
"will be ignored")
163-
else:
164-
specifically = "failing tests may report as passing"
165-
166-
# temporarily disable capture so we can print our warning
167-
capman = pluginmanager.getplugin('capturemanager')
168-
try:
169-
out, err = capman.suspendcapture()
170-
sys.stderr.write("WARNING: " + specifically +
171-
" because assert statements are not executed "
172-
"by the underlying Python interpreter "
173-
"(are you using python -O?)\n")
174-
finally:
175-
capman.resumecapture()
176-
sys.stdout.write(out)
177-
sys.stderr.write(err)
178-
179-
180151
# Expose this plugin's implementation for the pytest_assertrepr_compare hook
181152
pytest_assertrepr_compare = util.assertrepr_compare

_pytest/assertion/rewrite.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(self, config):
5151
self.session = None
5252
self.modules = {}
5353
self._register_with_pkg_resources()
54+
self._must_rewrite = set()
5455

5556
def set_session(self, session):
5657
self.session = session
@@ -87,7 +88,7 @@ def find_module(self, name, path=None):
8788
fn = os.path.join(pth, name.rpartition(".")[2] + ".py")
8889

8990
fn_pypath = py.path.local(fn)
90-
if not self._should_rewrite(fn_pypath, state):
91+
if not self._should_rewrite(name, fn_pypath, state):
9192
return None
9293

9394
# The requested module looks like a test file, so rewrite it. This is
@@ -137,7 +138,7 @@ def find_module(self, name, path=None):
137138
self.modules[name] = co, pyc
138139
return self
139140

140-
def _should_rewrite(self, fn_pypath, state):
141+
def _should_rewrite(self, name, fn_pypath, state):
141142
# always rewrite conftest files
142143
fn = str(fn_pypath)
143144
if fn_pypath.basename == 'conftest.py':
@@ -161,8 +162,29 @@ def _should_rewrite(self, fn_pypath, state):
161162
finally:
162163
self.session = session
163164
del session
165+
else:
166+
for marked in self._must_rewrite:
167+
if marked.startswith(name):
168+
return True
164169
return False
165170

171+
def mark_rewrite(self, *names):
172+
"""Mark import names as needing to be re-written.
173+
174+
The named module or package as well as any nested modules will
175+
be re-written on import.
176+
"""
177+
already_imported = set(names).intersection(set(sys.modules))
178+
if already_imported:
179+
self._warn_already_imported(already_imported)
180+
self._must_rewrite.update(names)
181+
182+
def _warn_already_imported(self, names):
183+
self.config.warn(
184+
'P1',
185+
'Modules are already imported so can not be re-written: %s' %
186+
','.join(names))
187+
166188
def load_module(self, name):
167189
# If there is an existing module object named 'fullname' in
168190
# sys.modules, the loader must use that existing module. (Otherwise,

_pytest/config.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import types
66
import warnings
77

8+
import pkg_resources
89
import py
910
# DON't import pytest here because it causes import cycle troubles
1011
import sys, os
@@ -918,14 +919,63 @@ def _initini(self, args):
918919
self._parser.addini('addopts', 'extra command line options', 'args')
919920
self._parser.addini('minversion', 'minimally required pytest version')
920921

922+
def _consider_importhook(self, args, entrypoint_name):
923+
"""Install the PEP 302 import hook if using assertion re-writing.
924+
925+
Needs to parse the --assert=<mode> option from the commandline
926+
and find all the installed plugins to mark them for re-writing
927+
by the importhook.
928+
"""
929+
import _pytest.assertion
930+
ns, unknown_args = self._parser.parse_known_and_unknown_args(args)
931+
mode = ns.assertmode
932+
if ns.noassert or ns.nomagic:
933+
mode = "plain"
934+
self._warn_about_missing_assertion(mode)
935+
if mode != 'plain':
936+
hook = _pytest.assertion.install_importhook(self, mode)
937+
if hook:
938+
for entrypoint in pkg_resources.iter_entry_points('pytest11'):
939+
for entry in entrypoint.dist._get_metadata('RECORD'):
940+
fn = entry.split(',')[0]
941+
is_simple_module = os.sep not in fn and fn.endswith('.py')
942+
is_package = fn.count(os.sep) == 1 and fn.endswith('__init__.py')
943+
if is_simple_module:
944+
module_name, ext = os.path.splitext(fn)
945+
hook.mark_rewrite(module_name)
946+
elif is_package:
947+
package_name = os.path.dirname(fn)
948+
hook.mark_rewrite(package_name)
949+
950+
def _warn_about_missing_assertion(self, mode):
951+
try:
952+
assert False
953+
except AssertionError:
954+
pass
955+
else:
956+
if mode == "rewrite":
957+
specifically = ("assertions not in test modules or plugins"
958+
"will be ignored")
959+
else:
960+
specifically = "failing tests may report as passing"
961+
sys.stderr.write("WARNING: " + specifically +
962+
" because assert statements are not executed "
963+
"by the underlying Python interpreter "
964+
"(are you using python -O?)\n")
965+
921966
def _preparse(self, args, addopts=True):
922967
self._initini(args)
923968
if addopts:
924969
args[:] = shlex.split(os.environ.get('PYTEST_ADDOPTS', '')) + args
925970
args[:] = self.getini("addopts") + args
926971
self._checkversion()
972+
entrypoint_name = 'pytest11'
973+
self._consider_importhook(args, entrypoint_name)
927974
self.pluginmanager.consider_preparse(args)
928-
self.pluginmanager.load_setuptools_entrypoints("pytest11")
975+
try:
976+
self.pluginmanager.load_setuptools_entrypoints(entrypoint_name)
977+
except ImportError as e:
978+
self.warn("I2", "could not load setuptools entry import: %s" % (e,))
929979
self.pluginmanager.consider_env()
930980
self.known_args_namespace = ns = self._parser.parse_known_args(args, namespace=self.option.copy())
931981
if self.known_args_namespace.confcutdir is None and self.inifile:

testing/test_assertion.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,116 @@ def getoption(self, name):
2626
def interpret(expr):
2727
return reinterpret.reinterpret(expr, _pytest._code.Frame(sys._getframe(1)))
2828

29+
30+
class TestImportHookInstallation:
31+
32+
@pytest.mark.parametrize('initial_conftest', [True, False])
33+
@pytest.mark.parametrize('mode', ['plain', 'rewrite', 'reinterp'])
34+
def test_conftest_assertion_rewrite(self, testdir, initial_conftest, mode):
35+
"""Test that conftest files are using assertion rewrite on import.
36+
(#1619)
37+
"""
38+
testdir.tmpdir.join('foo/tests').ensure(dir=1)
39+
conftest_path = 'conftest.py' if initial_conftest else 'foo/conftest.py'
40+
contents = {
41+
conftest_path: """
42+
import pytest
43+
@pytest.fixture
44+
def check_first():
45+
def check(values, value):
46+
assert values.pop(0) == value
47+
return check
48+
""",
49+
'foo/tests/test_foo.py': """
50+
def test(check_first):
51+
check_first([10, 30], 30)
52+
"""
53+
}
54+
testdir.makepyfile(**contents)
55+
result = testdir.runpytest_subprocess('--assert=%s' % mode)
56+
if mode == 'plain':
57+
expected = 'E AssertionError'
58+
elif mode == 'rewrite':
59+
expected = '*assert 10 == 30*'
60+
elif mode == 'reinterp':
61+
expected = '*AssertionError:*was re-run*'
62+
else:
63+
assert 0
64+
result.stdout.fnmatch_lines([expected])
65+
66+
@pytest.mark.parametrize('mode', ['plain', 'rewrite', 'reinterp'])
67+
def test_installed_plugin_rewrite(self, testdir, mode):
68+
# Make sure the hook is installed early enough so that plugins
69+
# installed via setuptools are re-written.
70+
ham = testdir.tmpdir.join('hampkg').ensure(dir=1)
71+
ham.join('__init__.py').write("""
72+
import pytest
73+
74+
@pytest.fixture
75+
def check_first2():
76+
def check(values, value):
77+
assert values.pop(0) == value
78+
return check
79+
""")
80+
testdir.makepyfile(
81+
spamplugin="""
82+
import pytest
83+
from hampkg import check_first2
84+
85+
@pytest.fixture
86+
def check_first():
87+
def check(values, value):
88+
assert values.pop(0) == value
89+
return check
90+
""",
91+
mainwrapper="""
92+
import pytest, pkg_resources
93+
94+
class DummyDistInfo:
95+
project_name = 'spam'
96+
version = '1.0'
97+
98+
def _get_metadata(self, name):
99+
return ['spamplugin.py,sha256=abc,123',
100+
'hampkg/__init__.py,sha256=abc,123']
101+
102+
class DummyEntryPoint:
103+
name = 'spam'
104+
module_name = 'spam.py'
105+
attrs = ()
106+
extras = None
107+
dist = DummyDistInfo()
108+
109+
def load(self, require=True, *args, **kwargs):
110+
import spamplugin
111+
return spamplugin
112+
113+
def iter_entry_points(name):
114+
yield DummyEntryPoint()
115+
116+
pkg_resources.iter_entry_points = iter_entry_points
117+
pytest.main()
118+
""",
119+
test_foo="""
120+
def test(check_first):
121+
check_first([10, 30], 30)
122+
123+
def test2(check_first2):
124+
check_first([10, 30], 30)
125+
""",
126+
)
127+
result = testdir.run(sys.executable, 'mainwrapper.py', '-s', '--assert=%s' % mode)
128+
if mode == 'plain':
129+
expected = 'E AssertionError'
130+
elif mode == 'rewrite':
131+
expected = '*assert 10 == 30*'
132+
elif mode == 'reinterp':
133+
expected = '*AssertionError:*was re-run*'
134+
else:
135+
assert 0
136+
result.stdout.fnmatch_lines([expected])
137+
138+
29139
class TestBinReprIntegration:
30140

31141
def test_pytest_assertrepr_compare_called(self, testdir):

0 commit comments

Comments
 (0)