Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/2201.feature.rst
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/changelog/2213.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Report fails when report does not support Unicode characters -- by :user:`gaborbernat`.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def resolve_xref(
"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]
Expand Down
6 changes: 6 additions & 0 deletions docs/plugins_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 10 additions & 1 deletion src/tox/plugin/manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Contains the plugin manager object"""
from pathlib import Path
from typing import List

import pluggy

Expand All @@ -16,6 +17,8 @@
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

Expand Down Expand Up @@ -58,9 +61,15 @@ def tox_add_core_config(self, core: ConfigSet) -> None:
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:
Expand Down
34 changes: 29 additions & 5 deletions src/tox/plugin/spec.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from argparse import ArgumentParser
from typing import Any, Callable, TypeVar, cast
from typing import Any, Callable, List, TypeVar, cast

import pluggy

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])
Expand All @@ -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.
Expand Down Expand Up @@ -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",
]
22 changes: 15 additions & 7 deletions src/tox/pytest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
A pytest plugin useful to test tox itself (and its plugins).
"""

import inspect
import os
import random
import re
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -145,6 +151,8 @@ def _setup_files(dest: Path, base: Optional[Path], content: Dict[str, Any]) -> N
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)
Expand Down
2 changes: 1 addition & 1 deletion src/tox/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def format(self, record: logging.LogRecord) -> str:
# 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:
Expand Down
23 changes: 15 additions & 8 deletions src/tox/session/cmd/run/single.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
19 changes: 15 additions & 4 deletions src/tox/util/spinner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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

Expand Down Expand Up @@ -34,11 +34,19 @@ def _file_support_encoding(chars: Sequence[str], file: IO[str]) -> bool:
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,
Expand All @@ -53,6 +61,9 @@ def __init__(
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
Expand Down Expand Up @@ -117,13 +128,13 @@ def add(self, name: str) -> None:
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)
Expand Down
84 changes: 77 additions & 7 deletions tests/plugin/test_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,83 @@

@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)
2 changes: 2 additions & 0 deletions whitelist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ getoption
getpid
getresult
getsockname
getsourcelines
globals
groupby
groupdict
Expand Down Expand Up @@ -219,6 +220,7 @@ unescaped
unimported
unittest
unlink
unregister
untyped
url2pathname
usedevelop
Expand Down