Skip to content

Commit

Permalink
Merge pull request #8920 from bluetech/stabilize-store
Browse files Browse the repository at this point in the history
Rename Store to Stash and make it public
  • Loading branch information
bluetech committed Jul 31, 2021
2 parents 60d9891 + 2aaea20 commit 6247a95
Show file tree
Hide file tree
Showing 21 changed files with 319 additions and 258 deletions.
2 changes: 2 additions & 0 deletions changelog/8920.feature.rst
@@ -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
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
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
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
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
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
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
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
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
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

0 comments on commit 6247a95

Please sign in to comment.