Skip to content

Commit bf558e3

Browse files
Expose a new tox_extend_envs hook in plugins API (#3591)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent eceba31 commit bf558e3

File tree

10 files changed

+90
-7
lines changed

10 files changed

+90
-7
lines changed

docs/changelog/3510.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3591.feature.rst

docs/changelog/3591.feature.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
A new tox life cycle event is now exposed for use via :doc:`Plugins
2+
API </plugins>` -- by :user:`webknjaz`.
3+
4+
The corresponding hook point is :func:`tox_extend_envs
5+
<tox.plugin.spec.tox_extend_envs>`. It allows plugin authors to
6+
declare ephemeral environments that they can then populate through
7+
the in-memory configuration loader interface.
8+
9+
This patch was made possible thanks to pair programming with
10+
:user:`gaborbernat` at PyCon US 2025.

src/tox/config/main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import os
44
from collections import OrderedDict, defaultdict
5+
from itertools import chain
56
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, Iterator, Sequence, TypeVar
7+
from typing import TYPE_CHECKING, Any, Iterable, Iterator, Sequence, TypeVar
78

89
from .sets import ConfigSet, CoreConfigSet, EnvConfigSet
910

@@ -22,18 +23,20 @@
2223
class Config:
2324
"""Main configuration object for tox."""
2425

25-
def __init__(
26+
def __init__( # noqa: PLR0913 # <- no way around many args
2627
self,
2728
config_source: Source,
2829
options: Parsed,
2930
root: Path,
3031
pos_args: Sequence[str] | None,
3132
work_dir: Path,
33+
extra_envs: Iterable[str],
3234
) -> None:
3335
self._pos_args = None if pos_args is None else tuple(pos_args)
3436
self._work_dir = work_dir
3537
self._root = root
3638
self._options = options
39+
self._extra_envs = extra_envs
3740

3841
self._overrides: OverrideMap = defaultdict(list)
3942
for override in options.override:
@@ -78,7 +81,7 @@ def src_path(self) -> Path:
7881

7982
def __iter__(self) -> Iterator[str]:
8083
""":return: an iterator that goes through existing environments"""
81-
return self._src.envs(self.core)
84+
return chain(self._src.envs(self.core), self._extra_envs)
8285

8386
def sections(self) -> Iterator[Section]:
8487
yield from self._src.sections()
@@ -91,7 +94,7 @@ def __contains__(self, item: str) -> bool:
9194
return any(name for name in self if name == item)
9295

9396
@classmethod
94-
def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source) -> Config:
97+
def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source, extra_envs: Iterable[str]) -> Config:
9598
"""Make a tox configuration object."""
9699
# root is the project root, where the configuration file is at
97100
# work dir is where we put our own files
@@ -106,6 +109,7 @@ def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source) ->
106109
pos_args=pos_args,
107110
root=root,
108111
work_dir=work_dir,
112+
extra_envs=extra_envs,
109113
)
110114

111115
@property

src/tox/plugin/manager.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import os
6-
from typing import TYPE_CHECKING, Any
6+
from typing import TYPE_CHECKING, Any, Iterable
77

88
import pluggy
99

@@ -80,6 +80,12 @@ def _load_external_plugins(self) -> None:
8080
self.manager.set_blocked(name)
8181
self.manager.load_setuptools_entrypoints(NAME)
8282

83+
def tox_extend_envs(self) -> list[Iterable[str]]:
84+
additional_env_names_hook_value = self.manager.hook.tox_extend_envs()
85+
# NOTE: S101 is suppressed below to allow for type narrowing in MyPy
86+
assert isinstance(additional_env_names_hook_value, list) # noqa: S101
87+
return additional_env_names_hook_value
88+
8389
def tox_add_option(self, parser: ToxParser) -> None:
8490
self.manager.hook.tox_add_option(parser=parser)
8591

src/tox/plugin/spec.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, Iterable
44

55
import pluggy
66

@@ -29,6 +29,24 @@ def tox_register_tox_env(register: ToxEnvRegister) -> None:
2929
"""
3030

3131

32+
@_spec
33+
def tox_extend_envs() -> Iterable[str]:
34+
"""Declare additional environment names.
35+
36+
This hook is called without any arguments early in the lifecycle. It
37+
is expected to return an iterable of strings with environment names
38+
for tox to consider. It can be used to facilitate dynamic creation of
39+
additional environments from within tox plugins.
40+
41+
This is ideal to pair with :func:`tox_add_core_config
42+
<tox.plugin.spec.tox_add_core_config>` that has access to
43+
``state.conf.memory_seed_loaders`` allowing to extend it with instances of
44+
:class:`tox.config.loader.memory.MemoryLoader` early enough before tox
45+
starts caching configuration values sourced elsewhere.
46+
"""
47+
return () # <- Please MyPy
48+
49+
3250
@_spec
3351
def tox_add_option(parser: ToxParser) -> None:
3452
"""
@@ -108,6 +126,7 @@ def tox_env_teardown(tox_env: ToxEnv) -> None:
108126
"tox_after_run_commands",
109127
"tox_before_run_commands",
110128
"tox_env_teardown",
129+
"tox_extend_envs",
111130
"tox_on_install",
112131
"tox_register_tox_env",
113132
]

src/tox/session/state.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

33
import sys
4+
from itertools import chain
45
from typing import TYPE_CHECKING, Sequence
56

67
from tox.config.main import Config
78
from tox.journal import Journal
89
from tox.plugin import impl
10+
from tox.plugin.manager import MANAGER
911

1012
from .env_select import EnvSelector
1113

@@ -18,7 +20,8 @@ class State:
1820
"""Runtime state holder."""
1921

2022
def __init__(self, options: Options, args: Sequence[str]) -> None:
21-
self.conf = Config.make(options.parsed, options.pos_args, options.source)
23+
extended_envs = chain.from_iterable(MANAGER.tox_extend_envs())
24+
self.conf = Config.make(options.parsed, options.pos_args, options.source, extended_envs)
2225
self.conf.core.add_constant(
2326
keys=["on_platform"],
2427
desc="platform we are running on",

tests/config/cli/test_cli_ini.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) ->
166166
Parsed(work_dir=dest, override=[], config_file=config_file, root_dir=None),
167167
pos_args=[],
168168
source=source,
169+
extra_envs=(),
169170
)
170171

171172

tests/config/loader/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def example(conf: str, pos_args: list[str] | None = None) -> str:
3232
root=tmp_path,
3333
pos_args=pos_args,
3434
work_dir=tmp_path,
35+
extra_envs=(),
3536
)
3637
loader = config.get_env("py").loaders[0]
3738
args = ConfigLoadArgs(chain=[], name="a", env_name="a")

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def func(conf: str, override: Sequence[Override] | None = None) -> Config:
6262
Parsed(work_dir=dest, override=override or [], config_file=config_file, root_dir=None),
6363
pos_args=[],
6464
source=source,
65+
extra_envs=(),
6566
)
6667

6768
return func

tests/plugin/test_inline.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
if TYPE_CHECKING:
66
from tox.config.cli.parser import ToxParser
7+
from tox.config.sets import ConfigSet
78
from tox.pytest import ToxProjectCreator
9+
from tox.session.state import State
810

911

1012
def test_inline_tox_py(tox_project: ToxProjectCreator) -> None:
@@ -22,3 +24,38 @@ def tox_add_option(parser: ToxParser) -> None:
2224
result = project.run("-h")
2325
result.assert_success()
2426
assert "--magic" in result.out
27+
28+
29+
def test_toxfile_py_w_ephemeral_envs(tox_project: ToxProjectCreator) -> None:
30+
"""Ensure additional ephemeral tox envs can be plugin-injected."""
31+
32+
def plugin() -> None: # pragma: no cover # the code is copied to a python file
33+
from tox.config.loader.memory import MemoryLoader # noqa: PLC0415
34+
from tox.plugin import impl # noqa: PLC0415
35+
36+
env_name = "sentinel-env-name"
37+
38+
@impl
39+
def tox_extend_envs() -> tuple[str]:
40+
return (env_name,)
41+
42+
@impl
43+
def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: ARG001
44+
in_memory_config_loader = MemoryLoader(
45+
base=["sentinel-base"],
46+
description="sentinel-description",
47+
)
48+
state.conf.memory_seed_loaders[env_name].append(
49+
in_memory_config_loader, # src/tox/provision.py:provision()
50+
)
51+
52+
project = tox_project({"toxfile.py": plugin})
53+
54+
tox_list_result = project.run("list", "-qq")
55+
tox_list_result.assert_success()
56+
expected_additional_env_txt = "\n\nadditional environments:\nsentinel-env-name -> sentinel-description"
57+
assert expected_additional_env_txt in tox_list_result.out
58+
59+
tox_config_result = project.run("config", "-e", "sentinel-env-name", "-qq")
60+
tox_config_result.assert_success()
61+
assert "base = sentinel-base" in tox_config_result.out

0 commit comments

Comments
 (0)