From 11e36c84930f80d67ba253d99aeca9de9fcee6a5 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 22 Oct 2021 11:34:36 +0300 Subject: [PATCH] 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. --- changelog/7469.deprecation.rst | 3 +- changelog/7469.feature.rst | 6 +++- doc/en/reference/reference.rst | 9 +++-- src/_pytest/pytester.py | 60 ++++++++++++++++++++++------------ src/pytest/__init__.py | 8 +++++ testing/test_pytester.py | 2 +- 6 files changed, 61 insertions(+), 27 deletions(-) diff --git a/changelog/7469.deprecation.rst b/changelog/7469.deprecation.rst index 423b1633b..ea8c7c0b4 100644 --- a/changelog/7469.deprecation.rst +++ b/changelog/7469.deprecation.rst @@ -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. diff --git a/changelog/7469.feature.rst b/changelog/7469.feature.rst index 301317087..d1dd8359b 100644 --- a/changelog/7469.feature.rst +++ b/changelog/7469.feature.rst @@ -12,8 +12,12 @@ The newly-exported types are: - ``pytest.ExceptionInfo`` for the :class:`ExceptionInfo ` type returned from :func:`pytest.raises` and passed to various hooks. - ``pytest.Parser`` for the :class:`Parser ` type passed to the :func:`pytest_addoption ` hook. - ``pytest.OptionGroup`` for the :class:`OptionGroup ` type returned from the :func:`parser.addgroup ` method. +- ``pytest.HookRecorder`` for the :class:`HookRecorder ` type returned from :class:`~pytest.Pytester`. +- ``pytest.RecordedHookCall`` for the :class:`RecordedHookCall ` type returned from :class:`~pytest.HookRecorder`. +- ``pytest.RunResult`` for the :class:`RunResult ` type returned from :class:`~pytest.Pytester`. +- ``pytest.LineMatcher`` for the :class:`LineMatcher ` 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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index d6b9cbb79..304c789de 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -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 diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 7bdbdcd46..146606976 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -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"" + return f"" 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) ` to reconstruct stdout, or the commonly used - :func:`stdout.fnmatch_lines() ` method. + Use e.g. :func:`str(stdout) ` to reconstruct stdout, or the commonly used + :func:`stdout.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 `. """ __tracebackhide__ = True val = self.stringio.getvalue() @@ -1731,6 +1748,7 @@ class Testdir: return str(self.tmpdir) +@final class LineMatcher: """Flexible matching of text. diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index bfdc7ae1b..37ef8fda3 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -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", diff --git a/testing/test_pytester.py b/testing/test_pytester.py index d9c97c896..15d777d1f 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -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