diff --git a/docs/changelog/3342.feature.rst b/docs/changelog/3342.feature.rst new file mode 100644 index 000000000..bdfb49165 --- /dev/null +++ b/docs/changelog/3342.feature.rst @@ -0,0 +1,7 @@ +Tox now creates ``CACHEDIR.TAG`` files in work directories it creates, +so that tools like ``tar`` can exclude them from e.g. backups where +ephemeral directories are not desired. + +Tag files are not created in directories Tox does not itself create. + +- by :user:`akx` diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index b98ad3b0a..f1490cbea 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -17,7 +17,7 @@ from tox.execute.request import ExecuteRequest from tox.tox_env.errors import Fail, Recreate, Skip from tox.tox_env.info import Info -from tox.util.path import ensure_empty_dir +from tox.util.path import ensure_cachedir_dir, ensure_empty_dir if TYPE_CHECKING: from tox.config.cli.parser import Parsed @@ -42,7 +42,7 @@ class ToxEnvCreateArgs(NamedTuple): log_handler: ToxHandler -class ToxEnv(ABC): +class ToxEnv(ABC): # noqa: PLR0904 """A tox environment.""" def __init__(self, create_args: ToxEnvCreateArgs) -> None: @@ -111,19 +111,19 @@ def register_config(self) -> None: self.conf.add_config( keys=["env_dir", "envdir"], of_type=Path, - default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name, # noqa: ARG005 + default=lambda conf, name: self.work_dir / self.name, # noqa: ARG005 desc="directory assigned to the tox environment", ) self.conf.add_config( keys=["env_tmp_dir", "envtmpdir"], of_type=Path, - default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name / "tmp", # noqa: ARG005 + default=lambda conf, name: self.work_dir / self.name / "tmp", # noqa: ARG005 desc="a folder that is always reset at the start of the run", ) self.conf.add_config( keys=["env_log_dir", "envlogdir"], of_type=Path, - default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name / "log", # noqa: ARG005 + default=lambda conf, name: self.work_dir / self.name / "log", # noqa: ARG005 desc="a folder for logging where tox will put logs of tool invocation", ) self.executor.register_conf(self) @@ -194,6 +194,16 @@ def env_log_dir(self) -> Path: """:return: the tox environments log folder""" return cast(Path, self.conf["env_log_dir"]) + @property + def work_dir(self) -> Path: + """:return: the tox work dir folder""" + return cast(Path, self.core["work_dir"]) + + @property + def temp_dir(self) -> Path: + """:return: the tox work dir folder""" + return cast(Path, self.core["temp_dir"]) + @property def name(self) -> str: return cast(str, self.conf["env_name"]) @@ -305,16 +315,27 @@ def _setup_with_env(self) -> None: # noqa: B027 # empty abstract base class def _done_with_setup(self) -> None: # noqa: B027 # empty abstract base class """Called when setup is done.""" + def _maybe_ensure_workdir(self) -> None: + if not self.work_dir.is_dir(): + # Populate the workdir with a CACHEDIR.TAG file only if we would + # be creating it now. If it already exists, do not touch it. + ensure_cachedir_dir(self.work_dir) + def _handle_env_tmp_dir(self) -> None: """Ensure exists and empty.""" env_tmp_dir = self.env_tmp_dir if env_tmp_dir.exists() and next(env_tmp_dir.iterdir(), None) is not None: LOGGER.debug("clear env temp folder %s", env_tmp_dir) ensure_empty_dir(env_tmp_dir) - env_tmp_dir.mkdir(parents=True, exist_ok=True) + if env_tmp_dir.parent == self.work_dir: + self._maybe_ensure_workdir() + ensure_cachedir_dir(env_tmp_dir) def _handle_core_tmp_dir(self) -> None: - self.core["temp_dir"].mkdir(parents=True, exist_ok=True) + temp_dir = self.temp_dir + if temp_dir.parent == self.work_dir: + self._maybe_ensure_workdir() + ensure_cachedir_dir(temp_dir) def _clean(self, transitive: bool = False) -> None: # noqa: ARG002, FBT001, FBT002 if self._run_state["clean"]: # pragma: no branch @@ -323,6 +344,7 @@ def _clean(self, transitive: bool = False) -> None: # noqa: ARG002, FBT001, FBT if env_dir.exists(): LOGGER.warning("remove tox env folder %s", env_dir) ensure_empty_dir(env_dir, except_filename="file.lock") + ensure_cachedir_dir(env_dir) self._log_id = 0 # we deleted logs, so start over counter self.cache.reset() self._run_state.update({"setup": False, "clean": True}) @@ -342,8 +364,8 @@ def environment_variables(self) -> dict[str, str]: for key in set_env: result[key] = set_env.load(key) result["TOX_ENV_NAME"] = self.name - result["TOX_WORK_DIR"] = str(self.core["work_dir"]) - result["TOX_ENV_DIR"] = str(self.conf["env_dir"]) + result["TOX_WORK_DIR"] = str(self.work_dir) + result["TOX_ENV_DIR"] = str(self.env_dir) return result @staticmethod diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py index 008910e8e..4b4d1f8f4 100644 --- a/src/tox/tox_env/package.py +++ b/src/tox/tox_env/package.py @@ -10,6 +10,8 @@ from filelock import FileLock +from tox.util.path import ensure_cachedir_dir + from .api import ToxEnv, ToxEnvCreateArgs if TYPE_CHECKING: @@ -67,9 +69,12 @@ def __getattribute__(self, name: str) -> Any: def register_config(self) -> None: super().register_config() - file_lock_path: Path = self.conf["env_dir"] / "file.lock" + env_dir = self.env_dir + if env_dir.parent == self.work_dir: + self._maybe_ensure_workdir() + ensure_cachedir_dir(env_dir) + file_lock_path: Path = env_dir / "file.lock" self._file_lock = FileLock(file_lock_path) - file_lock_path.parent.mkdir(parents=True, exist_ok=True) self.core.add_config( keys=["package_root", "setupdir"], of_type=Path, diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py index 3b3124a7a..274e01a5e 100644 --- a/src/tox/tox_env/python/api.py +++ b/src/tox/tox_env/python/api.py @@ -13,6 +13,7 @@ from tox.tox_env.api import ToxEnv, ToxEnvCreateArgs from tox.tox_env.errors import Fail, Recreate, Skip +from tox.util.path import ensure_cachedir_dir if TYPE_CHECKING: from tox.config.main import Config @@ -236,6 +237,7 @@ def ensure_python_env(self) -> None: with self.cache.compare(conf, Python.__name__) as (eq, old): if old is None: # does not exist -> create self.create_python_env() + ensure_cachedir_dir(self.env_dir) elif eq is False: # pragma: no branch # exists but changed -> recreate raise Recreate(self._diff_msg(conf, old)) diff --git a/src/tox/util/path.py b/src/tox/util/path.py index 9eea178f9..8b36d0b05 100644 --- a/src/tox/util/path.py +++ b/src/tox/util/path.py @@ -6,6 +6,12 @@ if TYPE_CHECKING: from pathlib import Path +CACHEDIR_TAG_CONTENT = b"""Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by the Tox automation project (https://tox.wiki/). +# For information about cache directory tags, see: +# http://www.brynosaurus.com/cachedir/ +""" + def ensure_empty_dir(path: Path, except_filename: str | None = None) -> None: if path.exists(): @@ -24,6 +30,18 @@ def ensure_empty_dir(path: Path, except_filename: str | None = None) -> None: path.mkdir(parents=True) +def ensure_cachedir_dir(path: Path) -> None: + """ + Ensure that the given path is a directory, exists and + contains a `CACHEDIR.TAG` file. + """ + path.mkdir(parents=True, exist_ok=True) + cachetag = path / "CACHEDIR.TAG" + if not cachetag.is_file(): + cachetag.write_bytes(CACHEDIR_TAG_CONTENT) + + __all__ = [ + "ensure_cachedir_dir", "ensure_empty_dir", ] diff --git a/tests/tox_env/test_cachedir_tag.py b/tests/tox_env/test_cachedir_tag.py new file mode 100644 index 000000000..2d4eed0e6 --- /dev/null +++ b/tests/tox_env/test_cachedir_tag.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +def test_cachedir_tag_created_in_new_workdir(tox_project: ToxProjectCreator) -> None: + prj = tox_project({"tox.ini": "[testenv]\ncommands=python --version"}) + cwd = prj.path + assert not (cwd / ".tox").exists() + result = prj.run("run", from_cwd=cwd) + result.assert_success() + assert (cwd / ".tox" / "CACHEDIR.TAG").exists() + + +def test_cachedir_tag_not_created_in_extant_workdir(tox_project: ToxProjectCreator, tmp_path) -> None: + workdir = tmp_path / "workworkwork" + workdir.mkdir(parents=True) + prj = tox_project({"tox.ini": "[testenv]\ncommands=python --version"}) + result = prj.run("--workdir", str(workdir), from_cwd=prj.path.parent) + result.assert_success() + assert not (workdir / "CACHEDIR.TAG").exists() diff --git a/tox.ini b/tox.ini index 89bb29d24..681b190d1 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ deps = uv>=0.4.17 commands = uv build --sdist --wheel --out-dir {env_tmp_dir} . - twine check {env_tmp_dir}{/}* + twine check {env_tmp_dir}{/}*.whl check-wheel-contents --no-config {env_tmp_dir} [testenv:release]