New pytester fixture (#7854)

This commit is contained in:
Bruno Oliveira 2020-10-12 12:13:06 -03:00 committed by GitHub
parent cb578a918e
commit 69419cb700
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 632 additions and 389 deletions

View File

@ -0,0 +1,5 @@
New :fixture:`pytester` fixture, which is identical to :fixture:`testdir` but its methods return :class:`pathlib.Path` when appropriate instead of ``py.path.local``.
This is part of the movement to use :class:`pathlib.Path` objects internally, in order to remove the dependency to ``py`` in the future.
Internally, the old :class:`Testdir` is now a thin wrapper around :class:`Pytester`, preserving the old interface.

View File

@ -499,17 +499,21 @@ monkeypatch
:members: :members:
.. fixture:: testdir .. fixture:: pytester
testdir pytester
~~~~~~~ ~~~~~~~~
.. versionadded:: 6.2
.. currentmodule:: _pytest.pytester .. currentmodule:: _pytest.pytester
This fixture provides a :class:`Testdir` instance useful for black-box testing of test files, making it ideal to Provides a :class:`Pytester` instance that can be used to run and test pytest itself.
test plugins.
To use it, include in your top-most ``conftest.py`` file: It provides an empty directory where pytest can be executed in isolation, and contains facilities
to write tests, configuration files, and match against expected output.
To use it, include in your topmost ``conftest.py`` file:
.. code-block:: python .. code-block:: python
@ -517,7 +521,7 @@ To use it, include in your top-most ``conftest.py`` file:
.. autoclass:: Testdir() .. autoclass:: Pytester()
:members: :members:
.. autoclass:: RunResult() .. autoclass:: RunResult()
@ -526,6 +530,15 @@ To use it, include in your top-most ``conftest.py`` file:
.. autoclass:: LineMatcher() .. autoclass:: LineMatcher()
:members: :members:
.. fixture:: testdir
testdir
~~~~~~~
Identical to :fixture:`pytester`, but provides an instance whose methods return
legacy ``py.path.local`` objects instead when applicable.
New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`.
.. fixture:: recwarn .. fixture:: recwarn

View File

@ -1,16 +1,19 @@
"""(Disabled by default) support for testing pytest and pytest plugins.""" """(Disabled by default) support for testing pytest and pytest plugins."""
import collections.abc import collections.abc
import contextlib
import gc import gc
import importlib import importlib
import os import os
import platform import platform
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import traceback import traceback
from fnmatch import fnmatch from fnmatch import fnmatch
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import Generator from typing import Generator
@ -19,12 +22,14 @@ from typing import List
from typing import Optional from typing import Optional
from typing import overload from typing import overload
from typing import Sequence from typing import Sequence
from typing import TextIO
from typing import Tuple from typing import Tuple
from typing import Type from typing import Type
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Union from typing import Union
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
import attr
import py import py
from iniconfig import IniConfig from iniconfig import IniConfig
@ -47,7 +52,7 @@ from _pytest.pathlib import make_numbered_dir
from _pytest.python import Module from _pytest.python import Module
from _pytest.reports import CollectReport from _pytest.reports import CollectReport
from _pytest.reports import TestReport from _pytest.reports import TestReport
from _pytest.tmpdir import TempdirFactory from _pytest.tmpdir import TempPathFactory
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Literal from typing_extensions import Literal
@ -176,11 +181,11 @@ def _pytest(request: FixtureRequest) -> "PytestArg":
class PytestArg: class PytestArg:
def __init__(self, request: FixtureRequest) -> None: def __init__(self, request: FixtureRequest) -> None:
self.request = request self._request = request
def gethookrecorder(self, hook) -> "HookRecorder": def gethookrecorder(self, hook) -> "HookRecorder":
hookrecorder = HookRecorder(hook._pm) hookrecorder = HookRecorder(hook._pm)
self.request.addfinalizer(hookrecorder.finish_recording) self._request.addfinalizer(hookrecorder.finish_recording)
return hookrecorder return hookrecorder
@ -430,13 +435,29 @@ def LineMatcher_fixture(request: FixtureRequest) -> Type["LineMatcher"]:
@pytest.fixture @pytest.fixture
def testdir(request: FixtureRequest, tmpdir_factory: TempdirFactory) -> "Testdir": def pytester(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> "Pytester":
"""A :class: `TestDir` instance, that can be used to run and test pytest itself.
It is particularly useful for testing plugins. It is similar to the `tmpdir` fixture
but provides methods which aid in testing pytest itself.
""" """
return Testdir(request, tmpdir_factory) Facilities to write tests/configuration files, execute pytest in isolation, and match
against expected output, perfect for black-box testing of pytest plugins.
It attempts to isolate the test run from external factors as much as possible, modifying
the current working directory to ``path`` and environment variables during initialization.
It is particularly useful for testing plugins. It is similar to the :fixture:`tmp_path`
fixture but provides methods which aid in testing pytest itself.
"""
return Pytester(request, tmp_path_factory)
@pytest.fixture
def testdir(pytester: "Pytester") -> "Testdir":
"""
Identical to :fixture:`pytester`, and provides an instance whose methods return
legacy ``py.path.local`` objects instead when applicable.
New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`.
"""
return Testdir(pytester)
@pytest.fixture @pytest.fixture
@ -599,16 +620,17 @@ class SysPathsSnapshot:
@final @final
class Testdir: class Pytester:
"""Temporary test directory with tools to test/run pytest itself. """
Facilities to write tests/configuration files, execute pytest in isolation, and match
against expected output, perfect for black-box testing of pytest plugins.
This is based on the :fixture:`tmpdir` fixture but provides a number of methods It attempts to isolate the test run from external factors as much as possible, modifying
which aid with testing pytest itself. Unless :py:meth:`chdir` is used all the current working directory to ``path`` and environment variables during initialization.
methods will use :py:attr:`tmpdir` as their current working directory.
Attributes: Attributes:
:ivar tmpdir: The :py:class:`py.path.local` instance of the temporary directory. :ivar Path path: temporary directory path used to create files/run tests from, etc.
:ivar plugins: :ivar plugins:
A list of plugins to use with :py:meth:`parseconfig` and A list of plugins to use with :py:meth:`parseconfig` and
@ -624,8 +646,10 @@ class Testdir:
class TimeoutExpired(Exception): class TimeoutExpired(Exception):
pass pass
def __init__(self, request: FixtureRequest, tmpdir_factory: TempdirFactory) -> None: def __init__(
self.request = request self, request: FixtureRequest, tmp_path_factory: TempPathFactory
) -> None:
self._request = request
self._mod_collections: WeakKeyDictionary[ self._mod_collections: WeakKeyDictionary[
Module, List[Union[Item, Collector]] Module, List[Union[Item, Collector]]
] = (WeakKeyDictionary()) ] = (WeakKeyDictionary())
@ -634,37 +658,40 @@ class Testdir:
else: else:
name = request.node.name name = request.node.name
self._name = name self._name = name
self.tmpdir = tmpdir_factory.mktemp(name, numbered=True) self._path: Path = tmp_path_factory.mktemp(name, numbered=True)
self.test_tmproot = tmpdir_factory.mktemp("tmp-" + name, numbered=True)
self.plugins: List[Union[str, _PluggyPlugin]] = [] self.plugins: List[Union[str, _PluggyPlugin]] = []
self._cwd_snapshot = CwdSnapshot() self._cwd_snapshot = CwdSnapshot()
self._sys_path_snapshot = SysPathsSnapshot() self._sys_path_snapshot = SysPathsSnapshot()
self._sys_modules_snapshot = self.__take_sys_modules_snapshot() self._sys_modules_snapshot = self.__take_sys_modules_snapshot()
self.chdir() self.chdir()
self.request.addfinalizer(self.finalize) self._request.addfinalizer(self._finalize)
self._method = self.request.config.getoption("--runpytest") self._method = self._request.config.getoption("--runpytest")
self._test_tmproot = tmp_path_factory.mktemp(f"tmp-{name}", numbered=True)
mp = self.monkeypatch = MonkeyPatch() self._monkeypatch = mp = MonkeyPatch()
mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self.test_tmproot)) mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self._test_tmproot))
# Ensure no unexpected caching via tox. # Ensure no unexpected caching via tox.
mp.delenv("TOX_ENV_DIR", raising=False) mp.delenv("TOX_ENV_DIR", raising=False)
# Discard outer pytest options. # Discard outer pytest options.
mp.delenv("PYTEST_ADDOPTS", raising=False) mp.delenv("PYTEST_ADDOPTS", raising=False)
# Ensure no user config is used. # Ensure no user config is used.
tmphome = str(self.tmpdir) tmphome = str(self.path)
mp.setenv("HOME", tmphome) mp.setenv("HOME", tmphome)
mp.setenv("USERPROFILE", tmphome) mp.setenv("USERPROFILE", tmphome)
# Do not use colors for inner runs by default. # Do not use colors for inner runs by default.
mp.setenv("PY_COLORS", "0") mp.setenv("PY_COLORS", "0")
@property
def path(self) -> Path:
"""Temporary directory where files are created and pytest is executed."""
return self._path
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Testdir {self.tmpdir!r}>" return f"<Pytester {self.path!r}>"
def __str__(self) -> str: def _finalize(self) -> None:
return str(self.tmpdir) """
Clean up global state artifacts.
def finalize(self) -> None:
"""Clean up global state artifacts.
Some methods modify the global interpreter state and this tries to Some methods modify the global interpreter state and this tries to
clean this up. It does not remove the temporary directory however so clean this up. It does not remove the temporary directory however so
@ -673,7 +700,7 @@ class Testdir:
self._sys_modules_snapshot.restore() self._sys_modules_snapshot.restore()
self._sys_path_snapshot.restore() self._sys_path_snapshot.restore()
self._cwd_snapshot.restore() self._cwd_snapshot.restore()
self.monkeypatch.undo() self._monkeypatch.undo()
def __take_sys_modules_snapshot(self) -> SysModulesSnapshot: def __take_sys_modules_snapshot(self) -> SysModulesSnapshot:
# Some zope modules used by twisted-related tests keep internal state # Some zope modules used by twisted-related tests keep internal state
@ -687,7 +714,7 @@ class Testdir:
def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder: def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder:
"""Create a new :py:class:`HookRecorder` for a PluginManager.""" """Create a new :py:class:`HookRecorder` for a PluginManager."""
pluginmanager.reprec = reprec = HookRecorder(pluginmanager) pluginmanager.reprec = reprec = HookRecorder(pluginmanager)
self.request.addfinalizer(reprec.finish_recording) self._request.addfinalizer(reprec.finish_recording)
return reprec return reprec
def chdir(self) -> None: def chdir(self) -> None:
@ -695,12 +722,18 @@ class Testdir:
This is done automatically upon instantiation. This is done automatically upon instantiation.
""" """
self.tmpdir.chdir() os.chdir(self.path)
def _makefile(self, ext: str, lines, files, encoding: str = "utf-8"): def _makefile(
self,
ext: str,
lines: Sequence[Union[Any, bytes]],
files: Dict[str, str],
encoding: str = "utf-8",
) -> Path:
items = list(files.items()) items = list(files.items())
def to_text(s): def to_text(s: Union[Any, bytes]) -> str:
return s.decode(encoding) if isinstance(s, bytes) else str(s) return s.decode(encoding) if isinstance(s, bytes) else str(s)
if lines: if lines:
@ -710,17 +743,18 @@ class Testdir:
ret = None ret = None
for basename, value in items: for basename, value in items:
p = self.tmpdir.join(basename).new(ext=ext) p = self.path.joinpath(basename).with_suffix(ext)
p.dirpath().ensure_dir() p.parent.mkdir(parents=True, exist_ok=True)
source_ = Source(value) source_ = Source(value)
source = "\n".join(to_text(line) for line in source_.lines) source = "\n".join(to_text(line) for line in source_.lines)
p.write(source.strip().encode(encoding), "wb") p.write_text(source.strip(), encoding=encoding)
if ret is None: if ret is None:
ret = p ret = p
assert ret is not None
return ret return ret
def makefile(self, ext: str, *args: str, **kwargs): def makefile(self, ext: str, *args: str, **kwargs: str) -> Path:
r"""Create new file(s) in the testdir. r"""Create new file(s) in the test directory.
:param str ext: :param str ext:
The extension the file(s) should use, including the dot, e.g. `.py`. The extension the file(s) should use, including the dot, e.g. `.py`.
@ -743,27 +777,27 @@ class Testdir:
""" """
return self._makefile(ext, args, kwargs) return self._makefile(ext, args, kwargs)
def makeconftest(self, source): def makeconftest(self, source: str) -> Path:
"""Write a contest.py file with 'source' as contents.""" """Write a contest.py file with 'source' as contents."""
return self.makepyfile(conftest=source) return self.makepyfile(conftest=source)
def makeini(self, source): def makeini(self, source: str) -> Path:
"""Write a tox.ini file with 'source' as contents.""" """Write a tox.ini file with 'source' as contents."""
return self.makefile(".ini", tox=source) return self.makefile(".ini", tox=source)
def getinicfg(self, source) -> IniConfig: def getinicfg(self, source: str) -> IniConfig:
"""Return the pytest section from the tox.ini config file.""" """Return the pytest section from the tox.ini config file."""
p = self.makeini(source) p = self.makeini(source)
return IniConfig(p)["pytest"] return IniConfig(p)["pytest"]
def makepyprojecttoml(self, source): def makepyprojecttoml(self, source: str) -> Path:
"""Write a pyproject.toml file with 'source' as contents. """Write a pyproject.toml file with 'source' as contents.
.. versionadded:: 6.0 .. versionadded:: 6.0
""" """
return self.makefile(".toml", pyproject=source) return self.makefile(".toml", pyproject=source)
def makepyfile(self, *args, **kwargs): def makepyfile(self, *args, **kwargs) -> Path:
r"""Shortcut for .makefile() with a .py extension. r"""Shortcut for .makefile() with a .py extension.
Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting
@ -783,7 +817,7 @@ class Testdir:
""" """
return self._makefile(".py", args, kwargs) return self._makefile(".py", args, kwargs)
def maketxtfile(self, *args, **kwargs): def maketxtfile(self, *args, **kwargs) -> Path:
r"""Shortcut for .makefile() with a .txt extension. r"""Shortcut for .makefile() with a .txt extension.
Defaults to the test name with a '.txt' extension, e.g test_foobar.txt, overwriting Defaults to the test name with a '.txt' extension, e.g test_foobar.txt, overwriting
@ -803,74 +837,77 @@ class Testdir:
""" """
return self._makefile(".txt", args, kwargs) return self._makefile(".txt", args, kwargs)
def syspathinsert(self, path=None) -> None: def syspathinsert(
self, path: Optional[Union[str, "os.PathLike[str]"]] = None
) -> None:
"""Prepend a directory to sys.path, defaults to :py:attr:`tmpdir`. """Prepend a directory to sys.path, defaults to :py:attr:`tmpdir`.
This is undone automatically when this object dies at the end of each This is undone automatically when this object dies at the end of each
test. test.
""" """
if path is None: if path is None:
path = self.tmpdir path = self.path
self.monkeypatch.syspath_prepend(str(path)) self._monkeypatch.syspath_prepend(str(path))
def mkdir(self, name) -> py.path.local: def mkdir(self, name: str) -> Path:
"""Create a new (sub)directory.""" """Create a new (sub)directory."""
return self.tmpdir.mkdir(name) p = self.path / name
p.mkdir()
return p
def mkpydir(self, name) -> py.path.local: def mkpydir(self, name: str) -> Path:
"""Create a new Python package. """Create a new python package.
This creates a (sub)directory with an empty ``__init__.py`` file so it This creates a (sub)directory with an empty ``__init__.py`` file so it
gets recognised as a Python package. gets recognised as a Python package.
""" """
p = self.mkdir(name) p = self.path / name
p.ensure("__init__.py") p.mkdir()
p.joinpath("__init__.py").touch()
return p return p
def copy_example(self, name=None) -> py.path.local: def copy_example(self, name: Optional[str] = None) -> Path:
"""Copy file from project's directory into the testdir. """Copy file from project's directory into the testdir.
:param str name: The name of the file to copy. :param str name: The name of the file to copy.
:returns: Path to the copied directory (inside ``self.tmpdir``). :return: path to the copied directory (inside ``self.path``).
"""
import warnings
from _pytest.warning_types import PYTESTER_COPY_EXAMPLE
warnings.warn(PYTESTER_COPY_EXAMPLE, stacklevel=2) """
example_dir = self.request.config.getini("pytester_example_dir") example_dir = self._request.config.getini("pytester_example_dir")
if example_dir is None: if example_dir is None:
raise ValueError("pytester_example_dir is unset, can't copy examples") raise ValueError("pytester_example_dir is unset, can't copy examples")
example_dir = self.request.config.rootdir.join(example_dir) example_dir = Path(str(self._request.config.rootdir)) / example_dir
for extra_element in self.request.node.iter_markers("pytester_example_path"): for extra_element in self._request.node.iter_markers("pytester_example_path"):
assert extra_element.args assert extra_element.args
example_dir = example_dir.join(*extra_element.args) example_dir = example_dir.joinpath(*extra_element.args)
if name is None: if name is None:
func_name = self._name func_name = self._name
maybe_dir = example_dir / func_name maybe_dir = example_dir / func_name
maybe_file = example_dir / (func_name + ".py") maybe_file = example_dir / (func_name + ".py")
if maybe_dir.isdir(): if maybe_dir.is_dir():
example_path = maybe_dir example_path = maybe_dir
elif maybe_file.isfile(): elif maybe_file.is_file():
example_path = maybe_file example_path = maybe_file
else: else:
raise LookupError( raise LookupError(
"{} cant be found as module or package in {}".format( f"{func_name} can't be found as module or package in {example_dir}"
func_name, example_dir.bestrelpath(self.request.config.rootdir)
)
) )
else: else:
example_path = example_dir.join(name) example_path = example_dir.joinpath(name)
if example_path.isdir() and not example_path.join("__init__.py").isfile(): if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file():
example_path.copy(self.tmpdir) # TODO: py.path.local.copy can copy files to existing directories,
return self.tmpdir # while with shutil.copytree the destination directory cannot exist,
elif example_path.isfile(): # we will need to roll our own in order to drop py.path.local completely
result = self.tmpdir.join(example_path.basename) py.path.local(example_path).copy(py.path.local(self.path))
example_path.copy(result) return self.path
elif example_path.is_file():
result = self.path.joinpath(example_path.name)
shutil.copy(example_path, result)
return result return result
else: else:
raise LookupError( raise LookupError(
@ -879,7 +916,9 @@ class Testdir:
Session = Session Session = Session
def getnode(self, config: Config, arg): def getnode(
self, config: Config, arg: Union[str, "os.PathLike[str]"]
) -> Optional[Union[Collector, Item]]:
"""Return the collection node of a file. """Return the collection node of a file.
:param _pytest.config.Config config: :param _pytest.config.Config config:
@ -896,7 +935,7 @@ class Testdir:
config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK) config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK)
return res return res
def getpathnode(self, path): def getpathnode(self, path: Union[str, "os.PathLike[str]"]):
"""Return the collection node of a file. """Return the collection node of a file.
This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to
@ -904,6 +943,7 @@ class Testdir:
:param py.path.local path: Path to the file. :param py.path.local path: Path to the file.
""" """
path = py.path.local(path)
config = self.parseconfigure(path) config = self.parseconfigure(path)
session = Session.from_config(config) session = Session.from_config(config)
x = session.fspath.bestrelpath(path) x = session.fspath.bestrelpath(path)
@ -924,7 +964,7 @@ class Testdir:
result.extend(session.genitems(colitem)) result.extend(session.genitems(colitem))
return result return result
def runitem(self, source): def runitem(self, source: str) -> Any:
"""Run the "test_func" Item. """Run the "test_func" Item.
The calling test instance (class containing the test method) must The calling test instance (class containing the test method) must
@ -935,11 +975,11 @@ class Testdir:
# used from runner functional tests # used from runner functional tests
item = self.getitem(source) item = self.getitem(source)
# the test class where we are called from wants to provide the runner # the test class where we are called from wants to provide the runner
testclassinstance = self.request.instance testclassinstance = self._request.instance
runner = testclassinstance.getrunner() runner = testclassinstance.getrunner()
return runner(item) return runner(item)
def inline_runsource(self, source, *cmdlineargs) -> HookRecorder: def inline_runsource(self, source: str, *cmdlineargs) -> HookRecorder:
"""Run a test module in process using ``pytest.main()``. """Run a test module in process using ``pytest.main()``.
This run writes "source" into a temporary file and runs This run writes "source" into a temporary file and runs
@ -968,7 +1008,10 @@ class Testdir:
return items, rec return items, rec
def inline_run( def inline_run(
self, *args, plugins=(), no_reraise_ctrlc: bool = False self,
*args: Union[str, "os.PathLike[str]"],
plugins=(),
no_reraise_ctrlc: bool = False,
) -> HookRecorder: ) -> HookRecorder:
"""Run ``pytest.main()`` in-process, returning a HookRecorder. """Run ``pytest.main()`` in-process, returning a HookRecorder.
@ -1016,7 +1059,7 @@ class Testdir:
rec.append(self.make_hook_recorder(config.pluginmanager)) rec.append(self.make_hook_recorder(config.pluginmanager))
plugins.append(Collect()) plugins.append(Collect())
ret = pytest.main(list(args), plugins=plugins) ret = pytest.main([str(x) for x in args], plugins=plugins)
if len(rec) == 1: if len(rec) == 1:
reprec = rec.pop() reprec = rec.pop()
else: else:
@ -1024,7 +1067,7 @@ class Testdir:
class reprec: # type: ignore class reprec: # type: ignore
pass pass
reprec.ret = ret reprec.ret = ret # type: ignore
# Typically we reraise keyboard interrupts from the child run # Typically we reraise keyboard interrupts from the child run
# because it's our user requesting interruption of the testing. # because it's our user requesting interruption of the testing.
@ -1037,7 +1080,9 @@ class Testdir:
for finalizer in finalizers: for finalizer in finalizers:
finalizer() finalizer()
def runpytest_inprocess(self, *args, **kwargs) -> RunResult: def runpytest_inprocess(
self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any
) -> RunResult:
"""Return result of running pytest in-process, providing a similar """Return result of running pytest in-process, providing a similar
interface to what self.runpytest() provides.""" interface to what self.runpytest() provides."""
syspathinsert = kwargs.pop("syspathinsert", False) syspathinsert = kwargs.pop("syspathinsert", False)
@ -1079,26 +1124,30 @@ class Testdir:
res.reprec = reprec # type: ignore res.reprec = reprec # type: ignore
return res return res
def runpytest(self, *args, **kwargs) -> RunResult: def runpytest(
self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any
) -> RunResult:
"""Run pytest inline or in a subprocess, depending on the command line """Run pytest inline or in a subprocess, depending on the command line
option "--runpytest" and return a :py:class:`RunResult`.""" option "--runpytest" and return a :py:class:`RunResult`."""
args = self._ensure_basetemp(args) new_args = self._ensure_basetemp(args)
if self._method == "inprocess": if self._method == "inprocess":
return self.runpytest_inprocess(*args, **kwargs) return self.runpytest_inprocess(*new_args, **kwargs)
elif self._method == "subprocess": elif self._method == "subprocess":
return self.runpytest_subprocess(*args, **kwargs) return self.runpytest_subprocess(*new_args, **kwargs)
raise RuntimeError(f"Unrecognized runpytest option: {self._method}") raise RuntimeError(f"Unrecognized runpytest option: {self._method}")
def _ensure_basetemp(self, args): def _ensure_basetemp(
args = list(args) self, args: Sequence[Union[str, "os.PathLike[str]"]]
for x in args: ) -> List[Union[str, "os.PathLike[str]"]]:
new_args = list(args)
for x in new_args:
if str(x).startswith("--basetemp"): if str(x).startswith("--basetemp"):
break break
else: else:
args.append("--basetemp=%s" % self.tmpdir.dirpath("basetemp")) new_args.append("--basetemp=%s" % self.path.parent.joinpath("basetemp"))
return args return new_args
def parseconfig(self, *args) -> Config: def parseconfig(self, *args: Union[str, "os.PathLike[str]"]) -> Config:
"""Return a new pytest Config instance from given commandline args. """Return a new pytest Config instance from given commandline args.
This invokes the pytest bootstrapping code in _pytest.config to create This invokes the pytest bootstrapping code in _pytest.config to create
@ -1109,18 +1158,19 @@ class Testdir:
If :py:attr:`plugins` has been populated they should be plugin modules If :py:attr:`plugins` has been populated they should be plugin modules
to be registered with the PluginManager. to be registered with the PluginManager.
""" """
args = self._ensure_basetemp(args)
import _pytest.config import _pytest.config
config = _pytest.config._prepareconfig(args, self.plugins) # type: ignore[arg-type] new_args = self._ensure_basetemp(args)
new_args = [str(x) for x in new_args]
config = _pytest.config._prepareconfig(new_args, self.plugins) # type: ignore[arg-type]
# we don't know what the test will do with this half-setup config # we don't know what the test will do with this half-setup config
# object and thus we make sure it gets unconfigured properly in any # object and thus we make sure it gets unconfigured properly in any
# case (otherwise capturing could still be active, for example) # case (otherwise capturing could still be active, for example)
self.request.addfinalizer(config._ensure_unconfigure) self._request.addfinalizer(config._ensure_unconfigure)
return config return config
def parseconfigure(self, *args) -> Config: def parseconfigure(self, *args: Union[str, "os.PathLike[str]"]) -> Config:
"""Return a new pytest configured Config instance. """Return a new pytest configured Config instance.
Returns a new :py:class:`_pytest.config.Config` instance like Returns a new :py:class:`_pytest.config.Config` instance like
@ -1130,7 +1180,7 @@ class Testdir:
config._do_configure() config._do_configure()
return config return config
def getitem(self, source, funcname: str = "test_func") -> Item: def getitem(self, source: str, funcname: str = "test_func") -> Item:
"""Return the test item for a test function. """Return the test item for a test function.
Writes the source to a python file and runs pytest's collection on Writes the source to a python file and runs pytest's collection on
@ -1150,7 +1200,7 @@ class Testdir:
funcname, source, items funcname, source, items
) )
def getitems(self, source) -> List[Item]: def getitems(self, source: str) -> List[Item]:
"""Return all test items collected from the module. """Return all test items collected from the module.
Writes the source to a Python file and runs pytest's collection on Writes the source to a Python file and runs pytest's collection on
@ -1159,7 +1209,9 @@ class Testdir:
modcol = self.getmodulecol(source) modcol = self.getmodulecol(source)
return self.genitems([modcol]) return self.genitems([modcol])
def getmodulecol(self, source, configargs=(), withinit: bool = False): def getmodulecol(
self, source: Union[str, Path], configargs=(), *, withinit: bool = False
):
"""Return the module collection node for ``source``. """Return the module collection node for ``source``.
Writes ``source`` to a file using :py:meth:`makepyfile` and then Writes ``source`` to a file using :py:meth:`makepyfile` and then
@ -1177,10 +1229,10 @@ class Testdir:
directory to ensure it is a package. directory to ensure it is a package.
""" """
if isinstance(source, Path): if isinstance(source, Path):
path = self.tmpdir.join(str(source)) path = self.path.joinpath(source)
assert not withinit, "not supported for paths" assert not withinit, "not supported for paths"
else: else:
kw = {self._name: Source(source).strip()} kw = {self._name: str(source)}
path = self.makepyfile(**kw) path = self.makepyfile(**kw)
if withinit: if withinit:
self.makepyfile(__init__="#") self.makepyfile(__init__="#")
@ -1208,8 +1260,8 @@ class Testdir:
def popen( def popen(
self, self,
cmdargs, cmdargs,
stdout=subprocess.PIPE, stdout: Union[int, TextIO] = subprocess.PIPE,
stderr=subprocess.PIPE, stderr: Union[int, TextIO] = subprocess.PIPE,
stdin=CLOSE_STDIN, stdin=CLOSE_STDIN,
**kw, **kw,
): ):
@ -1244,14 +1296,18 @@ class Testdir:
return popen return popen
def run( def run(
self, *cmdargs, timeout: Optional[float] = None, stdin=CLOSE_STDIN self,
*cmdargs: Union[str, "os.PathLike[str]"],
timeout: Optional[float] = None,
stdin=CLOSE_STDIN,
) -> RunResult: ) -> RunResult:
"""Run a command with arguments. """Run a command with arguments.
Run a process using subprocess.Popen saving the stdout and stderr. Run a process using subprocess.Popen saving the stdout and stderr.
:param args: :param cmdargs:
The sequence of arguments to pass to `subprocess.Popen()`. The sequence of arguments to pass to `subprocess.Popen()`, with path-like objects
being converted to ``str`` automatically.
:param timeout: :param timeout:
The period in seconds after which to timeout and raise The period in seconds after which to timeout and raise
:py:class:`Testdir.TimeoutExpired`. :py:class:`Testdir.TimeoutExpired`.
@ -1266,15 +1322,14 @@ class Testdir:
__tracebackhide__ = True __tracebackhide__ = True
cmdargs = tuple( cmdargs = tuple(
str(arg) if isinstance(arg, py.path.local) else arg for arg in cmdargs os.fspath(arg) if isinstance(arg, os.PathLike) else arg for arg in cmdargs
) )
p1 = self.tmpdir.join("stdout") p1 = self.path.joinpath("stdout")
p2 = self.tmpdir.join("stderr") p2 = self.path.joinpath("stderr")
print("running:", *cmdargs) print("running:", *cmdargs)
print(" in:", py.path.local()) print(" in:", Path.cwd())
f1 = open(str(p1), "w", encoding="utf8")
f2 = open(str(p2), "w", encoding="utf8") with p1.open("w", encoding="utf8") as f1, p2.open("w", encoding="utf8") as f2:
try:
now = timing.time() now = timing.time()
popen = self.popen( popen = self.popen(
cmdargs, cmdargs,
@ -1305,23 +1360,16 @@ class Testdir:
ret = popen.wait(timeout) ret = popen.wait(timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
handle_timeout() handle_timeout()
finally:
f1.close() with p1.open(encoding="utf8") as f1, p2.open(encoding="utf8") as f2:
f2.close()
f1 = open(str(p1), encoding="utf8")
f2 = open(str(p2), encoding="utf8")
try:
out = f1.read().splitlines() out = f1.read().splitlines()
err = f2.read().splitlines() err = f2.read().splitlines()
finally:
f1.close()
f2.close()
self._dump_lines(out, sys.stdout) self._dump_lines(out, sys.stdout)
self._dump_lines(err, sys.stderr) self._dump_lines(err, sys.stderr)
try:
with contextlib.suppress(ValueError):
ret = ExitCode(ret) ret = ExitCode(ret)
except ValueError:
pass
return RunResult(ret, out, err, timing.time() - now) return RunResult(ret, out, err, timing.time() - now)
def _dump_lines(self, lines, fp): def _dump_lines(self, lines, fp):
@ -1366,7 +1414,7 @@ class Testdir:
:rtype: RunResult :rtype: RunResult
""" """
__tracebackhide__ = True __tracebackhide__ = True
p = make_numbered_dir(root=Path(str(self.tmpdir)), prefix="runpytest-") p = make_numbered_dir(root=self.path, prefix="runpytest-")
args = ("--basetemp=%s" % p,) + args args = ("--basetemp=%s" % p,) + args
plugins = [x for x in self.plugins if isinstance(x, str)] plugins = [x for x in self.plugins if isinstance(x, str)]
if plugins: if plugins:
@ -1384,7 +1432,8 @@ class Testdir:
The pexpect child is returned. The pexpect child is returned.
""" """
basetemp = self.tmpdir.mkdir("temp-pexpect") basetemp = self.path / "temp-pexpect"
basetemp.mkdir()
invoke = " ".join(map(str, self._getpytestargs())) invoke = " ".join(map(str, self._getpytestargs()))
cmd = f"{invoke} --basetemp={basetemp} {string}" cmd = f"{invoke} --basetemp={basetemp} {string}"
return self.spawn(cmd, expect_timeout=expect_timeout) return self.spawn(cmd, expect_timeout=expect_timeout)
@ -1399,10 +1448,10 @@ class Testdir:
pytest.skip("pypy-64 bit not supported") pytest.skip("pypy-64 bit not supported")
if not hasattr(pexpect, "spawn"): if not hasattr(pexpect, "spawn"):
pytest.skip("pexpect.spawn not available") pytest.skip("pexpect.spawn not available")
logfile = self.tmpdir.join("spawn.out").open("wb") logfile = self.path.joinpath("spawn.out").open("wb")
child = pexpect.spawn(cmd, logfile=logfile) child = pexpect.spawn(cmd, logfile=logfile)
self.request.addfinalizer(logfile.close) self._request.addfinalizer(logfile.close)
child.timeout = expect_timeout child.timeout = expect_timeout
return child return child
@ -1425,6 +1474,178 @@ class LineComp:
LineMatcher(lines1).fnmatch_lines(lines2) LineMatcher(lines1).fnmatch_lines(lines2)
@final
@attr.s(repr=False, str=False)
class Testdir:
"""
Similar to :class:`Pytester`, but this class works with legacy py.path.local objects instead.
All methods just forward to an internal :class:`Pytester` instance, converting results
to `py.path.local` objects as necessary.
"""
__test__ = False
CLOSE_STDIN = Pytester.CLOSE_STDIN
TimeoutExpired = Pytester.TimeoutExpired
Session = Pytester.Session
_pytester: Pytester = attr.ib()
@property
def tmpdir(self) -> py.path.local:
return py.path.local(self._pytester.path)
@property
def test_tmproot(self) -> py.path.local:
return py.path.local(self._pytester._test_tmproot)
@property
def request(self):
return self._pytester._request
@property
def plugins(self):
return self._pytester.plugins
@plugins.setter
def plugins(self, plugins):
self._pytester.plugins = plugins
@property
def monkeypatch(self) -> MonkeyPatch:
return self._pytester._monkeypatch
def make_hook_recorder(self, pluginmanager) -> HookRecorder:
return self._pytester.make_hook_recorder(pluginmanager)
def chdir(self) -> None:
return self._pytester.chdir()
def finalize(self) -> None:
return self._pytester._finalize()
def makefile(self, ext, *args, **kwargs) -> py.path.local:
return py.path.local(str(self._pytester.makefile(ext, *args, **kwargs)))
def makeconftest(self, source) -> py.path.local:
return py.path.local(str(self._pytester.makeconftest(source)))
def makeini(self, source) -> py.path.local:
return py.path.local(str(self._pytester.makeini(source)))
def getinicfg(self, source) -> py.path.local:
return py.path.local(str(self._pytester.getinicfg(source)))
def makepyprojecttoml(self, source) -> py.path.local:
return py.path.local(str(self._pytester.makepyprojecttoml(source)))
def makepyfile(self, *args, **kwargs) -> py.path.local:
return py.path.local(str(self._pytester.makepyfile(*args, **kwargs)))
def maketxtfile(self, *args, **kwargs) -> py.path.local:
return py.path.local(str(self._pytester.maketxtfile(*args, **kwargs)))
def syspathinsert(self, path=None) -> None:
return self._pytester.syspathinsert(path)
def mkdir(self, name) -> py.path.local:
return py.path.local(str(self._pytester.mkdir(name)))
def mkpydir(self, name) -> py.path.local:
return py.path.local(str(self._pytester.mkpydir(name)))
def copy_example(self, name=None) -> py.path.local:
return py.path.local(str(self._pytester.copy_example(name)))
def getnode(self, config: Config, arg) -> Optional[Union[Item, Collector]]:
return self._pytester.getnode(config, arg)
def getpathnode(self, path):
return self._pytester.getpathnode(path)
def genitems(self, colitems: List[Union[Item, Collector]]) -> List[Item]:
return self._pytester.genitems(colitems)
def runitem(self, source):
return self._pytester.runitem(source)
def inline_runsource(self, source, *cmdlineargs):
return self._pytester.inline_runsource(source, *cmdlineargs)
def inline_genitems(self, *args):
return self._pytester.inline_genitems(*args)
def inline_run(self, *args, plugins=(), no_reraise_ctrlc: bool = False):
return self._pytester.inline_run(
*args, plugins=plugins, no_reraise_ctrlc=no_reraise_ctrlc
)
def runpytest_inprocess(self, *args, **kwargs) -> RunResult:
return self._pytester.runpytest_inprocess(*args, **kwargs)
def runpytest(self, *args, **kwargs) -> RunResult:
return self._pytester.runpytest(*args, **kwargs)
def parseconfig(self, *args) -> Config:
return self._pytester.parseconfig(*args)
def parseconfigure(self, *args) -> Config:
return self._pytester.parseconfigure(*args)
def getitem(self, source, funcname="test_func"):
return self._pytester.getitem(source, funcname)
def getitems(self, source):
return self._pytester.getitems(source)
def getmodulecol(self, source, configargs=(), withinit=False):
return self._pytester.getmodulecol(
source, configargs=configargs, withinit=withinit
)
def collect_by_name(
self, modcol: Module, name: str
) -> Optional[Union[Item, Collector]]:
return self._pytester.collect_by_name(modcol, name)
def popen(
self,
cmdargs,
stdout: Union[int, TextIO] = subprocess.PIPE,
stderr: Union[int, TextIO] = subprocess.PIPE,
stdin=CLOSE_STDIN,
**kw,
):
return self._pytester.popen(cmdargs, stdout, stderr, stdin, **kw)
def run(self, *cmdargs, timeout=None, stdin=CLOSE_STDIN) -> RunResult:
return self._pytester.run(*cmdargs, timeout=timeout, stdin=stdin)
def runpython(self, script) -> RunResult:
return self._pytester.runpython(script)
def runpython_c(self, command):
return self._pytester.runpython_c(command)
def runpytest_subprocess(self, *args, timeout=None) -> RunResult:
return self._pytester.runpytest_subprocess(*args, timeout=timeout)
def spawn_pytest(
self, string: str, expect_timeout: float = 10.0
) -> "pexpect.spawn":
return self._pytester.spawn_pytest(string, expect_timeout=expect_timeout)
def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn":
return self._pytester.spawn(cmd, expect_timeout=expect_timeout)
def __repr__(self) -> str:
return f"<Testdir {self.tmpdir!r}>"
def __str__(self) -> str:
return str(self.tmpdir)
class LineMatcher: class LineMatcher:
"""Flexible matching of text. """Flexible matching of text.

View File

@ -108,6 +108,3 @@ class UnformattedWarning(Generic[_W]):
def format(self, **kwargs: Any) -> _W: def format(self, **kwargs: Any) -> _W:
"""Return an instance of the warning category, formatted with given kwargs.""" """Return an instance of the warning category, formatted with given kwargs."""
return self.category(self.template.format(**kwargs)) return self.category(self.template.format(**kwargs))
PYTESTER_COPY_EXAMPLE = PytestExperimentalApiWarning.simple("testdir.copy_example")

View File

@ -9,7 +9,7 @@ import pytest
from _pytest.compat import importlib_metadata from _pytest.compat import importlib_metadata
from _pytest.config import ExitCode from _pytest.config import ExitCode
from _pytest.pathlib import symlink_or_skip from _pytest.pathlib import symlink_or_skip
from _pytest.pytester import Testdir from _pytest.pytester import Pytester
def prepend_pythonpath(*dirs): def prepend_pythonpath(*dirs):
@ -1276,14 +1276,14 @@ def test_tee_stdio_captures_and_live_prints(testdir):
sys.platform == "win32", sys.platform == "win32",
reason="Windows raises `OSError: [Errno 22] Invalid argument` instead", reason="Windows raises `OSError: [Errno 22] Invalid argument` instead",
) )
def test_no_brokenpipeerror_message(testdir: Testdir) -> None: def test_no_brokenpipeerror_message(pytester: Pytester) -> None:
"""Ensure that the broken pipe error message is supressed. """Ensure that the broken pipe error message is supressed.
In some Python versions, it reaches sys.unraisablehook, in others In some Python versions, it reaches sys.unraisablehook, in others
a BrokenPipeError exception is propagated, but either way it prints a BrokenPipeError exception is propagated, but either way it prints
to stderr on shutdown, so checking nothing is printed is enough. to stderr on shutdown, so checking nothing is printed is enough.
""" """
popen = testdir.popen((*testdir._getpytestargs(), "--help")) popen = pytester.popen((*pytester._getpytestargs(), "--help"))
popen.stdout.close() popen.stdout.close()
ret = popen.wait() ret = popen.wait()
assert popen.stderr.read() == b"" assert popen.stderr.read() == b""

File diff suppressed because it is too large Load Diff

View File

@ -801,9 +801,10 @@ def test_parse_summary_line_always_plural():
def test_makefile_joins_absolute_path(testdir: Testdir) -> None: def test_makefile_joins_absolute_path(testdir: Testdir) -> None:
absfile = testdir.tmpdir / "absfile" absfile = testdir.tmpdir / "absfile"
if sys.platform == "win32":
with pytest.raises(OSError):
testdir.makepyfile(**{str(absfile): ""})
else:
p1 = testdir.makepyfile(**{str(absfile): ""}) p1 = testdir.makepyfile(**{str(absfile): ""})
assert str(p1) == (testdir.tmpdir / absfile) + ".py" assert str(p1) == str(testdir.tmpdir / "absfile.py")
def test_testtmproot(testdir):
"""Check test_tmproot is a py.path attribute for backward compatibility."""
assert testdir.test_tmproot.check(dir=1)