Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog/8920.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added :class:`pytest.Stash`, a facility for plugins to store their data on :class:`~pytest.Config` and :class:`~_pytest.nodes.Node`\s in a type-safe and conflict-free manner.
See :ref:`plugin-stash` for details.
39 changes: 39 additions & 0 deletions doc/en/how-to/writing_hook_functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,42 @@ declaring the hook functions directly in your plugin module, for example:

This has the added benefit of allowing you to conditionally install hooks
depending on which plugins are installed.

.. _plugin-stash:

Storing data on items across hook functions
-------------------------------------------

Plugins often need to store data on :class:`~pytest.Item`\s in one hook
implementation, and access it in another. One common solution is to just
assign some private attribute directly on the item, but type-checkers like
mypy frown upon this, and it may also cause conflicts with other plugins.
So pytest offers a better way to do this, :attr:`_pytest.nodes.Node.stash <item.stash>`.

To use the "stash" in your plugins, first create "stash keys" somewhere at the
top level of your plugin:

.. code-block:: python

been_there_key: pytest.StashKey[bool]()
done_that_key: pytest.StashKey[str]()

then use the keys to stash your data at some point:

.. code-block:: python

def pytest_runtest_setup(item: pytest.Item) -> None:
item.stash[been_there_key] = True
item.stash[done_that_key] = "no"

and retrieve them at another point:

.. code-block:: python

def pytest_runtest_teardown(item: pytest.Item) -> None:
if not item.stash[been_there_key]:
print("Oh?")
item.stash[done_that_key] = "yes!"

Stashes are available on all node types (like :class:`~pytest.Class`,
:class:`~pytest.Session`) and also on :class:`~pytest.Config`, if needed.
12 changes: 12 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,18 @@ Result used within :ref:`hook wrappers <hookwrapper>`.
.. automethod:: pluggy.callers._Result.get_result
.. automethod:: pluggy.callers._Result.force_result

Stash
~~~~~

.. autoclass:: pytest.Stash
:special-members: __setitem__, __getitem__, __delitem__, __contains__, __len__
:members:

.. autoclass:: pytest.StashKey
:show-inheritance:
:members:


Global Variables
----------------

Expand Down
12 changes: 6 additions & 6 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ def __init__(self, config: Config, mode) -> None:

def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
"""Try to install the rewrite hook, raise SystemError if it fails."""
config._store[assertstate_key] = AssertionState(config, "rewrite")
config._store[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
config.stash[assertstate_key] = AssertionState(config, "rewrite")
config.stash[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
sys.meta_path.insert(0, hook)
config._store[assertstate_key].trace("installed rewrite import hook")
config.stash[assertstate_key].trace("installed rewrite import hook")

def undo() -> None:
hook = config._store[assertstate_key].hook
hook = config.stash[assertstate_key].hook
if hook is not None and hook in sys.meta_path:
sys.meta_path.remove(hook)

Expand All @@ -106,7 +106,7 @@ def pytest_collection(session: "Session") -> None:
# This hook is only called when test modules are collected
# so for example not in the managing process of pytest-xdist
# (which does not collect test modules).
assertstate = session.config._store.get(assertstate_key, None)
assertstate = session.config.stash.get(assertstate_key, None)
if assertstate:
if assertstate.hook is not None:
assertstate.hook.set_session(session)
Expand Down Expand Up @@ -169,7 +169,7 @@ def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:


def pytest_sessionfinish(session: "Session") -> None:
assertstate = session.config._store.get(assertstate_key, None)
assertstate = session.config.stash.get(assertstate_key, None)
if assertstate:
if assertstate.hook is not None:
assertstate.hook.set_session(None)
Expand Down
8 changes: 4 additions & 4 deletions src/_pytest/assertion/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@
from _pytest.main import Session
from _pytest.pathlib import absolutepath
from _pytest.pathlib import fnmatch_ex
from _pytest.store import StoreKey
from _pytest.stash import StashKey

if TYPE_CHECKING:
from _pytest.assertion import AssertionState


assertstate_key = StoreKey["AssertionState"]()
assertstate_key = StashKey["AssertionState"]()


# pytest caches rewritten pycs in pycache dirs
Expand Down Expand Up @@ -87,7 +87,7 @@ def find_spec(
) -> Optional[importlib.machinery.ModuleSpec]:
if self._writing_pyc:
return None
state = self.config._store[assertstate_key]
state = self.config.stash[assertstate_key]
if self._early_rewrite_bailout(name, state):
return None
state.trace("find_module called for: %s" % name)
Expand Down Expand Up @@ -131,7 +131,7 @@ def exec_module(self, module: types.ModuleType) -> None:
assert module.__spec__ is not None
assert module.__spec__.origin is not None
fn = Path(module.__spec__.origin)
state = self.config._store[assertstate_key]
state = self.config.stash[assertstate_key]

self._rewritten_names.add(module.__name__)

Expand Down
14 changes: 10 additions & 4 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
from _pytest.pathlib import import_path
from _pytest.pathlib import ImportMode
from _pytest.pathlib import resolve_package_path
from _pytest.store import Store
from _pytest.stash import Stash
from _pytest.warning_types import PytestConfigWarning

if TYPE_CHECKING:
Expand Down Expand Up @@ -923,6 +923,15 @@ def __init__(
:type: PytestPluginManager
"""

self.stash = Stash()
"""A place where plugins can store information on the config for their
own use.

:type: Stash
"""
# Deprecated alias. Was never public. Can be removed in a few releases.
self._store = self.stash

from .compat import PathAwareHookProxy

self.trace = self.pluginmanager.trace.root.get("config")
Expand All @@ -931,9 +940,6 @@ def __init__(
self._override_ini: Sequence[str] = ()
self._opt2dest: Dict[str, str] = {}
self._cleanup: List[Callable[[], None]] = []
# A place where plugins can store information on the config for their
# own use. Currently only intended for internal plugins.
self._store = Store()
self.pluginmanager.register(self, "pytestconfig")
self._configured = False
self.hook.pytest_addoption.call_historic(
Expand Down
22 changes: 11 additions & 11 deletions src/_pytest/faulthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
from _pytest.store import StoreKey
from _pytest.stash import StashKey


fault_handler_stderr_key = StoreKey[TextIO]()
fault_handler_originally_enabled_key = StoreKey[bool]()
fault_handler_stderr_key = StashKey[TextIO]()
fault_handler_originally_enabled_key = StashKey[bool]()


def pytest_addoption(parser: Parser) -> None:
Expand All @@ -27,20 +27,20 @@ def pytest_configure(config: Config) -> None:
import faulthandler

stderr_fd_copy = os.dup(get_stderr_fileno())
config._store[fault_handler_stderr_key] = open(stderr_fd_copy, "w")
config._store[fault_handler_originally_enabled_key] = faulthandler.is_enabled()
faulthandler.enable(file=config._store[fault_handler_stderr_key])
config.stash[fault_handler_stderr_key] = open(stderr_fd_copy, "w")
config.stash[fault_handler_originally_enabled_key] = faulthandler.is_enabled()
faulthandler.enable(file=config.stash[fault_handler_stderr_key])


def pytest_unconfigure(config: Config) -> None:
import faulthandler

faulthandler.disable()
# Close the dup file installed during pytest_configure.
if fault_handler_stderr_key in config._store:
config._store[fault_handler_stderr_key].close()
del config._store[fault_handler_stderr_key]
if config._store.get(fault_handler_originally_enabled_key, False):
if fault_handler_stderr_key in config.stash:
config.stash[fault_handler_stderr_key].close()
del config.stash[fault_handler_stderr_key]
if config.stash.get(fault_handler_originally_enabled_key, False):
# Re-enable the faulthandler if it was originally enabled.
faulthandler.enable(file=get_stderr_fileno())

Expand All @@ -67,7 +67,7 @@ def get_timeout_config_value(config: Config) -> float:
@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
timeout = get_timeout_config_value(item.config)
stderr = item.config._store[fault_handler_stderr_key]
stderr = item.config.stash[fault_handler_stderr_key]
if timeout > 0 and stderr is not None:
import faulthandler

Expand Down
6 changes: 3 additions & 3 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
from _pytest.outcomes import TEST_OUTCOME
from _pytest.pathlib import absolutepath
from _pytest.pathlib import bestrelpath
from _pytest.store import StoreKey
from _pytest.stash import StashKey

if TYPE_CHECKING:
from typing import Deque
Expand Down Expand Up @@ -149,7 +149,7 @@ def get_scope_node(


# Used for storing artificial fixturedefs for direct parametrization.
name2pseudofixturedef_key = StoreKey[Dict[str, "FixtureDef[Any]"]]()
name2pseudofixturedef_key = StashKey[Dict[str, "FixtureDef[Any]"]]()


def add_funcarg_pseudo_fixture_def(
Expand Down Expand Up @@ -199,7 +199,7 @@ def add_funcarg_pseudo_fixture_def(
name2pseudofixturedef = None
else:
default: Dict[str, FixtureDef[Any]] = {}
name2pseudofixturedef = node._store.setdefault(
name2pseudofixturedef = node.stash.setdefault(
name2pseudofixturedef_key, default
)
if name2pseudofixturedef is not None and argname in name2pseudofixturedef:
Expand Down
18 changes: 9 additions & 9 deletions src/_pytest/junitxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from _pytest.reports import TestReport
from _pytest.store import StoreKey
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter


xml_key = StoreKey["LogXML"]()
xml_key = StashKey["LogXML"]()


def bin_xml_escape(arg: object) -> str:
Expand Down Expand Up @@ -267,7 +267,7 @@ def _warn_incompatibility_with_xunit2(
"""Emit a PytestWarning about the given fixture being incompatible with newer xunit revisions."""
from _pytest.warning_types import PytestWarning

xml = request.config._store.get(xml_key, None)
xml = request.config.stash.get(xml_key, None)
if xml is not None and xml.family not in ("xunit1", "legacy"):
request.node.warn(
PytestWarning(
Expand Down Expand Up @@ -322,7 +322,7 @@ def add_attr_noop(name: str, value: object) -> None:

attr_func = add_attr_noop

xml = request.config._store.get(xml_key, None)
xml = request.config.stash.get(xml_key, None)
if xml is not None:
node_reporter = xml.node_reporter(request.node.nodeid)
attr_func = node_reporter.add_attribute
Expand Down Expand Up @@ -370,7 +370,7 @@ def record_func(name: str, value: object) -> None:
__tracebackhide__ = True
_check_record_param_type("name", name)

xml = request.config._store.get(xml_key, None)
xml = request.config.stash.get(xml_key, None)
if xml is not None:
record_func = xml.add_global_property # noqa
return record_func
Expand Down Expand Up @@ -428,7 +428,7 @@ def pytest_configure(config: Config) -> None:
# Prevent opening xmllog on worker nodes (xdist).
if xmlpath and not hasattr(config, "workerinput"):
junit_family = config.getini("junit_family")
config._store[xml_key] = LogXML(
config.stash[xml_key] = LogXML(
xmlpath,
config.option.junitprefix,
config.getini("junit_suite_name"),
Expand All @@ -437,13 +437,13 @@ def pytest_configure(config: Config) -> None:
junit_family,
config.getini("junit_log_passing_tests"),
)
config.pluginmanager.register(config._store[xml_key])
config.pluginmanager.register(config.stash[xml_key])


def pytest_unconfigure(config: Config) -> None:
xml = config._store.get(xml_key, None)
xml = config.stash.get(xml_key, None)
if xml:
del config._store[xml_key]
del config.stash[xml_key]
config.pluginmanager.unregister(xml)


Expand Down
20 changes: 10 additions & 10 deletions src/_pytest/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@
from _pytest.fixtures import fixture
from _pytest.fixtures import FixtureRequest
from _pytest.main import Session
from _pytest.store import StoreKey
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter


DEFAULT_LOG_FORMAT = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s"
DEFAULT_LOG_DATE_FORMAT = "%H:%M:%S"
_ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m")
caplog_handler_key = StoreKey["LogCaptureHandler"]()
caplog_records_key = StoreKey[Dict[str, List[logging.LogRecord]]]()
caplog_handler_key = StashKey["LogCaptureHandler"]()
caplog_records_key = StashKey[Dict[str, List[logging.LogRecord]]]()


def _remove_ansi_escape_sequences(text: str) -> str:
Expand Down Expand Up @@ -372,7 +372,7 @@ def handler(self) -> LogCaptureHandler:

:rtype: LogCaptureHandler
"""
return self._item._store[caplog_handler_key]
return self._item.stash[caplog_handler_key]

def get_records(self, when: str) -> List[logging.LogRecord]:
"""Get the logging records for one of the possible test phases.
Expand All @@ -385,7 +385,7 @@ def get_records(self, when: str) -> List[logging.LogRecord]:

.. versionadded:: 3.4
"""
return self._item._store[caplog_records_key].get(when, [])
return self._item.stash[caplog_records_key].get(when, [])

@property
def text(self) -> str:
Expand Down Expand Up @@ -694,8 +694,8 @@ def _runtest_for(self, item: nodes.Item, when: str) -> Generator[None, None, Non
) as report_handler:
caplog_handler.reset()
report_handler.reset()
item._store[caplog_records_key][when] = caplog_handler.records
item._store[caplog_handler_key] = caplog_handler
item.stash[caplog_records_key][when] = caplog_handler.records
item.stash[caplog_handler_key] = caplog_handler

yield

Expand All @@ -707,7 +707,7 @@ def pytest_runtest_setup(self, item: nodes.Item) -> Generator[None, None, None]:
self.log_cli_handler.set_when("setup")

empty: Dict[str, List[logging.LogRecord]] = {}
item._store[caplog_records_key] = empty
item.stash[caplog_records_key] = empty
yield from self._runtest_for(item, "setup")

@hookimpl(hookwrapper=True)
Expand All @@ -721,8 +721,8 @@ def pytest_runtest_teardown(self, item: nodes.Item) -> Generator[None, None, Non
self.log_cli_handler.set_when("teardown")

yield from self._runtest_for(item, "teardown")
del item._store[caplog_records_key]
del item._store[caplog_handler_key]
del item.stash[caplog_records_key]
del item.stash[caplog_handler_key]

@hookimpl
def pytest_runtest_logfinish(self) -> None:
Expand Down
Loading