Merge pull request #8920 from bluetech/stabilize-store

Rename Store to Stash and make it public
This commit is contained in:
Ran Benita 2021-07-31 10:32:30 +03:00 committed by GitHub
commit 6247a95601
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 319 additions and 258 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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
----------------

View File

@ -88,13 +88,13 @@ class AssertionState:
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)
@ -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)
@ -169,7 +169,7 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, None, 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)

View File

@ -38,13 +38,13 @@ from _pytest.config import Config
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
@ -87,7 +87,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
) -> 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)
@ -131,7 +131,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
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__)

View File

@ -56,7 +56,7 @@ from _pytest.pathlib import bestrelpath
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:
@ -923,6 +923,15 @@ class Config:
: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")
@ -931,9 +940,6 @@ class Config:
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(

View File

@ -8,11 +8,11 @@ import pytest
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:
@ -27,9 +27,9 @@ 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:
@ -37,10 +37,10 @@ def pytest_unconfigure(config: Config) -> None:
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())
@ -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

View File

@ -62,7 +62,7 @@ from _pytest.outcomes import fail
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
@ -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(
@ -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:

View File

@ -30,11 +30,11 @@ from _pytest.config import filename_arg
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:
@ -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(
@ -322,7 +322,7 @@ def record_xml_attribute(request: FixtureRequest) -> Callable[[str, object], Non
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
@ -370,7 +370,7 @@ def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object]
__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
@ -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"),
@ -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)

View File

@ -31,15 +31,15 @@ from _pytest.deprecated import check_ispytest
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:
@ -372,7 +372,7 @@ class LogCaptureFixture:
: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.
@ -385,7 +385,7 @@ class LogCaptureFixture:
.. 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:
@ -694,8 +694,8 @@ class LoggingPlugin:
) 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
@ -707,7 +707,7 @@ class LoggingPlugin:
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)
@ -721,8 +721,8 @@ class LoggingPlugin:
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:

View File

@ -25,7 +25,7 @@ from _pytest.config import UsageError
from _pytest.config.argparsing import Parser
from _pytest.deprecated import MINUS_K_COLON
from _pytest.deprecated import MINUS_K_DASH
from _pytest.store import StoreKey
from _pytest.stash import StashKey
if TYPE_CHECKING:
from _pytest.nodes import Item
@ -41,7 +41,7 @@ __all__ = [
]
old_mark_config_key = StoreKey[Optional[Config]]()
old_mark_config_key = StashKey[Optional[Config]]()
def param(
@ -266,7 +266,7 @@ def pytest_collection_modifyitems(items: "List[Item]", config: Config) -> None:
def pytest_configure(config: Config) -> None:
config._store[old_mark_config_key] = MARK_GEN._config
config.stash[old_mark_config_key] = MARK_GEN._config
MARK_GEN._config = config
empty_parameterset = config.getini(EMPTY_PARAMETERSET_OPTION)
@ -279,4 +279,4 @@ def pytest_configure(config: Config) -> None:
def pytest_unconfigure(config: Config) -> None:
MARK_GEN._config = config._store.get(old_mark_config_key, None)
MARK_GEN._config = config.stash.get(old_mark_config_key, None)

View File

@ -34,7 +34,7 @@ from _pytest.mark.structures import NodeKeywords
from _pytest.outcomes import fail
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.store import Store
from _pytest.stash import Stash
from _pytest.warning_types import PytestWarning
if TYPE_CHECKING:
@ -218,9 +218,13 @@ class Node(metaclass=NodeMeta):
if self.name != "()":
self._nodeid += "::" + self.name
# A place where plugins can store information on the node for their
# own use. Currently only intended for internal plugins.
self._store = Store()
#: A place where plugins can store information on the node for their
#: own use.
#:
#: :type: Stash
self.stash = Stash()
# Deprecated alias. Was never public. Can be removed in a few releases.
self._store = self.stash
@property
def fspath(self) -> LEGACY_PATH:

View File

@ -8,11 +8,11 @@ import pytest
from _pytest.config import Config
from _pytest.config import create_terminal_writer
from _pytest.config.argparsing import Parser
from _pytest.store import StoreKey
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter
pastebinfile_key = StoreKey[IO[bytes]]()
pastebinfile_key = StashKey[IO[bytes]]()
def pytest_addoption(parser: Parser) -> None:
@ -37,26 +37,26 @@ def pytest_configure(config: Config) -> None:
# when using pytest-xdist, for example.
if tr is not None:
# pastebin file will be UTF-8 encoded binary file.
config._store[pastebinfile_key] = tempfile.TemporaryFile("w+b")
config.stash[pastebinfile_key] = tempfile.TemporaryFile("w+b")
oldwrite = tr._tw.write
def tee_write(s, **kwargs):
oldwrite(s, **kwargs)
if isinstance(s, str):
s = s.encode("utf-8")
config._store[pastebinfile_key].write(s)
config.stash[pastebinfile_key].write(s)
tr._tw.write = tee_write
def pytest_unconfigure(config: Config) -> None:
if pastebinfile_key in config._store:
pastebinfile = config._store[pastebinfile_key]
if pastebinfile_key in config.stash:
pastebinfile = config.stash[pastebinfile_key]
# Get terminal contents and delete file.
pastebinfile.seek(0)
sessionlog = pastebinfile.read()
pastebinfile.close()
del config._store[pastebinfile_key]
del config.stash[pastebinfile_key]
# Undo our patching in the terminal reporter.
tr = config.pluginmanager.getplugin("terminalreporter")
del tr._tw.__dict__["write"]

View File

@ -21,7 +21,7 @@ from _pytest.outcomes import skip
from _pytest.outcomes import xfail
from _pytest.reports import BaseReport
from _pytest.runner import CallInfo
from _pytest.store import StoreKey
from _pytest.stash import StashKey
def pytest_addoption(parser: Parser) -> None:
@ -228,7 +228,7 @@ def evaluate_xfail_marks(item: Item) -> Optional[Xfail]:
# Saves the xfail mark evaluation. Can be refreshed during call if None.
xfailed_key = StoreKey[Optional[Xfail]]()
xfailed_key = StashKey[Optional[Xfail]]()
@hookimpl(tryfirst=True)
@ -237,16 +237,16 @@ def pytest_runtest_setup(item: Item) -> None:
if skipped:
raise skip.Exception(skipped.reason, _use_item_location=True)
item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item)
item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item)
if xfailed and not item.config.option.runxfail and not xfailed.run:
xfail("[NOTRUN] " + xfailed.reason)
@hookimpl(hookwrapper=True)
def pytest_runtest_call(item: Item) -> Generator[None, None, None]:
xfailed = item._store.get(xfailed_key, None)
xfailed = item.stash.get(xfailed_key, None)
if xfailed is None:
item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item)
item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item)
if xfailed and not item.config.option.runxfail and not xfailed.run:
xfail("[NOTRUN] " + xfailed.reason)
@ -254,16 +254,16 @@ def pytest_runtest_call(item: Item) -> Generator[None, None, None]:
yield
# The test run may have added an xfail mark dynamically.
xfailed = item._store.get(xfailed_key, None)
xfailed = item.stash.get(xfailed_key, None)
if xfailed is None:
item._store[xfailed_key] = xfailed = evaluate_xfail_marks(item)
item.stash[xfailed_key] = xfailed = evaluate_xfail_marks(item)
@hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: Item, call: CallInfo[None]):
outcome = yield
rep = outcome.get_result()
xfailed = item._store.get(xfailed_key, None)
xfailed = item.stash.get(xfailed_key, None)
if item.config.option.runxfail:
pass # don't interfere
elif call.excinfo and isinstance(call.excinfo.value, xfail.Exception):

112
src/_pytest/stash.py Normal file
View File

@ -0,0 +1,112 @@
from typing import Any
from typing import cast
from typing import Dict
from typing import Generic
from typing import TypeVar
from typing import Union
__all__ = ["Stash", "StashKey"]
T = TypeVar("T")
D = TypeVar("D")
class StashKey(Generic[T]):
"""``StashKey`` is an object used as a key to a :class:`Stash`.
A ``StashKey`` is associated with the type ``T`` of the value of the key.
A ``StashKey`` is unique and cannot conflict with another key.
"""
__slots__ = ()
class Stash:
r"""``Stash`` is a type-safe heterogeneous mutable mapping that
allows keys and value types to be defined separately from
where it (the ``Stash``) is created.
Usually you will be given an object which has a ``Stash``, for example
:class:`~pytest.Config` or a :class:`~_pytest.nodes.Node`:
.. code-block:: python
stash: Stash = some_object.stash
If a module or plugin wants to store data in this ``Stash``, it creates
:class:`StashKey`\s for its keys (at the module level):
.. code-block:: python
# At the top-level of the module
some_str_key = StashKey[str]()
some_bool_key = StashKey[bool]()
To store information:
.. code-block:: python
# Value type must match the key.
stash[some_str_key] = "value"
stash[some_bool_key] = True
To retrieve the information:
.. code-block:: python
# The static type of some_str is str.
some_str = stash[some_str_key]
# The static type of some_bool is bool.
some_bool = stash[some_bool_key]
"""
__slots__ = ("_storage",)
def __init__(self) -> None:
self._storage: Dict[StashKey[Any], object] = {}
def __setitem__(self, key: StashKey[T], value: T) -> None:
"""Set a value for key."""
self._storage[key] = value
def __getitem__(self, key: StashKey[T]) -> T:
"""Get the value for key.
Raises ``KeyError`` if the key wasn't set before.
"""
return cast(T, self._storage[key])
def get(self, key: StashKey[T], default: D) -> Union[T, D]:
"""Get the value for key, or return default if the key wasn't set
before."""
try:
return self[key]
except KeyError:
return default
def setdefault(self, key: StashKey[T], default: T) -> T:
"""Return the value of key if already set, otherwise set the value
of key to default and return default."""
try:
return self[key]
except KeyError:
self[key] = default
return default
def __delitem__(self, key: StashKey[T]) -> None:
"""Delete the value for key.
Raises ``KeyError`` if the key wasn't set before.
"""
del self._storage[key]
def __contains__(self, key: StashKey[T]) -> bool:
"""Return whether key was set."""
return key in self._storage
def __len__(self) -> int:
"""Return how many items exist in the stash."""
return len(self._storage)

View File

@ -1,125 +0,0 @@
from typing import Any
from typing import cast
from typing import Dict
from typing import Generic
from typing import TypeVar
from typing import Union
__all__ = ["Store", "StoreKey"]
T = TypeVar("T")
D = TypeVar("D")
class StoreKey(Generic[T]):
"""StoreKey is an object used as a key to a Store.
A StoreKey is associated with the type T of the value of the key.
A StoreKey is unique and cannot conflict with another key.
"""
__slots__ = ()
class Store:
"""Store is a type-safe heterogeneous mutable mapping that
allows keys and value types to be defined separately from
where it (the Store) is created.
Usually you will be given an object which has a ``Store``:
.. code-block:: python
store: Store = some_object.store
If a module wants to store data in this Store, it creates StoreKeys
for its keys (at the module level):
.. code-block:: python
some_str_key = StoreKey[str]()
some_bool_key = StoreKey[bool]()
To store information:
.. code-block:: python
# Value type must match the key.
store[some_str_key] = "value"
store[some_bool_key] = True
To retrieve the information:
.. code-block:: python
# The static type of some_str is str.
some_str = store[some_str_key]
# The static type of some_bool is bool.
some_bool = store[some_bool_key]
Why use this?
-------------
Problem: module Internal defines an object. Module External, which
module Internal doesn't know about, receives the object and wants to
attach information to it, to be retrieved later given the object.
Bad solution 1: Module External assigns private attributes directly on
the object. This doesn't work well because the type checker doesn't
know about these attributes and it complains about undefined attributes.
Bad solution 2: module Internal adds a ``Dict[str, Any]`` attribute to
the object. Module External stores its data in private keys of this dict.
This doesn't work well because retrieved values are untyped.
Good solution: module Internal adds a ``Store`` to the object. Module
External mints StoreKeys for its own keys. Module External stores and
retrieves its data using these keys.
"""
__slots__ = ("_store",)
def __init__(self) -> None:
self._store: Dict[StoreKey[Any], object] = {}
def __setitem__(self, key: StoreKey[T], value: T) -> None:
"""Set a value for key."""
self._store[key] = value
def __getitem__(self, key: StoreKey[T]) -> T:
"""Get the value for key.
Raises ``KeyError`` if the key wasn't set before.
"""
return cast(T, self._store[key])
def get(self, key: StoreKey[T], default: D) -> Union[T, D]:
"""Get the value for key, or return default if the key wasn't set
before."""
try:
return self[key]
except KeyError:
return default
def setdefault(self, key: StoreKey[T], default: T) -> T:
"""Return the value of key if already set, otherwise set the value
of key to default and return default."""
try:
return self[key]
except KeyError:
self[key] = default
return default
def __delitem__(self, key: StoreKey[T]) -> None:
"""Delete the value for key.
Raises ``KeyError`` if the key wasn't set before.
"""
del self._store[key]
def __contains__(self, key: StoreKey[T]) -> bool:
"""Return whether key was set."""
return key in self._store

View File

@ -55,6 +55,8 @@ from _pytest.recwarn import deprecated_call
from _pytest.recwarn import WarningsRecorder
from _pytest.recwarn import warns
from _pytest.runner import CallInfo
from _pytest.stash import Stash
from _pytest.stash import StashKey
from _pytest.tmpdir import TempdirFactory
from _pytest.tmpdir import TempPathFactory
from _pytest.warning_types import PytestAssertRewriteWarning
@ -131,6 +133,8 @@ __all__ = [
"Session",
"set_trace",
"skip",
"Stash",
"StashKey",
"version_tuple",
"TempPathFactory",
"Testdir",

View File

@ -169,7 +169,7 @@ def test_caplog_captures_for_all_stages(caplog, logging_during_setup_and_teardow
assert [x.message for x in caplog.get_records("setup")] == ["a_setup_log"]
# This reaches into private API, don't use this type of thing in real tests!
assert set(caplog._item._store[caplog_records_key]) == {"setup", "call"}
assert set(caplog._item.stash[caplog_records_key]) == {"setup", "call"}
def test_ini_controls_global_log_level(pytester: Pytester) -> None:

View File

@ -21,7 +21,7 @@ from _pytest.pytester import Pytester
from _pytest.pytester import RunResult
from _pytest.reports import BaseReport
from _pytest.reports import TestReport
from _pytest.store import Store
from _pytest.stash import Stash
@pytest.fixture(scope="session")
@ -951,7 +951,7 @@ def test_dont_configure_on_workers(tmp_path: Path) -> None:
def __init__(self):
self.pluginmanager = self
self.option = self
self._store = Store()
self.stash = Stash()
def getini(self, name):
return "pytest"

67
testing/test_stash.py Normal file
View File

@ -0,0 +1,67 @@
import pytest
from _pytest.stash import Stash
from _pytest.stash import StashKey
def test_stash() -> None:
stash = Stash()
assert len(stash) == 0
assert not stash
key1 = StashKey[str]()
key2 = StashKey[int]()
# Basic functionality - single key.
assert key1 not in stash
stash[key1] = "hello"
assert key1 in stash
assert stash[key1] == "hello"
assert stash.get(key1, None) == "hello"
stash[key1] = "world"
assert stash[key1] == "world"
# Has correct type (no mypy error).
stash[key1] + "string"
assert len(stash) == 1
assert stash
# No interaction with another key.
assert key2 not in stash
assert stash.get(key2, None) is None
with pytest.raises(KeyError):
stash[key2]
with pytest.raises(KeyError):
del stash[key2]
stash[key2] = 1
assert stash[key2] == 1
# Has correct type (no mypy error).
stash[key2] + 20
del stash[key1]
with pytest.raises(KeyError):
del stash[key1]
with pytest.raises(KeyError):
stash[key1]
# setdefault
stash[key1] = "existing"
assert stash.setdefault(key1, "default") == "existing"
assert stash[key1] == "existing"
key_setdefault = StashKey[bytes]()
assert stash.setdefault(key_setdefault, b"default") == b"default"
assert stash[key_setdefault] == b"default"
assert len(stash) == 3
assert stash
# Can't accidentally add attributes to stash object itself.
with pytest.raises(AttributeError):
stash.foo = "nope" # type: ignore[attr-defined]
# No interaction with anoter stash.
stash2 = Stash()
key3 = StashKey[int]()
assert key2 not in stash2
stash2[key2] = 100
stash2[key3] = 200
assert stash2[key2] + stash2[key3] == 300
assert stash[key2] == 1
assert key3 not in stash

View File

@ -1,60 +0,0 @@
import pytest
from _pytest.store import Store
from _pytest.store import StoreKey
def test_store() -> None:
store = Store()
key1 = StoreKey[str]()
key2 = StoreKey[int]()
# Basic functionality - single key.
assert key1 not in store
store[key1] = "hello"
assert key1 in store
assert store[key1] == "hello"
assert store.get(key1, None) == "hello"
store[key1] = "world"
assert store[key1] == "world"
# Has correct type (no mypy error).
store[key1] + "string"
# No interaction with another key.
assert key2 not in store
assert store.get(key2, None) is None
with pytest.raises(KeyError):
store[key2]
with pytest.raises(KeyError):
del store[key2]
store[key2] = 1
assert store[key2] == 1
# Has correct type (no mypy error).
store[key2] + 20
del store[key1]
with pytest.raises(KeyError):
del store[key1]
with pytest.raises(KeyError):
store[key1]
# setdefault
store[key1] = "existing"
assert store.setdefault(key1, "default") == "existing"
assert store[key1] == "existing"
key_setdefault = StoreKey[bytes]()
assert store.setdefault(key_setdefault, b"default") == b"default"
assert store[key_setdefault] == b"default"
# Can't accidentally add attributes to store object itself.
with pytest.raises(AttributeError):
store.foo = "nope" # type: ignore[attr-defined]
# No interaction with anoter store.
store2 = Store()
key3 = StoreKey[int]()
assert key2 not in store2
store2[key2] = 100
store2[key3] = 200
assert store2[key2] + store2[key3] == 300
assert store[key2] == 1
assert key3 not in store