Merge pull request #3041 from segevfiner/capture-no-disable-progress

Use classic console output when -s is used
This commit is contained in:
Bruno Oliveira 2017-12-16 12:34:34 -02:00 committed by GitHub
commit d87279115d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 322 additions and 76 deletions

View File

@ -74,6 +74,7 @@ Grig Gheorghiu
Grigorii Eremeev (budulianin)
Guido Wesdorp
Harald Armin Massa
Henk-Jaap Wagenaar
Hugo van Kemenade
Hui Wang (coldnight)
Ian Bicking

View File

@ -179,11 +179,13 @@ class AssertionRewritingHook(object):
The named module or package as well as any nested modules will
be rewritten on import.
"""
already_imported = set(names).intersection(set(sys.modules))
if already_imported:
for name in already_imported:
if name not in self._rewritten_names:
self._warn_already_imported(name)
already_imported = (set(names)
.intersection(sys.modules)
.difference(self._rewritten_names))
for name in already_imported:
if not AssertionRewriter.is_rewrite_disabled(
sys.modules[name].__doc__ or ""):
self._warn_already_imported(name)
self._must_rewrite.update(names)
def _warn_already_imported(self, name):
@ -635,7 +637,8 @@ class AssertionRewriter(ast.NodeVisitor):
not isinstance(field, ast.expr)):
nodes.append(field)
def is_rewrite_disabled(self, docstring):
@staticmethod
def is_rewrite_disabled(docstring):
return "PYTEST_DONT_REWRITE" in docstring
def variable(self):

View File

@ -267,7 +267,6 @@ class FixtureRequest(FuncargnamesCompatAttr):
self.fixturename = None
#: Scope string, one of "function", "class", "module", "session"
self.scope = "function"
self._fixture_values = {} # argname -> fixture value
self._fixture_defs = {} # argname -> FixtureDef
fixtureinfo = pyfuncitem._fixtureinfo
self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy()
@ -450,8 +449,7 @@ class FixtureRequest(FuncargnamesCompatAttr):
raise
# remove indent to prevent the python3 exception
# from leaking into the call
result = self._getfixturevalue(fixturedef)
self._fixture_values[argname] = result
self._compute_fixture_value(fixturedef)
self._fixture_defs[argname] = fixturedef
return fixturedef
@ -466,7 +464,14 @@ class FixtureRequest(FuncargnamesCompatAttr):
values.append(fixturedef)
current = current._parent_request
def _getfixturevalue(self, fixturedef):
def _compute_fixture_value(self, fixturedef):
"""
Creates a SubRequest based on "self" and calls the execute method of the given fixturedef object. This will
force the FixtureDef object to throw away any previous results and compute a new fixture value, which
will be stored into the FixtureDef object itself.
:param FixtureDef fixturedef:
"""
# prepare a subrequest object before calling fixture function
# (latter managed by fixturedef)
argname = fixturedef.argname
@ -515,12 +520,11 @@ class FixtureRequest(FuncargnamesCompatAttr):
exc_clear()
try:
# call the fixture function
val = fixturedef.execute(request=subrequest)
fixturedef.execute(request=subrequest)
finally:
# if fixture function failed it might have registered finalizers
self.session._setupstate.addfinalizer(functools.partial(fixturedef.finish, request=subrequest),
subrequest.node)
return val
def _check_scope(self, argname, invoking_scope, requested_scope):
if argname == "request":
@ -573,7 +577,6 @@ class SubRequest(FixtureRequest):
self.scope = scope
self._fixturedef = fixturedef
self._pyfuncitem = request._pyfuncitem
self._fixture_values = request._fixture_values
self._fixture_defs = request._fixture_defs
self._arg2fixturedefs = request._arg2fixturedefs
self._arg2index = request._arg2index

View File

@ -12,22 +12,31 @@ hookspec = HookspecMarker("pytest")
@hookspec(historic=True)
def pytest_addhooks(pluginmanager):
"""called at plugin registration time to allow adding new hooks via a call to
pluginmanager.add_hookspecs(module_or_class, prefix)."""
``pluginmanager.add_hookspecs(module_or_class, prefix)``.
:param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager
"""
@hookspec(historic=True)
def pytest_namespace():
"""
DEPRECATED: this hook causes direct monkeypatching on pytest, its use is strongly discouraged
(**Deprecated**) this hook causes direct monkeypatching on pytest, its use is strongly discouraged
return dict of name->object to be made globally available in
the pytest namespace. This hook is called at plugin registration
time.
the pytest namespace.
This hook is called at plugin registration time.
"""
@hookspec(historic=True)
def pytest_plugin_registered(plugin, manager):
""" a new pytest plugin got registered. """
""" a new pytest plugin got registered.
:param plugin: the plugin module or instance
:param _pytest.config.PytestPluginManager manager: pytest plugin manager
"""
@hookspec(historic=True)
@ -41,7 +50,7 @@ def pytest_addoption(parser):
files situated at the tests root directory due to how pytest
:ref:`discovers plugins during startup <pluginorder>`.
:arg parser: To add command line options, call
:arg _pytest.config.Parser parser: To add command line options, call
:py:func:`parser.addoption(...) <_pytest.config.Parser.addoption>`.
To add ini-file values call :py:func:`parser.addini(...)
<_pytest.config.Parser.addini>`.
@ -56,8 +65,7 @@ def pytest_addoption(parser):
a value read from an ini-style file.
The config object is passed around on many internal objects via the ``.config``
attribute or can be retrieved as the ``pytestconfig`` fixture or accessed
via (deprecated) ``pytest.config``.
attribute or can be retrieved as the ``pytestconfig`` fixture.
"""
@ -72,8 +80,7 @@ def pytest_configure(config):
After that, the hook is called for other conftest files as they are
imported.
:arg config: pytest config object
:type config: _pytest.config.Config
:arg _pytest.config.Config config: pytest config object
"""
# -------------------------------------------------------------------------
@ -87,11 +94,22 @@ def pytest_configure(config):
def pytest_cmdline_parse(pluginmanager, args):
"""return initialized config object, parsing the specified args.
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`
:param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager
:param list[str] args: list of arguments passed on the command line
"""
def pytest_cmdline_preparse(config, args):
"""(deprecated) modify command line arguments before option parsing. """
"""(**Deprecated**) modify command line arguments before option parsing.
This hook is considered deprecated and will be removed in a future pytest version. Consider
using :func:`pytest_load_initial_conftests` instead.
:param _pytest.config.Config config: pytest config object
:param list[str] args: list of arguments passed on the command line
"""
@hookspec(firstresult=True)
@ -99,12 +117,20 @@ def pytest_cmdline_main(config):
""" called for performing the main command line action. The default
implementation will invoke the configure hooks and runtest_mainloop.
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`
:param _pytest.config.Config config: pytest config object
"""
def pytest_load_initial_conftests(early_config, parser, args):
""" implements the loading of initial conftest files ahead
of command line option parsing. """
of command line option parsing.
:param _pytest.config.Config early_config: pytest config object
:param list[str] args: list of arguments passed on the command line
:param _pytest.config.Parser parser: to add command line options
"""
# -------------------------------------------------------------------------
@ -113,18 +139,29 @@ def pytest_load_initial_conftests(early_config, parser, args):
@hookspec(firstresult=True)
def pytest_collection(session):
""" perform the collection protocol for the given session.
"""Perform the collection protocol for the given session.
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`.
:param _pytest.main.Session session: the pytest session object
"""
def pytest_collection_modifyitems(session, config, items):
""" called after collection has been performed, may filter or re-order
the items in-place."""
the items in-place.
:param _pytest.main.Session session: the pytest session object
:param _pytest.config.Config config: pytest config object
:param List[_pytest.main.Item] items: list of item objects
"""
def pytest_collection_finish(session):
""" called after collection has been performed and modified. """
""" called after collection has been performed and modified.
:param _pytest.main.Session session: the pytest session object
"""
@hookspec(firstresult=True)
@ -134,6 +171,9 @@ def pytest_ignore_collect(path, config):
more specific hooks.
Stops at first non-None result, see :ref:`firstresult`
:param str path: the path to analyze
:param _pytest.config.Config config: pytest config object
"""
@ -141,12 +181,18 @@ def pytest_ignore_collect(path, config):
def pytest_collect_directory(path, parent):
""" called before traversing a directory for collection files.
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`
:param str path: the path to analyze
"""
def pytest_collect_file(path, parent):
""" return collection Node or None for the given path. Any new node
needs to have the specified ``parent`` as a parent."""
needs to have the specified ``parent`` as a parent.
:param str path: the path to collect
"""
# logging hooks for collection
@ -212,7 +258,12 @@ def pytest_make_parametrize_id(config, val, argname):
by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``.
The parameter name is available as ``argname``, if required.
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`
:param _pytest.config.Config config: pytest config object
:param val: the parametrized value
:param str argname: the automatic parameter name produced by pytest
"""
# -------------------------------------------------------------------------
# generic runtest related hooks
@ -224,11 +275,14 @@ def pytest_runtestloop(session):
""" called for performing the main runtest loop
(after collection finished).
Stops at first non-None result, see :ref:`firstresult` """
Stops at first non-None result, see :ref:`firstresult`
:param _pytest.main.Session session: the pytest session object
"""
def pytest_itemstart(item, node):
""" (deprecated, use pytest_runtest_logstart). """
"""(**Deprecated**) use pytest_runtest_logstart. """
@hookspec(firstresult=True)
@ -307,15 +361,25 @@ def pytest_fixture_post_finalizer(fixturedef, request):
def pytest_sessionstart(session):
""" before session.main() is called. """
""" before session.main() is called.
:param _pytest.main.Session session: the pytest session object
"""
def pytest_sessionfinish(session, exitstatus):
""" whole test run finishes. """
""" whole test run finishes.
:param _pytest.main.Session session: the pytest session object
:param int exitstatus: the status which pytest will return to the system
"""
def pytest_unconfigure(config):
""" called before test process is exited. """
""" called before test process is exited.
:param _pytest.config.Config config: pytest config object
"""
# -------------------------------------------------------------------------
@ -329,6 +393,8 @@ def pytest_assertrepr_compare(config, op, left, right):
of strings. The strings will be joined by newlines but any newlines
*in* a string will be escaped. Note that all but the first line will
be indented slightly, the intention is for the first line to be a summary.
:param _pytest.config.Config config: pytest config object
"""
# -------------------------------------------------------------------------
@ -339,7 +405,7 @@ def pytest_assertrepr_compare(config, op, left, right):
def pytest_report_header(config, startdir):
""" return a string or list of strings to be displayed as header info for terminal reporting.
:param config: the pytest config object.
:param _pytest.config.Config config: pytest config object
:param startdir: py.path object with the starting dir
.. note::
@ -358,7 +424,7 @@ def pytest_report_collectionfinish(config, startdir, items):
This strings will be displayed after the standard "collected X items" message.
:param config: the pytest config object.
:param _pytest.config.Config config: pytest config object
:param startdir: py.path object with the starting dir
:param items: list of pytest items that are going to be executed; this list should not be modified.
"""
@ -418,6 +484,5 @@ def pytest_enter_pdb(config):
""" called upon pdb.set_trace(), can be used by plugins to take special
action just before the python debugger enters in interactive mode.
:arg config: pytest config object
:type config: _pytest.config.Config
:param _pytest.config.Config config: pytest config object
"""

View File

@ -79,39 +79,28 @@ def pytest_addoption(parser):
@contextmanager
def logging_using_handler(handler, logger=None):
"""Context manager that safely registers a given handler."""
logger = logger or logging.getLogger(logger)
if handler in logger.handlers: # reentrancy
# Adding the same handler twice would confuse logging system.
# Just don't do that.
yield
else:
logger.addHandler(handler)
try:
yield
finally:
logger.removeHandler(handler)
@contextmanager
def catching_logs(handler, formatter=None,
level=logging.NOTSET, logger=None):
def catching_logs(handler, formatter=None, level=logging.NOTSET):
"""Context manager that prepares the whole logging machinery properly."""
logger = logger or logging.getLogger(logger)
root_logger = logging.getLogger()
if formatter is not None:
handler.setFormatter(formatter)
handler.setLevel(level)
with logging_using_handler(handler, logger):
orig_level = logger.level
logger.setLevel(min(orig_level, level))
try:
yield handler
finally:
logger.setLevel(orig_level)
# Adding the same handler twice would confuse logging system.
# Just don't do that.
add_new_handler = handler not in root_logger.handlers
if add_new_handler:
root_logger.addHandler(handler)
orig_level = root_logger.level
root_logger.setLevel(min(orig_level, level))
try:
yield handler
finally:
root_logger.setLevel(orig_level)
if add_new_handler:
root_logger.removeHandler(handler)
class LogCaptureHandler(logging.StreamHandler):

View File

@ -1,8 +1,10 @@
""" core implementation of testing process: init, session, runtest loop. """
from __future__ import absolute_import, division, print_function
import contextlib
import functools
import os
import pkgutil
import six
import sys
@ -206,6 +208,46 @@ def pytest_ignore_collect(path, config):
return False
@contextlib.contextmanager
def _patched_find_module():
"""Patch bug in pkgutil.ImpImporter.find_module
When using pkgutil.find_loader on python<3.4 it removes symlinks
from the path due to a call to os.path.realpath. This is not consistent
with actually doing the import (in these versions, pkgutil and __import__
did not share the same underlying code). This can break conftest
discovery for pytest where symlinks are involved.
The only supported python<3.4 by pytest is python 2.7.
"""
if six.PY2: # python 3.4+ uses importlib instead
def find_module_patched(self, fullname, path=None):
# Note: we ignore 'path' argument since it is only used via meta_path
subname = fullname.split(".")[-1]
if subname != fullname and self.path is None:
return None
if self.path is None:
path = None
else:
# original: path = [os.path.realpath(self.path)]
path = [self.path]
try:
file, filename, etc = pkgutil.imp.find_module(subname,
path)
except ImportError:
return None
return pkgutil.ImpLoader(fullname, file, filename, etc)
old_find_module = pkgutil.ImpImporter.find_module
pkgutil.ImpImporter.find_module = find_module_patched
try:
yield
finally:
pkgutil.ImpImporter.find_module = old_find_module
else:
yield
class FSHookProxy:
def __init__(self, fspath, pm, remove_mods):
self.fspath = fspath
@ -728,9 +770,10 @@ class Session(FSCollector):
"""Convert a dotted module name to path.
"""
import pkgutil
try:
loader = pkgutil.find_loader(x)
with _patched_find_module():
loader = pkgutil.find_loader(x)
except ImportError:
return x
if loader is None:
@ -738,7 +781,8 @@ class Session(FSCollector):
# This method is sometimes invoked when AssertionRewritingHook, which
# does not define a get_filename method, is already in place:
try:
path = loader.get_filename(x)
with _patched_find_module():
path = loader.get_filename(x)
except AttributeError:
# Retrieve path from AssertionRewritingHook:
path = loader.modules[x][0].co_filename

View File

@ -347,7 +347,7 @@ class RunResult:
:stdout: :py:class:`LineMatcher` of stdout, use ``stdout.str()`` to
reconstruct stdout or the commonly used ``stdout.fnmatch_lines()``
method
:stderrr: :py:class:`LineMatcher` of stderr
:stderr: :py:class:`LineMatcher` of stderr
:duration: duration in seconds
"""

View File

@ -153,7 +153,8 @@ class TerminalReporter:
self.hasmarkup = self._tw.hasmarkup
self.isatty = file.isatty()
self._progress_items_reported = 0
self._show_progress_info = self.config.getini('console_output_style') == 'progress'
self._show_progress_info = (self.config.getoption('capture') != 'no' and
self.config.getini('console_output_style') == 'progress')
def hasopt(self, char):
char = {'xfailed': 'x', 'skipped': 's'}.get(char, char)

1
changelog/2981.bugfix Normal file
View File

@ -0,0 +1 @@
Fix **memory leak** where objects returned by fixtures were never destructed by the garbage collector.

1
changelog/2985.bugfix Normal file
View File

@ -0,0 +1 @@
Fix conversion of pyargs to filename to not convert symlinks and not use deprecated features on Python 3.

1
changelog/2995.bugfix Normal file
View File

@ -0,0 +1 @@
``PYTEST_DONT_REWRITE`` is now checked for plugins too rather than only for test modules.

1
changelog/3021.trivial Normal file
View File

@ -0,0 +1 @@
Code cleanup.

1
changelog/3038.feature Normal file
View File

@ -0,0 +1 @@
Console output fallsback to "classic" mode when capture is disabled (``-s``), otherwise the output gets garbled to the point of being useless.

View File

@ -616,6 +616,7 @@ Collection hooks
``pytest`` calls the following hooks for collecting files and directories:
.. autofunction:: pytest_collection
.. autofunction:: pytest_ignore_collect
.. autofunction:: pytest_collect_directory
.. autofunction:: pytest_collect_file
@ -687,6 +688,14 @@ Reference of objects involved in hooks
:members:
:show-inheritance:
.. autoclass:: _pytest.main.FSCollector()
:members:
:show-inheritance:
.. autoclass:: _pytest.main.Session()
:members:
:show-inheritance:
.. autoclass:: _pytest.main.Item()
:members:
:show-inheritance:

View File

@ -151,7 +151,7 @@ def publish_release(ctx, version, user, pypi_name):
@invoke.task(help={
'version': 'version being released',
'write_out': 'write changes to the actial changelog'
'write_out': 'write changes to the actual changelog'
})
def changelog(ctx, version, write_out=False):
if write_out:

View File

@ -3,6 +3,8 @@ from __future__ import absolute_import, division, print_function
import os
import sys
import six
import _pytest._code
import py
import pytest
@ -645,6 +647,69 @@ class TestInvocationVariants(object):
"*1 passed*"
])
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="requires symlinks")
def test_cmdline_python_package_symlink(self, testdir, monkeypatch):
"""
test --pyargs option with packages with path containing symlink can
have conftest.py in their package (#2985)
"""
monkeypatch.delenv('PYTHONDONTWRITEBYTECODE', raising=False)
search_path = ["lib", os.path.join("local", "lib")]
dirname = "lib"
d = testdir.mkdir(dirname)
foo = d.mkdir("foo")
foo.ensure("__init__.py")
lib = foo.mkdir('bar')
lib.ensure("__init__.py")
lib.join("test_bar.py"). \
write("def test_bar(): pass\n"
"def test_other(a_fixture):pass")
lib.join("conftest.py"). \
write("import pytest\n"
"@pytest.fixture\n"
"def a_fixture():pass")
d_local = testdir.mkdir("local")
symlink_location = os.path.join(str(d_local), "lib")
if six.PY2:
os.symlink(str(d), symlink_location)
else:
os.symlink(str(d), symlink_location, target_is_directory=True)
# The structure of the test directory is now:
# .
# ├── local
# │ └── lib -> ../lib
# └── lib
# └── foo
# ├── __init__.py
# └── bar
# ├── __init__.py
# ├── conftest.py
# └── test_bar.py
def join_pythonpath(*dirs):
cur = os.getenv('PYTHONPATH')
if cur:
dirs += (cur,)
return os.pathsep.join(str(p) for p in dirs)
monkeypatch.setenv('PYTHONPATH', join_pythonpath(*search_path))
for p in search_path:
monkeypatch.syspath_prepend(p)
# module picked up in symlink-ed directory:
result = testdir.runpytest("--pyargs", "-v", "foo.bar")
testdir.chdir()
assert result.ret == 0
result.stdout.fnmatch_lines([
"*lib/foo/bar/test_bar.py::test_bar*PASSED*",
"*lib/foo/bar/test_bar.py::test_other*PASSED*",
"*2 passed*"
])
def test_cmdline_python_package_not_exists(self, testdir):
result = testdir.runpytest("--pyargs", "tpkgwhatv")
assert result.ret
@ -848,3 +913,46 @@ def test_deferred_hook_checking(testdir):
})
result = testdir.runpytest()
result.stdout.fnmatch_lines(['* 1 passed *'])
def test_fixture_values_leak(testdir):
"""Ensure that fixture objects are properly destroyed by the garbage collector at the end of their expected
life-times (#2981).
"""
testdir.makepyfile("""
import attr
import gc
import pytest
import weakref
@attr.s
class SomeObj(object):
name = attr.ib()
fix_of_test1_ref = None
session_ref = None
@pytest.fixture(scope='session')
def session_fix():
global session_ref
obj = SomeObj(name='session-fixture')
session_ref = weakref.ref(obj)
return obj
@pytest.fixture
def fix(session_fix):
global fix_of_test1_ref
obj = SomeObj(name='local-fixture')
fix_of_test1_ref = weakref.ref(obj)
return obj
def test1(fix):
assert fix_of_test1_ref() is fix
def test2():
gc.collect()
# fixture "fix" created during test1 must have been destroyed by now
assert fix_of_test1_ref() is None
""")
result = testdir.runpytest()
result.stdout.fnmatch_lines(['* 2 passed *'])

View File

@ -128,6 +128,16 @@ class TestAssertionRewrite(object):
assert len(m.body) == 1
assert m.body[0].msg is None
def test_dont_rewrite_plugin(self, testdir):
contents = {
"conftest.py": "pytest_plugins = 'plugin'; import plugin",
"plugin.py": "'PYTEST_DONT_REWRITE'",
"test_foo.py": "def test_foo(): pass",
}
testdir.makepyfile(**contents)
result = testdir.runpytest_subprocess()
assert "warnings" not in "".join(result.outlines)
def test_name(self):
def f():
assert False

View File

@ -1037,3 +1037,11 @@ class TestProgress:
r'\[gw\d\] \[\s*\d+%\] PASSED test_foo.py::test_foo\[1\]',
r'\[gw\d\] \[\s*\d+%\] PASSED test_foobar.py::test_foobar\[1\]',
])
def test_capture_no(self, many_tests_file, testdir):
output = testdir.runpytest('-s')
output.stdout.re_match_lines([
r'test_bar.py \.{10}',
r'test_foo.py \.{5}',
r'test_foobar.py \.{5}',
])