Skip to content

Commit 9d1a46a

Browse files
authored
Support for plugin before and after, fix ASCII report fails (#2214)
1 parent 48da366 commit 9d1a46a

File tree

12 files changed

+175
-33
lines changed

12 files changed

+175
-33
lines changed

docs/changelog/2201.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Allow running code in plugins before and after commands via
2+
:meth:`tox_before_run_commands <tox.plugin.spec.tox_before_run_commands>` and
3+
:meth:`tox_after_run_commands <tox.plugin.spec.tox_after_run_commands>` plugin points -- by :user:`gaborbernat`.

docs/changelog/2213.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Report fails when report does not support Unicode characters -- by :user:`gaborbernat`.

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def resolve_xref(
131131
"tox.config.loader.api.T": "typing.TypeVar",
132132
"tox.config.loader.convert.T": "typing.TypeVar",
133133
"tox.tox_env.installer.T": "typing.TypeVar",
134+
"ToxParserT": "typing.TypeVar",
134135
}
135136
if target in mapping:
136137
node["reftarget"] = mapping[target]

docs/plugins_api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ register
1616

1717
config
1818
------
19+
.. autoclass:: tox.config.cli.parser.ArgumentParserWithEnvAndConfig
20+
:members:
21+
22+
.. autoclass:: tox.config.cli.parser.ToxParser
23+
:members:
24+
1925
.. autoclass:: tox.config.cli.parser.Parsed
2026
:members:
2127

src/tox/plugin/manager.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Contains the plugin manager object"""
22
from pathlib import Path
3+
from typing import List
34

45
import pluggy
56

@@ -16,6 +17,8 @@
1617
from tox.tox_env.register import REGISTER, ToxEnvRegister
1718

1819
from ..config.main import Config
20+
from ..execute import Outcome
21+
from ..tox_env.api import ToxEnv
1922
from . import NAME, spec
2023
from .inline import load_inline
2124

@@ -58,9 +61,15 @@ def tox_add_core_config(self, core: ConfigSet) -> None:
5861
def tox_configure(self, config: Config) -> None:
5962
self.manager.hook.tox_configure(config=config)
6063

61-
def tox_register_tox_env(self, register: "ToxEnvRegister") -> None:
64+
def tox_register_tox_env(self, register: ToxEnvRegister) -> None:
6265
self.manager.hook.tox_register_tox_env(register=register)
6366

67+
def tox_before_run_commands(self, tox_env: ToxEnv) -> None:
68+
self.manager.hook.tox_before_run_commands(tox_env=tox_env)
69+
70+
def tox_after_run_commands(self, tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]) -> None:
71+
self.manager.hook.tox_after_run_commands(tox_env=tox_env, exit_code=exit_code, outcomes=outcomes)
72+
6473
def load_inline_plugin(self, path: Path) -> None:
6574
result = load_inline(path)
6675
if result is not None:

src/tox/plugin/spec.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
from argparse import ArgumentParser
2-
from typing import Any, Callable, TypeVar, cast
1+
from typing import Any, Callable, List, TypeVar, cast
32

43
import pluggy
54

65
from tox.config.main import Config
76
from tox.config.sets import ConfigSet
87
from tox.tox_env.register import ToxEnvRegister
98

9+
from ..config.cli.parser import ToxParser
10+
from ..execute import Outcome
11+
from ..tox_env.api import ToxEnv
1012
from . import NAME
1113

1214
_F = TypeVar("_F", bound=Callable[..., Any])
@@ -30,7 +32,7 @@ def tox_register_tox_env(register: ToxEnvRegister) -> None: # noqa: U100
3032

3133

3234
@_spec
33-
def tox_add_option(parser: ArgumentParser) -> None: # noqa: U100
35+
def tox_add_option(parser: ToxParser) -> None: # noqa: U100
3436
"""
3537
Add a command line argument. This is the first hook to be called, right after the logging setup and config source
3638
discovery.
@@ -58,10 +60,32 @@ def tox_configure(config: Config) -> None: # noqa: U100
5860
"""
5961

6062

61-
__all__ = (
63+
@_spec
64+
def tox_before_run_commands(tox_env: ToxEnv) -> None: # noqa: U100
65+
"""
66+
Called before the commands set is executed.
67+
68+
:param tox_env: the tox environment being executed
69+
"""
70+
71+
72+
@_spec
73+
def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]) -> None: # noqa: U100
74+
"""
75+
Called after the commands set is executed.
76+
77+
:param tox_env: the tox environment being executed
78+
:param exit_code: exit code of the command
79+
:param outcomes: outcome of each command execution
80+
"""
81+
82+
83+
__all__ = [
6284
"NAME",
6385
"tox_register_tox_env",
6486
"tox_add_option",
6587
"tox_add_core_config",
6688
"tox_configure",
67-
)
89+
"tox_before_run_commands",
90+
"tox_after_run_commands",
91+
]

src/tox/pytest.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
A pytest plugin useful to test tox itself (and its plugins).
33
"""
4-
4+
import inspect
55
import os
66
import random
77
import re
@@ -71,12 +71,18 @@ def ensure_logging_framework_not_altered() -> Iterator[None]: # noqa: PT004
7171

7272

7373
@pytest.fixture(autouse=True)
74-
def disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Optional[MagicMock]:
75-
return (
76-
None
77-
if request.node.get_closest_marker("plugin_test")
78-
else mocker.patch("tox.plugin.inline._load_plugin", return_value=None)
79-
)
74+
def _disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Iterator[None]:
75+
"""unless this is a plugin test do not allow loading toxfile.py"""
76+
if request.node.get_closest_marker("plugin_test"): # unregister inline plugin
77+
from tox.plugin import manager
78+
79+
inline_plugin = mocker.spy(manager, "load_inline")
80+
yield
81+
if inline_plugin.spy_return is not None: # pragma: no branch
82+
manager.MANAGER.manager.unregister(inline_plugin.spy_return)
83+
else: # do not allow loading inline plugins
84+
mocker.patch("tox.plugin.inline._load_plugin", return_value=None)
85+
yield
8086

8187

8288
@contextmanager
@@ -145,6 +151,8 @@ def _setup_files(dest: Path, base: Optional[Path], content: Dict[str, Any]) -> N
145151
if not isinstance(key, str):
146152
raise TypeError(f"{key!r} at {dest}") # pragma: no cover
147153
at_path = dest / key
154+
if callable(value):
155+
value = textwrap.dedent("\n".join(inspect.getsourcelines(value)[0][1:]))
148156
if isinstance(value, dict):
149157
at_path.mkdir(exist_ok=True)
150158
ToxProject._setup_files(at_path, None, value)

src/tox/report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def format(self, record: logging.LogRecord) -> str:
178178
# shorten the pathname to start from within the site-packages folder
179179
record.env_name = "root" if self._local.name is None else self._local.name # type: ignore[attr-defined]
180180
basename = os.path.dirname(record.pathname)
181-
len_sys_path_match = max(len(p) for p in sys.path if basename.startswith(p))
181+
len_sys_path_match = max((len(p) for p in sys.path if basename.startswith(p)), default=-1)
182182
record.pathname = record.pathname[len_sys_path_match + 1 :]
183183

184184
if record.levelno >= logging.ERROR:

src/tox/session/cmd/run/single.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,26 @@ def _evaluate(tox_env: RunToxEnv, no_test: bool) -> Tuple[bool, int, List[Outcom
6363
def run_commands(tox_env: RunToxEnv, no_test: bool) -> Tuple[int, List[Outcome]]:
6464
outcomes: List[Outcome] = []
6565
if no_test:
66-
status_pre, status_main, status_post = Outcome.OK, Outcome.OK, Outcome.OK
66+
exit_code = Outcome.OK
6767
else:
68+
from tox.plugin.manager import MANAGER # importing this here to avoid circular import
69+
6870
chdir: Path = tox_env.conf["change_dir"]
6971
ignore_errors: bool = tox_env.conf["ignore_errors"]
72+
MANAGER.tox_before_run_commands(tox_env)
73+
status_pre, status_main, status_post = -1, -1, -1
7074
try:
71-
status_pre = run_command_set(tox_env, "commands_pre", chdir, ignore_errors, outcomes)
72-
if status_pre == Outcome.OK or ignore_errors:
73-
status_main = run_command_set(tox_env, "commands", chdir, ignore_errors, outcomes)
74-
else:
75-
status_main = Outcome.OK
75+
try:
76+
status_pre = run_command_set(tox_env, "commands_pre", chdir, ignore_errors, outcomes)
77+
if status_pre == Outcome.OK or ignore_errors:
78+
status_main = run_command_set(tox_env, "commands", chdir, ignore_errors, outcomes)
79+
else:
80+
status_main = Outcome.OK
81+
finally:
82+
status_post = run_command_set(tox_env, "commands_post", chdir, ignore_errors, outcomes)
7683
finally:
77-
status_post = run_command_set(tox_env, "commands_post", chdir, ignore_errors, outcomes)
78-
exit_code = status_pre or status_main or status_post # first non-success
84+
exit_code = status_pre or status_main or status_post # first non-success
85+
MANAGER.tox_after_run_commands(tox_env, exit_code, outcomes)
7986
return exit_code, outcomes
8087

8188

src/tox/util/spinner.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
from collections import OrderedDict
88
from types import TracebackType
9-
from typing import IO, Dict, List, Optional, Sequence, Type, TypeVar
9+
from typing import IO, Dict, List, NamedTuple, Optional, Sequence, Type, TypeVar
1010

1111
from colorama import Fore
1212

@@ -34,11 +34,19 @@ def _file_support_encoding(chars: Sequence[str], file: IO[str]) -> bool:
3434
MISS_DURATION = 0.01
3535

3636

37+
class Outcome(NamedTuple):
38+
ok: str
39+
fail: str
40+
skip: str
41+
42+
3743
class Spinner:
3844
CLEAR_LINE = "\033[K"
3945
max_width = 120
4046
UNICODE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
4147
ASCII_FRAMES = ["|", "-", "+", "x", "*"]
48+
UNICODE_OUTCOME = Outcome(ok="✔", fail="✖", skip="⚠")
49+
ASCII_OUTCOME = Outcome(ok="+", fail="!", skip="?")
4250

4351
def __init__(
4452
self,
@@ -53,6 +61,9 @@ def __init__(
5361
self.enabled = enabled
5462
stream = sys.stdout if stream is None else stream
5563
self.frames = self.UNICODE_FRAMES if _file_support_encoding(self.UNICODE_FRAMES, stream) else self.ASCII_FRAMES
64+
self.outcome = (
65+
self.UNICODE_OUTCOME if _file_support_encoding(self.UNICODE_OUTCOME, stream) else self.ASCII_OUTCOME
66+
)
5667
self.stream = stream
5768
self.total = total
5869
self.print_report = True
@@ -117,13 +128,13 @@ def add(self, name: str) -> None:
117128
self._envs[name] = time.monotonic()
118129

119130
def succeed(self, key: str) -> None:
120-
self.finalize(key, "OK ", Fore.GREEN)
131+
self.finalize(key, f"OK {self.outcome.ok}", Fore.GREEN)
121132

122133
def fail(self, key: str) -> None:
123-
self.finalize(key, "FAIL ", Fore.RED)
134+
self.finalize(key, f"FAIL {self.outcome.fail}", Fore.RED)
124135

125136
def skip(self, key: str) -> None:
126-
self.finalize(key, "SKIP ", Fore.YELLOW)
137+
self.finalize(key, f"SKIP {self.outcome.skip}", Fore.YELLOW)
127138

128139
def finalize(self, key: str, status: str, color: int) -> None:
129140
start_at = self._envs.pop(key, None)

0 commit comments

Comments
 (0)