diff --git a/.gitignore b/.gitignore index 935da3b9a..3cac2474a 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml .project .settings .vscode +__pycache__/ # generated by pip pip-wheel-metadata/ diff --git a/AUTHORS b/AUTHORS index ca2872f32..55b0237ea 100644 --- a/AUTHORS +++ b/AUTHORS @@ -154,6 +154,7 @@ Ian Bicking Ian Lesperance Ilya Konstantinov Ionuț Turturică +Itxaso Aizpurua Iwan Briquemont Jaap Broekhuizen Jakob van Santen @@ -356,6 +357,7 @@ Victor Uriarte Vidar T. Fauske Virgil Dupras Vitaly Lashmanov +Vivaan Verma Vlad Dragos Vlad Radziuk Vladyslav Rachek diff --git a/changelog/10196.trivial.rst b/changelog/10196.trivial.rst new file mode 100644 index 000000000..edf458f84 --- /dev/null +++ b/changelog/10196.trivial.rst @@ -0,0 +1 @@ +:class:`~pytest.PytestReturnNotNoneWarning` is now a subclass of :class:`~pytest.PytestRemovedIn8Warning`: the plan is to make returning non-``None`` from tests an error in the future. diff --git a/changelog/10344.doc.rst b/changelog/10344.doc.rst new file mode 100644 index 000000000..1c7885edc --- /dev/null +++ b/changelog/10344.doc.rst @@ -0,0 +1 @@ +Update information on writing plugins to use ``pyproject.toml`` instead of ``setup.py``. diff --git a/changelog/3426.improvement.rst b/changelog/3426.improvement.rst new file mode 100644 index 000000000..e232d56aa --- /dev/null +++ b/changelog/3426.improvement.rst @@ -0,0 +1 @@ +Assertion failures with strings in NFC and NFD forms that normalize to the same string now have a dedicated error message detailing the issue, and their utf-8 representation is expresed instead. diff --git a/changelog/9886.deprecation.rst b/changelog/9886.deprecation.rst new file mode 100644 index 000000000..94f51decf --- /dev/null +++ b/changelog/9886.deprecation.rst @@ -0,0 +1,10 @@ +The functionality for running tests written for ``nose`` has been officially deprecated. + +This includes: + +* Plain ``setup`` and ``teardown`` functions and methods: this might catch users by surprise, as ``setup()`` and ``teardown()`` are not pytest idioms, but part of the ``nose`` support. +* Setup/teardown using the `@with_setup `_ decorator. + +For more details, consult the :ref:`deprecation docs `. + +.. _`with-setup-nose`: https://nose.readthedocs.io/en/latest/testing_tools.html?highlight=with_setup#nose.tools.with_setup diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index e61bf5567..e5fe61ab6 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -18,6 +18,113 @@ Deprecated Features Below is a complete list of all pytest features which are considered deprecated. Using those features will issue :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. + +.. _nose-deprecation: + +Support for tests written for nose +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 7.2 + +Support for running tests written for `nose `__ is now deprecated. + +``nose`` has been in maintenance mode-only for years, and maintaining the plugin is not trivial as it spills +over the code base (see :issue:`9886` for more details). + +setup/teardown +^^^^^^^^^^^^^^ + +One thing that might catch users by surprise is that plain ``setup`` and ``teardown`` methods are not pytest native, +they are in fact part of the ``nose`` support. + + +.. code-block:: python + + class Test: + def setup(self): + self.resource = make_resource() + + def teardown(self): + self.resource.close() + + def test_foo(self): + ... + + def test_bar(self): + ... + + + +Native pytest support uses ``setup_method`` and ``teardown_method`` (see :ref:`xunit-method-setup`), so the above should be changed to: + +.. code-block:: python + + class Test: + def setup_method(self): + self.resource = make_resource() + + def teardown_method(self): + self.resource.close() + + def test_foo(self): + ... + + def test_bar(self): + ... + + +This is easy to do in an entire code base by doing a simple find/replace. + +@with_setup +^^^^^^^^^^^ + +Code using `@with_setup `_ such as this: + +.. code-block:: python + + from nose.tools import with_setup + + + def setup_some_resource(): + ... + + + def teardown_some_resource(): + ... + + + @with_setup(setup_some_resource, teardown_some_resource) + def test_foo(): + ... + +Will also need to be ported to a supported pytest style. One way to do it is using a fixture: + +.. code-block:: python + + import pytest + + + def setup_some_resource(): + ... + + + def teardown_some_resource(): + ... + + + @pytest.fixture + def some_resource(): + setup_some_resource() + yield + teardown_some_resource() + + + def test_foo(some_resource): + ... + + +.. _`with-setup-nose`: https://nose.readthedocs.io/en/latest/testing_tools.html?highlight=with_setup#nose.tools.with_setup + .. _instance-collector-deprecation: The ``pytest.Instance`` collector diff --git a/doc/en/how-to/nose.rst b/doc/en/how-to/nose.rst index 621c243b2..a736dfa55 100644 --- a/doc/en/how-to/nose.rst +++ b/doc/en/how-to/nose.rst @@ -5,6 +5,9 @@ How to run tests written for nose ``pytest`` has basic support for running tests written for nose_. +.. warning:: + This functionality has been deprecated and is likely to be removed in ``pytest 8.x``. + .. _nosestyle: Usage diff --git a/doc/en/how-to/writing_plugins.rst b/doc/en/how-to/writing_plugins.rst index 2fbf49718..f15b69c23 100644 --- a/doc/en/how-to/writing_plugins.rst +++ b/doc/en/how-to/writing_plugins.rst @@ -147,27 +147,33 @@ Making your plugin installable by others If you want to make your plugin externally available, you may define a so-called entry point for your distribution so -that ``pytest`` finds your plugin module. Entry points are -a feature that is provided by :std:doc:`setuptools:index`. pytest looks up -the ``pytest11`` entrypoint to discover its -plugins and you can thus make your plugin available by defining -it in your setuptools-invocation: +that ``pytest`` finds your plugin module. Entry points are +a feature that is provided by :std:doc:`setuptools `. -.. sourcecode:: python +pytest looks up the ``pytest11`` entrypoint to discover its +plugins, thus you can make your plugin available by defining +it in your ``pyproject.toml`` file. - # sample ./setup.py file - from setuptools import setup +.. sourcecode:: toml + # sample ./pyproject.toml file + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" - name_of_plugin = "myproject" # register plugin with this name - setup( - name="myproject", - packages=["myproject"], - # the following makes a plugin available to pytest - entry_points={"pytest11": [f"{name_of_plugin} = myproject.pluginmodule"]}, - # custom PyPI classifier for pytest plugins - classifiers=["Framework :: Pytest"], - ) + [project] + name = "myproject" + classifiers = [ + "Framework :: Pytest", + ] + + [tool.setuptools] + packages = ["myproject"] + + [project.entry_points] + pytest11 = [ + "myproject = myproject.pluginmodule", + ] If a package is installed this way, ``pytest`` will load ``myproject.pluginmodule`` as a plugin which can define diff --git a/doc/en/how-to/xunit_setup.rst b/doc/en/how-to/xunit_setup.rst index eb432a405..3de6681ff 100644 --- a/doc/en/how-to/xunit_setup.rst +++ b/doc/en/how-to/xunit_setup.rst @@ -63,6 +63,8 @@ and after all test methods of the class are called: setup_class. """ +.. _xunit-method-setup: + Method and function level setup/teardown ----------------------------------------------- diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index c4402ff41..6edb88b92 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1136,6 +1136,9 @@ Custom warnings generated in some situations such as improper usage or deprecate .. autoclass:: pytest.PytestReturnNotNoneWarning :show-inheritance: +.. autoclass:: pytest.PytestRemovedIn8Warning + :show-inheritance: + .. autoclass:: pytest.PytestUnhandledCoroutineWarning :show-inheritance: diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index a27e8c2a6..c70187223 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -41,7 +41,7 @@ class SafeRepr(reprlib.Repr): information on exceptions raised during the call. """ - def __init__(self, maxsize: Optional[int]) -> None: + def __init__(self, maxsize: Optional[int], use_ascii: bool = False) -> None: """ :param maxsize: If not None, will truncate the resulting repr to that specific size, using ellipsis @@ -54,10 +54,15 @@ class SafeRepr(reprlib.Repr): # truncation. self.maxstring = maxsize if maxsize is not None else 1_000_000_000 self.maxsize = maxsize + self.use_ascii = use_ascii def repr(self, x: object) -> str: try: - s = super().repr(x) + if self.use_ascii: + s = ascii(x) + else: + s = super().repr(x) + except (KeyboardInterrupt, SystemExit): raise except BaseException as exc: @@ -94,7 +99,9 @@ def safeformat(obj: object) -> str: DEFAULT_REPR_MAX_SIZE = 240 -def saferepr(obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE) -> str: +def saferepr( + obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False +) -> str: """Return a size-limited safe repr-string for the given object. Failing __repr__ functions of user instances will be represented @@ -104,10 +111,11 @@ def saferepr(obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE) -> str This function is a wrapper around the Repr/reprlib functionality of the stdlib. """ - return SafeRepr(maxsize).repr(obj) + + return SafeRepr(maxsize, use_ascii).repr(obj) -def saferepr_unlimited(obj: object) -> str: +def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str: """Return an unlimited-size safe repr-string for the given object. As with saferepr, failing __repr__ functions of user instances @@ -119,6 +127,8 @@ def saferepr_unlimited(obj: object) -> str: when maxsize=None, but that might affect some other code. """ try: + if use_ascii: + return ascii(obj) return repr(obj) except Exception as exc: return _format_repr_exception(exc, obj) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 0c34b83ea..fc5dfdbd5 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -10,6 +10,7 @@ from typing import List from typing import Mapping from typing import Optional from typing import Sequence +from unicodedata import normalize import _pytest._code from _pytest import outcomes @@ -156,20 +157,32 @@ def has_default_eq( return True -def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[str]]: +def assertrepr_compare( + config, op: str, left: Any, right: Any, use_ascii: bool = False +) -> Optional[List[str]]: """Return specialised explanations for some operators/operands.""" verbose = config.getoption("verbose") + + # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. + # See issue #3246. + use_ascii = ( + isinstance(left, str) + and isinstance(right, str) + and normalize("NFD", left) == normalize("NFD", right) + ) + if verbose > 1: - left_repr = saferepr_unlimited(left) - right_repr = saferepr_unlimited(right) + left_repr = saferepr_unlimited(left, use_ascii=use_ascii) + right_repr = saferepr_unlimited(right, use_ascii=use_ascii) else: # XXX: "15 chars indentation" is wrong # ("E AssertionError: assert "); should use term width. maxsize = ( 80 - 15 - len(op) - 2 ) // 2 # 15 chars indentation, 1 space around op - left_repr = saferepr(left, maxsize=maxsize) - right_repr = saferepr(right, maxsize=maxsize) + + left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii) + right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii) summary = f"{left_repr} {op} {right_repr}" diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index fb528e38b..5874eeb99 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -22,6 +22,21 @@ DEPRECATED_EXTERNAL_PLUGINS = { "pytest_faulthandler", } +NOSE_SUPPORT = UnformattedWarning( + PytestRemovedIn8Warning, + "Support for nose tests is deprecated and will be removed in a future release.\n" + "{nodeid} is using nose method: `{method}` ({stage})\n" + "See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose", +) + +NOSE_SUPPORT_METHOD = UnformattedWarning( + PytestRemovedIn8Warning, + "Support for nose tests is deprecated and will be removed in a future release.\n" + "{nodeid} is using nose-specific method: `{method}(self)`\n" + "To remove this warning, rename it to `{method}_method(self)`\n" + "See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose", +) + # This can be* removed pytest 8, but it's harmless and common, so no rush to remove. # * If you're in the future: "could have been". diff --git a/src/_pytest/nose.py b/src/_pytest/nose.py index b0699d22b..273bd045f 100644 --- a/src/_pytest/nose.py +++ b/src/_pytest/nose.py @@ -1,5 +1,8 @@ """Run testsuites written for nose.""" +import warnings + from _pytest.config import hookimpl +from _pytest.deprecated import NOSE_SUPPORT from _pytest.fixtures import getfixturemarker from _pytest.nodes import Item from _pytest.python import Function @@ -18,8 +21,8 @@ def pytest_runtest_setup(item: Item) -> None: # see https://github.com/python/mypy/issues/2608 func = item - call_optional(func.obj, "setup") - func.addfinalizer(lambda: call_optional(func.obj, "teardown")) + call_optional(func.obj, "setup", func.nodeid) + func.addfinalizer(lambda: call_optional(func.obj, "teardown", func.nodeid)) # NOTE: Module- and class-level fixtures are handled in python.py # with `pluginmanager.has_plugin("nose")` checks. @@ -27,7 +30,7 @@ def pytest_runtest_setup(item: Item) -> None: # it's not straightforward. -def call_optional(obj: object, name: str) -> bool: +def call_optional(obj: object, name: str, nodeid: str) -> bool: method = getattr(obj, name, None) if method is None: return False @@ -36,6 +39,11 @@ def call_optional(obj: object, name: str) -> bool: return False if not callable(method): return False + # Warn about deprecation of this plugin. + method_name = getattr(method, "__name__", str(method)) + warnings.warn( + NOSE_SUPPORT.format(nodeid=nodeid, method=method_name, stage=name), stacklevel=2 + ) # If there are any problems allow the exception to raise rather than # silently ignoring it. method() diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3db877506..1e30d42ce 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -59,6 +59,7 @@ from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH from _pytest.deprecated import INSTANCE_COLLECTOR +from _pytest.deprecated import NOSE_SUPPORT_METHOD from _pytest.fixtures import FuncFixtureInfo from _pytest.main import Session from _pytest.mark import MARK_GEN @@ -872,19 +873,23 @@ class Class(PyCollector): """Inject a hidden autouse, function scoped fixture into the collected class object that invokes setup_method/teardown_method if either or both are available. - Using a fixture to invoke this methods ensures we play nicely and unsurprisingly with + Using a fixture to invoke these methods ensures we play nicely and unsurprisingly with other fixtures (#517). """ has_nose = self.config.pluginmanager.has_plugin("nose") setup_name = "setup_method" setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) + emit_nose_setup_warning = False if setup_method is None and has_nose: setup_name = "setup" + emit_nose_setup_warning = True setup_method = _get_first_non_fixture_func(self.obj, (setup_name,)) teardown_name = "teardown_method" teardown_method = getattr(self.obj, teardown_name, None) + emit_nose_teardown_warning = False if teardown_method is None and has_nose: teardown_name = "teardown" + emit_nose_teardown_warning = True teardown_method = getattr(self.obj, teardown_name, None) if setup_method is None and teardown_method is None: return @@ -900,10 +905,24 @@ class Class(PyCollector): if setup_method is not None: func = getattr(self, setup_name) _call_with_optional_argument(func, method) + if emit_nose_setup_warning: + warnings.warn( + NOSE_SUPPORT_METHOD.format( + nodeid=request.node.nodeid, method="setup" + ), + stacklevel=2, + ) yield if teardown_method is not None: func = getattr(self, teardown_name) _call_with_optional_argument(func, method) + if emit_nose_teardown_warning: + warnings.warn( + NOSE_SUPPORT_METHOD.format( + nodeid=request.node.nodeid, method="teardown" + ), + stacklevel=2, + ) self.obj.__pytest_setup_method = xunit_setup_method_fixture diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index 3be1648fe..620860c1b 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -51,14 +51,13 @@ class PytestDeprecationWarning(PytestWarning, DeprecationWarning): __module__ = "pytest" -@final class PytestRemovedIn8Warning(PytestDeprecationWarning): """Warning class for features that will be removed in pytest 8.""" __module__ = "pytest" -class PytestReturnNotNoneWarning(PytestDeprecationWarning): +class PytestReturnNotNoneWarning(PytestRemovedIn8Warning): """Warning emitted when a test function is returning value other than None.""" __module__ = "pytest" diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 2934e69cc..c4868a8a0 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -330,3 +330,62 @@ def test_fixture_disallowed_between_marks(): raise NotImplementedError() assert len(record) == 2 # one for each mark decorator + + +@pytest.mark.filterwarnings("default") +def test_nose_deprecated_with_setup(pytester: Pytester) -> None: + pytest.importorskip("nose") + pytester.makepyfile( + """ + from nose.tools import with_setup + + def setup_fn_no_op(): + ... + + def teardown_fn_no_op(): + ... + + @with_setup(setup_fn_no_op, teardown_fn_no_op) + def test_omits_warnings(): + ... + """ + ) + output = pytester.runpytest() + message = [ + "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", + "*test_nose_deprecated_with_setup.py::test_omits_warnings is using nose method: `setup_fn_no_op` (setup)", + "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", + "*test_nose_deprecated_with_setup.py::test_omits_warnings is using nose method: `teardown_fn_no_op` (teardown)", + ] + output.stdout.fnmatch_lines(message) + output.assert_outcomes(passed=1) + + +@pytest.mark.filterwarnings("default") +def test_nose_deprecated_setup_teardown(pytester: Pytester) -> None: + pytest.importorskip("nose") + pytester.makepyfile( + """ + class Test: + + def setup(self): + ... + + def teardown(self): + ... + + def test(self): + ... + """ + ) + output = pytester.runpytest() + message = [ + "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", + "*test_nose_deprecated_setup_teardown.py::Test::test is using nose-specific method: `setup(self)`", + "*To remove this warning, rename it to `setup_method(self)`", + "*PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release.", + "*test_nose_deprecated_setup_teardown.py::Test::test is using nose-specific method: `teardown(self)`", + "*To remove this warning, rename it to `teardown_method(self)`", + ] + output.stdout.fnmatch_lines(message) + output.assert_outcomes(passed=1) diff --git a/testing/plugins_integration/requirements.txt b/testing/plugins_integration/requirements.txt index 82929c87a..e76231d26 100644 --- a/testing/plugins_integration/requirements.txt +++ b/testing/plugins_integration/requirements.txt @@ -1,12 +1,12 @@ anyio[curio,trio]==3.6.1 -django==4.1.1 +django==4.1.2 pytest-asyncio==0.19.0 pytest-bdd==6.0.1 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-flakes==4.0.5 pytest-html==3.1.1 -pytest-mock==3.9.0 +pytest-mock==3.10.0 pytest-rerunfailures==10.2 pytest-sugar==0.9.5 pytest-trio==0.7.0 diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 2bc06d65a..d8844f2e4 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -776,6 +776,24 @@ class TestAssert_reprcompare: msg = "\n".join(expl) assert msg + def test_nfc_nfd_same_string(self) -> None: + # issue 3426 + left = "hyv\xe4" + right = "hyva\u0308" + expl = callequal(left, right) + assert expl == [ + r"'hyv\xe4' == 'hyva\u0308'", + f"- {str(right)}", + f"+ {str(left)}", + ] + + expl = callequal(left, right, verbose=2) + assert expl == [ + r"'hyv\xe4' == 'hyva\u0308'", + f"- {str(right)}", + f"+ {str(left)}", + ] + class TestAssert_reprcompare_dataclass: def test_dataclasses(self, pytester: Pytester) -> None: diff --git a/testing/test_nose.py b/testing/test_nose.py index cab5a81a2..92d6b95fd 100644 --- a/testing/test_nose.py +++ b/testing/test_nose.py @@ -37,7 +37,7 @@ def test_setup_func_with_setup_decorator() -> None: def f(self): values.append(1) - call_optional(A(), "f") + call_optional(A(), "f", "A.f") assert not values @@ -47,7 +47,7 @@ def test_setup_func_not_callable() -> None: class A: f = 1 - call_optional(A(), "f") + call_optional(A(), "f", "A.f") def test_nose_setup_func(pytester: Pytester) -> None: