diff options
-rw-r--r-- | docs/changelog/2201.feature.rst | 3 | ||||
-rw-r--r-- | docs/changelog/2213.bugfix.rst | 1 | ||||
-rw-r--r-- | docs/conf.py | 1 | ||||
-rw-r--r-- | docs/plugins_api.rst | 6 | ||||
-rw-r--r-- | src/tox/plugin/manager.py | 11 | ||||
-rw-r--r-- | src/tox/plugin/spec.py | 34 | ||||
-rw-r--r-- | src/tox/pytest.py | 22 | ||||
-rw-r--r-- | src/tox/report.py | 2 | ||||
-rw-r--r-- | src/tox/session/cmd/run/single.py | 23 | ||||
-rw-r--r-- | src/tox/util/spinner.py | 19 | ||||
-rw-r--r-- | tests/plugin/test_inline.py | 84 | ||||
-rw-r--r-- | whitelist.txt | 2 |
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 |