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/1884.feature.rst
Original file line number Diff line number Diff line change
@@ -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`)
22 changes: 20 additions & 2 deletions src/virtualenv/app_data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/test_util.py
Original file line number Diff line number Diff line change
@@ -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

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