diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bac8bb6e2..faae82372 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,13 +5,13 @@ repos: hooks: - id: black args: [--safe, --quiet] - language_version: python3.6 + language_version: python3 - repo: https://github.com/asottile/blacken-docs rev: v0.2.0 hooks: - id: blacken-docs additional_dependencies: [black==18.6b4] - language_version: python3.6 + language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v1.3.0 hooks: @@ -37,7 +37,6 @@ repos: files: ^(CHANGELOG.rst|HOWTORELEASE.rst|README.rst|changelog/.*)$ language: python additional_dependencies: [pygments, restructuredtext_lint] - python_version: python3.6 - id: changelogs-rst name: changelog files must end in .rst entry: ./scripts/fail diff --git a/AUTHORS b/AUTHORS index 49440194e..1641ea15e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -98,6 +98,7 @@ Javier Domingo Cansino Javier Romero Jeff Rackauckas Jeff Widman +Jenni Rinker John Eddie Ayson John Towler Jon Sonesen @@ -182,6 +183,7 @@ Russel Winder Ryan Wooden Samuel Dion-Girardeau Samuele Pedroni +Sankt Petersbug Segev Finer Serhii Mozghovyi Simon Gomizelj @@ -205,6 +207,7 @@ Trevor Bekolay Tyler Goodlet Tzu-ping Chung Vasily Kuznetsov +Victor Maryama Victor Uriarte Vidar T. Fauske Vitaly Lashmanov diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e837807cb..6a864e8a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,40 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 3.7.2 (2018-08-16) +========================= + +Bug Fixes +--------- + +- `#3671 `_: Fix ``filterwarnings`` not being registered as a builtin mark. + + +- `#3768 `_, `#3789 `_: Fix test collection from packages mixed with normal directories. + + +- `#3771 `_: Fix infinite recursion during collection if a ``pytest_ignore_collect`` hook returns ``False`` instead of ``None``. + + +- `#3774 `_: Fix bug where decorated fixtures would lose functionality (for example ``@mock.patch``). + + +- `#3775 `_: Fix bug where importing modules or other objects with prefix ``pytest_`` prefix would raise a ``PluginValidationError``. + + +- `#3788 `_: Fix ``AttributeError`` during teardown of ``TestCase`` subclasses which raise an exception during ``__init__``. + + +- `#3804 `_: Fix traceback reporting for exceptions with ``__cause__`` cycles. + + + +Improved Documentation +---------------------- + +- `#3746 `_: Add documentation for ``metafunc.config`` that had been mistakenly hidden. + + pytest 3.7.1 (2018-08-02) ========================= diff --git a/changelog/3033.bugfix.rst b/changelog/3033.bugfix.rst new file mode 100644 index 000000000..3fcd9dd11 --- /dev/null +++ b/changelog/3033.bugfix.rst @@ -0,0 +1 @@ +Fixtures during teardown can again use ``capsys`` and ``cafd`` to inspect output captured during tests. diff --git a/changelog/3771.bugfix.rst b/changelog/3771.bugfix.rst deleted file mode 100644 index 09c953aa2..000000000 --- a/changelog/3771.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix infinite recursion during collection if a ``pytest_ignore_collect`` returns ``False`` instead of ``None``. diff --git a/changelog/3775.bugfix.rst b/changelog/3775.bugfix.rst deleted file mode 100644 index dd5263f74..000000000 --- a/changelog/3775.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix bug where importing modules or other objects with prefix ``pytest_`` prefix would raise a ``PluginValidationError``. diff --git a/changelog/3788.bugfix.rst b/changelog/3788.bugfix.rst deleted file mode 100644 index aa391e28b..000000000 --- a/changelog/3788.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix ``AttributeError`` during teardown of ``TestCase`` subclasses which raise an exception during ``__init__``. diff --git a/changelog/3816.bugfix.rst b/changelog/3816.bugfix.rst new file mode 100644 index 000000000..a50c8f729 --- /dev/null +++ b/changelog/3816.bugfix.rst @@ -0,0 +1 @@ +Fix bug where ``--show-capture=no`` option would still show logs printed during fixture teardown. diff --git a/changelog/3819.bugfix.rst b/changelog/3819.bugfix.rst new file mode 100644 index 000000000..02b33f9b1 --- /dev/null +++ b/changelog/3819.bugfix.rst @@ -0,0 +1 @@ +Fix ``stdout/stderr`` not getting captured when real-time cli logging is active. diff --git a/changelog/3824.doc.rst b/changelog/3824.doc.rst new file mode 100644 index 000000000..016065120 --- /dev/null +++ b/changelog/3824.doc.rst @@ -0,0 +1 @@ +Added example for multiple glob pattern matches in ``python_files``. diff --git a/changelog/3826.trivial.rst b/changelog/3826.trivial.rst new file mode 100644 index 000000000..5354d0df9 --- /dev/null +++ b/changelog/3826.trivial.rst @@ -0,0 +1 @@ +Replace broken type annotations with type comments. diff --git a/changelog/3833.doc.rst b/changelog/3833.doc.rst new file mode 100644 index 000000000..254e2e4b6 --- /dev/null +++ b/changelog/3833.doc.rst @@ -0,0 +1 @@ +Added missing docs for ``pytester.Testdir`` diff --git a/changelog/3843.bugfix.rst b/changelog/3843.bugfix.rst new file mode 100644 index 000000000..3186c3fc5 --- /dev/null +++ b/changelog/3843.bugfix.rst @@ -0,0 +1 @@ +Fix collection error when specifying test functions directly in the command line using ``test.py::test`` syntax together with ``--doctest-module``. diff --git a/changelog/3845.trivial.rst b/changelog/3845.trivial.rst new file mode 100644 index 000000000..29c45ab56 --- /dev/null +++ b/changelog/3845.trivial.rst @@ -0,0 +1,2 @@ +Remove a reference to issue `#568 `_ from the documentation, which has since been +fixed. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index d0f79a500..1e7f2ce0f 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-3.7.2 release-3.7.1 release-3.7.0 release-3.6.4 diff --git a/doc/en/announce/release-3.7.2.rst b/doc/en/announce/release-3.7.2.rst new file mode 100644 index 000000000..4f7e0744d --- /dev/null +++ b/doc/en/announce/release-3.7.2.rst @@ -0,0 +1,25 @@ +pytest-3.7.2 +======================================= + +pytest 3.7.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at http://doc.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Daniel Hahler +* Josh Holland +* Ronny Pfannschmidt +* Sankt Petersbug +* Wes Thomas +* turturica + + +Happy testing, +The pytest Development Team diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index 93dc37197..1ae99436d 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -200,6 +200,8 @@ You can ask which markers exist for your test suite - the list includes our just $ pytest --markers @pytest.mark.webtest: mark a test as a webtest. + @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see http://pytest.org/latest/warnings.html#pytest-mark-filterwarnings + @pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test. @pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value. Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see http://pytest.org/latest/skipping.html @@ -374,6 +376,8 @@ The ``--markers`` option always gives you a list of available markers:: $ pytest --markers @pytest.mark.env(name): mark test to run only on named environment + @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see http://pytest.org/latest/warnings.html#pytest-mark-filterwarnings + @pytest.mark.skip(reason=None): skip the given test function with an optional reason. Example: skip(reason="no way of currently testing this") skips the test. @pytest.mark.skipif(condition): skip the given test function if eval(condition) results in a True value. Evaluation happens within the module global context. Example: skipif('sys.platform == "win32"') skips the test if we are on the win32 platform. see http://pytest.org/latest/skipping.html diff --git a/doc/en/example/nonpython.rst b/doc/en/example/nonpython.rst index 2bc70c4cc..bda15065a 100644 --- a/doc/en/example/nonpython.rst +++ b/doc/en/example/nonpython.rst @@ -84,8 +84,9 @@ interesting to just look at the collection tree:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y rootdir: $REGENDOC_TMPDIR/nonpython, inifile: collected 2 items - - - + + + + ======================= no tests ran in 0.12 seconds ======================= diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index 43f4f598f..fdc802554 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -411,11 +411,10 @@ is to be run with different sets of arguments for its three arguments: Running it results in some skips if we don't have all the python interpreters installed and otherwise runs all combinations (5 interpreters times 5 interpreters times 3 objects to serialize/deserialize):: . $ pytest -rs -q multipython.py - ...ssssssssssssssssssssssss [100%] + ...sss...sssssssss...sss... [100%] ========================= short test summary info ========================== - SKIP [12] $REGENDOC_TMPDIR/CWD/multipython.py:28: 'python3.4' not found - SKIP [12] $REGENDOC_TMPDIR/CWD/multipython.py:28: 'python3.5' not found - 3 passed, 24 skipped in 0.12 seconds + SKIP [15] $REGENDOC_TMPDIR/CWD/multipython.py:28: 'python3.4' not found + 12 passed, 15 skipped in 0.12 seconds Indirect parametrization of optional implementations/imports -------------------------------------------------------------------- diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 8e9d3ae62..b4950a75c 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -100,19 +100,21 @@ Changing naming conventions You can configure different naming conventions by setting the :confval:`python_files`, :confval:`python_classes` and -:confval:`python_functions` configuration options. Example:: +:confval:`python_functions` configuration options. +Here is an example:: + # Example 1: have pytest look for "check" instead of "test" # content of pytest.ini # can also be defined in tox.ini or setup.cfg file, although the section # name in setup.cfg files should be "tool:pytest" [pytest] - python_files=check_*.py - python_classes=Check - python_functions=*_check + python_files = check_*.py + python_classes = Check + python_functions = *_check This would make ``pytest`` look for tests in files that match the ``check_* .py`` glob-pattern, ``Check`` prefixes in classes, and functions and methods -that match ``*_check``. For example, if we have:: +that match ``*_check``. For example, if we have:: # content of check_myapp.py class CheckMyApp(object): @@ -121,7 +123,7 @@ that match ``*_check``. For example, if we have:: def complex_check(self): pass -then the test collection looks like this:: +The test collection would look like this:: $ pytest --collect-only =========================== test session starts ============================ @@ -136,11 +138,19 @@ then the test collection looks like this:: ======================= no tests ran in 0.12 seconds ======================= +You can check for multiple glob patterns by adding a space between the patterns:: + + # Example 2: have pytest look for files with "test" and "example" + # content of pytest.ini, tox.ini, or setup.cfg file (replace "pytest" + # with "tool:pytest" for setup.cfg) + [pytest] + python_files = test_*.py example_*.py + .. note:: the ``python_functions`` and ``python_classes`` options has no effect for ``unittest.TestCase`` test discovery because pytest delegates - detection of test case methods to unittest code. + discovery of test case methods to unittest code. Interpreting cmdline arguments as Python packages ----------------------------------------------------- diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 86d92cf07..042df9687 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -460,7 +460,7 @@ To use it, include in your top-most ``conftest.py`` file:: .. autoclass:: Testdir() - :members: runpytest,runpytest_subprocess,runpytest_inprocess,makeconftest,makepyfile + :members: .. autoclass:: RunResult() :members: @@ -1229,7 +1229,8 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: python_classes One or more name prefixes or glob-style patterns determining which classes - are considered for test collection. By default, pytest will consider any + are considered for test collection. Search for multiple glob patterns by + adding a space between patterns. By default, pytest will consider any class prefixed with ``Test`` as a test collection. Here is an example of how to collect tests from classes that end in ``Suite``: @@ -1246,15 +1247,23 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: python_files One or more Glob-style file patterns determining which python files - are considered as test modules. By default, pytest will consider - any file matching with ``test_*.py`` and ``*_test.py`` globs as a test - module. + are considered as test modules. Search for multiple glob patterns by + adding a space between patterns:: + + .. code-block:: ini + + [pytest] + python_files = test_*.py check_*.py example_*.py + + By default, pytest will consider any file matching with ``test_*.py`` + and ``*_test.py`` globs as a test module. .. confval:: python_functions One or more name prefixes or glob-patterns determining which test functions - and methods are considered tests. By default, pytest will consider any + and methods are considered tests. Search for multiple glob patterns by + adding a space between patterns. By default, pytest will consider any function prefixed with ``test`` as a test. Here is an example of how to collect test functions and methods that end in ``_test``: diff --git a/doc/en/skipping.rst b/doc/en/skipping.rst index cda67554d..efdf008fb 100644 --- a/doc/en/skipping.rst +++ b/doc/en/skipping.rst @@ -136,12 +136,6 @@ You can use the ``skipif`` marker (as any other marker) on classes:: If the condition is ``True``, this marker will produce a skip result for each of the test methods of that class. -.. warning:: - - The use of ``skipif`` on classes that use inheritance is strongly - discouraged. `A Known bug `_ - in pytest's markers may cause unexpected behavior in super classes. - If you want to skip all test functions of a module, you may use the ``pytestmark`` name on the global level: diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 78644db8a..d6c5cd90e 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -719,7 +719,9 @@ class FormattedExcinfo(object): repr_chain = [] e = excinfo.value descr = None - while e is not None: + seen = set() + while e is not None and id(e) not in seen: + seen.add(id(e)) if excinfo: reprtraceback = self.repr_traceback(excinfo) reprcrash = excinfo._getreprcrash() diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index faa767a86..97b88ee9d 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -16,7 +16,6 @@ import six import pytest from _pytest.compat import CaptureIO - patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"} @@ -63,8 +62,9 @@ def pytest_load_initial_conftests(early_config, parser, args): # finally trigger conftest loading but while capturing (issue93) capman.start_global_capturing() outcome = yield - out, err = capman.suspend_global_capture() + capman.suspend_global_capture() if outcome.excinfo is not None: + out, err = capman.read_global_capture() sys.stdout.write(out) sys.stderr.write(err) @@ -85,6 +85,7 @@ class CaptureManager(object): def __init__(self, method): self._method = method self._global_capturing = None + self._current_item = None def _getcapture(self, method): if method == "fd": @@ -96,6 +97,8 @@ class CaptureManager(object): else: raise ValueError("unknown capturing method: %r" % method) + # Global capturing control + def start_global_capturing(self): assert self._global_capturing is None self._global_capturing = self._getcapture(self._method) @@ -110,16 +113,15 @@ class CaptureManager(object): def resume_global_capture(self): self._global_capturing.resume_capturing() - def suspend_global_capture(self, item=None, in_=False): - if item is not None: - self.deactivate_fixture(item) + def suspend_global_capture(self, in_=False): cap = getattr(self, "_global_capturing", None) if cap is not None: - try: - outerr = cap.readouterr() - finally: - cap.suspend_capturing(in_=in_) - return outerr + cap.suspend_capturing(in_=in_) + + def read_global_capture(self): + return self._global_capturing.readouterr() + + # Fixture Control (its just forwarding, think about removing this later) def activate_fixture(self, item): """If the current item is using ``capsys`` or ``capfd``, activate them so they take precedence over @@ -135,12 +137,53 @@ class CaptureManager(object): if fixture is not None: fixture.close() + def suspend_fixture(self, item): + fixture = getattr(item, "_capture_fixture", None) + if fixture is not None: + fixture._suspend() + + def resume_fixture(self, item): + fixture = getattr(item, "_capture_fixture", None) + if fixture is not None: + fixture._resume() + + # Helper context managers + + @contextlib.contextmanager + def global_and_fixture_disabled(self): + """Context manager to temporarily disables global and current fixture capturing.""" + # Need to undo local capsys-et-al if exists before disabling global capture + self.suspend_fixture(self._current_item) + self.suspend_global_capture(in_=False) + try: + yield + finally: + self.resume_global_capture() + self.resume_fixture(self._current_item) + + @contextlib.contextmanager + def item_capture(self, when, item): + self.resume_global_capture() + self.activate_fixture(item) + try: + yield + finally: + self.deactivate_fixture(item) + self.suspend_global_capture(in_=False) + + out, err = self.read_global_capture() + item.add_report_section(when, "stdout", out) + item.add_report_section(when, "stderr", err) + + # Hooks + @pytest.hookimpl(hookwrapper=True) def pytest_make_collect_report(self, collector): if isinstance(collector, pytest.File): self.resume_global_capture() outcome = yield - out, err = self.suspend_global_capture() + self.suspend_global_capture() + out, err = self.read_global_capture() rep = outcome.get_result() if out: rep.sections.append(("Captured stdout", out)) @@ -150,29 +193,25 @@ class CaptureManager(object): yield @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(self, item): - self.resume_global_capture() - # no need to activate a capture fixture because they activate themselves during creation; this - # only makes sense when a fixture uses a capture fixture, otherwise the capture fixture will - # be activated during pytest_runtest_call + def pytest_runtest_protocol(self, item): + self._current_item = item yield - self.suspend_capture_item(item, "setup") + self._current_item = None + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_setup(self, item): + with self.item_capture("setup", item): + yield @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(self, item): - self.resume_global_capture() - # it is important to activate this fixture during the call phase so it overwrites the "global" - # capture - self.activate_fixture(item) - yield - self.suspend_capture_item(item, "call") + with self.item_capture("call", item): + yield @pytest.hookimpl(hookwrapper=True) def pytest_runtest_teardown(self, item): - self.resume_global_capture() - self.activate_fixture(item) - yield - self.suspend_capture_item(item, "teardown") + with self.item_capture("teardown", item): + yield @pytest.hookimpl(tryfirst=True) def pytest_keyboard_interrupt(self, excinfo): @@ -182,11 +221,6 @@ class CaptureManager(object): def pytest_internalerror(self, excinfo): self.stop_global_capturing() - def suspend_capture_item(self, item, when, in_=False): - out, err = self.suspend_global_capture(item, in_=in_) - item.add_report_section(when, "stdout", out) - item.add_report_section(when, "stderr", err) - capture_fixtures = {"capfd", "capfdbinary", "capsys", "capsysbinary"} @@ -290,40 +324,54 @@ class CaptureFixture(object): def __init__(self, captureclass, request): self.captureclass = captureclass self.request = request + self._capture = None + self._captured_out = self.captureclass.EMPTY_BUFFER + self._captured_err = self.captureclass.EMPTY_BUFFER def _start(self): - self._capture = MultiCapture( - out=True, err=True, in_=False, Capture=self.captureclass - ) - self._capture.start_capturing() + # Start if not started yet + if getattr(self, "_capture", None) is None: + self._capture = MultiCapture( + out=True, err=True, in_=False, Capture=self.captureclass + ) + self._capture.start_capturing() def close(self): - cap = self.__dict__.pop("_capture", None) - if cap is not None: - self._outerr = cap.pop_outerr_to_orig() - cap.stop_capturing() + if self._capture is not None: + out, err = self._capture.pop_outerr_to_orig() + self._captured_out += out + self._captured_err += err + self._capture.stop_capturing() + self._capture = None def readouterr(self): """Read and return the captured output so far, resetting the internal buffer. :return: captured content as a namedtuple with ``out`` and ``err`` string attributes """ - try: - return self._capture.readouterr() - except AttributeError: - return self._outerr + captured_out, captured_err = self._captured_out, self._captured_err + if self._capture is not None: + out, err = self._capture.readouterr() + captured_out += out + captured_err += err + self._captured_out = self.captureclass.EMPTY_BUFFER + self._captured_err = self.captureclass.EMPTY_BUFFER + return CaptureResult(captured_out, captured_err) + + def _suspend(self): + """Suspends this fixture's own capturing temporarily.""" + self._capture.suspend_capturing() + + def _resume(self): + """Resumes this fixture's own capturing temporarily.""" + self._capture.resume_capturing() @contextlib.contextmanager def disabled(self): """Temporarily disables capture while inside the 'with' block.""" - self._capture.suspend_capturing() capmanager = self.request.config.pluginmanager.getplugin("capturemanager") - capmanager.suspend_global_capture(item=None, in_=False) - try: + with capmanager.global_and_fixture_disabled(): yield - finally: - capmanager.resume_global_capture() - self._capture.resume_capturing() def safe_text_dupfile(f, mode, default_encoding="UTF8"): @@ -440,6 +488,7 @@ class MultiCapture(object): class NoCapture(object): + EMPTY_BUFFER = None __init__ = start = done = suspend = resume = lambda *args: None @@ -449,6 +498,8 @@ class FDCaptureBinary(object): snap() produces `bytes` """ + EMPTY_BUFFER = bytes() + def __init__(self, targetfd, tmpfile=None): self.targetfd = targetfd try: @@ -522,6 +573,8 @@ class FDCapture(FDCaptureBinary): snap() produces text """ + EMPTY_BUFFER = str() + def snap(self): res = FDCaptureBinary.snap(self) enc = getattr(self.tmpfile, "encoding", None) @@ -531,6 +584,9 @@ class FDCapture(FDCaptureBinary): class SysCapture(object): + + EMPTY_BUFFER = str() + def __init__(self, fd, tmpfile=None): name = patchsysdict[fd] self._old = getattr(sys, name) @@ -568,6 +624,8 @@ class SysCapture(object): class SysCaptureBinary(SysCapture): + EMPTY_BUFFER = bytes() + def snap(self): res = self.tmpfile.buffer.getvalue() self.tmpfile.seek(0) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index c3ecaf912..ea369ccf2 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -8,6 +8,7 @@ import functools import inspect import re import sys +from contextlib import contextmanager import py @@ -151,6 +152,13 @@ def getfuncargnames(function, is_method=False, cls=None): return arg_names +@contextmanager +def dummy_context_manager(): + """Context manager that does nothing, useful in situations where you might need an actual context manager or not + depending on some condition. Using this allow to keep the same code""" + yield + + def get_default_arg_names(function): # Note: this code intentionally mirrors the code at the beginning of getfuncargnames, # to get the arguments which were excluded from its result because they had default values diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 21d99b0ce..5a4e35b88 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -174,23 +174,23 @@ class Argument(object): if isinstance(typ, six.string_types): if typ == "choice": warnings.warn( - "type argument to addoption() is a string %r." - " For parsearg this is optional and when supplied" - " should be a type." + "`type` argument to addoption() is the string %r." + " For choices this is optional and can be omitted, " + " but when supplied should be a type (for example `str` or `int`)." " (options: %s)" % (typ, names), DeprecationWarning, - stacklevel=3, + stacklevel=4, ) # argparse expects a type here take it from # the type of the first element attrs["type"] = type(attrs["choices"][0]) else: warnings.warn( - "type argument to addoption() is a string %r." - " For parsearg this should be a type." + "`type` argument to addoption() is the string %r, " + " but when supplied should be a type (for example `str` or `int`)." " (options: %s)" % (typ, names), DeprecationWarning, - stacklevel=3, + stacklevel=4, ) attrs["type"] = Argument._typ_map[typ] # used in test_parseopt -> test_parse_defaultgetter diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 9991307d0..f51dff373 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -102,7 +102,8 @@ class PdbInvoke(object): def pytest_exception_interact(self, node, call, report): capman = node.config.pluginmanager.getplugin("capturemanager") if capman: - out, err = capman.suspend_global_capture(in_=True) + capman.suspend_global_capture(in_=True) + out, err = capman.read_global_capture() sys.stdout.write(out) sys.stdout.write(err) _enter_pdb(node, call.excinfo, report) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index a6634cd11..cc8921e65 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -307,8 +307,8 @@ class FuncFixtureInfo(object): # fixture names specified via usefixtures and via autouse=True in fixture # definitions. initialnames = attr.ib(type=tuple) - names_closure = attr.ib(type="List[str]") - name2fixturedefs = attr.ib(type="List[str, List[FixtureDef]]") + names_closure = attr.ib() # type: List[str] + name2fixturedefs = attr.ib() # type: List[str, List[FixtureDef]] def prune_dependency_tree(self): """Recompute names_closure from initialnames and name2fixturedefs diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 1472b0dbd..c9c65c4c1 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -6,6 +6,7 @@ from contextlib import closing, contextmanager import re import six +from _pytest.compat import dummy_context_manager from _pytest.config import create_terminal_writer import pytest import py @@ -369,11 +370,6 @@ def pytest_configure(config): config.pluginmanager.register(LoggingPlugin(config), "logging-plugin") -@contextmanager -def _dummy_context_manager(): - yield - - class LoggingPlugin(object): """Attaches to the logging module and captures log messages for each test. """ @@ -537,7 +533,7 @@ class LoggingPlugin(object): log_cli_handler, formatter=log_cli_formatter, level=log_cli_level ) else: - self.live_logs_context = _dummy_context_manager() + self.live_logs_context = dummy_context_manager() class _LiveLoggingStreamHandler(logging.StreamHandler): @@ -572,9 +568,12 @@ class _LiveLoggingStreamHandler(logging.StreamHandler): self._test_outcome_written = False def emit(self, record): - if self.capture_manager is not None: - self.capture_manager.suspend_global_capture() - try: + ctx_manager = ( + self.capture_manager.global_and_fixture_disabled() + if self.capture_manager + else dummy_context_manager() + ) + with ctx_manager: if not self._first_record_emitted: self.stream.write("\n") self._first_record_emitted = True @@ -586,6 +585,3 @@ class _LiveLoggingStreamHandler(logging.StreamHandler): self.stream.section("live log " + self._when, sep="-", bold=True) self._section_name_shown = True logging.StreamHandler.emit(self, record) - finally: - if self.capture_manager is not None: - self.capture_manager.resume_global_capture() diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 105891e46..947c6aa4b 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -505,8 +505,9 @@ class Session(nodes.FSCollector): root = self._node_cache[pkginit] else: col = root._collectfile(pkginit) - if col and isinstance(col, Package): - root = col[0] + if col: + if isinstance(col[0], Package): + root = col[0] self._node_cache[root.fspath] = root # If it's a directory argument, recurse and look for any Subpackages. @@ -624,11 +625,12 @@ class Session(nodes.FSCollector): resultnodes.append(node) continue assert isinstance(node, nodes.Collector) - if node.nodeid in self._node_cache: - rep = self._node_cache[node.nodeid] + key = (type(node), node.nodeid) + if key in self._node_cache: + rep = self._node_cache[key] else: rep = collect_one_node(node) - self._node_cache[node.nodeid] = rep + self._node_cache[key] = rep if rep.passed: has_matched = False for x in rep.result: diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 5b42b81ee..b40a9e267 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -550,18 +550,22 @@ class Testdir(object): return ret def makefile(self, ext, *args, **kwargs): - """Create a new file in the testdir. + r"""Create new file(s) in the testdir. - ext: The extension the file should use, including the dot, e.g. `.py`. - - args: All args will be treated as strings and joined using newlines. + :param str ext: The extension the file(s) should use, including the dot, e.g. `.py`. + :param list[str] args: All args will be treated as strings and joined using newlines. The result will be written as contents to the file. The name of the file will be based on the test function requesting this fixture. - E.g. "testdir.makefile('.txt', 'line1', 'line2')" - - kwargs: Each keyword is the name of a file, while the value of it will + :param kwargs: Each keyword is the name of a file, while the value of it will be written as contents of the file. - E.g. "testdir.makefile('.ini', pytest='[pytest]\naddopts=-rs\n')" + + Examples: + + .. code-block:: python + + testdir.makefile(".txt", "line1", "line2") + + testdir.makefile(".ini", pytest="[pytest]\naddopts=-rs\n") """ return self._makefile(ext, args, kwargs) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ad8a5f252..51bc28fe5 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -216,18 +216,6 @@ def pytest_pycollect_makemodule(path, parent): return Module(path, parent) -def pytest_ignore_collect(path, config): - # Skip duplicate packages. - keepduplicates = config.getoption("keepduplicates") - if keepduplicates: - duplicate_paths = config.pluginmanager._duplicatepaths - if path.basename == "__init__.py": - if path in duplicate_paths: - return True - else: - duplicate_paths.add(path) - - @hookimpl(hookwrapper=True) def pytest_pycollect_makeitem(collector, name, obj): outcome = yield @@ -554,9 +542,7 @@ class Package(Module): self.name = fspath.dirname self.trace = session.trace self._norecursepatterns = session._norecursepatterns - for path in list(session.config.pluginmanager._duplicatepaths): - if path.dirname == fspath.dirname and path != fspath: - session.config.pluginmanager._duplicatepaths.remove(path) + self.fspath = fspath def _recurse(self, path): ihook = self.gethookproxy(path.dirpath()) @@ -594,6 +580,15 @@ class Package(Module): return path in self.session._initialpaths def collect(self): + # XXX: HACK! + # Before starting to collect any files from this package we need + # to cleanup the duplicate paths added by the session's collect(). + # Proper fix is to not track these as duplicates in the first place. + for path in list(self.session.config.pluginmanager._duplicatepaths): + # if path.parts()[:len(self.fspath.dirpath().parts())] == self.fspath.dirpath().parts(): + if path.dirname.startswith(self.name): + self.session.config.pluginmanager._duplicatepaths.remove(path) + this_path = self.fspath.dirpath() pkg_prefix = None yield Module(this_path.join("__init__.py"), self) @@ -884,12 +879,13 @@ class Metafunc(fixtures.FuncargnamesCompatAttr): """ def __init__(self, definition, fixtureinfo, config, cls=None, module=None): - #: access to the :class:`_pytest.config.Config` object for the test session assert ( isinstance(definition, FunctionDefinition) or type(definition).__name__ == "DefinitionMock" ) self.definition = definition + + #: access to the :class:`_pytest.config.Config` object for the test session self.config = config #: the module object where the test function is defined in. diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 81240d9d0..c3edc5f81 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -51,7 +51,8 @@ def _show_fixture_action(fixturedef, msg): config = fixturedef._fixturemanager.config capman = config.pluginmanager.getplugin("capturemanager") if capman: - out, err = capman.suspend_global_capture() + capman.suspend_global_capture() + out, err = capman.read_global_capture() tw = config.get_terminal_writer() tw.line() diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 7dd2edd6f..f79624989 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -706,7 +706,12 @@ class TerminalReporter(object): self._outrep_summary(rep) def print_teardown_sections(self, rep): + showcapture = self.config.option.showcapture + if showcapture == "no": + return for secname, content in rep.sections: + if showcapture != "all" and showcapture not in secname: + continue if "teardown" in secname: self._tw.sep("-", secname) if content[-1:] == "\n": diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index abd04801b..f2f23a6e2 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -49,6 +49,14 @@ def pytest_addoption(parser): ) +def pytest_configure(config): + config.addinivalue_line( + "markers", + "filterwarnings(warning): add a warning filter to the given test. " + "see http://pytest.org/latest/warnings.html#pytest-mark-filterwarnings ", + ) + + @contextmanager def catch_warnings_for_item(item): """ diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index bc4e3bed8..5d6baf121 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -660,6 +660,16 @@ class TestInvocationVariants(object): ["*test_world.py::test_other*PASSED*", "*1 passed*"] ) + def test_invoke_test_and_doctestmodules(self, testdir): + p = testdir.makepyfile( + """ + def test(): + pass + """ + ) + result = testdir.runpytest(str(p) + "::test", "--doctest-modules") + result.stdout.fnmatch_lines(["*1 passed*"]) + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="requires symlinks") def test_cmdline_python_package_symlink(self, testdir, monkeypatch): """ diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 403063ad6..fbdaeacf7 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, division, print_function import operator import os import sys +import textwrap import _pytest import py import pytest @@ -1265,6 +1266,50 @@ raise ValueError() ] ) + @pytest.mark.skipif("sys.version_info[0] < 3") + def test_exc_chain_repr_cycle(self, importasmod): + mod = importasmod( + """ + class Err(Exception): + pass + def fail(): + return 0 / 0 + def reraise(): + try: + fail() + except ZeroDivisionError as e: + raise Err() from e + def unreraise(): + try: + reraise() + except Err as e: + raise e.__cause__ + """ + ) + excinfo = pytest.raises(ZeroDivisionError, mod.unreraise) + r = excinfo.getrepr(style="short") + tw = TWMock() + r.toterminal(tw) + out = "\n".join(line for line in tw.lines if isinstance(line, str)) + expected_out = textwrap.dedent( + """\ + :13: in unreraise + reraise() + :10: in reraise + raise Err() from e + E test_exc_chain_repr_cycle0.mod.Err + + During handling of the above exception, another exception occurred: + :15: in unreraise + raise e.__cause__ + :8: in reraise + fail() + :5: in fail + return 0 / 0 + E ZeroDivisionError: division by zero""" + ) + assert out == expected_out + @pytest.mark.parametrize("style", ["short", "long"]) @pytest.mark.parametrize("encoding", [None, "utf8", "utf16"]) diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 07c092191..363982cf9 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -876,22 +876,18 @@ def test_live_logging_suspends_capture(has_capture_manager, request): is installed. """ import logging + import contextlib from functools import partial - from _pytest.capture import CaptureManager from _pytest.logging import _LiveLoggingStreamHandler class MockCaptureManager: calls = [] - def suspend_global_capture(self): - self.calls.append("suspend_global_capture") - - def resume_global_capture(self): - self.calls.append("resume_global_capture") - - # sanity check - assert CaptureManager.suspend_capture_item - assert CaptureManager.resume_global_capture + @contextlib.contextmanager + def global_and_fixture_disabled(self): + self.calls.append("enter disabled") + yield + self.calls.append("exit disabled") class DummyTerminal(six.StringIO): def section(self, *args, **kwargs): @@ -908,10 +904,7 @@ def test_live_logging_suspends_capture(has_capture_manager, request): logger.critical("some message") if has_capture_manager: - assert MockCaptureManager.calls == [ - "suspend_global_capture", - "resume_global_capture", - ] + assert MockCaptureManager.calls == ["enter disabled", "exit disabled"] else: assert MockCaptureManager.calls == [] assert out_file.getvalue() == "\nsome message\n" diff --git a/testing/python/collect.py b/testing/python/collect.py index 907b368eb..c040cc09e 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1583,3 +1583,43 @@ def test_package_collection_infinite_recursion(testdir): testdir.copy_example("collect/package_infinite_recursion") result = testdir.runpytest() result.stdout.fnmatch_lines("*1 passed*") + + +def test_package_with_modules(testdir): + """ + . + └── root + ├── __init__.py + ├── sub1 + │ ├── __init__.py + │ └── sub1_1 + │ ├── __init__.py + │ └── test_in_sub1.py + └── sub2 + └── test + └── test_in_sub2.py + + """ + root = testdir.mkpydir("root") + sub1 = root.mkdir("sub1") + sub1.ensure("__init__.py") + sub1_test = sub1.mkdir("sub1_1") + sub1_test.ensure("__init__.py") + sub2 = root.mkdir("sub2") + sub2_test = sub2.mkdir("sub2") + + sub1_test.join("test_in_sub1.py").write("def test_1(): pass") + sub2_test.join("test_in_sub2.py").write("def test_2(): pass") + + # Execute from . + result = testdir.runpytest("-v", "-s") + result.assert_outcomes(passed=2) + + # Execute from . with one argument "root" + result = testdir.runpytest("-v", "-s", "root") + result.assert_outcomes(passed=2) + + # Chdir into package's root and execute with no args + root.chdir() + result = testdir.runpytest("-v", "-s") + result.assert_outcomes(passed=2) diff --git a/testing/test_capture.py b/testing/test_capture.py index 5f5e1b98d..93eaaa85c 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -70,19 +70,23 @@ class TestCaptureManager(object): try: capman = CaptureManager(method) capman.start_global_capturing() - outerr = capman.suspend_global_capture() + capman.suspend_global_capture() + outerr = capman.read_global_capture() assert outerr == ("", "") - outerr = capman.suspend_global_capture() + capman.suspend_global_capture() + outerr = capman.read_global_capture() assert outerr == ("", "") print("hello") - out, err = capman.suspend_global_capture() + capman.suspend_global_capture() + out, err = capman.read_global_capture() if method == "no": assert old == (sys.stdout, sys.stderr, sys.stdin) else: assert not out capman.resume_global_capture() print("hello") - out, err = capman.suspend_global_capture() + capman.suspend_global_capture() + out, err = capman.read_global_capture() if method != "no": assert out == "hello\n" capman.stop_global_capturing() @@ -647,6 +651,34 @@ class TestCaptureFixture(object): assert "stdout contents begin" not in result.stdout.str() assert "stderr contents begin" not in result.stdout.str() + @pytest.mark.parametrize("cap", ["capsys", "capfd"]) + def test_fixture_use_by_other_fixtures_teardown(self, testdir, cap): + """Ensure we can access setup and teardown buffers from teardown when using capsys/capfd (##3033)""" + testdir.makepyfile( + """ + import sys + import pytest + import os + + @pytest.fixture() + def fix({cap}): + print("setup out") + sys.stderr.write("setup err\\n") + yield + out, err = {cap}.readouterr() + assert out == 'setup out\\ncall out\\n' + assert err == 'setup err\\ncall err\\n' + + def test_a(fix): + print("call out") + sys.stderr.write("call err\\n") + """.format( + cap=cap + ) + ) + reprec = testdir.inline_run() + reprec.assertoutcome(passed=1) + def test_setup_failure_does_not_kill_capturing(testdir): sub1 = testdir.mkpydir("sub1") @@ -1385,3 +1417,95 @@ def test_pickling_and_unpickling_encoded_file(): ef = capture.EncodedFile(None, None) ef_as_str = pickle.dumps(ef) pickle.loads(ef_as_str) + + +def test_global_capture_with_live_logging(testdir): + # Issue 3819 + # capture should work with live cli logging + + # Teardown report seems to have the capture for the whole process (setup, capture, teardown) + testdir.makeconftest( + """ + def pytest_runtest_logreport(report): + if "test_global" in report.nodeid: + if report.when == "teardown": + with open("caplog", "w") as f: + f.write(report.caplog) + with open("capstdout", "w") as f: + f.write(report.capstdout) + """ + ) + + testdir.makepyfile( + """ + import logging + import sys + import pytest + + logger = logging.getLogger(__name__) + + @pytest.fixture + def fix1(): + print("fix setup") + logging.info("fix setup") + yield + logging.info("fix teardown") + print("fix teardown") + + def test_global(fix1): + print("begin test") + logging.info("something in test") + print("end test") + """ + ) + result = testdir.runpytest_subprocess("--log-cli-level=INFO") + assert result.ret == 0 + + with open("caplog", "r") as f: + caplog = f.read() + + assert "fix setup" in caplog + assert "something in test" in caplog + assert "fix teardown" in caplog + + with open("capstdout", "r") as f: + capstdout = f.read() + + assert "fix setup" in capstdout + assert "begin test" in capstdout + assert "end test" in capstdout + assert "fix teardown" in capstdout + + +@pytest.mark.parametrize("capture_fixture", ["capsys", "capfd"]) +def test_capture_with_live_logging(testdir, capture_fixture): + # Issue 3819 + # capture should work with live cli logging + + testdir.makepyfile( + """ + import logging + import sys + + logger = logging.getLogger(__name__) + + def test_capture({0}): + print("hello") + sys.stderr.write("world\\n") + captured = {0}.readouterr() + assert captured.out == "hello\\n" + assert captured.err == "world\\n" + + logging.info("something") + print("next") + logging.info("something") + + captured = {0}.readouterr() + assert captured.out == "next\\n" + """.format( + capture_fixture + ) + ) + + result = testdir.runpytest_subprocess("--log-cli-level=INFO") + assert result.ret == 0 diff --git a/testing/test_collection.py b/testing/test_collection.py index 23d82cb14..5b494ba31 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -638,6 +638,10 @@ class Test_getinitialnodes(object): assert col.config is config def test_pkgfile(self, testdir): + """Verify nesting when a module is within a package. + The parent chain should match: Module -> Package -> Session. + Session's parent should always be None. + """ tmpdir = testdir.tmpdir subdir = tmpdir.join("subdir") x = subdir.ensure("x.py") @@ -645,9 +649,12 @@ class Test_getinitialnodes(object): with subdir.as_cwd(): config = testdir.parseconfigure(x) col = testdir.getnode(config, x) - assert isinstance(col, pytest.Module) assert col.name == "x.py" - assert col.parent.parent is None + assert isinstance(col, pytest.Module) + assert isinstance(col.parent, pytest.Package) + assert isinstance(col.parent.parent, pytest.Session) + # session is batman (has no parents) + assert col.parent.parent.parent is None for col in col.listchain(): assert col.config is config diff --git a/testing/test_terminal.py b/testing/test_terminal.py index a9da27980..88e5287e8 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -948,6 +948,46 @@ def pytest_report_header(config, startdir): assert "!This is stderr!" not in stdout assert "!This is a warning log msg!" not in stdout + def test_show_capture_with_teardown_logs(self, testdir): + """Ensure that the capturing of teardown logs honor --show-capture setting""" + testdir.makepyfile( + """ + import logging + import sys + import pytest + + @pytest.fixture(scope="function", autouse="True") + def hook_each_test(request): + yield + sys.stdout.write("!stdout!") + sys.stderr.write("!stderr!") + logging.warning("!log!") + + def test_func(): + assert False + """ + ) + + result = testdir.runpytest("--show-capture=stdout", "--tb=short").stdout.str() + assert "!stdout!" in result + assert "!stderr!" not in result + assert "!log!" not in result + + result = testdir.runpytest("--show-capture=stderr", "--tb=short").stdout.str() + assert "!stdout!" not in result + assert "!stderr!" in result + assert "!log!" not in result + + result = testdir.runpytest("--show-capture=log", "--tb=short").stdout.str() + assert "!stdout!" not in result + assert "!stderr!" not in result + assert "!log!" in result + + result = testdir.runpytest("--show-capture=no", "--tb=short").stdout.str() + assert "!stdout!" not in result + assert "!stderr!" not in result + assert "!log!" not in result + @pytest.mark.xfail("not hasattr(os, 'dup')") def test_fdopen_kept_alive_issue124(testdir): diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 15ec36600..a26fb4597 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -287,3 +287,18 @@ def test_non_string_warning_argument(testdir): ) result = testdir.runpytest("-W", "always") result.stdout.fnmatch_lines(["*= 1 passed, 1 warnings in *"]) + + +def test_filterwarnings_mark_registration(testdir): + """Ensure filterwarnings mark is registered""" + testdir.makepyfile( + """ + import pytest + + @pytest.mark.filterwarnings('error') + def test_func(): + pass + """ + ) + result = testdir.runpytest("--strict") + assert result.ret == 0 diff --git a/tox.ini b/tox.ini index a126dbbf1..6514421b6 100644 --- a/tox.ini +++ b/tox.ini @@ -115,8 +115,6 @@ skipsdist = True usedevelop = True changedir = doc/en deps = - attrs - more-itertools PyYAML sphinx sphinxcontrib-trio