diff --git a/changelog/6566.bugfix.rst b/changelog/6566.bugfix.rst new file mode 100644 index 000000000..4af976f22 --- /dev/null +++ b/changelog/6566.bugfix.rst @@ -0,0 +1 @@ +Fix ``EncodedFile.writelines`` to call the underlying buffer's ``writelines`` method. diff --git a/scripts/report-coverage.sh b/scripts/report-coverage.sh index fbcf20ca9..6aa931383 100755 --- a/scripts/report-coverage.sh +++ b/scripts/report-coverage.sh @@ -14,5 +14,5 @@ python -m coverage combine python -m coverage xml python -m coverage report -m # Set --connect-timeout to work around https://github.com/curl/curl/issues/4461 -curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash -o codecov-upload.sh +curl -S -L --connect-timeout 5 --retry 6 --retry-connrefused -s https://codecov.io/bash -o codecov-upload.sh bash codecov-upload.sh -Z -X fix -f coverage.xml "$@" diff --git a/setup.cfg b/setup.cfg index 54b64af96..708951da4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,6 @@ project_urls = author = Holger Krekel, Bruno Oliveira, Ronny Pfannschmidt, Floris Bruynooghe, Brianna Laugher, Florian Bruhin and others license = MIT license -license_file = LICENSE keywords = test, unittest classifiers = Development Status :: 6 - Mature @@ -66,6 +65,7 @@ formats = sdist.tgz,bdist_wheel mypy_path = src ignore_missing_imports = True no_implicit_optional = True +show_error_codes = True strict_equality = True warn_redundant_casts = True warn_return_any = True diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 26b473bb0..08f520629 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -9,6 +9,8 @@ import os import sys from io import UnsupportedOperation from tempfile import TemporaryFile +from typing import BinaryIO +from typing import Iterable import pytest from _pytest.compat import CaptureAndPassthroughIO @@ -416,30 +418,27 @@ def safe_text_dupfile(f, mode, default_encoding="UTF8"): class EncodedFile: errors = "strict" # possibly needed by py3 code (issue555) - def __init__(self, buffer, encoding): + def __init__(self, buffer: BinaryIO, encoding: str) -> None: self.buffer = buffer self.encoding = encoding - def write(self, obj): - if isinstance(obj, str): - obj = obj.encode(self.encoding, "replace") - else: + def write(self, s: str) -> int: + if not isinstance(s, str): raise TypeError( - "write() argument must be str, not {}".format(type(obj).__name__) + "write() argument must be str, not {}".format(type(s).__name__) ) - return self.buffer.write(obj) + return self.buffer.write(s.encode(self.encoding, "replace")) - def writelines(self, linelist): - data = "".join(linelist) - self.write(data) + def writelines(self, lines: Iterable[str]) -> None: + self.buffer.writelines(x.encode(self.encoding, "replace") for x in lines) @property - def name(self): + def name(self) -> str: """Ensure that file.name is a string.""" return repr(self.buffer) @property - def mode(self): + def mode(self) -> str: return self.buffer.mode.replace("b", "") def __getattr__(self, name): diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index bc62297c1..73c35ce63 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -50,6 +50,13 @@ if TYPE_CHECKING: from .argparsing import Argument +_PluggyPlugin = object +"""A type to represent plugin objects. +Plugins can be any namespace, so we can't narrow it down much, but we use an +alias to make the intent clear. +Ideally this type would be provided by pluggy itself.""" + + hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 3f08d5117..5cafa5e7b 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -29,12 +29,17 @@ from _pytest._io.saferepr import saferepr from _pytest.capture import MultiCapture from _pytest.capture import SysCapture from _pytest.compat import TYPE_CHECKING +from _pytest.config import _PluggyPlugin from _pytest.fixtures import FixtureRequest from _pytest.main import ExitCode from _pytest.main import Session from _pytest.monkeypatch import MonkeyPatch +from _pytest.nodes import Collector +from _pytest.nodes import Item from _pytest.pathlib import Path +from _pytest.python import Module from _pytest.reports import TestReport +from _pytest.tmpdir import TempdirFactory if TYPE_CHECKING: from typing import Type @@ -534,13 +539,15 @@ class Testdir: class TimeoutExpired(Exception): pass - def __init__(self, request, tmpdir_factory): + def __init__(self, request: FixtureRequest, tmpdir_factory: TempdirFactory) -> None: self.request = request - self._mod_collections = WeakKeyDictionary() + self._mod_collections = ( + WeakKeyDictionary() + ) # type: WeakKeyDictionary[Module, List[Union[Item, Collector]]] name = request.function.__name__ self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True) - self.plugins = [] + self.plugins = [] # type: List[Union[str, _PluggyPlugin]] self._cwd_snapshot = CwdSnapshot() self._sys_path_snapshot = SysPathsSnapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot() @@ -1060,7 +1067,9 @@ class Testdir: self.config = config = self.parseconfigure(path, *configargs) return self.getnode(config, path) - def collect_by_name(self, modcol, name): + def collect_by_name( + self, modcol: Module, name: str + ) -> Optional[Union[Item, Collector]]: """Return the collection node for name from the module collection. This will search a module collection node for a collection node @@ -1069,13 +1078,13 @@ class Testdir: :param modcol: a module collection node; see :py:meth:`getmodulecol` :param name: the name of the node to return - """ if modcol not in self._mod_collections: self._mod_collections[modcol] = list(modcol.collect()) for colitem in self._mod_collections[modcol]: if colitem.name == name: return colitem + return None def popen( self, diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 201f42f32..4333bbb00 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -946,7 +946,7 @@ def test_collection_collect_only_live_logging(testdir, verbose): expected_lines.extend( [ "*test_collection_collect_only_live_logging.py::test_simple*", - "no tests ran in 0.[0-9][0-9]s", + "no tests ran in [0-1].[0-9][0-9]s", ] ) elif verbose == "-qq": diff --git a/testing/test_capture.py b/testing/test_capture.py index e0e38ea51..7d6afa1e4 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -7,6 +7,8 @@ import sys import textwrap from io import StringIO from io import UnsupportedOperation +from typing import BinaryIO +from typing import Generator from typing import List from typing import TextIO @@ -854,7 +856,7 @@ def test_dontreadfrominput(): @pytest.fixture -def tmpfile(testdir): +def tmpfile(testdir) -> Generator[BinaryIO, None, None]: f = testdir.makepyfile("").open("wb+") yield f if not f.closed: @@ -1537,3 +1539,15 @@ def test_typeerror_encodedfile_write(testdir): def test_stderr_write_returns_len(capsys): """Write on Encoded files, namely captured stderr, should return number of characters written.""" assert sys.stderr.write("Foo") == 3 + + +def test_encodedfile_writelines(tmpfile: BinaryIO) -> None: + ef = capture.EncodedFile(tmpfile, "utf-8") + with pytest.raises(AttributeError): + ef.writelines([b"line1", b"line2"]) # type: ignore[list-item] # noqa: F821 + assert ef.writelines(["line1", "line2"]) is None # type: ignore[func-returns-value] # noqa: F821 + tmpfile.seek(0) + assert tmpfile.read() == b"line1line2" + tmpfile.close() + with pytest.raises(ValueError): + ef.read() diff --git a/testing/test_collection.py b/testing/test_collection.py index 760cb2b7f..56f2efc84 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -9,6 +9,7 @@ import pytest from _pytest.main import _in_venv from _pytest.main import ExitCode from _pytest.main import Session +from _pytest.pytester import Testdir class TestCollector: @@ -18,7 +19,7 @@ class TestCollector: assert not issubclass(Collector, Item) assert not issubclass(Item, Collector) - def test_check_equality(self, testdir): + def test_check_equality(self, testdir: Testdir) -> None: modcol = testdir.getmodulecol( """ def test_pass(): pass @@ -40,12 +41,15 @@ class TestCollector: assert fn1 != fn3 for fn in fn1, fn2, fn3: - assert fn != 3 + assert isinstance(fn, pytest.Function) + assert fn != 3 # type: ignore[comparison-overlap] # noqa: F821 assert fn != modcol - assert fn != [1, 2, 3] - assert [1, 2, 3] != fn + assert fn != [1, 2, 3] # type: ignore[comparison-overlap] # noqa: F821 + assert [1, 2, 3] != fn # type: ignore[comparison-overlap] # noqa: F821 assert modcol != fn + assert testdir.collect_by_name(modcol, "doesnotexist") is None + def test_getparent(self, testdir): modcol = testdir.getmodulecol( """ diff --git a/testing/test_config.py b/testing/test_config.py index 498cbf7eb..cc54e5b23 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -670,6 +670,32 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): assert has_loaded == should_load +def test_plugin_loading_order(testdir): + """Test order of plugin loading with `-p`.""" + p1 = testdir.makepyfile( + """ + def test_terminal_plugin(request): + import myplugin + assert myplugin.terminal_plugin == [True, True] + """, + **{ + "myplugin": """ + terminal_plugin = [] + + def pytest_configure(config): + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + + def pytest_sessionstart(session): + config = session.config + terminal_plugin.append(bool(config.pluginmanager.get_plugin("terminalreporter"))) + """ + } + ) + testdir.syspathinsert() + result = testdir.runpytest("-p", "myplugin", str(p1)) + assert result.ret == 0 + + def test_cmdline_processargs_simple(testdir): testdir.makeconftest( """ diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 88ee75493..8c14acc80 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -13,6 +13,7 @@ import py import pytest from _pytest.main import ExitCode +from _pytest.pytester import Testdir from _pytest.reports import BaseReport from _pytest.terminal import _folded_skips from _pytest.terminal import _get_line_with_reprcrash_message @@ -1978,3 +1979,11 @@ def test_collecterror(testdir): "*= 1 error in *", ] ) + + +def test_via_exec(testdir: Testdir) -> None: + p1 = testdir.makepyfile("exec('def test_via_exec(): pass')") + result = testdir.runpytest(str(p1), "-vv") + result.stdout.fnmatch_lines( + ["test_via_exec.py::test_via_exec <- PASSED*", "*= 1 passed in *"] + )