summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/changelog/2201.feature.rst3
-rw-r--r--docs/changelog/2213.bugfix.rst1
-rw-r--r--docs/conf.py1
-rw-r--r--docs/plugins_api.rst6
-rw-r--r--src/tox/plugin/manager.py11
-rw-r--r--src/tox/plugin/spec.py34
-rw-r--r--src/tox/pytest.py22
-rw-r--r--src/tox/report.py2
-rw-r--r--src/tox/session/cmd/run/single.py23
-rw-r--r--src/tox/util/spinner.py19
-rw-r--r--tests/plugin/test_inline.py84
-rw-r--r--whitelist.txt2
12 files changed, 175 insertions, 33 deletions
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 @@
+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/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/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/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)
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