From e911788a7e0b9f727464b4b28a001a97406eb590 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 30 Aug 2025 13:05:57 -0400 Subject: [PATCH 01/13] pass through CI as __TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI (#3592) --- docs/changelog/3442.feature.rst | 1 + docs/config.rst | 2 ++ src/tox/tox_env/api.py | 2 ++ tests/tox_env/test_ci_passthrough.py | 29 ++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+) create mode 100644 docs/changelog/3442.feature.rst create mode 100644 tests/tox_env/test_ci_passthrough.py diff --git a/docs/changelog/3442.feature.rst b/docs/changelog/3442.feature.rst new file mode 100644 index 0000000000..4fc3ee3395 --- /dev/null +++ b/docs/changelog/3442.feature.rst @@ -0,0 +1 @@ +Add ``__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI``, which passes through the ``CI`` variable if present. This is intended for use by other libraries to detect if tox is running under CI. diff --git a/docs/config.rst b/docs/config.rst index 19e702d5d0..b50c14e22e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -561,6 +561,8 @@ Base options - ✅ - ❌ + If the environment variable ``CI`` is present, ``__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI`` will be set to the value of ``CI``. The ``CI`` variable itself will not be passed through. + More environment variable-related information can be found in :ref:`environment variable substitutions`. .. conf:: diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index 5e50a5a32e..d07177de35 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -379,6 +379,8 @@ def environment_variables(self) -> dict[str, str]: result["TOX_ENV_NAME"] = self.name result["TOX_WORK_DIR"] = str(self.core["work_dir"]) result["TOX_ENV_DIR"] = str(self.conf["env_dir"]) + if (ci := os.environ.get("CI")) is not None: + result["__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI"] = ci return result @staticmethod diff --git a/tests/tox_env/test_ci_passthrough.py b/tests/tox_env/test_ci_passthrough.py new file mode 100644 index 0000000000..28c6438163 --- /dev/null +++ b/tests/tox_env/test_ci_passthrough.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +@pytest.mark.parametrize("value", ["1", "0", "", "arbitrary_value"]) +def test_ci_passthrough_present(value: str, tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI", value) + prj = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(0)'\n"}) + execute_calls = prj.patch_execute(lambda _r: 0) + result = prj.run("r", "-e", "py") + result.assert_success() + req = execute_calls.call_args[0][3] + assert req.env["__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI"] == value + + +def test_ci_passthrough_absent(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CI", raising=False) + prj = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(0)'\n"}) + execute_calls = prj.patch_execute(lambda _r: 0) + result = prj.run("r", "-e", "py") + result.assert_success() + req = execute_calls.call_args[0][3] + assert "__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI" not in req.env From 5493ba0a6ebeb47f8682511b79cc2a5d3efb130c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 07:45:55 -0700 Subject: [PATCH 02/13] [pre-commit.ci] pre-commit autoupdate (#3596) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0cc0e0497..f6630b01ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: hooks: - id: validate-pyproject - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.12.10" + rev: "v0.12.11" hooks: - id: ruff-check args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] From 732a308cf5086c855118e43c1676cadbeed2499e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=87=BA=F0=9F=87=A6=20Sviatoslav=20Sydorenko=20=28?= =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D1=82=D0=BE=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=29?= Date: Tue, 2 Sep 2025 17:05:03 +0200 Subject: [PATCH 03/13] Fix the built docs HTML path hint in `tox.toml` (#3594) --- tox.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.toml b/tox.toml index fee6aa48cc..d0986bc708 100644 --- a/tox.toml +++ b/tox.toml @@ -85,7 +85,7 @@ commands = [ [ "python", "-c", - 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")', + 'print(r"documentation available under file://{work_dir}{/}docs_out{/}html{/}index.html")', ], ] From adf0997b760ecd2a877e0f42f01da996911f6f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=87=BA=F0=9F=87=A6=20Sviatoslav=20Sydorenko=20=28?= =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D1=82=D0=BE=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=29?= Date: Tue, 2 Sep 2025 17:06:00 +0200 Subject: [PATCH 04/13] Add a "version added" note for `tox_extend_envs` (#3595) --- src/tox/plugin/spec.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py index ba3eae96b6..e69f206b5e 100644 --- a/src/tox/plugin/spec.py +++ b/src/tox/plugin/spec.py @@ -33,6 +33,8 @@ def tox_register_tox_env(register: ToxEnvRegister) -> None: def tox_extend_envs() -> Iterable[str]: """Declare additional environment names. + .. versionadded:: 4.29.0 + 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 From 0b8f66f259f8d0ab413d5041834073f1f9066231 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Tue, 2 Sep 2025 16:07:15 +0100 Subject: [PATCH 05/13] fix: provide clear messaging about config file loading (#3578) --- docs/changelog/3578.bugfix.rst | 2 ++ src/tox/config/source/discover.py | 24 ++++++++++++++++++------ src/tox/config/source/legacy_toml.py | 5 ++++- src/tox/config/source/setup_cfg.py | 3 ++- src/tox/config/source/toml_pyproject.py | 3 ++- src/tox/config/types.py | 7 +++++++ tests/config/source/test_discover.py | 6 +++--- tests/config/source/test_setup_cfg.py | 2 +- tests/session/cmd/test_legacy.py | 3 ++- 9 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/3578.bugfix.rst diff --git a/docs/changelog/3578.bugfix.rst b/docs/changelog/3578.bugfix.rst new file mode 100644 index 0000000000..236ab29210 --- /dev/null +++ b/docs/changelog/3578.bugfix.rst @@ -0,0 +1,2 @@ +Makes the error message more clear when pyproject.toml file cannot be loaded +or is missing expected keys. diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py index f04b9a7a0c..988c4976da 100644 --- a/src/tox/config/source/discover.py +++ b/src/tox/config/source/discover.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from tox.config.types import MissingRequiredConfigKeyError from tox.report import HandledError from .legacy_toml import LegacyToml @@ -59,21 +60,32 @@ def _locate_source() -> Source | None: for base in chain([folder], folder.parents): for src_type in SOURCE_TYPES: candidate: Path = base / src_type.FILENAME - try: - return src_type(candidate) - except ValueError: - pass + if candidate.exists(): + try: + return src_type(candidate) + except MissingRequiredConfigKeyError as exc: + msg = f"{src_type.__name__} skipped loading {candidate.resolve()} due to {exc}" + logging.info(msg) + except ValueError as exc: + msg = f"{src_type.__name__} failed loading {candidate.resolve()} due to {exc}" + raise HandledError(msg) from exc return None def _load_exact_source(config_file: Path) -> Source: # if the filename matches to the letter some config file name do not fallback to other source types + if not config_file.exists(): + msg = f"config file {config_file} does not exist" + raise HandledError(msg) exact_match = [s for s in SOURCE_TYPES if config_file.name == s.FILENAME] # pragma: no cover for src_type in exact_match or SOURCE_TYPES: # pragma: no branch try: return src_type(config_file) - except ValueError: # noqa: PERF203 + except MissingRequiredConfigKeyError: # noqa: PERF203 pass + except ValueError as exc: + msg = f"{src_type.__name__} failed loading {config_file.resolve()} due to {exc}" + raise HandledError(msg) from exc msg = f"could not recognize config file {config_file}" raise HandledError(msg) @@ -88,7 +100,7 @@ def _create_default_source(root_dir: Path | None) -> Source: else: # if not set use where we find pyproject.toml in the tree or cwd empty = root_dir names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES}) - logging.warning("No %s found, assuming empty tox.ini at %s", names, empty) + logging.warning("No loadable %s found, assuming empty tox.ini at %s", names, empty) return ToxIni(empty / "tox.ini", content="") diff --git a/src/tox/config/source/legacy_toml.py b/src/tox/config/source/legacy_toml.py index db6227cddd..6de39f11fe 100644 --- a/src/tox/config/source/legacy_toml.py +++ b/src/tox/config/source/legacy_toml.py @@ -2,6 +2,8 @@ import sys +from tox.config.types import MissingRequiredConfigKeyError + if sys.version_info >= (3, 11): # pragma: no cover (py311+) import tomllib else: # pragma: no cover (py311+) @@ -27,7 +29,8 @@ def __init__(self, path: Path) -> None: try: content = toml_content["tool"]["tox"]["legacy_tox_ini"] except KeyError as exc: - raise ValueError(path) from exc + msg = f"`tool.tox.legacy_tox_ini` missing from {path}" + raise MissingRequiredConfigKeyError(msg) from exc super().__init__(path, content=content) diff --git a/src/tox/config/source/setup_cfg.py b/src/tox/config/source/setup_cfg.py index d1d316eac9..ffb994afdd 100644 --- a/src/tox/config/source/setup_cfg.py +++ b/src/tox/config/source/setup_cfg.py @@ -18,7 +18,8 @@ class SetupCfg(IniSource): def __init__(self, path: Path) -> None: super().__init__(path) if not self._parser.has_section(self.CORE_SECTION.key): - raise ValueError + msg = f"section {self.CORE_SECTION.key} not found" + raise ValueError(msg) __all__ = ("SetupCfg",) diff --git a/src/tox/config/source/toml_pyproject.py b/src/tox/config/source/toml_pyproject.py index 4c03e41ac6..b70b1404b3 100644 --- a/src/tox/config/source/toml_pyproject.py +++ b/src/tox/config/source/toml_pyproject.py @@ -7,6 +7,7 @@ from tox.config.loader.section import Section from tox.config.loader.toml import TomlLoader +from tox.config.types import MissingRequiredConfigKeyError from tox.report import HandledError from .api import Source @@ -81,7 +82,7 @@ def __init__(self, path: Path) -> None: our_content = our_content[key] self._our_content = our_content except KeyError as exc: - raise ValueError(path) from exc + raise MissingRequiredConfigKeyError(path) from exc super().__init__(path) def get_core_section(self) -> Section: diff --git a/src/tox/config/types.py b/src/tox/config/types.py index 42c2c48149..0e9070ad40 100644 --- a/src/tox/config/types.py +++ b/src/tox/config/types.py @@ -10,6 +10,13 @@ class CircularChainError(ValueError): """circular chain in config""" +class MissingRequiredConfigKeyError(ValueError): + """missing required config key + + Used by the two toml loaders in order to identify if config keys are present. + """ + + class Command: # noqa: PLW1641 """A command to execute.""" diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py index 86370e5cec..d86fb750bc 100644 --- a/tests/config/source/test_discover.py +++ b/tests/config/source/test_discover.py @@ -10,8 +10,8 @@ def out_no_src(path: Path) -> str: return ( - f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {path}\n" - f"default environments:\npy -> [no description]\n" + f"ROOT: No loadable tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {path}" + f"\ndefault environments:\npy -> [no description]\n" ) @@ -47,4 +47,4 @@ def test_bad_src_content(tox_project: ToxProjectCreator, tmp_path: Path) -> None outcome = project.run("l", "-c", str(tmp_path / "setup.cfg")) outcome.assert_failed() - assert outcome.out == f"ROOT: HandledError| could not recognize config file {tmp_path / 'setup.cfg'}\n" + assert outcome.out == f"ROOT: HandledError| config file {tmp_path / 'setup.cfg'} does not exist\n" diff --git a/tests/config/source/test_setup_cfg.py b/tests/config/source/test_setup_cfg.py index 3de4747353..e5a84ec9d4 100644 --- a/tests/config/source/test_setup_cfg.py +++ b/tests/config/source/test_setup_cfg.py @@ -19,4 +19,4 @@ def test_bad_conf_setup_cfg(tox_project: ToxProjectCreator) -> None: filename = str(project.path / "setup.cfg") outcome = project.run("l", "-c", filename) outcome.assert_failed() - assert outcome.out == f"ROOT: HandledError| could not recognize config file {filename}\n" + assert outcome.out == f"ROOT: HandledError| SetupCfg failed loading {filename} due to section tox:tox not found\n" diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py index 0d04dc6a12..08d65fe2d5 100644 --- a/tests/session/cmd/test_legacy.py +++ b/tests/session/cmd/test_legacy.py @@ -66,7 +66,8 @@ def test_legacy_list_env_with_no_tox_file(tox_project: ToxProjectCreator) -> Non outcome = project.run("le", "-l") outcome.assert_success() out = ( - f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {project.path}\n" + "ROOT: No loadable tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at " + f"{project.path}\n" ) assert not outcome.err assert outcome.out == out From 48fecab4a8691674448c39ab7cde6531038fb308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=87=BA=F0=9F=87=A6=20Sviatoslav=20Sydorenko=20=28?= =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D1=82=D0=BE=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=29?= Date: Tue, 2 Sep 2025 20:15:29 +0200 Subject: [PATCH 06/13] Ensure `tox_extend_envs` list can be read twice (#3598) --- docs/changelog/3598.bugfix.rst | 6 ++++++ src/tox/config/main.py | 8 ++++++-- src/tox/session/state.py | 4 ++-- tests/plugin/test_inline.py | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/3598.bugfix.rst diff --git a/docs/changelog/3598.bugfix.rst b/docs/changelog/3598.bugfix.rst new file mode 100644 index 0000000000..f1ee47ffa9 --- /dev/null +++ b/docs/changelog/3598.bugfix.rst @@ -0,0 +1,6 @@ +The :func:`tox_extend_envs() hook ` +recently added in :pull:`3591` turned out to not work well with +``tox run``. It was fixed internally, not to exhaust the underlying +iterator on the first use. + +-- by :user:`webknjaz` diff --git a/src/tox/config/main.py b/src/tox/config/main.py index ff264ea21c..9fbd9f4bcb 100644 --- a/src/tox/config/main.py +++ b/src/tox/config/main.py @@ -2,7 +2,7 @@ import os from collections import OrderedDict, defaultdict -from itertools import chain +from itertools import chain, tee from pathlib import Path from typing import TYPE_CHECKING, Any, Iterable, Iterator, Sequence, TypeVar @@ -81,7 +81,11 @@ def src_path(self) -> Path: def __iter__(self) -> Iterator[str]: """:return: an iterator that goes through existing environments""" - return chain(self._src.envs(self.core), self._extra_envs) + # NOTE: `tee(self._extra_envs)[1]` is necessary for compatibility with + # NOTE: Python 3.11 and older versions. Once Python 3.12 is the lowest + # NOTE: supported version, it can be changed to + # NOTE: `chain.from_iterable(tee(self._extra_envs, 1))`. + return chain(self._src.envs(self.core), tee(self._extra_envs)[1]) def sections(self) -> Iterator[Section]: yield from self._src.sections() diff --git a/src/tox/session/state.py b/src/tox/session/state.py index 6b63f39daa..3f59ecd73f 100644 --- a/src/tox/session/state.py +++ b/src/tox/session/state.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from itertools import chain +from itertools import chain, tee from typing import TYPE_CHECKING, Sequence from tox.config.main import Config @@ -20,7 +20,7 @@ class State: """Runtime state holder.""" def __init__(self, options: Options, args: Sequence[str]) -> None: - extended_envs = chain.from_iterable(MANAGER.tox_extend_envs()) + (extended_envs,) = tee(chain.from_iterable(MANAGER.tox_extend_envs()), 1) self.conf = Config.make(options.parsed, options.pos_args, options.source, extended_envs) self.conf.core.add_constant( keys=["on_platform"], diff --git a/tests/plugin/test_inline.py b/tests/plugin/test_inline.py index 4fae64b27a..46af969a33 100644 --- a/tests/plugin/test_inline.py +++ b/tests/plugin/test_inline.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -43,6 +44,7 @@ def tox_extend_envs() -> tuple[str]: def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: ARG001 in_memory_config_loader = MemoryLoader( base=["sentinel-base"], + commands_pre=["sentinel-cmd"], description="sentinel-description", ) state.conf.memory_seed_loaders[env_name].append( @@ -59,3 +61,15 @@ def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: AR 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 + + tox_run_result = project.run("run", "-e", "sentinel-env-name", "-q") + tox_run_result.assert_failed() + underlying_expected_oserror_msg = ( + "[WinError 2] The system cannot find the file specified" + if sys.platform == "win32" + else "[Errno 2] No such file or directory: 'sentinel-cmd'" + ) + expected_cmd_lookup_error_txt = ( + f"sentinel-env-name: Exception running subprocess {underlying_expected_oserror_msg!s}\n" + ) + assert expected_cmd_lookup_error_txt in tox_run_result.out From aa90652cd6b451ca1cf75144467b23d32daab448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 3 Sep 2025 07:34:05 -0700 Subject: [PATCH 07/13] release 4.30.0 --- docs/changelog.rst | 18 ++++++++++++++++++ docs/changelog/3442.feature.rst | 1 - docs/changelog/3578.bugfix.rst | 2 -- docs/changelog/3598.bugfix.rst | 6 ------ 4 files changed, 18 insertions(+), 9 deletions(-) delete mode 100644 docs/changelog/3442.feature.rst delete mode 100644 docs/changelog/3578.bugfix.rst delete mode 100644 docs/changelog/3598.bugfix.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c67981768..761f053b58 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,24 @@ Release History .. towncrier release notes start +v4.30.0 (2025-09-03) +-------------------- + +Features - 4.30.0 +~~~~~~~~~~~~~~~~~ +- Add ``__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI``, which passes through the ``CI`` variable if present. This is intended for use by other libraries to detect if tox is running under CI. (:issue:`3442`) + +Bugfixes - 4.30.0 +~~~~~~~~~~~~~~~~~ +- Makes the error message more clear when pyproject.toml file cannot be loaded + or is missing expected keys. (:issue:`3578`) +- The :func:`tox_extend_envs() hook ` + recently added in :pull:`3591` turned out to not work well with + ``tox run``. It was fixed internally, not to exhaust the underlying + iterator on the first use. + + -- by :user:`webknjaz` (:issue:`3598`) + v4.29.0 (2025-08-29) -------------------- diff --git a/docs/changelog/3442.feature.rst b/docs/changelog/3442.feature.rst deleted file mode 100644 index 4fc3ee3395..0000000000 --- a/docs/changelog/3442.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Add ``__TOX_ENVIRONMENT_VARIABLE_ORIGINAL_CI``, which passes through the ``CI`` variable if present. This is intended for use by other libraries to detect if tox is running under CI. diff --git a/docs/changelog/3578.bugfix.rst b/docs/changelog/3578.bugfix.rst deleted file mode 100644 index 236ab29210..0000000000 --- a/docs/changelog/3578.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Makes the error message more clear when pyproject.toml file cannot be loaded -or is missing expected keys. diff --git a/docs/changelog/3598.bugfix.rst b/docs/changelog/3598.bugfix.rst deleted file mode 100644 index f1ee47ffa9..0000000000 --- a/docs/changelog/3598.bugfix.rst +++ /dev/null @@ -1,6 +0,0 @@ -The :func:`tox_extend_envs() hook ` -recently added in :pull:`3591` turned out to not work well with -``tox run``. It was fixed internally, not to exhaust the underlying -iterator on the first use. - --- by :user:`webknjaz` From 9ea1c3223aef5d919dcd44baf0bf2de4aedbf7f7 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 3 Sep 2025 16:47:56 +0200 Subject: [PATCH 08/13] Prevent Tox from hanging with `--installpkg` sdist due to orphaned build backend (#3530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bernát Gábor Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/changelog/3530.bugfix.rst | 3 ++ pyproject.toml | 1 + .../python/virtual_env/package/pyproject.py | 6 +++ .../python/virtual_env/package/conftest.py | 40 ++++++++++++++++++- .../package/test_package_pyproject.py | 21 ++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/3530.bugfix.rst diff --git a/docs/changelog/3530.bugfix.rst b/docs/changelog/3530.bugfix.rst new file mode 100644 index 0000000000..51d8627ca6 --- /dev/null +++ b/docs/changelog/3530.bugfix.rst @@ -0,0 +1,3 @@ +Prevent tox from hanging upon exit due to orphaned build threads and subprocesses when the ``--installpkg`` option is +used with *sdist*. +- by :user:`vytas7` diff --git a/pyproject.toml b/pyproject.toml index ca4c720388..a7323bf836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ test = [ "flaky>=3.8.1", "hatch-vcs>=0.5", "hatchling>=1.27", + "pdm-backend", "psutil>=7", "pytest>=8.4.1", "pytest-cov>=6.2.1", diff --git a/src/tox/tox_env/python/virtual_env/package/pyproject.py b/src/tox/tox_env/python/virtual_env/package/pyproject.py index 4b4be011f9..37fb4625a2 100644 --- a/src/tox/tox_env/python/virtual_env/package/pyproject.py +++ b/src/tox/tox_env/python/virtual_env/package/pyproject.py @@ -118,6 +118,12 @@ def root(self) -> Path: @root.setter def root(self, value: Path) -> None: + # Recreating the frontend with a new root would orphan the current frontend.backend_executor, if any, making tox + # hang upon exit waiting for its threads and subprocesses (#3512). + # Therefore, we make sure to close the existing back-end executor in the case of an existing PEP 517 frontend. + if self._frontend is not None: + self._frontend.backend_executor.close() + self._root = value self._frontend_ = None # force recreating the frontend with new root diff --git a/tests/tox_env/python/virtual_env/package/conftest.py b/tests/tox_env/python/virtual_env/package/conftest.py index ca0fd355cd..e96910954e 100644 --- a/tests/tox_env/python/virtual_env/package/conftest.py +++ b/tests/tox_env/python/virtual_env/package/conftest.py @@ -2,7 +2,7 @@ import sys from textwrap import dedent -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import pytest @@ -41,3 +41,41 @@ def pkg_with_extras_project(tmp_path_factory: pytest.TempPathFactory) -> Path: toml = '[build-system]\nrequires=["setuptools"]\nbuild-backend = "setuptools.build_meta"' (tmp_path / "pyproject.toml").write_text(toml) return tmp_path + + +@pytest.fixture(scope="session") +def pkg_with_pdm_backend( + tmp_path_factory: pytest.TempPathFactory, + pkg_builder: Callable[[Path, Path, list[str], bool], Path], +) -> Path: + tmp_path = tmp_path_factory.mktemp("skeleton") + + pyproject_toml = """ + [build-system] + requires = ["pdm-backend"] + build-backend = "pdm.backend" + + [project] + name = "skeleton" + description = "Just a skeleton for reproducing #3512." + version = "0.1.1337" + dependencies = [ + "requests", + ] + + [tool.pdm.build] + includes = [ + "skeleton/", + ] + source-includes = [ + "tox.ini", + ] + """ + (tmp_path / "pyproject.toml").write_text(dedent(pyproject_toml)) + (tmp_path / "skeleton").mkdir(exist_ok=True) + (tmp_path / "skeleton" / "__init__.py").touch() + + dist = tmp_path / "dist" + pkg_builder(dist, tmp_path, ["sdist"], False) + + return tmp_path diff --git a/tests/tox_env/python/virtual_env/package/test_package_pyproject.py b/tests/tox_env/python/virtual_env/package/test_package_pyproject.py index b756b7621e..88fb0c2fc5 100644 --- a/tests/tox_env/python/virtual_env/package/test_package_pyproject.py +++ b/tests/tox_env/python/virtual_env/package/test_package_pyproject.py @@ -464,3 +464,24 @@ def test_pyproject_config_settings_editable_legacy( "get_requires_for_build_wheel": {"C": "3"}, "prepare_metadata_for_build_wheel": {"D": "4"}, } + + +@pytest.mark.usefixtures("enable_pip_pypi_access") +def test_pyproject_installpkg_pep517_envs(tox_project: ToxProjectCreator, pkg_with_pdm_backend: Path) -> None: + # Regression test for #3512 + tox_ini = """ + [tox] + envlist = dummy1,dummy2 + + [testenv:dummy1] + commands = + python -c print(1) + + [testenv:dummy2] + commands = + python -c print(42) + """ + sdist = pkg_with_pdm_backend / "dist" / "skeleton-0.1.1337.tar.gz" + proj = tox_project({"tox.ini": tox_ini}, base=pkg_with_pdm_backend) + result = proj.run("--installpkg", str(sdist)) + result.assert_success() From b33a43c270eb87e61833b7ed934b401d23aeded5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 3 Sep 2025 07:48:59 -0700 Subject: [PATCH 09/13] release 4.30.1 --- docs/changelog.rst | 9 +++++++++ docs/changelog/3530.bugfix.rst | 3 --- 2 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 docs/changelog/3530.bugfix.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index 761f053b58..225ce668f6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,15 @@ Release History .. towncrier release notes start +v4.30.1 (2025-09-03) +-------------------- + +Bugfixes - 4.30.1 +~~~~~~~~~~~~~~~~~ +- Prevent tox from hanging upon exit due to orphaned build threads and subprocesses when the ``--installpkg`` option is + used with *sdist*. + - by :user:`vytas7` (:issue:`3530`) + v4.30.0 (2025-09-03) -------------------- diff --git a/docs/changelog/3530.bugfix.rst b/docs/changelog/3530.bugfix.rst deleted file mode 100644 index 51d8627ca6..0000000000 --- a/docs/changelog/3530.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Prevent tox from hanging upon exit due to orphaned build threads and subprocesses when the ``--installpkg`` option is -used with *sdist*. -- by :user:`vytas7` From 2c31dbcbfc4d28944b4116b86a70420a815a012a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 07:14:44 -0700 Subject: [PATCH 10/13] Bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0 (#3603) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 79a3a25610..1820f90de9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -44,6 +44,6 @@ jobs: name: ${{ env.dists-artifact-name }} path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.12.4 + uses: pypa/gh-action-pypi-publish@v1.13.0 with: attestations: true From 723008808899afec3c1aa7412bd7771694d66f5a Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 4 Sep 2025 17:23:59 +0200 Subject: [PATCH 11/13] Ensure automatically provisioned environment is torn down (#3601) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/changelog/3600.bugfix.rst | 4 +++ src/tox/provision.py | 12 ++++--- src/tox/tox_env/package.py | 4 ++- tests/test_provision.py | 63 +++++++++++++++++++++++++++++----- 4 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 docs/changelog/3600.bugfix.rst diff --git a/docs/changelog/3600.bugfix.rst b/docs/changelog/3600.bugfix.rst new file mode 100644 index 0000000000..4fa82025bd --- /dev/null +++ b/docs/changelog/3600.bugfix.rst @@ -0,0 +1,4 @@ +Previously, when tox ran in an automatically provisioned environment, it could hang waiting for a PEP 517 build backend +if used in conjunction with the ``--installpkg`` option. This has been fixed by properly tearing down the automatically +provisioned environment after the tests. +- by :user:`vytas7` diff --git a/src/tox/provision.py b/src/tox/provision.py index c989c39ddd..86fedf9122 100644 --- a/src/tox/provision.py +++ b/src/tox/provision.py @@ -146,10 +146,14 @@ def run_provision(name: str, state: State) -> int: logging.info("will run in a automatically provisioned python environment under %s", env_python) try: tox_env.setup() + args: list[str] = [str(env_python), "-m", "tox"] + args.extend(state.args) + outcome = tox_env.execute( + cmd=args, stdin=StdinSource.user_only(), show=True, run_id="provision", cwd=Path.cwd() + ) + return cast("int", outcome.exit_code) except Skip as exception: msg = f"cannot provision tox environment {tox_env.conf['env_name']} because {exception}" raise HandledError(msg) from exception - args: list[str] = [str(env_python), "-m", "tox"] - args.extend(state.args) - outcome = tox_env.execute(cmd=args, stdin=StdinSource.user_only(), show=True, run_id="provision", cwd=Path.cwd()) - return cast("int", outcome.exit_code) + finally: + tox_env.teardown() diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py index a870d3649b..a097b5a67d 100644 --- a/src/tox/tox_env/package.py +++ b/src/tox/tox_env/package.py @@ -97,7 +97,9 @@ def mark_active_run_env(self, run_env: RunToxEnv) -> None: self._envs.add(run_env.conf.name) def teardown_env(self, conf: EnvConfigSet) -> None: - self._envs.remove(conf.name) + if conf.name in self._envs: + # conf.name (".tox") may be missing in self._envs in the case of an automatically provisioned environment + self._envs.remove(conf.name) if len(self._envs) == 0: self._teardown() diff --git a/tests/test_provision.py b/tests/test_provision.py index 0cf4de5898..2cde4f68bd 100644 --- a/tests/test_provision.py +++ b/tests/test_provision.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from pathlib import Path from subprocess import check_call -from typing import TYPE_CHECKING, Callable, Iterator +from typing import TYPE_CHECKING, Callable, Iterator, Sequence from unittest import mock from zipfile import ZipFile @@ -16,6 +16,7 @@ from packaging.requirements import Requirement if TYPE_CHECKING: + from build import DistributionType from devpi_process import Index, IndexServer from tox.pytest import MonkeyPatch, TempPathFactory, ToxProjectCreator @@ -102,22 +103,38 @@ def tox_wheels(tox_wheel: Path, tmp_path_factory: TempPathFactory) -> list[Path] @pytest.fixture(scope="session") -def pypi_index_self(pypi_server: IndexServer, tox_wheels: list[Path], demo_pkg_inline_wheel: Path) -> Index: - with elapsed("start devpi and create index"): # takes around 1s +def local_pypi_indexes( + pypi_server: IndexServer, tox_wheels: list[Path], demo_pkg_inline_wheel: Path +) -> tuple[Index, Index]: + with elapsed("start devpi and create indexes"): # takes around 1s + pypi_server.create_index("mirror", "type=mirror", "mirror_url=https://pypi.org/simple/") + mirrored_index = pypi_server.create_index("magic", f"bases={pypi_server.user}/mirror") self_index = pypi_server.create_index("self", "volatile=False") with elapsed("upload tox and its wheels to devpi"): # takes around 3.2s on build + mirrored_index.upload(*tox_wheels, demo_pkg_inline_wheel) self_index.upload(*tox_wheels, demo_pkg_inline_wheel) - return self_index + return mirrored_index, self_index -@pytest.fixture -def _pypi_index_self(pypi_index_self: Index, monkeypatch: MonkeyPatch) -> None: - pypi_index_self.use() - monkeypatch.setenv("PIP_INDEX_URL", pypi_index_self.url) +def _use_pypi_index(pypi_index: Index, monkeypatch: MonkeyPatch) -> None: + pypi_index.use() + monkeypatch.setenv("PIP_INDEX_URL", pypi_index.url) monkeypatch.setenv("PIP_RETRIES", str(2)) monkeypatch.setenv("PIP_TIMEOUT", str(5)) +@pytest.fixture +def _pypi_index_mirrored(local_pypi_indexes: tuple[Index, Index], monkeypatch: MonkeyPatch) -> None: + pypi_index_mirrored, _ = local_pypi_indexes + _use_pypi_index(pypi_index_mirrored, monkeypatch) + + +@pytest.fixture +def _pypi_index_self(local_pypi_indexes: tuple[Index, Index], monkeypatch: MonkeyPatch) -> None: + _, pypi_index_self = local_pypi_indexes + _use_pypi_index(pypi_index_self, monkeypatch) + + def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None: ini = "[tox]\nrequires = pkg-does-not-exist\n setuptools==1\nskipsdist=true\n" outcome = tox_project({"tox.ini": ini}).run("c", "-e", "py") @@ -254,3 +271,33 @@ def test_provision_default_arguments_exists(tox_project: ToxProjectCreator, subc outcome = project.run(subcommand) for argument in ["result_json", "hash_seed", "discover", "list_dependencies"]: assert hasattr(outcome.state.conf.options, argument) + + +@pytest.mark.integration +@pytest.mark.usefixtures("_pypi_index_mirrored") +def test_provision_install_pkg_pep517( + tmp_path_factory: TempPathFactory, + tox_project: ToxProjectCreator, + pkg_builder: Callable[[Path, Path, Sequence[DistributionType], bool], Path], +) -> None: + example = tmp_path_factory.mktemp("example") + skeleton = """ + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + [project] + name = "skeleton" + version = "0.1.1337" + """ + (example / "pyproject.toml").write_text(skeleton) + sdist = pkg_builder(example / "dist", example, ["sdist"], False) + + tox_ini = r""" + [tox] + requires = demo-pkg-inline + [testenv] + commands = python -c "print(42)" + """ + project = tox_project({"tox.ini": tox_ini}, base=example) + result = project.run("r", "-e", "py", "--installpkg", str(sdist), "--notest") + result.assert_success() From 64e8a34883369a80350b311ceb3550f30931cd51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:03:35 -0700 Subject: [PATCH 12/13] Bump pypa/gh-action-pypi-publish in /.github/workflows (#3604) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 5e0784a7f5ea5f89c089351c9a7e23863bd131b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 4 Sep 2025 09:23:53 -0700 Subject: [PATCH 13/13] release 4.30.2 --- docs/changelog.rst | 10 ++++++++++ docs/changelog/3600.bugfix.rst | 4 ---- 2 files changed, 10 insertions(+), 4 deletions(-) delete mode 100644 docs/changelog/3600.bugfix.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index 225ce668f6..952f15fb78 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,16 @@ Release History .. towncrier release notes start +v4.30.2 (2025-09-04) +-------------------- + +Bugfixes - 4.30.2 +~~~~~~~~~~~~~~~~~ +- Previously, when tox ran in an automatically provisioned environment, it could hang waiting for a PEP 517 build backend + if used in conjunction with the ``--installpkg`` option. This has been fixed by properly tearing down the automatically + provisioned environment after the tests. + - by :user:`vytas7` (:issue:`3600`) + v4.30.1 (2025-09-03) -------------------- diff --git a/docs/changelog/3600.bugfix.rst b/docs/changelog/3600.bugfix.rst deleted file mode 100644 index 4fa82025bd..0000000000 --- a/docs/changelog/3600.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Previously, when tox ran in an automatically provisioned environment, it could hang waiting for a PEP 517 build backend -if used in conjunction with the ``--installpkg`` option. This has been fixed by properly tearing down the automatically -provisioned environment after the tests. -- by :user:`vytas7`