Skip to content

Allow disabling plugins on a one-off #3560

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 20, 2025
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
10 changes: 5 additions & 5 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ jobs:
- "3.10"
- "3.9"
os:
- ubuntu-latest
- windows-latest
- macos-latest
- ubuntu-24.04
- windows-2025
- macos-15
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -70,8 +70,8 @@ jobs:
- docs
- pkg_meta
os:
- ubuntu-latest
- windows-latest
- ubuntu-24.04
- windows-2025
exclude:
- { os: windows-latest, tox_env: docs }
steps:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ env:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -31,7 +31,7 @@ jobs:
release:
needs:
- build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
environment:
name: release
url: https://pypi.org/project/tox/${{ github.ref_name }}
Expand Down
1 change: 1 addition & 0 deletions docs/changelog/3468.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow disabling tox plugins via the ``TOX_DISABLED_EXTERNAL_PLUGINS`` environment variable - by :user:`gaborbernat`.
7 changes: 7 additions & 0 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ installed. For example:

For more information, refer to :ref:`the user guide <auto-provisioning>`.

Plugins can be disabled via the ``TOX_DISABLED_EXTERNAL_PLUGINS`` environment variable. This variable can be set to a
comma separated list of plugin names, e.g.:

```bash
env TOX_DISABLED_EXTERNAL_PLUGINS=tox-uv,tox-extra tox --version
```

Developing your own plugin
--------------------------

Expand Down
70 changes: 35 additions & 35 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[build-system]
build-backend = "hatchling.build"
requires = [
"hatch-vcs>=0.4",
"hatch-vcs>=0.5",
"hatchling>=1.27",
]

Expand Down Expand Up @@ -50,22 +50,17 @@ dynamic = [
"version",
]
dependencies = [
"cachetools>=5.5.1",
"cachetools>=6.1",
"chardet>=5.2",
"colorama>=0.4.6",
"filelock>=3.16.1",
"packaging>=24.2",
"platformdirs>=4.3.6",
"pluggy>=1.5",
"pyproject-api>=1.8",
"filelock>=3.18",
"packaging>=25",
"platformdirs>=4.3.8",
"pluggy>=1.6",
"pyproject-api>=1.9.1",
"tomli>=2.2.1; python_version<'3.11'",
"typing-extensions>=4.12.2; python_version<'3.11'",
"virtualenv>=20.31",
]
optional-dependencies.test = [
"devpi-process>=1.0.2",
"pytest>=8.3.4",
"pytest-mock>=3.14",
"typing-extensions>=4.14.1; python_version<'3.11'",
"virtualenv>=20.31.2",
]
urls.Documentation = "https://tox.wiki"
urls.Homepage = "http://tox.readthedocs.org"
Expand All @@ -83,35 +78,35 @@ dev = [
test = [
"build[virtualenv]>=1.2.2.post1",
"covdefaults>=2.3",
"coverage>=7.9.1",
"coverage>=7.9.2",
"detect-test-pollution>=1.2",
"devpi-process>=1.0.2",
"diff-cover>=9.2",
"distlib>=0.3.9",
"diff-cover>=9.6",
"distlib>=0.4",
"flaky>=3.8.1",
"hatch-vcs>=0.4",
"hatch-vcs>=0.5",
"hatchling>=1.27",
"psutil>=6.1.1",
"pytest>=8.3.4",
"pytest-cov>=5",
"pytest-mock>=3.14",
"pytest-xdist>=3.6.1",
"psutil>=7",
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
"pytest-mock>=3.14.1",
"pytest-xdist>=3.8",
"re-assert>=1.1",
"setuptools>=75.8",
"time-machine>=2.15; implementation_name!='pypy'",
"setuptools>=80.9",
"time-machine>=2.16; implementation_name!='pypy'",
"wheel>=0.45.1",
]
type = [
"mypy==1.15",
"types-cachetools>=5.5.0.20240820",
"mypy==1.17",
"types-cachetools>=6.1.0.20250717",
"types-chardet>=5.0.4.6",
{ include-group = "test" },
]
docs = [
"furo>=2024.8.6",
"sphinx>=8.1.3",
"furo>=2025.7.19",
"sphinx>=8.2.3",
"sphinx-argparse-cli>=1.19",
"sphinx-autodoc-typehints>=3.0.1",
"sphinx-autodoc-typehints>=3.2",
"sphinx-copybutton>=0.5.2",
"sphinx-inline-tabs>=2023.4.21",
"sphinxcontrib-towncrier>=0.2.1a0",
Expand All @@ -121,13 +116,13 @@ fix = [
"pre-commit-uv>=4.1.4",
]
pkg-meta = [
"check-wheel-contents>=0.6.1",
"check-wheel-contents>=0.6.2",
"twine>=6.1",
"uv>=0.5.29",
"uv>=0.8",
]
release = [
"gitpython>=3.1.44",
"packaging>=24.2",
"packaging>=25",
"towncrier>=24.8",
]

Expand Down Expand Up @@ -201,8 +196,13 @@ max_supported_python = "3.14"
testpaths = [
"tests",
]
addopts = "--tb=auto -ra --showlocals --no-success-flaky-report"
# Keep temporary directories only for failed or errored tests.
addopts = "--no-success-flaky-report"
verbosity_assertions = 2
filterwarnings = [
"error",
"ignore:unclosed database in <sqlite3.Connection object at:ResourceWarning",
"ignore:unclosed file <_io.TextIOWrapper:ResourceWarning",
]
tmp_path_retention_policy = "failed"

[tool.coverage]
Expand Down
2 changes: 1 addition & 1 deletion src/tox/config/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ def add_argument_group(self, *args: Any, **kwargs: Any) -> Any:

def add_mutually_exclusive_group(**e_kwargs: Any) -> Any:
def add_argument(*a_args: str, of_type: type[Any] | None = None, **a_kwargs: Any) -> Action:
res_args: Action = prev_add_arg(*a_args, **a_kwargs) # type: ignore[has-type]
res_args: Action = prev_add_arg(*a_args, **a_kwargs)
arguments.append((a_args, of_type, a_kwargs))
return res_args

Expand Down
4 changes: 2 additions & 2 deletions src/tox/config/loader/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,13 @@ def load(
converted_override = _STR_CONVERT.to(override.value, of_type, factory)
if override.append and converted is not None:
if isinstance(converted, list) and isinstance(converted_override, list):
converted += converted_override
converted += converted_override # type: ignore[assignment]
elif isinstance(converted, dict) and isinstance(converted_override, dict):
converted.update(converted_override)
elif isinstance(converted, SetEnv) and isinstance(converted_override, SetEnv):
converted.update(converted_override, override=True)
elif isinstance(converted, PythonDeps) and isinstance(converted_override, PythonDeps):
converted += converted_override
converted += converted_override # type: ignore[operator]
else:
msg = "Only able to append to lists and dicts"
raise ValueError(msg)
Expand Down
2 changes: 1 addition & 1 deletion src/tox/config/loader/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def to(self, raw: T, of_type: type[V], factory: Factory[V]) -> V: # noqa: PLR09
return list(self.to_list(raw, of_type=of_type)) # type: ignore[return-value]
if isinstance(raw, of_type): # already target type no need to transform it
# do it this late to allow normalization - e.g. string strip
return raw # type: ignore[no-any-return]
return raw
if factory:
return factory(raw)
return of_type(raw) # type: ignore[no-any-return]
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 @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any

import pluggy
Expand Down Expand Up @@ -49,7 +50,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:

if inline is not None:
self.manager.register(inline)
self.manager.load_setuptools_entrypoints(NAME)
self._load_external_plugins()
internal_plugins = (
loader_api,
provision,
Expand All @@ -74,6 +75,11 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
self.manager.register(state)
self.manager.check_pending()

def _load_external_plugins(self) -> None:
for name in os.environ.get("TOX_DISABLED_EXTERNAL_PLUGINS", "").split(","):
self.manager.set_blocked(name)
self.manager.load_setuptools_entrypoints(NAME)

def tox_add_option(self, parser: ToxParser) -> None:
self.manager.hook.tox_add_option(parser=parser)

Expand Down
6 changes: 5 additions & 1 deletion src/tox/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def _load_inline(path: Path) -> ModuleType | None: # register only on first run
@contextmanager
def check_os_environ() -> Iterator[None]:
old = os.environ.copy()
to_clean = {k: os.environ.pop(k, None) for k in (ENV_VAR_KEY, "TOX_WORK_DIR", "PYTHONPATH", "COV_CORE_CONTEXT")}
to_clean = {
k: os.environ.pop(k, None)
for k in (ENV_VAR_KEY, "TOX_WORK_DIR", "PYTHONPATH", "COV_CORE_CONTEXT", "TOX_DISABLED_EXTERNAL_PLUGINS")
}

yield

Expand All @@ -93,6 +96,7 @@ def check_os_environ() -> Iterator[None]:
new = os.environ
extra = {k: new[k] for k in set(new) - set(old)}
extra.pop("PLAT", None)
extra.pop("TOX_DISABLED_EXTERNAL_PLUGINS", None)
miss = {k: old[k] for k in set(old) - set(new)}
diff = {
f"{k} = {old[k]} vs {new[k]}" for k in set(old) & set(new) if old[k] != new[k] and not k.startswith("PYTEST_")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def extract_install_info(self, for_env: EnvConfigSet, path: Path) -> list[Packag
if not work_dir.exists(): # pragma: no branch
work_dir.mkdir()
with tarfile.open(str(path), "r:gz") as tar:
tar.extractall(path=str(work_dir)) # noqa: S202
tar.extractall(path=str(work_dir), filter=tarfile.data_filter) # noqa: S202
# the register run env is guaranteed to be called before this
assert self._sdist_meta_tox_env is not None # noqa: S101
with self._sdist_meta_tox_env.display_context(self._has_display_suspended):
Expand Down
44 changes: 44 additions & 0 deletions tests/plugin/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from tox.config.sets import ConfigSet, CoreConfigSet, EnvConfigSet
from tox.execute import Outcome
from tox.plugin import impl
from tox.plugin.manager import Plugin
from tox.pytest import ToxProjectCreator, register_inline_plugin
from tox.session.state import State
from tox.tox_env.api import ToxEnv
Expand All @@ -21,6 +22,7 @@
if TYPE_CHECKING:
from pathlib import Path

import pluggy
from pytest_mock import MockerFixture


Expand Down Expand Up @@ -268,3 +270,45 @@ def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outco
project = tox_project({"tox.ini": "[testenv]\npackage=skip"})
result = project.run("r")
result.assert_success()


@pytest.mark.parametrize(
("env_val", "expect_a", "expect_b"),
[
pytest.param("", True, True, id="none_disabled"),
pytest.param("dummy_plugin_a,dummy_plugin_b", False, False, id="both_disabled"),
pytest.param("dummy_plugin_a", False, True, id="only_a_disabled"),
pytest.param("dummy_plugin_b", True, False, id="only_b_disabled"),
],
)
def test_disable_external_plugins(
tox_project: ToxProjectCreator,
env_val: str,
expect_a: bool,
expect_b: bool,
) -> None:
class DummyPluginA:
@staticmethod
@impl
def tox_add_option(parser: ToxParser) -> None: # noqa: ARG004
logging.warning("dummy plugin A called")

class DummyPluginB:
@staticmethod
@impl
def tox_add_option(parser: ToxParser) -> None: # noqa: ARG004
logging.warning("dummy plugin B called")

def fake_load_entrypoints(self: pluggy.PluginManager, name: str) -> None: # noqa: ARG001
self.register(DummyPluginA(), name="dummy_plugin_a")
self.register(DummyPluginB(), name="dummy_plugin_b")

project = tox_project({"tox.ini": ""})
with patch("pluggy.PluginManager.load_setuptools_entrypoints", fake_load_entrypoints), patch(
"tox.plugin.manager.MANAGER", Plugin()
), patch.dict(os.environ, {"TOX_DISABLED_EXTERNAL_PLUGINS": env_val}, clear=False):
result = project.run("--version")

result.assert_success()
assert ("dummy plugin A called" in result.out) == expect_a
assert ("dummy plugin B called" in result.out) == expect_b
3 changes: 3 additions & 0 deletions tests/session/cmd/test_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ def test_parallel_run_live_out(tox_project: ToxProjectCreator) -> None:
def test_parallel_show_output_with_pkg(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None:
ini = "[testenv]\nparallel_show_output=True\ncommands=python -c 'print(\"r {env_name}\")'"
project = tox_project({"tox.ini": ini})

result = project.run("p", "--root", str(demo_pkg_inline))

result.assert_success()
assert "r py" in result.out


Expand Down
7 changes: 4 additions & 3 deletions tests/session/cmd/test_sequential.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def test_result_json_sequential(
"setup.py": "from setuptools import setup\nsetup(name='a', version='1.0', py_modules=['run'],"
"install_requires=['setuptools>44'])",
"run.py": "print('run')",
"pyproject.toml": '[build-system]\nrequires=["setuptools","wheel"]\nbuild-backend="setuptools.build_meta"',
"pyproject.toml": '[build-system]\nrequires=["setuptools"]\nbuild-backend="setuptools.build_meta"',
},
)
log = project.path / "log.json"
Expand Down Expand Up @@ -104,7 +104,7 @@ def test_result_json_sequential(
packaging_test = get_cmd_exit_run_id(log_report, ".pkg", "test")
assert packaging_test == [(None, "build_wheel")]
packaging_installed = log_report["testenvs"][".pkg"].pop("installed_packages")
assert {i[: i.find("==")] for i in packaging_installed} == {"pip", "setuptools", "wheel"}
assert {i[: i.find("==")] for i in packaging_installed} == {"pip", "setuptools"}

result_py = log_report["testenvs"]["py"].pop("result")
assert result_py.pop("duration") > 0
Expand Down Expand Up @@ -153,8 +153,9 @@ def test_rerun_sequential_wheel(tox_project: ToxProjectCreator, demo_pkg_inline:
proj = tox_project(
{"tox.ini": "[testenv]\npackage=wheel\ncommands=python -c 'from demo_pkg_inline import do; do()'"},
)
result_first = proj.run("--root", str(demo_pkg_inline))
result_first = proj.run("--root", str(demo_pkg_inline), "-vv")
result_first.assert_success()

result_rerun = proj.run("--root", str(demo_pkg_inline))
result_rerun.assert_success()

Expand Down
7 changes: 5 additions & 2 deletions tests/test_provision.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,17 @@ def tox_wheels(tox_wheel: Path, tmp_path_factory: TempPathFactory) -> list[Path]
distribution = Distribution.at(dist_info)
wheel_cache = ROOT / ".wheel_cache" / f"{sys.version_info.major}.{sys.version_info.minor}"
wheel_cache.mkdir(parents=True, exist_ok=True)
cmd = [sys.executable, "-I", "-m", "pip", "download", "-d", str(wheel_cache)]
cmd = [sys.executable, "-m", "pip", "download", "-d", str(wheel_cache)]
assert distribution.requires is not None
for req in distribution.requires:
requirement = Requirement(req)
if not requirement.extras: # pragma: no branch # we don't need to install any extras (tests/docs/etc)
if not requirement.extras: # pragma: no branch # we don't need to install any extras (tests/docs/etc)
cmd.append(req)
check_call(cmd)
result.extend(wheel_cache.iterdir())
res = "\n".join(str(i) for i in result)
with elapsed(f"acquired dependencies for current tox: {res}"):
pass
return result


Expand Down
Loading