Make transitive Pytester types public

Export `HookRecorder`, `RecordedHookCall` (originally `ParsedCall`),
`RunResult`, `LineMatcher`.

These types are reachable through `Pytester` and so should be public
themselves for typing and other purposes.

The name `ParsedCall` I think is too generic under the `pytest`
namespace, so rename it to `RecordedHookCall`.

The `HookRecorder`'s constructor is made private -- it should only be
constructed by `Pytester`.

`LineMatcher` and `RunResult` are exported as is - no private and no
rename, since they're being used.

All of the classes are made final as they are not designed for
subclassing.
This commit is contained in:
Ran Benita 2021-10-22 11:34:36 +03:00
parent 259cff59f5
commit 11e36c8493
6 changed files with 61 additions and 27 deletions

View File

@ -8,5 +8,6 @@ Directly constructing the following classes is now deprecated:
- ``_pytest._code.ExceptionInfo``
- ``_pytest.config.argparsing.Parser``
- ``_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.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.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.
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()
:members:
.. autoclass:: _pytest.pytester.RunResult()
.. autoclass:: pytest.RunResult()
:members:
.. autoclass:: _pytest.pytester.LineMatcher()
.. autoclass:: pytest.LineMatcher()
:members:
:special-members: __str__
.. autoclass:: _pytest.pytester.HookRecorder()
.. autoclass:: pytest.HookRecorder()
:members:
.. autoclass:: pytest.RecordedHookCall()
:members:
.. 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] != "_"]
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:
self.__dict__.update(kwargs)
self._name = name
@ -222,7 +235,7 @@ class ParsedCall:
def __repr__(self) -> str:
d = self.__dict__.copy()
del d["_name"]
return f"<ParsedCall {self._name!r}(**{d!r})>"
return f"<RecordedHookCall {self._name!r}(**{d!r})>"
if TYPE_CHECKING:
# The class has undetermined attributes, this tells mypy about it.
@ -230,20 +243,27 @@ class ParsedCall:
...
@final
class HookRecorder:
"""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
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.calls: List[ParsedCall] = []
self.calls: List[RecordedHookCall] = []
self.ret: Optional[Union[int, ExitCode]] = 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:
pass
@ -253,7 +273,8 @@ class HookRecorder:
def finish_recording(self) -> None:
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):
names = names.split()
return [call for call in self.calls if call._name in names]
@ -279,7 +300,7 @@ class HookRecorder:
else:
fail(f"could not find {name!r} check {check!r}")
def popcall(self, name: str) -> ParsedCall:
def popcall(self, name: str) -> RecordedHookCall:
__tracebackhide__ = True
for i, call in enumerate(self.calls):
if call._name == name:
@ -289,7 +310,7 @@ class HookRecorder:
lines.extend([" %s" % x for x in self.calls])
fail("\n".join(lines))
def getcall(self, name: str) -> ParsedCall:
def getcall(self, name: str) -> RecordedHookCall:
values = self.getcalls(name)
assert len(values) == 1, (name, values)
return values[0]
@ -507,8 +528,9 @@ rex_session_duration = re.compile(r"\d+\.\d\ds")
rex_outcome = re.compile(r"(\d+) (\w+)")
@final
class RunResult:
"""The result of running a command."""
"""The result of running a command from :class:`~pytest.Pytester`."""
def __init__(
self,
@ -527,13 +549,13 @@ class RunResult:
self.errlines = errlines
"""List of lines captured from stderr."""
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
:func:`stdout.fnmatch_lines() <LineMatcher.fnmatch_lines()>` method.
Use e.g. :func:`str(stdout) <pytest.LineMatcher.__str__()>` to reconstruct stdout, or the commonly used
:func:`stdout.fnmatch_lines() <pytest.LineMatcher.fnmatch_lines()>` method.
"""
self.stderr = LineMatcher(errlines)
""":class:`LineMatcher` of stderr."""
""":class:`~pytest.LineMatcher` of stderr."""
self.duration = duration
"""Duration in seconds."""
@ -741,7 +763,7 @@ class Pytester:
def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder:
"""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)
return reprec
@ -1021,10 +1043,7 @@ class Pytester:
for the result.
:param source: The source code of the test module.
:param cmdlineargs: Any extra command line arguments to use.
:returns: :py:class:`HookRecorder` instance of the result.
"""
p = self.makepyfile(source)
values = list(cmdlineargs) + [p]
@ -1062,8 +1081,6 @@ class Pytester:
:param no_reraise_ctrlc:
Typically we reraise keyboard interrupts from the child run. If
True, the KeyboardInterrupt exception is captured.
:returns: A :py:class:`HookRecorder` instance.
"""
# (maybe a cpython bug?) the importlib cache sometimes isn't updated
# properly between file creation and inline_run (especially if imports
@ -1162,7 +1179,7 @@ class Pytester:
self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any
) -> RunResult:
"""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)
if self._method == "inprocess":
return self.runpytest_inprocess(*new_args, **kwargs)
@ -1504,7 +1521,7 @@ class LineComp:
def assert_contains_lines(self, lines2: Sequence[str]) -> None:
"""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
val = self.stringio.getvalue()
@ -1731,6 +1748,7 @@ class Testdir:
return str(self.tmpdir)
@final
class LineMatcher:
"""Flexible matching of text.

View File

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

View File

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