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
1 change: 1 addition & 0 deletions docs/changelog/3510.feature.rst
10 changes: 10 additions & 0 deletions docs/changelog/3591.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
A new tox life cycle event is now exposed for use via :doc:`Plugins
API </plugins>` -- by :user:`webknjaz`.

The corresponding hook point is :func:`tox_extend_envs
<tox.plugin.spec.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.
12 changes: 8 additions & 4 deletions src/tox/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/tox/plugin/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
21 changes: 20 additions & 1 deletion src/tox/plugin/spec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Iterable

import pluggy

Expand Down Expand Up @@ -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
<tox.plugin.spec.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:
"""
Expand Down Expand Up @@ -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",
]
5 changes: 4 additions & 1 deletion src/tox/session/state.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/config/cli/test_cli_ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(),
)


Expand Down
1 change: 1 addition & 0 deletions tests/config/loader/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/plugin/test_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -22,3 +24,38 @@ 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