Repository: tox-dev/tox

Issue:
tox 4: enable plugins to run before/after each testenv
My plugin [tox-docker](https://github.com/tox-dev/tox-docker) needs a way to run code before and after the testing part of each testenv.

Before each testenv runs, tox-docker will start appropriate docker containers based on configuration (see #2200 for more), and sets environment variables for the testenv to inform tests of the containers' IP and ports. After the test run, the containers are stopped and removed.

This could either mean bringing back `tox_runtest_pre` and `tox_runtest_post`, or adding `tox_wrap_test` which could act like a generator-context manager. For example:

```python
@impl
@contextmanager
def tox_wrap_test(env: TestEnv):
    # ... start docker containers ...

    try:
        yield  # tests run here
    finally:
        # ... clean up started containers ...
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) Report
> Merging [#2214](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) (f9a3913) into [rewrite](https://codecov.io/gh/tox-dev/tox/commit/48da3664ecfee2173838f6604e73f48d579e56ce?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) (48da366) will **increase** coverage by `0.08%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tox-dev/tox/pull/2214/graphs/tree.svg?width=650&height=150&src=pr&token=DYodAwDCZ5&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)

```diff
@@             Coverage Diff             @@
##           rewrite    #2214      +/-   ##
===========================================
+ Coverage    99.85%   99.94%   +0.08%     
===========================================
  Files          157      156       -1     
  Lines         9017     8953      -64     
  Branches       942      921      -21     
===========================================
- Hits          9004     8948      -56     
- Misses           3        4       +1     
+ Partials        10        1       -9     
```

| Flag | Coverage Δ | |
|---|---|---|
| tests | `99.94% <100.00%> (+0.08%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) | Coverage Δ | |
|---|---|---|
| [src/tox/plugin/manager.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9wbHVnaW4vbWFuYWdlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/plugin/spec.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9wbHVnaW4vc3BlYy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/pytest.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9weXRlc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [src/tox/report.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9yZXBvcnQucHk=) | `100.00% <100.00%> (ø)` | |
| [src/tox/session/cmd/run/single.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9zZXNzaW9uL2NtZC9ydW4vc2luZ2xlLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/util/spinner.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC91dGlsL3NwaW5uZXIucHk=) | `100.00% <100.00%> (+2.27%)` | :arrow_up: |
| [tests/plugin/test\_inline.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-dGVzdHMvcGx1Z2luL3Rlc3RfaW5saW5lLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/session/cmd/run/common.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9zZXNzaW9uL2NtZC9ydW4vY29tbW9uLnB5) | `97.63% <0.00%> (-2.37%)` | :arrow_down: |
| [src/tox/tox\_env/api.py](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC90b3hfZW52L2FwaS5weQ==) | `100.00% <0.00%> (ø)` | |
| ... and [10 more](https://codecov.io/gh/tox-dev/tox/pull/2214/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev). Last update [48da366...f9a3913](https://codecov.io/gh/tox-dev/tox/pull/2214?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev).


Patch:
diff --git a/docs/changelog/2201.feature.rst b/docs/changelog/2201.feature.rst
new file mode 100644
index 00000000..9bd4610b
--- /dev/null
+++ b/docs/changelog/2201.feature.rst
@@ -0,0 +1,3 @@
+Allow running code in plugins before and after commands via
+:meth:`tox_before_run_commands <tox.plugin.spec.tox_before_run_commands>` and
+:meth:`tox_after_run_commands <tox.plugin.spec.tox_after_run_commands>` plugin points -- by :user:`gaborbernat`.
diff --git a/docs/changelog/2213.bugfix.rst b/docs/changelog/2213.bugfix.rst
new file mode 100644
index 00000000..746b2f2c
--- /dev/null
+++ b/docs/changelog/2213.bugfix.rst
@@ -0,0 +1,1 @@
+Report fails when report does not support Unicode characters -- by :user:`gaborbernat`.
diff --git a/docs/conf.py b/docs/conf.py
index e06d3848..b44db551 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -131,6 +131,7 @@ def setup(app: Sphinx) -> None:
                 "tox.config.loader.api.T": "typing.TypeVar",
                 "tox.config.loader.convert.T": "typing.TypeVar",
                 "tox.tox_env.installer.T": "typing.TypeVar",
+                "ToxParserT": "typing.TypeVar",
             }
             if target in mapping:
                 node["reftarget"] = mapping[target]
diff --git a/docs/plugins_api.rst b/docs/plugins_api.rst
index e196b250..067d9adb 100644
--- a/docs/plugins_api.rst
+++ b/docs/plugins_api.rst
@@ -16,6 +16,12 @@ register
 
 config
 ------
+.. autoclass:: tox.config.cli.parser.ArgumentParserWithEnvAndConfig
+   :members:
+
+.. autoclass:: tox.config.cli.parser.ToxParser
+   :members:
+
 .. autoclass:: tox.config.cli.parser.Parsed
    :members:
 
diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py
index 786d9819..f173743d 100644
--- a/src/tox/plugin/manager.py
+++ b/src/tox/plugin/manager.py
@@ -1,5 +1,6 @@
 """Contains the plugin manager object"""
 from pathlib import Path
+from typing import List
 
 import pluggy
 
@@ -16,6 +17,8 @@ from tox.tox_env.python.virtual_env.package import api
 from tox.tox_env.register import REGISTER, ToxEnvRegister
 
 from ..config.main import Config
+from ..execute import Outcome
+from ..tox_env.api import ToxEnv
 from . import NAME, spec
 from .inline import load_inline
 
@@ -58,9 +61,15 @@ class Plugin:
     def tox_configure(self, config: Config) -> None:
         self.manager.hook.tox_configure(config=config)
 
-    def tox_register_tox_env(self, register: "ToxEnvRegister") -> None:
+    def tox_register_tox_env(self, register: ToxEnvRegister) -> None:
         self.manager.hook.tox_register_tox_env(register=register)
 
+    def tox_before_run_commands(self, tox_env: ToxEnv) -> None:
+        self.manager.hook.tox_before_run_commands(tox_env=tox_env)
+
+    def tox_after_run_commands(self, tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]) -> None:
+        self.manager.hook.tox_after_run_commands(tox_env=tox_env, exit_code=exit_code, outcomes=outcomes)
+
     def load_inline_plugin(self, path: Path) -> None:
         result = load_inline(path)
         if result is not None:
diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py
index 4ad80529..1e772f38 100644
--- a/src/tox/plugin/spec.py
+++ b/src/tox/plugin/spec.py
@@ -1,5 +1,4 @@
-from argparse import ArgumentParser
-from typing import Any, Callable, TypeVar, cast
+from typing import Any, Callable, List, TypeVar, cast
 
 import pluggy
 
@@ -7,6 +6,9 @@ from tox.config.main import Config
 from tox.config.sets import ConfigSet
 from tox.tox_env.register import ToxEnvRegister
 
+from ..config.cli.parser import ToxParser
+from ..execute import Outcome
+from ..tox_env.api import ToxEnv
 from . import NAME
 
 _F = TypeVar("_F", bound=Callable[..., Any])
@@ -30,7 +32,7 @@ def tox_register_tox_env(register: ToxEnvRegister) -> None:  # noqa: U100
 
 
 @_spec
-def tox_add_option(parser: ArgumentParser) -> None:  # noqa: U100
+def tox_add_option(parser: ToxParser) -> None:  # noqa: U100
     """
     Add a command line argument. This is the first hook to be called, right after the logging setup and config source
     discovery.
@@ -58,10 +60,32 @@ def tox_configure(config: Config) -> None:  # noqa: U100
     """
 
 
-__all__ = (
+@_spec
+def tox_before_run_commands(tox_env: ToxEnv) -> None:  # noqa: U100
+    """
+    Called before the commands set is executed.
+
+    :param tox_env: the tox environment being executed
+    """
+
+
+@_spec
+def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]) -> None:  # noqa: U100
+    """
+    Called after the commands set is executed.
+
+    :param tox_env: the tox environment being executed
+    :param exit_code: exit code of the command
+    :param outcomes: outcome of each command execution
+    """
+
+
+__all__ = [
     "NAME",
     "tox_register_tox_env",
     "tox_add_option",
     "tox_add_core_config",
     "tox_configure",
-)
+    "tox_before_run_commands",
+    "tox_after_run_commands",
+]
diff --git a/src/tox/report.py b/src/tox/report.py
index 572982b5..3e71a286 100644
--- a/src/tox/report.py
+++ b/src/tox/report.py
@@ -178,7 +178,7 @@ class ToxHandler(logging.StreamHandler):
         # shorten the pathname to start from within the site-packages folder
         record.env_name = "root" if self._local.name is None else self._local.name  # type: ignore[attr-defined]
         basename = os.path.dirname(record.pathname)
-        len_sys_path_match = max(len(p) for p in sys.path if basename.startswith(p))
+        len_sys_path_match = max((len(p) for p in sys.path if basename.startswith(p)), default=-1)
         record.pathname = record.pathname[len_sys_path_match + 1 :]
 
         if record.levelno >= logging.ERROR:
diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py
index aa2a2c05..c16236a7 100644
--- a/src/tox/session/cmd/run/single.py
+++ b/src/tox/session/cmd/run/single.py
@@ -63,19 +63,26 @@ def _evaluate(tox_env: RunToxEnv, no_test: bool) -> Tuple[bool, int, List[Outcom
 def run_commands(tox_env: RunToxEnv, no_test: bool) -> Tuple[int, List[Outcome]]:
     outcomes: List[Outcome] = []
     if no_test:
-        status_pre, status_main, status_post = Outcome.OK, Outcome.OK, Outcome.OK
+        exit_code = Outcome.OK
     else:
+        from tox.plugin.manager import MANAGER  # importing this here to avoid circular import
+
         chdir: Path = tox_env.conf["change_dir"]
         ignore_errors: bool = tox_env.conf["ignore_errors"]
+        MANAGER.tox_before_run_commands(tox_env)
+        status_pre, status_main, status_post = -1, -1, -1
         try:
-            status_pre = run_command_set(tox_env, "commands_pre", chdir, ignore_errors, outcomes)
-            if status_pre == Outcome.OK or ignore_errors:
-                status_main = run_command_set(tox_env, "commands", chdir, ignore_errors, outcomes)
-            else:
-                status_main = Outcome.OK
+            try:
+                status_pre = run_command_set(tox_env, "commands_pre", chdir, ignore_errors, outcomes)
+                if status_pre == Outcome.OK or ignore_errors:
+                    status_main = run_command_set(tox_env, "commands", chdir, ignore_errors, outcomes)
+                else:
+                    status_main = Outcome.OK
+            finally:
+                status_post = run_command_set(tox_env, "commands_post", chdir, ignore_errors, outcomes)
         finally:
-            status_post = run_command_set(tox_env, "commands_post", chdir, ignore_errors, outcomes)
-    exit_code = status_pre or status_main or status_post  # first non-success
+            exit_code = status_pre or status_main or status_post  # first non-success
+            MANAGER.tox_after_run_commands(tox_env, exit_code, outcomes)
     return exit_code, outcomes
 
 
diff --git a/src/tox/util/spinner.py b/src/tox/util/spinner.py
index ca2dbb06..d8cf9600 100644
--- a/src/tox/util/spinner.py
+++ b/src/tox/util/spinner.py
@@ -6,7 +6,7 @@ import threading
 import time
 from collections import OrderedDict
 from types import TracebackType
-from typing import IO, Dict, List, Optional, Sequence, Type, TypeVar
+from typing import IO, Dict, List, NamedTuple, Optional, Sequence, Type, TypeVar
 
 from colorama import Fore
 
@@ -34,11 +34,19 @@ T = TypeVar("T", bound="Spinner")
 MISS_DURATION = 0.01
 
 
+class Outcome(NamedTuple):
+    ok: str
+    fail: str
+    skip: str
+
+
 class Spinner:
     CLEAR_LINE = "\033[K"
     max_width = 120
     UNICODE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
     ASCII_FRAMES = ["|", "-", "+", "x", "*"]
+    UNICODE_OUTCOME = Outcome(ok="✔", fail="✖", skip="⚠")
+    ASCII_OUTCOME = Outcome(ok="+", fail="!", skip="?")
 
     def __init__(
         self,
@@ -53,6 +61,9 @@ class Spinner:
         self.enabled = enabled
         stream = sys.stdout if stream is None else stream
         self.frames = self.UNICODE_FRAMES if _file_support_encoding(self.UNICODE_FRAMES, stream) else self.ASCII_FRAMES
+        self.outcome = (
+            self.UNICODE_OUTCOME if _file_support_encoding(self.UNICODE_OUTCOME, stream) else self.ASCII_OUTCOME
+        )
         self.stream = stream
         self.total = total
         self.print_report = True
@@ -117,13 +128,13 @@ class Spinner:
         self._envs[name] = time.monotonic()
 
     def succeed(self, key: str) -> None:
-        self.finalize(key, "OK ✔", Fore.GREEN)
+        self.finalize(key, f"OK {self.outcome.ok}", Fore.GREEN)
 
     def fail(self, key: str) -> None:
-        self.finalize(key, "FAIL ✖", Fore.RED)
+        self.finalize(key, f"FAIL {self.outcome.fail}", Fore.RED)
 
     def skip(self, key: str) -> None:
-        self.finalize(key, "SKIP ⚠", Fore.YELLOW)
+        self.finalize(key, f"SKIP {self.outcome.skip}", Fore.YELLOW)
 
     def finalize(self, key: str, status: str, color: int) -> None:
         start_at = self._envs.pop(key, None)
diff --git a/whitelist.txt b/whitelist.txt
index fe47b850..0680bfde 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -92,6 +92,7 @@ getoption
 getpid
 getresult
 getsockname
+getsourcelines
 globals
 groupby
 groupdict
@@ -219,6 +220,7 @@ unescaped
 unimported
 unittest
 unlink
+unregister
 untyped
 url2pathname
 usedevelop


Test patch:
diff --git a/src/tox/pytest.py b/src/tox/pytest.py
index b7d7be7d..97241ea8 100644
--- a/src/tox/pytest.py
+++ b/src/tox/pytest.py
@@ -1,7 +1,7 @@
 """
 A pytest plugin useful to test tox itself (and its plugins).
 """
-
+import inspect
 import os
 import random
 import re
@@ -71,12 +71,18 @@ def ensure_logging_framework_not_altered() -> Iterator[None]:  # noqa: PT004
 
 
 @pytest.fixture(autouse=True)
-def disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Optional[MagicMock]:
-    return (
-        None
-        if request.node.get_closest_marker("plugin_test")
-        else mocker.patch("tox.plugin.inline._load_plugin", return_value=None)
-    )
+def _disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Iterator[None]:
+    """unless this is a plugin test do not allow loading toxfile.py"""
+    if request.node.get_closest_marker("plugin_test"):  # unregister inline plugin
+        from tox.plugin import manager
+
+        inline_plugin = mocker.spy(manager, "load_inline")
+        yield
+        if inline_plugin.spy_return is not None:  # pragma: no branch
+            manager.MANAGER.manager.unregister(inline_plugin.spy_return)
+    else:  # do not allow loading inline plugins
+        mocker.patch("tox.plugin.inline._load_plugin", return_value=None)
+        yield
 
 
 @contextmanager
@@ -145,6 +151,8 @@ class ToxProject:
             if not isinstance(key, str):
                 raise TypeError(f"{key!r} at {dest}")  # pragma: no cover
             at_path = dest / key
+            if callable(value):
+                value = textwrap.dedent("\n".join(inspect.getsourcelines(value)[0][1:]))
             if isinstance(value, dict):
                 at_path.mkdir(exist_ok=True)
                 ToxProject._setup_files(at_path, None, value)
diff --git a/tests/plugin/test_inline.py b/tests/plugin/test_inline.py
index 170743a1..716579ae 100644
--- a/tests/plugin/test_inline.py
+++ b/tests/plugin/test_inline.py
@@ -5,13 +5,83 @@ from tox.pytest import ToxProjectCreator
 
 @pytest.mark.plugin_test()
 def test_inline_tox_py(tox_project: ToxProjectCreator) -> None:
-    ini = """
-    from tox.plugin import impl
-    @impl
-    def tox_add_option(parser):
-        parser.add_argument("--magic", action="store_true")
-    """
-    project = tox_project({"toxfile.py": ini})
+    def plugin() -> None:  # pragma: no cover # the code is copied to a python file
+        import logging
+
+        from tox.config.cli.parser import ToxParser
+        from tox.plugin import impl
+
+        @impl
+        def tox_add_option(parser: ToxParser) -> None:
+            logging.warning("Add magic")
+            parser.add_argument("--magic", action="store_true")
+
+    project = tox_project({"toxfile.py": plugin})
     result = project.run("-h")
     result.assert_success()
     assert "--magic" in result.out
+
+
+@pytest.mark.plugin_test()
+def test_plugin_hooks(tox_project: ToxProjectCreator) -> None:
+    def plugin() -> None:  # pragma: no cover # the code is copied to a python file
+        import logging
+        from typing import List
+
+        from tox.config.cli.parser import ToxParser
+        from tox.config.main import Config
+        from tox.config.sets import ConfigSet
+        from tox.execute import Outcome
+        from tox.plugin import impl
+        from tox.tox_env.api import ToxEnv
+        from tox.tox_env.register import ToxEnvRegister
+
+        @impl
+        def tox_register_tox_env(register: ToxEnvRegister) -> None:
+            assert isinstance(register, ToxEnvRegister)
+            logging.warning("tox_register_tox_env")
+
+        @impl
+        def tox_add_option(parser: ToxParser) -> None:
+            assert isinstance(parser, ToxParser)
+            logging.warning("tox_add_option")
+
+        @impl
+        def tox_add_core_config(core: ConfigSet) -> None:
+            assert isinstance(core, ConfigSet)
+            logging.warning("tox_add_core_config")
+
+        @impl
+        def tox_configure(config: Config) -> None:
+            assert isinstance(config, Config)
+            logging.warning("tox_configure")
+
+        @impl
+        def tox_before_run_commands(tox_env: ToxEnv) -> None:
+            assert isinstance(tox_env, ToxEnv)
+            logging.warning("tox_before_run_commands")
+
+        @impl
+        def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]) -> None:
+            assert isinstance(tox_env, ToxEnv)
+            assert exit_code == 0
+            assert isinstance(outcomes, list)
+            assert all(isinstance(i, Outcome) for i in outcomes)
+            logging.warning("tox_after_run_commands")
+
+    project = tox_project({"toxfile.py": plugin, "tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(1)'"})
+    result = project.run("r")
+    result.assert_success()
+    output = r"""
+    ROOT: tox_register_tox_env
+    ROOT: tox_add_option
+    ROOT: tox_configure
+    ROOT: tox_add_core_config
+    py: tox_before_run_commands
+    py: commands\[0\]> python -c .*
+    1.*
+    py: tox_after_run_commands
+      py: OK \(.* seconds\)
+      congratulations :\) \(.* seconds\)
+    """
+    result.assert_out_err(output, err="", dedent=True, regex=True)