Skip to content

Commit 11e36c8

Browse files
committed
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.
1 parent 259cff5 commit 11e36c8

File tree

6 files changed

+61
-27
lines changed

6 files changed

+61
-27
lines changed

changelog/7469.deprecation.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ Directly constructing the following classes is now deprecated:
88
- ``_pytest._code.ExceptionInfo``
99
- ``_pytest.config.argparsing.Parser``
1010
- ``_pytest.config.argparsing.OptionGroup``
11+
- ``_pytest.pytester.HookRecorder``
1112

12-
These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 8.0.0.
13+
These constructors have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 8.

changelog/7469.feature.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ The newly-exported types are:
1212
- ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo <pytest.ExceptionInfo>` type returned from :func:`pytest.raises` and passed to various hooks.
1313
- ``pytest.Parser`` for the :class:`Parser <pytest.Parser>` type passed to the :func:`pytest_addoption <pytest.hookspec.pytest_addoption>` hook.
1414
- ``pytest.OptionGroup`` for the :class:`OptionGroup <pytest.OptionGroup>` type returned from the :func:`parser.addgroup <pytest.Parser.getgroup>` method.
15+
- ``pytest.HookRecorder`` for the :class:`HookRecorder <pytest.HookRecorder>` type returned from :class:`~pytest.Pytester`.
16+
- ``pytest.RecordedHookCall`` for the :class:`RecordedHookCall <pytest.HookRecorder>` type returned from :class:`~pytest.HookRecorder`.
17+
- ``pytest.RunResult`` for the :class:`RunResult <pytest.RunResult>` type returned from :class:`~pytest.Pytester`.
18+
- ``pytest.LineMatcher`` for the :class:`LineMatcher <pytest.RunResult>` type used in :class:`~pytest.RunResult` and others.
1519

16-
Constructing them directly is not supported; they are only meant for use in type annotations.
20+
Constructing most of them directly is not supported; they are only meant for use in type annotations.
1721
Doing so will emit a deprecation warning, and may become a hard-error in pytest 8.0.
1822

1923
Subclassing them is also not supported. This is not currently enforced at runtime, but is detected by type-checkers such as mypy.

doc/en/reference/reference.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -558,14 +558,17 @@ To use it, include in your topmost ``conftest.py`` file:
558558
.. autoclass:: pytest.Pytester()
559559
:members:
560560

561-
.. autoclass:: _pytest.pytester.RunResult()
561+
.. autoclass:: pytest.RunResult()
562562
:members:
563563

564-
.. autoclass:: _pytest.pytester.LineMatcher()
564+
.. autoclass:: pytest.LineMatcher()
565565
:members:
566566
:special-members: __str__
567567

568-
.. autoclass:: _pytest.pytester.HookRecorder()
568+
.. autoclass:: pytest.HookRecorder()
569+
:members:
570+
571+
.. autoclass:: pytest.RecordedHookCall()
569572
:members:
570573

571574
.. fixture:: testdir

src/_pytest/pytester.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -214,36 +214,56 @@ def get_public_names(values: Iterable[str]) -> List[str]:
214214
return [x for x in values if x[0] != "_"]
215215

216216

217-
class ParsedCall:
217+
@final
218+
class RecordedHookCall:
219+
"""A recorded call to a hook.
220+
221+
The arguments to the hook call are set as attributes.
222+
For example:
223+
224+
.. code-block:: python
225+
226+
calls = hook_recorder.getcalls("pytest_runtest_setup")
227+
# Suppose pytest_runtest_setup was called once with `item=an_item`.
228+
assert calls[0].item is an_item
229+
"""
230+
218231
def __init__(self, name: str, kwargs) -> None:
219232
self.__dict__.update(kwargs)
220233
self._name = name
221234

222235
def __repr__(self) -> str:
223236
d = self.__dict__.copy()
224237
del d["_name"]
225-
return f"<ParsedCall {self._name!r}(**{d!r})>"
238+
return f"<RecordedHookCall {self._name!r}(**{d!r})>"
226239

227240
if TYPE_CHECKING:
228241
# The class has undetermined attributes, this tells mypy about it.
229242
def __getattr__(self, key: str):
230243
...
231244

232245

246+
@final
233247
class HookRecorder:
234248
"""Record all hooks called in a plugin manager.
235249
250+
Hook recorders are created by :class:`Pytester`.
251+
236252
This wraps all the hook calls in the plugin manager, recording each call
237253
before propagating the normal calls.
238254
"""
239255

240-
def __init__(self, pluginmanager: PytestPluginManager) -> None:
256+
def __init__(
257+
self, pluginmanager: PytestPluginManager, *, _ispytest: bool = False
258+
) -> None:
259+
check_ispytest(_ispytest)
260+
241261
self._pluginmanager = pluginmanager
242-
self.calls: List[ParsedCall] = []
262+
self.calls: List[RecordedHookCall] = []
243263
self.ret: Optional[Union[int, ExitCode]] = None
244264

245265
def before(hook_name: str, hook_impls, kwargs) -> None:
246-
self.calls.append(ParsedCall(hook_name, kwargs))
266+
self.calls.append(RecordedHookCall(hook_name, kwargs))
247267

248268
def after(outcome, hook_name: str, hook_impls, kwargs) -> None:
249269
pass
@@ -253,7 +273,8 @@ def after(outcome, hook_name: str, hook_impls, kwargs) -> None:
253273
def finish_recording(self) -> None:
254274
self._undo_wrapping()
255275

256-
def getcalls(self, names: Union[str, Iterable[str]]) -> List[ParsedCall]:
276+
def getcalls(self, names: Union[str, Iterable[str]]) -> List[RecordedHookCall]:
277+
"""Get all recorded calls to hooks with the given names (or name)."""
257278
if isinstance(names, str):
258279
names = names.split()
259280
return [call for call in self.calls if call._name in names]
@@ -279,7 +300,7 @@ def assert_contains(self, entries: Sequence[Tuple[str, str]]) -> None:
279300
else:
280301
fail(f"could not find {name!r} check {check!r}")
281302

282-
def popcall(self, name: str) -> ParsedCall:
303+
def popcall(self, name: str) -> RecordedHookCall:
283304
__tracebackhide__ = True
284305
for i, call in enumerate(self.calls):
285306
if call._name == name:
@@ -289,7 +310,7 @@ def popcall(self, name: str) -> ParsedCall:
289310
lines.extend([" %s" % x for x in self.calls])
290311
fail("\n".join(lines))
291312

292-
def getcall(self, name: str) -> ParsedCall:
313+
def getcall(self, name: str) -> RecordedHookCall:
293314
values = self.getcalls(name)
294315
assert len(values) == 1, (name, values)
295316
return values[0]
@@ -507,8 +528,9 @@ def _config_for_test() -> Generator[Config, None, None]:
507528
rex_outcome = re.compile(r"(\d+) (\w+)")
508529

509530

531+
@final
510532
class RunResult:
511-
"""The result of running a command."""
533+
"""The result of running a command from :class:`~pytest.Pytester`."""
512534

513535
def __init__(
514536
self,
@@ -527,13 +549,13 @@ def __init__(
527549
self.errlines = errlines
528550
"""List of lines captured from stderr."""
529551
self.stdout = LineMatcher(outlines)
530-
""":class:`LineMatcher` of stdout.
552+
""":class:`~pytest.LineMatcher` of stdout.
531553
532-
Use e.g. :func:`str(stdout) <LineMatcher.__str__()>` to reconstruct stdout, or the commonly used
533-
:func:`stdout.fnmatch_lines() <LineMatcher.fnmatch_lines()>` method.
554+
Use e.g. :func:`str(stdout) <pytest.LineMatcher.__str__()>` to reconstruct stdout, or the commonly used
555+
:func:`stdout.fnmatch_lines() <pytest.LineMatcher.fnmatch_lines()>` method.
534556
"""
535557
self.stderr = LineMatcher(errlines)
536-
""":class:`LineMatcher` of stderr."""
558+
""":class:`~pytest.LineMatcher` of stderr."""
537559
self.duration = duration
538560
"""Duration in seconds."""
539561

@@ -741,7 +763,7 @@ def preserve_module(name):
741763

742764
def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder:
743765
"""Create a new :py:class:`HookRecorder` for a PluginManager."""
744-
pluginmanager.reprec = reprec = HookRecorder(pluginmanager)
766+
pluginmanager.reprec = reprec = HookRecorder(pluginmanager, _ispytest=True)
745767
self._request.addfinalizer(reprec.finish_recording)
746768
return reprec
747769

@@ -1021,10 +1043,7 @@ def inline_runsource(self, source: str, *cmdlineargs) -> HookRecorder:
10211043
for the result.
10221044
10231045
:param source: The source code of the test module.
1024-
10251046
:param cmdlineargs: Any extra command line arguments to use.
1026-
1027-
:returns: :py:class:`HookRecorder` instance of the result.
10281047
"""
10291048
p = self.makepyfile(source)
10301049
values = list(cmdlineargs) + [p]
@@ -1062,8 +1081,6 @@ def inline_run(
10621081
:param no_reraise_ctrlc:
10631082
Typically we reraise keyboard interrupts from the child run. If
10641083
True, the KeyboardInterrupt exception is captured.
1065-
1066-
:returns: A :py:class:`HookRecorder` instance.
10671084
"""
10681085
# (maybe a cpython bug?) the importlib cache sometimes isn't updated
10691086
# properly between file creation and inline_run (especially if imports
@@ -1162,7 +1179,7 @@ def runpytest(
11621179
self, *args: Union[str, "os.PathLike[str]"], **kwargs: Any
11631180
) -> RunResult:
11641181
"""Run pytest inline or in a subprocess, depending on the command line
1165-
option "--runpytest" and return a :py:class:`RunResult`."""
1182+
option "--runpytest" and return a :py:class:`~pytest.RunResult`."""
11661183
new_args = self._ensure_basetemp(args)
11671184
if self._method == "inprocess":
11681185
return self.runpytest_inprocess(*new_args, **kwargs)
@@ -1504,7 +1521,7 @@ def __init__(self) -> None:
15041521
def assert_contains_lines(self, lines2: Sequence[str]) -> None:
15051522
"""Assert that ``lines2`` are contained (linearly) in :attr:`stringio`'s value.
15061523
1507-
Lines are matched using :func:`LineMatcher.fnmatch_lines`.
1524+
Lines are matched using :func:`LineMatcher.fnmatch_lines <pytest.LineMatcher.fnmatch_lines>`.
15081525
"""
15091526
__tracebackhide__ = True
15101527
val = self.stringio.getvalue()
@@ -1731,6 +1748,7 @@ def __str__(self) -> str:
17311748
return str(self.tmpdir)
17321749

17331750

1751+
@final
17341752
class LineMatcher:
17351753
"""Flexible matching of text.
17361754

src/pytest/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@
4141
from _pytest.outcomes import importorskip
4242
from _pytest.outcomes import skip
4343
from _pytest.outcomes import xfail
44+
from _pytest.pytester import HookRecorder
45+
from _pytest.pytester import LineMatcher
4446
from _pytest.pytester import Pytester
47+
from _pytest.pytester import RecordedHookCall
48+
from _pytest.pytester import RunResult
4549
from _pytest.pytester import Testdir
4650
from _pytest.python import Class
4751
from _pytest.python import Function
@@ -98,10 +102,12 @@
98102
"freeze_includes",
99103
"Function",
100104
"hookimpl",
105+
"HookRecorder",
101106
"hookspec",
102107
"importorskip",
103108
"Instance",
104109
"Item",
110+
"LineMatcher",
105111
"LogCaptureFixture",
106112
"main",
107113
"mark",
@@ -129,7 +135,9 @@
129135
"PytestUnraisableExceptionWarning",
130136
"PytestWarning",
131137
"raises",
138+
"RecordedHookCall",
132139
"register_assert_rewrite",
140+
"RunResult",
133141
"Session",
134142
"set_trace",
135143
"skip",

testing/test_pytester.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def pytest_xyz_noarg():
192192
def test_hookrecorder_basic(holder) -> None:
193193
pm = PytestPluginManager()
194194
pm.add_hookspecs(holder)
195-
rec = HookRecorder(pm)
195+
rec = HookRecorder(pm, _ispytest=True)
196196
pm.hook.pytest_xyz(arg=123)
197197
call = rec.popcall("pytest_xyz")
198198
assert call.arg == 123

0 commit comments

Comments
 (0)