diff --git a/docs/changelog/1884.feature.rst b/docs/changelog/1884.feature.rst new file mode 100644 index 000000000..7a512ee94 --- /dev/null +++ b/docs/changelog/1884.feature.rst @@ -0,0 +1 @@ +Store app data (pip/setuptools/wheel caches) under the OS cache directory (``platformdirs.user_cache_dir``) instead of the data directory (``platformdirs.user_data_dir``). Existing app data at the old location is automatically migrated on first use. This ensures cached files that can be redownloaded are placed in the standard cache location (e.g. ``~/.cache`` on Linux, ``~/Library/Caches`` on macOS) where they are excluded from backups and can be cleaned by system tools - by :user:`rahuldevikar`. (:issue:`1884`) diff --git a/src/virtualenv/app_data/__init__.py b/src/virtualenv/app_data/__init__.py index 7a9d38e92..be2707929 100644 --- a/src/virtualenv/app_data/__init__.py +++ b/src/virtualenv/app_data/__init__.py @@ -4,8 +4,9 @@ import logging import os +import shutil -from platformdirs import user_data_dir +from platformdirs import user_cache_dir, user_data_dir from .na import AppDataDisabled from .read_only import ReadOnlyAppData @@ -19,7 +20,24 @@ def _default_app_data_dir(env): key = "VIRTUALENV_OVERRIDE_APP_DATA" if key in env: return env[key] - return user_data_dir(appname="virtualenv", appauthor="pypa") + return _cache_dir_with_migration() + + +def _cache_dir_with_migration(): + new_dir = user_cache_dir(appname="virtualenv", appauthor="pypa") + old_dir = user_data_dir(appname="virtualenv", appauthor="pypa") + if new_dir == old_dir: + return new_dir + if os.path.isdir(old_dir) and not os.path.isdir(new_dir): + LOGGER.info("migrating app data from %s to %s", old_dir, new_dir) + try: + shutil.move(old_dir, new_dir) + except OSError as exception: + LOGGER.warning( + "could not migrate app data from %s to %s: %r, using old location", old_dir, new_dir, exception + ) + return old_dir + return new_dir def make_app_data(folder, **kwargs): diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index b83c247a2..89226a8ad 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -1,10 +1,12 @@ from __future__ import annotations import concurrent.futures +import os import traceback import pytest +from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.subprocess import run_cmd @@ -34,3 +36,106 @@ def recreate_target_file(): task.result() except Exception: # noqa: BLE001, PERF203 pytest.fail(traceback.format_exc()) + + +class TestDefaultAppDataDir: + def test_override_env_var(self, tmp_path): + custom = str(tmp_path / "custom") + env = {"VIRTUALENV_OVERRIDE_APP_DATA": custom} + assert _default_app_data_dir(env) == custom + + def test_no_override_returns_cache_dir(self, monkeypatch): + monkeypatch.delenv("VIRTUALENV_OVERRIDE_APP_DATA", raising=False) + result = _default_app_data_dir(os.environ) + assert result + + +class TestCacheDirMigration: + def test_migrate_old_to_new(self, tmp_path, monkeypatch): + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + (tmp_path / "old-data" / "test.txt").write_text("hello") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert os.path.isdir(new_dir) + assert not os.path.isdir(old_dir) + assert (tmp_path / "new-cache" / "test.txt").read_text() == "hello" + + def test_no_migration_when_old_missing(self, tmp_path, monkeypatch): + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert not os.path.isdir(old_dir) + + def test_no_migration_when_new_exists(self, tmp_path, monkeypatch): + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + os.makedirs(new_dir) + (tmp_path / "old-data" / "old.txt").write_text("old") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert os.path.isdir(old_dir) + + def test_same_dir_returns_immediately(self, tmp_path, monkeypatch): + same_dir = str(tmp_path / "same") + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: same_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: same_dir) + + result = _cache_dir_with_migration() + assert result == same_dir + + def test_fallback_on_migration_error(self, tmp_path, monkeypatch): + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + def broken_move(_src, _dst): + msg = "permission denied" + raise OSError(msg) + + monkeypatch.setattr("virtualenv.app_data.shutil.move", broken_move) + + result = _cache_dir_with_migration() + assert result == old_dir + + @pytest.mark.parametrize("symlink_flag", [True, False]) + def test_symlink_app_data_survives_migration(self, tmp_path, monkeypatch, symlink_flag): # noqa: ARG002 + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + wheel_img = tmp_path / "old-data" / "wheel" / "3.12" / "image" / "pip" + wheel_img.mkdir(parents=True) + (wheel_img / "pip.dist-info").mkdir() + (wheel_img / "pip.dist-info" / "METADATA").write_text("Name: pip") + + venv_dir = tmp_path / "my-venv" / "lib" / "site-packages" + venv_dir.mkdir(parents=True) + try: + os.symlink(str(wheel_img / "pip.dist-info"), str(venv_dir / "pip.dist-info")) + except OSError: + pytest.skip("symlinks not supported on this filesystem") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert (tmp_path / "new-cache" / "wheel" / "3.12" / "image" / "pip" / "pip.dist-info" / "METADATA").exists()