Skip to content

Commit 24fbbbe

Browse files
authored
Merge pull request #1641 from flub/rewrite-plugins
Rewrite plugins
2 parents 691dc8b + 51ee7f8 commit 24fbbbe

File tree

8 files changed

+355
-96
lines changed

8 files changed

+355
-96
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: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
import os
66
import sys
77

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

1211

1312
def pytest_addoption(parser):
@@ -27,6 +26,34 @@ def pytest_addoption(parser):
2726
provide assert expression information. """)
2827

2928

29+
def pytest_namespace():
30+
return {'register_assert_rewrite': register_assert_rewrite}
31+
32+
33+
def register_assert_rewrite(*names):
34+
"""Register a module name to be rewritten on import.
35+
36+
This function will make sure that the module will get it's assert
37+
statements rewritten when it is imported. Thus you should make
38+
sure to call this before the module is actually imported, usually
39+
in your __init__.py if you are a plugin using a package.
40+
"""
41+
for hook in sys.meta_path:
42+
if isinstance(hook, rewrite.AssertionRewritingHook):
43+
importhook = hook
44+
break
45+
else:
46+
importhook = DummyRewriteHook()
47+
importhook.mark_rewrite(*names)
48+
49+
50+
class DummyRewriteHook(object):
51+
"""A no-op import hook for when rewriting is disabled."""
52+
53+
def mark_rewrite(self, *names):
54+
pass
55+
56+
3057
class AssertionState:
3158
"""State for the assertion plugin."""
3259

@@ -35,10 +62,7 @@ def __init__(self, config, mode):
3562
self.trace = config.trace.root.get("assertion")
3663

3764

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
65+
def install_importhook(config, mode):
4266
if mode == "rewrite":
4367
try:
4468
import ast # noqa
@@ -51,37 +75,38 @@ def pytest_load_initial_conftests(early_config, parser, args):
5175
sys.version_info[:3] == (2, 6, 0)):
5276
mode = "reinterp"
5377

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

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
80+
_load_modules(mode)
81+
from _pytest.monkeypatch import MonkeyPatch
82+
m = MonkeyPatch()
83+
config._cleanup.append(m.undo)
84+
m.setattr(py.builtin.builtins, 'AssertionError',
85+
reinterpret.AssertionError) # noqa
6386

6487
hook = None
6588
if mode == "rewrite":
66-
hook = rewrite.AssertionRewritingHook(early_config) # noqa
89+
hook = rewrite.AssertionRewritingHook(config) # noqa
6790
sys.meta_path.insert(0, hook)
6891

69-
early_config._assertstate.hook = hook
70-
early_config._assertstate.trace("configured with mode set to %r" % (mode,))
92+
config._assertstate.hook = hook
93+
config._assertstate.trace("configured with mode set to %r" % (mode,))
7194
def undo():
72-
hook = early_config._assertstate.hook
95+
hook = config._assertstate.hook
7396
if hook is not None and hook in sys.meta_path:
7497
sys.meta_path.remove(hook)
75-
early_config.add_cleanup(undo)
98+
config.add_cleanup(undo)
99+
return hook
76100

77101

78102
def pytest_collection(session):
79103
# this hook is only called when test modules are collected
80104
# so for example not in the master process of pytest-xdist
81105
# (which does not collect test modules)
82-
hook = session.config._assertstate.hook
83-
if hook is not None:
84-
hook.set_session(session)
106+
assertstate = getattr(session.config, '_assertstate', None)
107+
if assertstate:
108+
if assertstate.hook is not None:
109+
assertstate.hook.set_session(session)
85110

86111

87112
def _running_on_ci():
@@ -138,9 +163,10 @@ def pytest_runtest_teardown(item):
138163

139164

140165
def pytest_sessionfinish(session):
141-
hook = session.config._assertstate.hook
142-
if hook is not None:
143-
hook.session = None
166+
assertstate = getattr(session.config, '_assertstate', None)
167+
if assertstate:
168+
if assertstate.hook is not None:
169+
assertstate.hook.set_session(None)
144170

145171

146172
def _load_modules(mode):
@@ -151,31 +177,5 @@ def _load_modules(mode):
151177
from _pytest.assertion import rewrite # noqa
152178

153179

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-
180180
# Expose this plugin's implementation for the pytest_assertrepr_compare hook
181181
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: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
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
1112
import _pytest._code
1213
import _pytest.hookspec # the extension point definitions
14+
import _pytest.assertion
1315
from _pytest._pluggy import PluginManager, HookimplMarker, HookspecMarker
1416

1517
hookimpl = HookimplMarker("pytest")
@@ -160,6 +162,9 @@ def __init__(self):
160162
self.trace.root.setwriter(err.write)
161163
self.enable_tracing()
162164

165+
# Config._consider_importhook will set a real object if required.
166+
self.rewrite_hook = _pytest.assertion.DummyRewriteHook()
167+
163168
def addhooks(self, module_or_class):
164169
"""
165170
.. deprecated:: 2.8
@@ -368,7 +373,9 @@ def consider_env(self):
368373
self._import_plugin_specs(os.environ.get("PYTEST_PLUGINS"))
369374

370375
def consider_module(self, mod):
371-
self._import_plugin_specs(getattr(mod, "pytest_plugins", None))
376+
plugins = getattr(mod, 'pytest_plugins', [])
377+
self.rewrite_hook.mark_rewrite(*plugins)
378+
self._import_plugin_specs(plugins)
372379

373380
def _import_plugin_specs(self, spec):
374381
if spec:
@@ -925,14 +932,58 @@ def _initini(self, args):
925932
self._parser.addini('addopts', 'extra command line options', 'args')
926933
self._parser.addini('minversion', 'minimally required pytest version')
927934

935+
def _consider_importhook(self, args, entrypoint_name):
936+
"""Install the PEP 302 import hook if using assertion re-writing.
937+
938+
Needs to parse the --assert=<mode> option from the commandline
939+
and find all the installed plugins to mark them for re-writing
940+
by the importhook.
941+
"""
942+
ns, unknown_args = self._parser.parse_known_and_unknown_args(args)
943+
mode = ns.assertmode
944+
self._warn_about_missing_assertion(mode)
945+
if mode != 'plain':
946+
hook = _pytest.assertion.install_importhook(self, mode)
947+
if hook:
948+
self.pluginmanager.rewrite_hook = hook
949+
for entrypoint in pkg_resources.iter_entry_points('pytest11'):
950+
for entry in entrypoint.dist._get_metadata('RECORD'):
951+
fn = entry.split(',')[0]
952+
is_simple_module = os.sep not in fn and fn.endswith('.py')
953+
is_package = fn.count(os.sep) == 1 and fn.endswith('__init__.py')
954+
if is_simple_module:
955+
module_name, ext = os.path.splitext(fn)
956+
hook.mark_rewrite(module_name)
957+
elif is_package:
958+
package_name = os.path.dirname(fn)
959+
hook.mark_rewrite(package_name)
960+
961+
def _warn_about_missing_assertion(self, mode):
962+
try:
963+
assert False
964+
except AssertionError:
965+
pass
966+
else:
967+
if mode == "rewrite":
968+
specifically = ("assertions not in test modules or plugins"
969+
"will be ignored")
970+
else:
971+
specifically = "failing tests may report as passing"
972+
sys.stderr.write("WARNING: " + specifically +
973+
" because assert statements are not executed "
974+
"by the underlying Python interpreter "
975+
"(are you using python -O?)\n")
976+
928977
def _preparse(self, args, addopts=True):
929978
self._initini(args)
930979
if addopts:
931980
args[:] = shlex.split(os.environ.get('PYTEST_ADDOPTS', '')) + args
932981
args[:] = self.getini("addopts") + args
933982
self._checkversion()
983+
entrypoint_name = 'pytest11'
984+
self._consider_importhook(args, entrypoint_name)
934985
self.pluginmanager.consider_preparse(args)
935-
self.pluginmanager.load_setuptools_entrypoints("pytest11")
986+
self.pluginmanager.load_setuptools_entrypoints(entrypoint_name)
936987
self.pluginmanager.consider_env()
937988
self.known_args_namespace = ns = self._parser.parse_known_args(args, namespace=self.option.copy())
938989
if self.known_args_namespace.confcutdir is None and self.inifile:

_pytest/pytester.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import py
1717
import pytest
1818
from _pytest.main import Session, EXIT_OK
19+
from _pytest.assertion.rewrite import AssertionRewritingHook
1920

2021

2122
def pytest_addoption(parser):
@@ -685,8 +686,17 @@ def inline_run(self, *args, **kwargs):
685686
``pytest.main()`` instance should use.
686687
687688
:return: A :py:class:`HookRecorder` instance.
688-
689689
"""
690+
# When running py.test inline any plugins active in the main
691+
# test process are already imported. So this disables the
692+
# warning which will trigger to say they can no longer be
693+
# re-written, which is fine as they are already re-written.
694+
orig_warn = AssertionRewritingHook._warn_already_imported
695+
def revert():
696+
AssertionRewritingHook._warn_already_imported = orig_warn
697+
self.request.addfinalizer(revert)
698+
AssertionRewritingHook._warn_already_imported = lambda *a: None
699+
690700
rec = []
691701
class Collect:
692702
def pytest_configure(x, config):

0 commit comments

Comments
 (0)