Merge pull request #9230 from bluetech/pytester-transitive-public

Export pytester transitive types
This commit is contained in:
Ran Benita 2021-10-27 08:05:08 +03:00 committed by GitHub
commit 3c5c5feb04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 63 additions and 31 deletions

View File

@ -8,5 +8,6 @@ Directly constructing the following classes is now deprecated:
- ``_pytest._code.ExceptionInfo`` - ``_pytest._code.ExceptionInfo``
- ``_pytest.config.argparsing.Parser`` - ``_pytest.config.argparsing.Parser``
- ``_pytest.config.argparsing.OptionGroup`` - ``_pytest.config.argparsing.OptionGroup``
- ``_pytest.pytester.HookRecorder``
These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 8.0.0. These constructors have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 8.

View File

@ -12,8 +12,12 @@ The newly-exported types are:
- ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo <pytest.ExceptionInfo>` type returned from :func:`pytest.raises` and passed to various hooks. - ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo <pytest.ExceptionInfo>` type returned from :func:`pytest.raises` and passed to various hooks.
- ``pytest.Parser`` for the :class:`Parser <pytest.Parser>` type passed to the :func:`pytest_addoption <pytest.hookspec.pytest_addoption>` hook. - ``pytest.Parser`` for the :class:`Parser <pytest.Parser>` type passed to the :func:`pytest_addoption <pytest.hookspec.pytest_addoption>` hook.
- ``pytest.OptionGroup`` for the :class:`OptionGroup <pytest.OptionGroup>` type returned from the :func:`parser.addgroup <pytest.Parser.getgroup>` method. - ``pytest.OptionGroup`` for the :class:`OptionGroup <pytest.OptionGroup>` type returned from the :func:`parser.addgroup <pytest.Parser.getgroup>` method.
- ``pytest.HookRecorder`` for the :class:`HookRecorder <pytest.HookRecorder>` type returned from :class:`~pytest.Pytester`.
- ``pytest.RecordedHookCall`` for the :class:`RecordedHookCall <pytest.HookRecorder>` type returned from :class:`~pytest.HookRecorder`.
- ``pytest.RunResult`` for the :class:`RunResult <pytest.RunResult>` type returned from :class:`~pytest.Pytester`.
- ``pytest.LineMatcher`` for the :class:`LineMatcher <pytest.RunResult>` type used in :class:`~pytest.RunResult` and others.
Constructing them directly is not supported; they are only meant for use in type annotations. Constructing most of them directly is not supported; they are only meant for use in type annotations.
Doing so will emit a deprecation warning, and may become a hard-error in pytest 8.0. Doing so will emit a deprecation warning, and may become a hard-error in pytest 8.0.
Subclassing them is also not supported. This is not currently enforced at runtime, but is detected by type-checkers such as mypy. Subclassing them is also not supported. This is not currently enforced at runtime, but is detected by type-checkers such as mypy.

View File

@ -558,14 +558,17 @@ To use it, include in your topmost ``conftest.py`` file:
.. autoclass:: pytest.Pytester() .. autoclass:: pytest.Pytester()
:members: :members:
.. autoclass:: _pytest.pytester.RunResult() .. autoclass:: pytest.RunResult()
:members: :members:
.. autoclass:: _pytest.pytester.LineMatcher() .. autoclass:: pytest.LineMatcher()
:members: :members:
:special-members: __str__ :special-members: __str__
.. autoclass:: _pytest.pytester.HookRecorder() .. autoclass:: pytest.HookRecorder()
:members:
.. autoclass:: pytest.RecordedHookCall()
:members: :members:
.. fixture:: testdir .. fixture:: testdir

View File

@ -214,7 +214,20 @@ def get_public_names(values: Iterable[str]) -> List[str]:
return [x for x in values if x[0] != "_"] return [x for x in values if x[0] != "_"]
class ParsedCall: @final
class RecordedHookCall:
"""A recorded call to a hook.
The arguments to the hook call are set as attributes.
For example:
.. code-block:: python
calls = hook_recorder.getcalls("pytest_runtest_setup")
# Suppose pytest_runtest_setup was called once with `item=an_item`.
assert calls[0].item is an_item
"""
def __init__(self, name: str, kwargs) -> None: def __init__(self, name: str, kwargs) -> None:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
self._name = name self._name = name
@ -222,7 +235,7 @@ class ParsedCall:
def __repr__(self) -> str: def __repr__(self) -> str:
d = self.__dict__.copy() d = self.__dict__.copy()
del d["_name"] del d["_name"]
return f"<ParsedCall {self._name!r}(**{d!r})>" return f"<RecordedHookCall {self._name!r}(**{d!r})>"
if TYPE_CHECKING: if TYPE_CHECKING:
# The class has undetermined attributes, this tells mypy about it. # The class has undetermined attributes, this tells mypy about it.
@ -230,20 +243,27 @@ class ParsedCall:
... ...
@final
class HookRecorder: class HookRecorder:
"""Record all hooks called in a plugin manager. """Record all hooks called in a plugin manager.
Hook recorders are created by :class:`Pytester`.
This wraps all the hook calls in the plugin manager, recording each call This wraps all the hook calls in the plugin manager, recording each call
before propagating the normal calls. before propagating the normal calls.
""" """
def __init__(self, pluginmanager: PytestPluginManager) -> None: def __init__(
self, pluginmanager: PytestPluginManager, *, _ispytest: bool = False
) -> None:
check_ispytest(_ispytest)
self._pluginmanager = pluginmanager self._pluginmanager = pluginmanager
self.calls: List[ParsedCall] = [] self.calls: List[RecordedHookCall] = []
self.ret: Optional[Union[int, ExitCode]] = None self.ret: Optional[Union[int, ExitCode]] = None
def before(hook_name: str, hook_impls, kwargs) -> None: def before(hook_name: str, hook_impls, kwargs) -> None:
self.calls.append(ParsedCall(hook_name, kwargs)) self.calls.append(RecordedHookCall(hook_name, kwargs))
def after(outcome, hook_name: str, hook_impls, kwargs) -> None: def after(outcome, hook_name: str, hook_impls, kwargs) -> None:
pass pass
@ -253,7 +273,8 @@ class HookRecorder:
def finish_recording(self) -> None: def finish_recording(self) -> None:
self._undo_wrapping() self._undo_wrapping()
def getcalls(self, names: Union[str, Iterable[str]]) -> List[ParsedCall]: def getcalls(self, names: Union[str, Iterable[str]]) -> List[RecordedHookCall]:
"""Get all recorded calls to hooks with the given names (or name)."""
if isinstance(names, str): if isinstance(names, str):
names = names.split() names = names.split()
return [call for call in self.calls if call._name in names] return [call for call in self.calls if call._name in names]
@ -279,7 +300,7 @@ class HookRecorder:
else: else:
fail(f"could not find {name!r} check {check!r}") fail(f"could not find {name!r} check {check!r}")
def popcall(self, name: str) -> ParsedCall: def popcall(self, name: str) -> RecordedHookCall:
__tracebackhide__ = True __tracebackhide__ = True
for i, call in enumerate(self.calls): for i, call in enumerate(self.calls):
if call._name == name: if call._name == name:
@ -289,7 +310,7 @@ class HookRecorder:
lines.extend([" %s" % x for x in self.calls]) lines.extend([" %s" % x for x in self.calls])
fail("\n".join(lines)) fail("\n".join(lines))
def getcall(self, name: str) -> ParsedCall: def getcall(self, name: str) -> RecordedHookCall:
values = self.getcalls(name) values = self.getcalls(name)
assert len(values) == 1, (name, values) assert len(values) == 1, (name, values)
return values[0] return values[0]
@ -507,8 +528,9 @@ rex_session_duration = re.compile(r"\d+\.\d\ds")
rex_outcome = re.compile(r"(\d+) (\w+)") rex_outcome = re.compile(r"(\d+) (\w+)")
@final
class RunResult: class RunResult:
"""The result of running a command.""" """The result of running a command from :class:`~pytest.Pytester`."""
def __init__( def __init__(
self, self,
@ -527,13 +549,13 @@ class RunResult:
self.errlines = errlines self.errlines = errlines
"""List of lines captured from stderr.""" """List of lines captured from stderr."""
self.stdout = LineMatcher(outlines) self.stdout = LineMatcher(outlines)
""":class:`LineMatcher` of stdout. """:class:`~pytest.LineMatcher` of stdout.
Use e.g. :func:`str(stdout) <LineMatcher.__str__()>` to reconstruct stdout, or the commonly used Use e.g. :func:`str(stdout) <pytest.LineMatcher.__str__()>` to reconstruct stdout, or the commonly used
:func:`stdout.fnmatch_lines() <LineMatcher.fnmatch_lines()>` method. :func:`stdout.fnmatch_lines() <pytest.LineMatcher.fnmatch_lines()>` method.
""" """
self.stderr = LineMatcher(errlines) self.stderr = LineMatcher(errlines)
""":class:`LineMatcher` of stderr.""" """:class:`~pytest.LineMatcher` of stderr."""
self.duration = duration self.duration = duration
"""Duration in seconds.""" """Duration in seconds."""
@ -741,7 +763,7 @@ class Pytester:
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, _ispytest=True)
self._request.addfinalizer(reprec.finish_recording) self._request.addfinalizer(reprec.finish_recording)
return reprec return reprec
@ -950,8 +972,6 @@ class Pytester:
f'example "{example_path}" is not found as a file or directory' f'example "{example_path}" is not found as a file or directory'
) )
Session = Session
def getnode( def getnode(
self, config: Config, arg: Union[str, "os.PathLike[str]"] self, config: Config, arg: Union[str, "os.PathLike[str]"]
) -> Optional[Union[Collector, Item]]: ) -> Optional[Union[Collector, Item]]:
@ -1023,10 +1043,7 @@ class Pytester:
for the result. for the result.
:param source: The source code of the test module. :param source: The source code of the test module.
:param cmdlineargs: Any extra command line arguments to use. :param cmdlineargs: Any extra command line arguments to use.
:returns: :py:class:`HookRecorder` instance of the result.
""" """
p = self.makepyfile(source) p = self.makepyfile(source)
values = list(cmdlineargs) + [p] values = list(cmdlineargs) + [p]
@ -1064,8 +1081,6 @@ class Pytester:
:param no_reraise_ctrlc: :param no_reraise_ctrlc:
Typically we reraise keyboard interrupts from the child run. If Typically we reraise keyboard interrupts from the child run. If
True, the KeyboardInterrupt exception is captured. True, the KeyboardInterrupt exception is captured.
:returns: A :py:class:`HookRecorder` instance.
""" """
# (maybe a cpython bug?) the importlib cache sometimes isn't updated # (maybe a cpython bug?) the importlib cache sometimes isn't updated
# properly between file creation and inline_run (especially if imports # properly between file creation and inline_run (especially if imports
@ -1164,7 +1179,7 @@ class Pytester:
self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any
) -> RunResult: ) -> 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:`~pytest.RunResult`."""
new_args = self._ensure_basetemp(args) new_args = self._ensure_basetemp(args)
if self._method == "inprocess": if self._method == "inprocess":
return self.runpytest_inprocess(*new_args, **kwargs) return self.runpytest_inprocess(*new_args, **kwargs)
@ -1506,7 +1521,7 @@ class LineComp:
def assert_contains_lines(self, lines2: Sequence[str]) -> None: def assert_contains_lines(self, lines2: Sequence[str]) -> None:
"""Assert that ``lines2`` are contained (linearly) in :attr:`stringio`'s value. """Assert that ``lines2`` are contained (linearly) in :attr:`stringio`'s value.
Lines are matched using :func:`LineMatcher.fnmatch_lines`. Lines are matched using :func:`LineMatcher.fnmatch_lines <pytest.LineMatcher.fnmatch_lines>`.
""" """
__tracebackhide__ = True __tracebackhide__ = True
val = self.stringio.getvalue() val = self.stringio.getvalue()
@ -1529,7 +1544,6 @@ class Testdir:
CLOSE_STDIN: "Final" = Pytester.CLOSE_STDIN CLOSE_STDIN: "Final" = Pytester.CLOSE_STDIN
TimeoutExpired: "Final" = Pytester.TimeoutExpired TimeoutExpired: "Final" = Pytester.TimeoutExpired
Session: "Final" = Pytester.Session
def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None: def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest) check_ispytest(_ispytest)
@ -1734,6 +1748,7 @@ class Testdir:
return str(self.tmpdir) return str(self.tmpdir)
@final
class LineMatcher: class LineMatcher:
"""Flexible matching of text. """Flexible matching of text.

View File

@ -41,7 +41,11 @@ from _pytest.outcomes import fail
from _pytest.outcomes import importorskip from _pytest.outcomes import importorskip
from _pytest.outcomes import skip from _pytest.outcomes import skip
from _pytest.outcomes import xfail from _pytest.outcomes import xfail
from _pytest.pytester import HookRecorder
from _pytest.pytester import LineMatcher
from _pytest.pytester import Pytester from _pytest.pytester import Pytester
from _pytest.pytester import RecordedHookCall
from _pytest.pytester import RunResult
from _pytest.pytester import Testdir from _pytest.pytester import Testdir
from _pytest.python import Class from _pytest.python import Class
from _pytest.python import Function from _pytest.python import Function
@ -98,10 +102,12 @@ __all__ = [
"freeze_includes", "freeze_includes",
"Function", "Function",
"hookimpl", "hookimpl",
"HookRecorder",
"hookspec", "hookspec",
"importorskip", "importorskip",
"Instance", "Instance",
"Item", "Item",
"LineMatcher",
"LogCaptureFixture", "LogCaptureFixture",
"main", "main",
"mark", "mark",
@ -129,7 +135,9 @@ __all__ = [
"PytestUnraisableExceptionWarning", "PytestUnraisableExceptionWarning",
"PytestWarning", "PytestWarning",
"raises", "raises",
"RecordedHookCall",
"register_assert_rewrite", "register_assert_rewrite",
"RunResult",
"Session", "Session",
"set_trace", "set_trace",
"skip", "skip",

View File

@ -7,6 +7,7 @@ from typing import Dict
import _pytest._code import _pytest._code
import pytest import pytest
from _pytest.config import ExitCode from _pytest.config import ExitCode
from _pytest.main import Session
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from _pytest.nodes import Collector from _pytest.nodes import Collector
from _pytest.pytester import Pytester from _pytest.pytester import Pytester
@ -294,7 +295,7 @@ class TestFunction:
from _pytest.fixtures import FixtureManager from _pytest.fixtures import FixtureManager
config = pytester.parseconfigure() config = pytester.parseconfigure()
session = pytester.Session.from_config(config) session = Session.from_config(config)
session._fixturemanager = FixtureManager(session) session._fixturemanager = FixtureManager(session)
return pytest.Function.from_parent(parent=session, **kwargs) return pytest.Function.from_parent(parent=session, **kwargs)

View File

@ -192,7 +192,7 @@ def make_holder():
def test_hookrecorder_basic(holder) -> None: def test_hookrecorder_basic(holder) -> None:
pm = PytestPluginManager() pm = PytestPluginManager()
pm.add_hookspecs(holder) pm.add_hookspecs(holder)
rec = HookRecorder(pm) rec = HookRecorder(pm, _ispytest=True)
pm.hook.pytest_xyz(arg=123) pm.hook.pytest_xyz(arg=123)
call = rec.popcall("pytest_xyz") call = rec.popcall("pytest_xyz")
assert call.arg == 123 assert call.arg == 123