From b095e0de47a684bf7a7a3a7ec49c5457fabf6903 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 9 Aug 2019 21:35:03 +0200 Subject: [PATCH 01/11] Improve docs of pytest.importorskip --- doc/en/reference.rst | 2 ++ doc/en/skipping.rst | 9 ++++----- src/_pytest/outcomes.py | 19 +++++++++++++------ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 7e47ffce0..991050c23 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -27,6 +27,8 @@ pytest.skip .. autofunction:: _pytest.outcomes.skip(msg, [allow_module_level=False]) +.. _`pytest.importorskip ref`: + pytest.importorskip ~~~~~~~~~~~~~~~~~~~ diff --git a/doc/en/skipping.rst b/doc/en/skipping.rst index eb00a6dfb..57f565472 100644 --- a/doc/en/skipping.rst +++ b/doc/en/skipping.rst @@ -179,16 +179,15 @@ information. Skipping on a missing import dependency ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can use the following helper at module level -or within a test or test setup function: +You can skip tests on a missing import by using :ref:`pytest.importorskip ref` +at module level or within a test or test setup function. .. code-block:: python docutils = pytest.importorskip("docutils") -If ``docutils`` cannot be imported here, this will lead to a -skip outcome of the test. You can also skip based on the -version number of a library: +If ``docutils`` cannot be imported here, this will lead to a skip outcome of +the test. You can also skip based on the version number of a library: .. code-block:: python diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 55c30803f..a3b6d4054 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -143,14 +143,21 @@ xfail.Exception = XFailed # type: ignore def importorskip(modname, minversion=None, reason=None): - """Imports and returns the requested module ``modname``, or skip the current test - if the module cannot be imported. + """Imports and returns the requested module ``modname``, or skip the + current test if the module cannot be imported. :param str modname: the name of the module to import - :param str minversion: if given, the imported module ``__version__`` attribute must be - at least this minimal version, otherwise the test is still skipped. - :param str reason: if given, this reason is shown as the message when the module - cannot be imported. + :param str minversion: if given, the imported module ``__version__`` + attribute must be at least this minimal version, otherwise the test is + still skipped. + :param str reason: if given, this reason is shown as the message when the + module cannot be imported. + :returns: The imported module. This should be assigned to its canonical + name. + + Example:: + + docutils = pytest.importorskip("docutils") """ import warnings From e0ce8b79d5bb6d1114201ce5d85fd6f02948e325 Mon Sep 17 00:00:00 2001 From: martbln Date: Sat, 10 Aug 2019 00:55:39 +0300 Subject: [PATCH 02/11] pytester: add docstrings for Testdir.copy_example --- changelog/5669.doc.rst | 1 + src/_pytest/pytester.py | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 changelog/5669.doc.rst diff --git a/changelog/5669.doc.rst b/changelog/5669.doc.rst new file mode 100644 index 000000000..0ec9626ae --- /dev/null +++ b/changelog/5669.doc.rst @@ -0,0 +1 @@ +Add docstring for ``Testdir.copy_example``. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 2068761fa..eef876152 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -630,6 +630,12 @@ class Testdir: return p def copy_example(self, name=None): + """Copy file from project's directory into the testdir. + + :param str name: The name of the file for copy. + :return: self.tmpdir + + """ import warnings from _pytest.warning_types import PYTESTER_COPY_EXAMPLE From ee936b27a8bda9e46af327ce093c209463b488ab Mon Sep 17 00:00:00 2001 From: Ilya Stepin Date: Sat, 10 Aug 2019 09:12:04 +0300 Subject: [PATCH 03/11] pytester: fix docstrings Co-Authored-By: Bruno Oliveira --- src/_pytest/pytester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index eef876152..29c200ce6 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -633,7 +633,7 @@ class Testdir: """Copy file from project's directory into the testdir. :param str name: The name of the file for copy. - :return: self.tmpdir + :return: path to the copied directory (inside ``self.tmpdir``). """ import warnings From 0767f080a4dfe4fdcfbab4e66bfdccff4ecad52e Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Sat, 10 Aug 2019 12:38:13 -0700 Subject: [PATCH 04/11] =?UTF-8?q?Update=20URL:=20python/black=20=E2=86=92?= =?UTF-8?q?=20psf/black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- CONTRIBUTING.rst | 2 +- README.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9940674d..13cdb8551 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ exclude: doc/en/example/py2py3/test_py2.py repos: -- repo: https://github.com/python/black +- repo: https://github.com/psf/black rev: 19.3b0 hooks: - id: black diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5ef418e0b..8e59191ab 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -167,7 +167,7 @@ Short version #. Enable and install `pre-commit `_ to ensure style-guides and code checks are followed. #. Target ``master`` for bugfixes and doc changes. #. Target ``features`` for new features or functionality changes. -#. Follow **PEP-8** for naming and `black `_ for formatting. +#. Follow **PEP-8** for naming and `black `_ for formatting. #. Tests are run using ``tox``:: tox -e linting,py37 diff --git a/README.rst b/README.rst index 301e49538..4bf9cce2f 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ :target: https://dev.azure.com/pytest-dev/pytest .. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/python/black + :target: https://github.com/psf/black .. image:: https://www.codetriage.com/pytest-dev/pytest/badges/users.svg :target: https://www.codetriage.com/pytest-dev/pytest From 0db9dade653d9fbaf830cefaf305c25193837b15 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 10 Aug 2019 23:30:49 +0200 Subject: [PATCH 05/11] test_non_ascii_paste_text: mock call to urlopen Likely to fix flaky coverage due to requests failing sometimes. Ref: https://codecov.io/gh/pytest-dev/pytest/compare/f7e81dab9aa8d57498511e1b5655b53cbfbed0d0...83a1f4bd668fe337d42f909cc2ef2d8d986e9748/changes --- testing/test_pastebin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index fd443ed40..9afa1e23f 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -55,7 +55,7 @@ class TestPasteCapture: ] ) - def test_non_ascii_paste_text(self, testdir): + def test_non_ascii_paste_text(self, testdir, pastebinlist): """Make sure that text which contains non-ascii characters is pasted correctly. See #1219. """ @@ -74,6 +74,7 @@ class TestPasteCapture: "*Sending information to Paste Service*", ] ) + assert len(pastebinlist) == 1 class TestPaste: From 3eb4973065c17d0e6904b5e622875a590af0bd0b Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 12 Aug 2019 00:09:53 -0600 Subject: [PATCH 06/11] remove %s formatting from docs --- doc/en/capture.rst | 2 +- doc/en/example/assertion/failure_demo.py | 2 +- doc/en/example/markers.rst | 4 ++-- doc/en/example/multipython.py | 2 +- doc/en/example/nonpython/conftest.py | 4 ++-- doc/en/example/reportingdemo.rst | 2 +- doc/en/example/simple.rst | 4 ++-- doc/en/fixture.rst | 14 +++++++------- doc/en/getting-started.rst | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 356da2678..55714c25b 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -57,7 +57,7 @@ is that you can use print statements for debugging: def setup_function(function): - print("setting up %s" % function) + print("setting up", function) def test_func1(): diff --git a/doc/en/example/assertion/failure_demo.py b/doc/en/example/assertion/failure_demo.py index 129362cd7..26454e48d 100644 --- a/doc/en/example/assertion/failure_demo.py +++ b/doc/en/example/assertion/failure_demo.py @@ -177,7 +177,7 @@ class TestRaises: def test_reinterpret_fails_with_print_for_the_fun_of_it(self): items = [1, 2, 3] - print("items is %r" % items) + print("items is {!r}".format(items)) a, b = items.pop() def test_some_error(self): diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index a1fe65874..909f23a2e 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -384,7 +384,7 @@ specifies via named environments: envnames = [mark.args[0] for mark in item.iter_markers(name="env")] if envnames: if item.config.getoption("-E") not in envnames: - pytest.skip("test requires env in %r" % envnames) + pytest.skip("test requires env in {!r}".format(envnames)) A test file using this local plugin: @@ -578,7 +578,7 @@ for your particular platform, you could use the following plugin: supported_platforms = ALL.intersection(mark.name for mark in item.iter_markers()) plat = sys.platform if supported_platforms and plat not in supported_platforms: - pytest.skip("cannot run on platform %s" % (plat)) + pytest.skip("cannot run on platform {}".format(plat)) then tests will be skipped if they were specified for a different platform. Let's do a little test file to show how this looks like: diff --git a/doc/en/example/multipython.py b/doc/en/example/multipython.py index 3dc1d9b29..9db6879ed 100644 --- a/doc/en/example/multipython.py +++ b/doc/en/example/multipython.py @@ -69,4 +69,4 @@ class Python: @pytest.mark.parametrize("obj", [42, {}, {1: 3}]) def test_basic_objects(python1, python2, obj): python1.dumps(obj) - python2.load_and_is_true("obj == %s" % obj) + python2.load_and_is_true("obj == {}".format(obj)) diff --git a/doc/en/example/nonpython/conftest.py b/doc/en/example/nonpython/conftest.py index c94620e97..93d8285bf 100644 --- a/doc/en/example/nonpython/conftest.py +++ b/doc/en/example/nonpython/conftest.py @@ -33,13 +33,13 @@ class YamlItem(pytest.Item): return "\n".join( [ "usecase execution failed", - " spec failed: %r: %r" % excinfo.value.args[1:3], + " spec failed: {1!r}: {2!r}".format(*excinfo.value.args), " no further details known at this point.", ] ) def reportinfo(self): - return self.fspath, 0, "usecase: %s" % self.name + return self.fspath, 0, "usecase: {}".format(self.name) class YamlException(Exception): diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index ae8928c51..05d06ecb6 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -434,7 +434,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_reinterpret_fails_with_print_for_the_fun_of_it(self): items = [1, 2, 3] - print("items is %r" % items) + print("items is {!r}".format(items)) > a, b = items.pop() E TypeError: 'int' object is not iterable diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index dbfba0fa0..b4baa2b9b 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -478,7 +478,7 @@ an ``incremental`` marker which is to be used on classes: if "incremental" in item.keywords: previousfailed = getattr(item.parent, "_previousfailed", None) if previousfailed is not None: - pytest.xfail("previous test failed (%s)" % previousfailed.name) + pytest.xfail("previous test failed ({})".format(previousfailed.name)) These two hook implementations work together to abort incremental-marked tests in a class. Here is a test module example: @@ -684,7 +684,7 @@ case we just write some information out to a ``failures`` file: with open("failures", mode) as f: # let's also access a fixture for the fun of it if "tmpdir" in item.fixturenames: - extra = " (%s)" % item.funcargs["tmpdir"] + extra = " ({})".format(item.funcargs["tmpdir"]) else: extra = "" diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index ea5e2dc86..b494ec0fe 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -629,7 +629,7 @@ through the special :py:class:`request ` object: def smtp_connection(request): smtp_connection = smtplib.SMTP(request.param, 587, timeout=5) yield smtp_connection - print("finalizing %s" % smtp_connection) + print("finalizing {}".format(smtp_connection)) smtp_connection.close() The main change is the declaration of ``params`` with @@ -902,25 +902,25 @@ to show the setup/teardown flow: @pytest.fixture(scope="module", params=["mod1", "mod2"]) def modarg(request): param = request.param - print(" SETUP modarg %s" % param) + print(" SETUP modarg", param) yield param - print(" TEARDOWN modarg %s" % param) + print(" TEARDOWN modarg", param) @pytest.fixture(scope="function", params=[1, 2]) def otherarg(request): param = request.param - print(" SETUP otherarg %s" % param) + print(" SETUP otherarg", param) yield param - print(" TEARDOWN otherarg %s" % param) + print(" TEARDOWN otherarg", param) def test_0(otherarg): - print(" RUN test0 with otherarg %s" % otherarg) + print(" RUN test0 with otherarg", otherarg) def test_1(modarg): - print(" RUN test1 with modarg %s" % modarg) + print(" RUN test1 with modarg", modarg) def test_2(otherarg, modarg): diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index 149bd33f1..f1c28769f 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -28,7 +28,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - This is pytest version 5.x.y, imported from $PYTHON_PREFIX/lib/python3.6/site-packages/pytest.py + This is pytest version 5.x.y, imported from $PYTHON_PREFIX/lib/python3.x/site-packages/pytest.py .. _`simpletest`: From 7183335e6269980161ae9cc67679bf07d65bf1c5 Mon Sep 17 00:00:00 2001 From: "dmitry.dygalo" Date: Mon, 12 Aug 2019 07:47:40 +0200 Subject: [PATCH 07/11] Capture warnings during ``pytest_configure`` Fix #5115 --- changelog/5115.bugfix.rst | 1 + src/_pytest/config/__init__.py | 4 +++- testing/test_warnings.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 changelog/5115.bugfix.rst diff --git a/changelog/5115.bugfix.rst b/changelog/5115.bugfix.rst new file mode 100644 index 000000000..af75499a3 --- /dev/null +++ b/changelog/5115.bugfix.rst @@ -0,0 +1 @@ +Warnings issued during ``pytest_configure`` are explicitly not treated as errors, even if configured as such, because it otherwise completely breaks pytest. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c1bd2e7eb..1ddf78829 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -642,7 +642,9 @@ class Config: def _do_configure(self): assert not self._configured self._configured = True - self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) + with warnings.catch_warnings(): + warnings.simplefilter("default") + self.hook.pytest_configure.call_historic(kwargs=dict(config=self)) def _ensure_unconfigure(self): if self._configured: diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 2ce83ae88..4580ab53f 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -645,3 +645,21 @@ def test_group_warnings_by_message(testdir): warning_code = 'warnings.warn(UserWarning("foo"))' assert warning_code in result.stdout.str() assert result.stdout.str().count(warning_code) == 1 + + +def test_pytest_configure_warning(testdir, recwarn): + """Issue 5115.""" + testdir.makeconftest( + """ + def pytest_configure(): + import warnings + + warnings.warn("from pytest_configure") + """ + ) + + result = testdir.runpytest() + assert result.ret == 5 + assert "INTERNALERROR" not in result.stderr.str() + warning = recwarn.pop() + assert str(warning.message) == "from pytest_configure" From 2f1b192fe67eb4ce1c60ad61dc377ec8523293b6 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 12 Aug 2019 21:58:10 +0100 Subject: [PATCH 08/11] Issue a warning for async gen functions Co-Authored-By: Bruno Oliveira --- changelog/5734.bugfix.rst | 1 + src/_pytest/compat.py | 16 +++++++++------- src/_pytest/python.py | 10 ++++++---- testing/acceptance_test.py | 32 ++++++++++++++++++++++++++++++-- testing/test_compat.py | 26 +++++++++++++++++++++++--- 5 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 changelog/5734.bugfix.rst diff --git a/changelog/5734.bugfix.rst b/changelog/5734.bugfix.rst new file mode 100644 index 000000000..dc20e6b52 --- /dev/null +++ b/changelog/5734.bugfix.rst @@ -0,0 +1 @@ +Skip async generator test functions, and update the warning message to refer to ``async def`` functions. diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 52ffc36bc..20be12d3e 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -40,14 +40,16 @@ def is_generator(func): def iscoroutinefunction(func): - """Return True if func is a decorated coroutine function. - - Note: copied and modified from Python 3.5's builtin couroutines.py to avoid import asyncio directly, - which in turns also initializes the "logging" module as side-effect (see issue #8). """ - return getattr(func, "_is_coroutine", False) or ( - hasattr(inspect, "iscoroutinefunction") and inspect.iscoroutinefunction(func) - ) + Return True if func is a coroutine function (a function defined with async + def syntax, and doesn't contain yield), or a function decorated with + @asyncio.coroutine. + + Note: copied and modified from Python 3.5's builtin couroutines.py to avoid + importing asyncio directly, which in turns also initializes the "logging" + module as a side-effect (see issue #8). + """ + return inspect.iscoroutinefunction(func) or getattr(func, "_is_coroutine", False) def getlocation(function, curdir): diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 66d853060..70b486fb1 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -23,6 +23,7 @@ from _pytest.compat import getfslineno from _pytest.compat import getimfunc from _pytest.compat import getlocation from _pytest.compat import is_generator +from _pytest.compat import iscoroutinefunction from _pytest.compat import NOTSET from _pytest.compat import REGEX_TYPE from _pytest.compat import safe_getattr @@ -151,15 +152,16 @@ def pytest_configure(config): @hookimpl(trylast=True) def pytest_pyfunc_call(pyfuncitem): testfunction = pyfuncitem.obj - iscoroutinefunction = getattr(inspect, "iscoroutinefunction", None) - if iscoroutinefunction is not None and iscoroutinefunction(testfunction): - msg = "Coroutine functions are not natively supported and have been skipped.\n" + if iscoroutinefunction(testfunction) or ( + sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) + ): + msg = "async def functions are not natively supported and have been skipped.\n" msg += "You need to install a suitable plugin for your async framework, for example:\n" msg += " - pytest-asyncio\n" msg += " - pytest-trio\n" msg += " - pytest-tornasync" warnings.warn(PytestUnhandledCoroutineWarning(msg.format(pyfuncitem.nodeid))) - skip(msg="coroutine function and no async plugin installed (see warnings)") + skip(msg="async def function and no async plugin installed (see warnings)") funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} testfunction(**testargs) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index d2a348f40..55e3b966d 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1199,11 +1199,39 @@ def test_warn_on_async_function(testdir): [ "test_async.py::test_1", "test_async.py::test_2", - "*Coroutine functions are not natively supported*", + "*async def functions are not natively supported*", "*2 skipped, 2 warnings in*", ] ) # ensure our warning message appears only once assert ( - result.stdout.str().count("Coroutine functions are not natively supported") == 1 + result.stdout.str().count("async def functions are not natively supported") == 1 + ) + + +@pytest.mark.filterwarnings("default") +@pytest.mark.skipif( + sys.version_info < (3, 6), reason="async gen syntax available in Python 3.6+" +) +def test_warn_on_async_gen_function(testdir): + testdir.makepyfile( + test_async=""" + async def test_1(): + yield + async def test_2(): + yield + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines( + [ + "test_async.py::test_1", + "test_async.py::test_2", + "*async def functions are not natively supported*", + "*2 skipped, 2 warnings in*", + ] + ) + # ensure our warning message appears only once + assert ( + result.stdout.str().count("async def functions are not natively supported") == 1 ) diff --git a/testing/test_compat.py b/testing/test_compat.py index 9e7d05c5d..0244c07ac 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -91,9 +91,6 @@ def test_is_generator_asyncio(testdir): result.stdout.fnmatch_lines(["*1 passed*"]) -@pytest.mark.skipif( - sys.version_info < (3, 5), reason="async syntax available in Python 3.5+" -) def test_is_generator_async_syntax(testdir): testdir.makepyfile( """ @@ -113,6 +110,29 @@ def test_is_generator_async_syntax(testdir): result.stdout.fnmatch_lines(["*1 passed*"]) +@pytest.mark.skipif( + sys.version_info < (3, 6), reason="async gen syntax available in Python 3.6+" +) +def test_is_generator_async_gen_syntax(testdir): + testdir.makepyfile( + """ + from _pytest.compat import is_generator + def test_is_generator_py36(): + async def foo(): + yield + await foo() + + async def bar(): + yield + + assert not is_generator(foo) + assert not is_generator(bar) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*1 passed*"]) + + class ErrorsHelper: @property def raise_exception(self): From 137255816ea18b0ce8ea8f18c19fb8232d3f77a2 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 6 Aug 2019 15:02:46 +0100 Subject: [PATCH 09/11] Fix collection of staticmethods defined with functools.partial Related to #5701 --- changelog/5701.bugfix.rst | 1 + src/_pytest/compat.py | 12 ++++++---- src/_pytest/fixtures.py | 4 ++-- testing/python/collect.py | 46 -------------------------------------- testing/python/fixtures.py | 46 +++++++++++++++++++++++++++++++++++--- testing/test_compat.py | 11 +++++++++ 6 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 changelog/5701.bugfix.rst diff --git a/changelog/5701.bugfix.rst b/changelog/5701.bugfix.rst new file mode 100644 index 000000000..b654e7447 --- /dev/null +++ b/changelog/5701.bugfix.rst @@ -0,0 +1 @@ +Fix collection of ``staticmethod`` objects defined with ``functools.partial``. diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 52ffc36bc..97c06e3ff 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -78,7 +78,7 @@ def num_mock_patch_args(function): ) -def getfuncargnames(function, is_method=False, cls=None): +def getfuncargnames(function, *, name: str = "", is_method=False, cls=None): """Returns the names of a function's mandatory arguments. This should return the names of all function arguments that: @@ -91,11 +91,12 @@ def getfuncargnames(function, is_method=False, cls=None): be treated as a bound method even though it's not unless, only in the case of cls, the function is a static method. + The name parameter should be the original name in which the function was collected. + @RonnyPfannschmidt: This function should be refactored when we revisit fixtures. The fixture mechanism should ask the node for the fixture names, and not try to obtain directly from the function object well after collection has occurred. - """ # The parameters attribute of a Signature object contains an # ordered mapping of parameter names to Parameter instances. This @@ -118,11 +119,14 @@ def getfuncargnames(function, is_method=False, cls=None): ) and p.default is Parameter.empty ) + if not name: + name = function.__name__ + # If this function should be treated as a bound method even though # it's passed as an unbound method or function, remove the first # parameter name. if is_method or ( - cls and not isinstance(cls.__dict__.get(function.__name__, None), staticmethod) + cls and not isinstance(cls.__dict__.get(name, None), staticmethod) ): arg_names = arg_names[1:] # Remove any names that will be replaced with mocks. @@ -245,7 +249,7 @@ def get_real_method(obj, holder): try: is_method = hasattr(obj, "__func__") obj = get_real_func(obj) - except Exception: + except Exception: # pragma: no cover return obj if is_method and hasattr(obj, "__get__") and callable(obj.__get__): obj = obj.__get__(holder) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 965a2e6e9..6a3e82907 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -828,7 +828,7 @@ class FixtureDef: where=baseid, ) self.params = params - self.argnames = getfuncargnames(func, is_method=unittest) + self.argnames = getfuncargnames(func, name=argname, is_method=unittest) self.unittest = unittest self.ids = ids self._finalizers = [] @@ -1143,7 +1143,7 @@ class FixtureManager: def getfixtureinfo(self, node, func, cls, funcargs=True): if funcargs and not getattr(node, "nofuncargs", False): - argnames = getfuncargnames(func, cls=cls) + argnames = getfuncargnames(func, name=node.name, cls=cls) else: argnames = () diff --git a/testing/python/collect.py b/testing/python/collect.py index 61fc58579..e6dd3e870 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1143,52 +1143,6 @@ def test_unorderable_types(testdir): assert result.ret == ExitCode.NO_TESTS_COLLECTED -def test_collect_functools_partial(testdir): - """ - Test that collection of functools.partial object works, and arguments - to the wrapped functions are dealt with correctly (see #811). - """ - testdir.makepyfile( - """ - import functools - import pytest - - @pytest.fixture - def fix1(): - return 'fix1' - - @pytest.fixture - def fix2(): - return 'fix2' - - def check1(i, fix1): - assert i == 2 - assert fix1 == 'fix1' - - def check2(fix1, i): - assert i == 2 - assert fix1 == 'fix1' - - def check3(fix1, i, fix2): - assert i == 2 - assert fix1 == 'fix1' - assert fix2 == 'fix2' - - test_ok_1 = functools.partial(check1, i=2) - test_ok_2 = functools.partial(check1, i=2, fix1='fix1') - test_ok_3 = functools.partial(check1, 2) - test_ok_4 = functools.partial(check2, i=2) - test_ok_5 = functools.partial(check3, i=2) - test_ok_6 = functools.partial(check3, i=2, fix1='fix1') - - test_fail_1 = functools.partial(check2, 2) - test_fail_2 = functools.partial(check3, 2) - """ - ) - result = testdir.inline_run() - result.assertoutcome(passed=6, failed=2) - - @pytest.mark.filterwarnings("default") def test_dont_collect_non_function_callable(testdir): """Test for issue https://github.com/pytest-dev/pytest/issues/331 diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index c0c230ccf..a85a0d731 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -10,7 +10,9 @@ from _pytest.pytester import get_public_names from _pytest.warnings import SHOW_PYTEST_WARNINGS_ARG -def test_getfuncargnames(): +def test_getfuncargnames_functions(): + """Test getfuncargnames for normal functions""" + def f(): pass @@ -31,18 +33,56 @@ def test_getfuncargnames(): assert fixtures.getfuncargnames(j) == ("arg1", "arg2") + +def test_getfuncargnames_methods(): + """Test getfuncargnames for normal methods""" + class A: def f(self, arg1, arg2="hello"): pass + assert fixtures.getfuncargnames(A().f) == ("arg1",) + + +def test_getfuncargnames_staticmethod(): + """Test getfuncargnames for staticmethods""" + + class A: @staticmethod - def static(arg1, arg2): + def static(arg1, arg2, x=1): pass - assert fixtures.getfuncargnames(A().f) == ("arg1",) assert fixtures.getfuncargnames(A.static, cls=A) == ("arg1", "arg2") +def test_getfuncargnames_partial(): + """Check getfuncargnames for methods defined with functools.partial (#5701)""" + import functools + + def check(arg1, arg2, i): + pass + + class T: + test_ok = functools.partial(check, i=2) + + values = fixtures.getfuncargnames(T().test_ok, name="test_ok") + assert values == ("arg1", "arg2") + + +def test_getfuncargnames_staticmethod_partial(): + """Check getfuncargnames for staticmethods defined with functools.partial (#5701)""" + import functools + + def check(arg1, arg2, i): + pass + + class T: + test_ok = staticmethod(functools.partial(check, i=2)) + + values = fixtures.getfuncargnames(T().test_ok, name="test_ok") + assert values == ("arg1", "arg2") + + @pytest.mark.pytester_example_path("fixtures/fill_fixtures") class TestFillFixtures: def test_fillfuncargs_exposed(self): diff --git a/testing/test_compat.py b/testing/test_compat.py index 9e7d05c5d..fb2470d07 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -1,4 +1,5 @@ import sys +from functools import partial from functools import wraps import pytest @@ -72,6 +73,16 @@ def test_get_real_func(): assert get_real_func(wrapped_func2) is wrapped_func +def test_get_real_func_partial(): + """Test get_real_func handles partial instances correctly""" + + def foo(x): + return x + + assert get_real_func(foo) is foo + assert get_real_func(partial(foo)) is foo + + def test_is_generator_asyncio(testdir): testdir.makepyfile( """ From 6b9d729ed3d6a495d0ca721dee3f32bd1c25f340 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 15 Aug 2019 12:03:06 +0100 Subject: [PATCH 10/11] also warn on awaitable or async iterable test results --- src/_pytest/python.py | 15 ++++++++++----- testing/acceptance_test.py | 10 ++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 70b486fb1..951da792b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -151,10 +151,7 @@ def pytest_configure(config): @hookimpl(trylast=True) def pytest_pyfunc_call(pyfuncitem): - testfunction = pyfuncitem.obj - if iscoroutinefunction(testfunction) or ( - sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) - ): + def async_warn(): msg = "async def functions are not natively supported and have been skipped.\n" msg += "You need to install a suitable plugin for your async framework, for example:\n" msg += " - pytest-asyncio\n" @@ -162,9 +159,17 @@ def pytest_pyfunc_call(pyfuncitem): msg += " - pytest-tornasync" warnings.warn(PytestUnhandledCoroutineWarning(msg.format(pyfuncitem.nodeid))) skip(msg="async def function and no async plugin installed (see warnings)") + + testfunction = pyfuncitem.obj + if iscoroutinefunction(testfunction) or ( + sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) + ): + async_warn() funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} - testfunction(**testargs) + result = testfunction(**testargs) + if hasattr(result, "__await__") or hasattr(result, "__aiter__"): + async_warn() return True diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 55e3b966d..7abbe734e 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1192,6 +1192,8 @@ def test_warn_on_async_function(testdir): pass async def test_2(): pass + def test_3(): + return test_2() """ ) result = testdir.runpytest() @@ -1199,8 +1201,9 @@ def test_warn_on_async_function(testdir): [ "test_async.py::test_1", "test_async.py::test_2", + "test_async.py::test_3", "*async def functions are not natively supported*", - "*2 skipped, 2 warnings in*", + "*3 skipped, 3 warnings in*", ] ) # ensure our warning message appears only once @@ -1220,6 +1223,8 @@ def test_warn_on_async_gen_function(testdir): yield async def test_2(): yield + def test_3(): + return test_2() """ ) result = testdir.runpytest() @@ -1227,8 +1232,9 @@ def test_warn_on_async_gen_function(testdir): [ "test_async.py::test_1", "test_async.py::test_2", + "test_async.py::test_3", "*async def functions are not natively supported*", - "*2 skipped, 2 warnings in*", + "*3 skipped, 3 warnings in*", ] ) # ensure our warning message appears only once From 1049a38cee2d8642635733f2a9ea0e776c0929db Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 15 Aug 2019 10:05:42 -0300 Subject: [PATCH 11/11] Fix wording as suggested in review of #5741 --- doc/en/skipping.rst | 2 +- src/_pytest/outcomes.py | 2 +- src/_pytest/pytester.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/en/skipping.rst b/doc/en/skipping.rst index 57f565472..2b654560e 100644 --- a/doc/en/skipping.rst +++ b/doc/en/skipping.rst @@ -180,7 +180,7 @@ Skipping on a missing import dependency ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can skip tests on a missing import by using :ref:`pytest.importorskip ref` -at module level or within a test or test setup function. +at module level, within a test, or test setup function. .. code-block:: python diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 13eb0a295..947136625 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -161,7 +161,7 @@ def importorskip( current test if the module cannot be imported. :param str modname: the name of the module to import - :param str minversion: if given, the imported module ``__version__`` + :param str minversion: if given, the imported module's ``__version__`` attribute must be at least this minimal version, otherwise the test is still skipped. :param str reason: if given, this reason is shown as the message when the diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index d9ceb0e78..94058f70c 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -632,7 +632,7 @@ class Testdir: def copy_example(self, name=None): """Copy file from project's directory into the testdir. - :param str name: The name of the file for copy. + :param str name: The name of the file to copy. :return: path to the copied directory (inside ``self.tmpdir``). """