From d0c37b1bb90d75dc65fff7381f7a61590c3b19a7 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sydorenko Date: Fri, 29 Aug 2025 04:32:35 +0200 Subject: [PATCH 1/4] Expose a new `tox_extend_envs` hook in plugins API This patch adds a new hook point in the tox lifecycle that lets plugin authors declare additional environment names dynamically. This can be used to provide extra tox environments shipped as installable plugins. Resolves #3510. --- src/tox/config/main.py | 12 ++++++++---- src/tox/plugin/manager.py | 8 +++++++- src/tox/plugin/spec.py | 21 ++++++++++++++++++++- src/tox/session/state.py | 5 ++++- tests/config/cli/test_cli_ini.py | 1 + tests/config/loader/conftest.py | 1 + tests/conftest.py | 1 + 7 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/tox/config/main.py b/src/tox/config/main.py index 6e734191bc..ff264ea21c 100644 --- a/src/tox/config/main.py +++ b/src/tox/config/main.py @@ -2,8 +2,9 @@ import os from collections import OrderedDict, defaultdict +from itertools import chain from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterator, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Iterable, Iterator, Sequence, TypeVar from .sets import ConfigSet, CoreConfigSet, EnvConfigSet @@ -22,18 +23,20 @@ class Config: """Main configuration object for tox.""" - def __init__( + def __init__( # noqa: PLR0913 # <- no way around many args self, config_source: Source, options: Parsed, root: Path, pos_args: Sequence[str] | None, work_dir: Path, + extra_envs: Iterable[str], ) -> None: self._pos_args = None if pos_args is None else tuple(pos_args) self._work_dir = work_dir self._root = root self._options = options + self._extra_envs = extra_envs self._overrides: OverrideMap = defaultdict(list) for override in options.override: @@ -78,7 +81,7 @@ def src_path(self) -> Path: def __iter__(self) -> Iterator[str]: """:return: an iterator that goes through existing environments""" - return self._src.envs(self.core) + return chain(self._src.envs(self.core), self._extra_envs) def sections(self) -> Iterator[Section]: yield from self._src.sections() @@ -91,7 +94,7 @@ def __contains__(self, item: str) -> bool: return any(name for name in self if name == item) @classmethod - def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source) -> Config: + def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source, extra_envs: Iterable[str]) -> Config: """Make a tox configuration object.""" # root is the project root, where the configuration file is at # 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) -> pos_args=pos_args, root=root, work_dir=work_dir, + extra_envs=extra_envs, ) @property diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py index 4063199e29..df6dcfdd1a 100644 --- a/src/tox/plugin/manager.py +++ b/src/tox/plugin/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterable import pluggy @@ -80,6 +80,12 @@ def _load_external_plugins(self) -> None: self.manager.set_blocked(name) self.manager.load_setuptools_entrypoints(NAME) + def tox_extend_envs(self) -> list[Iterable[str]]: + additional_env_names_hook_value = self.manager.hook.tox_extend_envs() + # NOTE: S101 is suppressed below to allow for type narrowing in MyPy + assert isinstance(additional_env_names_hook_value, list) # noqa: S101 + return additional_env_names_hook_value + def tox_add_option(self, parser: ToxParser) -> None: self.manager.hook.tox_add_option(parser=parser) diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py index c421c6ac71..ba3eae96b6 100644 --- a/src/tox/plugin/spec.py +++ b/src/tox/plugin/spec.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Iterable import pluggy @@ -29,6 +29,24 @@ def tox_register_tox_env(register: ToxEnvRegister) -> None: """ +@_spec +def tox_extend_envs() -> Iterable[str]: + """Declare additional environment names. + + This hook is called without any arguments early in the lifecycle. It + is expected to return an iterable of strings with environment names + for tox to consider. It can be used to facilitate dynamic creation of + additional environments from within tox plugins. + + This is ideal to pair with :func:`tox_add_core_config + ` that has access to + ``state.conf.memory_seed_loaders`` allowing to extend it with instances of + :class:`tox.config.loader.memory.MemoryLoader` early enough before tox + starts caching configuration values sourced elsewhere. + """ + return () # <- Please MyPy + + @_spec def tox_add_option(parser: ToxParser) -> None: """ @@ -108,6 +126,7 @@ def tox_env_teardown(tox_env: ToxEnv) -> None: "tox_after_run_commands", "tox_before_run_commands", "tox_env_teardown", + "tox_extend_envs", "tox_on_install", "tox_register_tox_env", ] diff --git a/src/tox/session/state.py b/src/tox/session/state.py index f81c9031a6..6b63f39daa 100644 --- a/src/tox/session/state.py +++ b/src/tox/session/state.py @@ -1,11 +1,13 @@ from __future__ import annotations import sys +from itertools import chain from typing import TYPE_CHECKING, Sequence from tox.config.main import Config from tox.journal import Journal from tox.plugin import impl +from tox.plugin.manager import MANAGER from .env_select import EnvSelector @@ -18,7 +20,8 @@ class State: """Runtime state holder.""" def __init__(self, options: Options, args: Sequence[str]) -> None: - self.conf = Config.make(options.parsed, options.pos_args, options.source) + extended_envs = chain.from_iterable(MANAGER.tox_extend_envs()) + self.conf = Config.make(options.parsed, options.pos_args, options.source, extended_envs) self.conf.core.add_constant( keys=["on_platform"], desc="platform we are running on", diff --git a/tests/config/cli/test_cli_ini.py b/tests/config/cli/test_cli_ini.py index d608f57a02..3af81965ed 100644 --- a/tests/config/cli/test_cli_ini.py +++ b/tests/config/cli/test_cli_ini.py @@ -166,6 +166,7 @@ def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) -> Parsed(work_dir=dest, override=[], config_file=config_file, root_dir=None), pos_args=[], source=source, + extra_envs=(), ) diff --git a/tests/config/loader/conftest.py b/tests/config/loader/conftest.py index 05cb0869aa..6a8577176e 100644 --- a/tests/config/loader/conftest.py +++ b/tests/config/loader/conftest.py @@ -32,6 +32,7 @@ def example(conf: str, pos_args: list[str] | None = None) -> str: root=tmp_path, pos_args=pos_args, work_dir=tmp_path, + extra_envs=(), ) loader = config.get_env("py").loaders[0] args = ConfigLoadArgs(chain=[], name="a", env_name="a") diff --git a/tests/conftest.py b/tests/conftest.py index c5b12d93ef..27d3e19bb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,7 @@ def func(conf: str, override: Sequence[Override] | None = None) -> Config: Parsed(work_dir=dest, override=override or [], config_file=config_file, root_dir=None), pos_args=[], source=source, + extra_envs=(), ) return func From 81ef83a79400f1dace5dfcb5c071b5bd5cf098d4 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sydorenko Date: Fri, 29 Aug 2025 18:53:26 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A7=AA=20Add=20a=20test=20injecting?= =?UTF-8?q?=20ephemeral=20envs=20w/=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/plugin/test_inline.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/plugin/test_inline.py b/tests/plugin/test_inline.py index 667e248a6c..ad587bc3ff 100644 --- a/tests/plugin/test_inline.py +++ b/tests/plugin/test_inline.py @@ -4,7 +4,9 @@ if TYPE_CHECKING: from tox.config.cli.parser import ToxParser + from tox.config.sets import ConfigSet from tox.pytest import ToxProjectCreator + from tox.session.state import State def test_inline_tox_py(tox_project: ToxProjectCreator) -> None: @@ -22,3 +24,37 @@ def tox_add_option(parser: ToxParser) -> None: result = project.run("-h") result.assert_success() assert "--magic" in result.out + + +def test_toxfile_py_w_ephemeral_envs(tox_project: ToxProjectCreator) -> None: + """Ensure additional ephemeral tox envs can be plugin-injected.""" + def plugin() -> None: # pragma: no cover # the code is copied to a python file + from tox.config.loader.memory import MemoryLoader # noqa: PLC0415 + from tox.plugin import impl # noqa: PLC0415 + + env_name = "sentinel-env-name" + + @impl + def tox_extend_envs() -> tuple[str]: + return (env_name,) + + @impl + def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: ARG001 + in_memory_config_loader = MemoryLoader( + base=["sentinel-base"], + description="sentinel-description", + ) + state.conf.memory_seed_loaders[env_name].append( + in_memory_config_loader, # src/tox/provision.py:provision() + ) + + project = tox_project({"toxfile.py": plugin}) + + tox_list_result = project.run("list", "-qq") + tox_list_result.assert_success() + expected_additional_env_txt = "\n\nadditional environments:\nsentinel-env-name -> sentinel-description" + assert expected_additional_env_txt in tox_list_result.out + + tox_config_result = project.run("config", "-e", "sentinel-env-name", "-qq") + tox_config_result.assert_success() + assert "base = sentinel-base" in tox_config_result.out From 6df11598e1b55ce4b3c3e9873b8b961d72b6c1ea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:00:13 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/plugin/test_inline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/plugin/test_inline.py b/tests/plugin/test_inline.py index ad587bc3ff..4fae64b27a 100644 --- a/tests/plugin/test_inline.py +++ b/tests/plugin/test_inline.py @@ -28,6 +28,7 @@ def tox_add_option(parser: ToxParser) -> None: def test_toxfile_py_w_ephemeral_envs(tox_project: ToxProjectCreator) -> None: """Ensure additional ephemeral tox envs can be plugin-injected.""" + def plugin() -> None: # pragma: no cover # the code is copied to a python file from tox.config.loader.memory import MemoryLoader # noqa: PLC0415 from tox.plugin import impl # noqa: PLC0415 From 34b43423e39ba7ae708a8fb1cd5d06d2ef24d999 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sydorenko Date: Fri, 29 Aug 2025 18:59:43 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9D=20Add=20a=20change=20note=20fo?= =?UTF-8?q?r=20PR=20#3591?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/3510.feature.rst | 1 + docs/changelog/3591.feature.rst | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 120000 docs/changelog/3510.feature.rst create mode 100644 docs/changelog/3591.feature.rst diff --git a/docs/changelog/3510.feature.rst b/docs/changelog/3510.feature.rst new file mode 120000 index 0000000000..d53138e43d --- /dev/null +++ b/docs/changelog/3510.feature.rst @@ -0,0 +1 @@ +3591.feature.rst \ No newline at end of file diff --git a/docs/changelog/3591.feature.rst b/docs/changelog/3591.feature.rst new file mode 100644 index 0000000000..fe66fa627d --- /dev/null +++ b/docs/changelog/3591.feature.rst @@ -0,0 +1,10 @@ +A new tox life cycle event is now exposed for use via :doc:`Plugins +API ` -- by :user:`webknjaz`. + +The corresponding hook point is :func:`tox_extend_envs +`. It allows plugin authors to +declare ephemeral environments that they can then populate through +the in-memory configuration loader interface. + +This patch was made possible thanks to pair programming with +:user:`gaborbernat` at PyCon US 2025.