From 620ba5971f7c44b36f1c6b62d70c89914c6a071e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 6 Jun 2017 22:25:15 -0300 Subject: [PATCH 1/2] deprecated_call context manager captures warnings already raised Fix #2469 --- _pytest/recwarn.py | 62 ++++++++++++++++++++++------------------- changelog/2469.bugfix | 4 +++ testing/test_recwarn.py | 58 +++++++++++++++++++++++++++++++------- 3 files changed, 86 insertions(+), 38 deletions(-) create mode 100644 changelog/2469.bugfix diff --git a/_pytest/recwarn.py b/_pytest/recwarn.py index 7ad6fef89..36b22e940 100644 --- a/_pytest/recwarn.py +++ b/_pytest/recwarn.py @@ -27,10 +27,8 @@ def recwarn(): def deprecated_call(func=None, *args, **kwargs): - """ assert that calling ``func(*args, **kwargs)`` triggers a - ``DeprecationWarning`` or ``PendingDeprecationWarning``. - - This function can be used as a context manager:: + """context manager that can be used to ensure a block of code triggers a + ``DeprecationWarning`` or ``PendingDeprecationWarning``:: >>> import warnings >>> def api_call_v2(): @@ -40,38 +38,46 @@ def deprecated_call(func=None, *args, **kwargs): >>> with deprecated_call(): ... assert api_call_v2() == 200 - Note: we cannot use WarningsRecorder here because it is still subject - to the mechanism that prevents warnings of the same type from being - triggered twice for the same module. See #1190. + ``deprecated_call`` can also be used by passing a function and ``*args`` and ``*kwargs``, + in which case it will ensure calling ``func(*args, **kwargs)`` produces one of the warnings + types above. """ if not func: - return WarningsChecker(expected_warning=(DeprecationWarning, PendingDeprecationWarning)) + return _DeprecatedCallContext() + else: + with _DeprecatedCallContext(): + return func(*args, **kwargs) - categories = [] - def warn_explicit(message, category, *args, **kwargs): - categories.append(category) +class _DeprecatedCallContext(object): + """Implements the logic to capture deprecation warnings as a context manager.""" - def warn(message, category=None, *args, **kwargs): + def __enter__(self): + self._captured_categories = [] + self._old_warn = warnings.warn + self._old_warn_explicit = warnings.warn_explicit + warnings.warn_explicit = self._warn_explicit + warnings.warn = self._warn + + def _warn_explicit(self, message, category, *args, **kwargs): + self._captured_categories.append(category) + + def _warn(self, message, category=None, *args, **kwargs): if isinstance(message, Warning): - categories.append(message.__class__) + self._captured_categories.append(message.__class__) else: - categories.append(category) + self._captured_categories.append(category) - old_warn = warnings.warn - old_warn_explicit = warnings.warn_explicit - warnings.warn_explicit = warn_explicit - warnings.warn = warn - try: - ret = func(*args, **kwargs) - finally: - warnings.warn_explicit = old_warn_explicit - warnings.warn = old_warn - deprecation_categories = (DeprecationWarning, PendingDeprecationWarning) - if not any(issubclass(c, deprecation_categories) for c in categories): - __tracebackhide__ = True - raise AssertionError("%r did not produce DeprecationWarning" % (func,)) - return ret + def __exit__(self, exc_type, exc_val, exc_tb): + warnings.warn_explicit = self._old_warn_explicit + warnings.warn = self._old_warn + + if exc_type is None: + deprecation_categories = (DeprecationWarning, PendingDeprecationWarning) + if not any(issubclass(c, deprecation_categories) for c in self._captured_categories): + __tracebackhide__ = True + msg = "Did not produce DeprecationWarning or PendingDeprecationWarning" + raise AssertionError(msg) def warns(expected_warning, *args, **kwargs): diff --git a/changelog/2469.bugfix b/changelog/2469.bugfix new file mode 100644 index 000000000..492c62e08 --- /dev/null +++ b/changelog/2469.bugfix @@ -0,0 +1,4 @@ +``deprecated_call`` in context-manager form now captures deprecation warnings even if +the same warning has already been raised. Also, ``deprecated_call`` will always produce +the same error message (previously it would produce different messages in context-manager vs. +function-call mode). diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index 75dacc040..f1048f07d 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -77,7 +77,7 @@ class TestDeprecatedCall(object): def test_deprecated_call_raises(self): with pytest.raises(AssertionError) as excinfo: pytest.deprecated_call(self.dep, 3, 5) - assert str(excinfo).find("did not produce") != -1 + assert 'Did not produce' in str(excinfo) def test_deprecated_call(self): pytest.deprecated_call(self.dep, 0, 5) @@ -106,31 +106,69 @@ class TestDeprecatedCall(object): pytest.deprecated_call(self.dep_explicit, 0) pytest.deprecated_call(self.dep_explicit, 0) - def test_deprecated_call_as_context_manager_no_warning(self): - with pytest.raises(pytest.fail.Exception, matches='^DID NOT WARN'): - with pytest.deprecated_call(): - self.dep(1) + @pytest.mark.parametrize('mode', ['context_manager', 'call']) + def test_deprecated_call_no_warning(self, mode): + """Ensure deprecated_call() raises the expected failure when its block/function does + not raise a deprecation warning. + """ + def f(): + pass + + msg = 'Did not produce DeprecationWarning or PendingDeprecationWarning' + with pytest.raises(AssertionError, matches=msg): + if mode == 'call': + pytest.deprecated_call(f) + else: + with pytest.deprecated_call(): + f() @pytest.mark.parametrize('warning_type', [PendingDeprecationWarning, DeprecationWarning]) @pytest.mark.parametrize('mode', ['context_manager', 'call']) - def test_deprecated_call_modes(self, warning_type, mode): + @pytest.mark.parametrize('call_f_first', [True, False]) + def test_deprecated_call_modes(self, warning_type, mode, call_f_first): + """Ensure deprecated_call() captures a deprecation warning as expected inside its + block/function. + """ def f(): warnings.warn(warning_type("hi")) - + return 10 + + # ensure deprecated_call() can capture the warning even if it has already been triggered + if call_f_first: + assert f() == 10 if mode == 'call': - pytest.deprecated_call(f) + assert pytest.deprecated_call(f) == 10 else: with pytest.deprecated_call(): - f() + assert f() == 10 + + @pytest.mark.parametrize('mode', ['context_manager', 'call']) + def test_deprecated_call_exception_is_raised(self, mode): + """If the block of the code being tested by deprecated_call() raises an exception, + it must raise the exception undisturbed. + """ + def f(): + raise ValueError('some exception') + + with pytest.raises(ValueError, match='some exception'): + if mode == 'call': + pytest.deprecated_call(f) + else: + with pytest.deprecated_call(): + f() def test_deprecated_call_specificity(self): other_warnings = [Warning, UserWarning, SyntaxWarning, RuntimeWarning, FutureWarning, ImportWarning, UnicodeWarning] for warning in other_warnings: def f(): - py.std.warnings.warn(warning("hi")) + warnings.warn(warning("hi")) + with pytest.raises(AssertionError): pytest.deprecated_call(f) + with pytest.raises(AssertionError): + with pytest.deprecated_call(): + f() def test_deprecated_function_already_called(self, testdir): """deprecated_call should be able to catch a call to a deprecated From ff8dbd0ad8d50cf1ff9c07e0c6e04fe26d58cc9a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 22 Jun 2017 08:54:39 -0300 Subject: [PATCH 2/2] Add tracebackhide to function call form of deprecated_call --- _pytest/recwarn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/_pytest/recwarn.py b/_pytest/recwarn.py index 36b22e940..9cc404a49 100644 --- a/_pytest/recwarn.py +++ b/_pytest/recwarn.py @@ -45,6 +45,7 @@ def deprecated_call(func=None, *args, **kwargs): if not func: return _DeprecatedCallContext() else: + __tracebackhide__ = True with _DeprecatedCallContext(): return func(*args, **kwargs) @@ -71,7 +72,7 @@ class _DeprecatedCallContext(object): def __exit__(self, exc_type, exc_val, exc_tb): warnings.warn_explicit = self._old_warn_explicit warnings.warn = self._old_warn - + if exc_type is None: deprecation_categories = (DeprecationWarning, PendingDeprecationWarning) if not any(issubclass(c, deprecation_categories) for c in self._captured_categories):