diff --git a/changelog/8920.feature.rst b/changelog/8920.feature.rst new file mode 100644 index 00000000000..05bdab6da75 --- /dev/null +++ b/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. diff --git a/doc/en/how-to/writing_hook_functions.rst b/doc/en/how-to/writing_hook_functions.rst index ac038cd6325..88445c7a757 100644 --- a/doc/en/how-to/writing_hook_functions.rst +++ b/doc/en/how-to/writing_hook_functions.rst @@ -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 `. + +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. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 85a565b9bda..b9b68c01d79 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -962,6 +962,18 @@ Result used within :ref:`hook wrappers `. .. 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 ---------------- diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 29c25d03577..480a26ad867 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -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) @@ -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 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) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 33e2ef6cc49..8c85240efe5 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -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 @@ -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) @@ -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__) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index f3299113780..18423a47ef0 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -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: @@ -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") @@ -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( diff --git a/src/_pytest/faulthandler.py b/src/_pytest/faulthandler.py index c8eb0310128..aaee307ff2c 100644 --- a/src/_pytest/faulthandler.py +++ b/src/_pytest/faulthandler.py @@ -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: @@ -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 diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b19c850a6b7..347d490032a 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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 @@ -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: diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index fafb5fa1aa6..6d13e89a43d 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -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: @@ -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 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 @@ -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 @@ -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) diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 8b4865b5d87..7ed1820bb31 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -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: @@ -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. @@ -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: @@ -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 @@ -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) @@ -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: diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 0cefe47c371..a16654c782d 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -25,7 +25,7 @@ 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 @@ ] -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) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index c8e2865761c..e695f89bb3c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -34,7 +34,7 @@ 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 @@ def __init__( 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: diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 189ed3c5e10..b33383504b0 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -8,11 +8,11 @@ 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"] diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index f7a026ae74e..f18716b1431 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -21,7 +21,7 @@ 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): diff --git a/src/_pytest/stash.py b/src/_pytest/stash.py new file mode 100644 index 00000000000..e61d75b95f7 --- /dev/null +++ b/src/_pytest/stash.py @@ -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) diff --git a/src/_pytest/store.py b/src/_pytest/store.py deleted file mode 100644 index 43fd9e89e6b..00000000000 --- a/src/_pytest/store.py +++ /dev/null @@ -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 diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 3694f0fc471..bfdc7ae1bf9 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -55,6 +55,8 @@ 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 @@ "Session", "set_trace", "skip", + "Stash", + "StashKey", "version_tuple", "TempPathFactory", "Testdir", diff --git a/testing/logging/test_fixture.py b/testing/logging/test_fixture.py index f82df19715b..bcb20de5805 100644 --- a/testing/logging/test_fixture.py +++ b/testing/logging/test_fixture.py @@ -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: diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 5cb062932cd..edd1dfde622 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -21,7 +21,7 @@ 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 @@ class FakeConfig: def __init__(self): self.pluginmanager = self self.option = self - self._store = Store() + self.stash = Stash() def getini(self, name): return "pytest" diff --git a/testing/test_stash.py b/testing/test_stash.py new file mode 100644 index 00000000000..bb294f5da35 --- /dev/null +++ b/testing/test_stash.py @@ -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 diff --git a/testing/test_store.py b/testing/test_store.py deleted file mode 100644 index b6d4208a092..00000000000 --- a/testing/test_store.py +++ /dev/null @@ -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