diff --git a/pip_audit/_cache.py b/pip_audit/_cache.py index 41d6e462..cef93e65 100644 --- a/pip_audit/_cache.py +++ b/pip_audit/_cache.py @@ -6,6 +6,7 @@ import logging import os +import shutil import subprocess import sys from pathlib import Path @@ -17,6 +18,7 @@ from cachecontrol import CacheControl from cachecontrol.caches import FileCache from packaging.version import Version +from platformdirs import user_cache_path from pip_audit._service.interface import ServiceError @@ -28,7 +30,7 @@ _PIP_VERSION = Version(str(pip_api.PIP_VERSION)) -_PIP_AUDIT_INTERNAL_CACHE = Path.home() / ".pip-audit-cache" +_PIP_AUDIT_LEGACY_INTERNAL_CACHE = Path.home() / ".pip-audit-cache" def _get_pip_cache() -> Path: @@ -60,6 +62,16 @@ def _get_cache_dir(custom_cache_dir: Path | None, *, use_pip: bool = True) -> Pa if custom_cache_dir is not None: return custom_cache_dir + # Retrieve pip-audit's default internal cache using `platformdirs`. + pip_audit_cache_dir = user_cache_path("pip-audit", appauthor=False, ensure_exists=True) + + # If the retrieved cache isn't the legacy one, try to delete the old cache if it exists. + if ( + _PIP_AUDIT_LEGACY_INTERNAL_CACHE.exists() + and pip_audit_cache_dir != _PIP_AUDIT_LEGACY_INTERNAL_CACHE + ): + shutil.rmtree(_PIP_AUDIT_LEGACY_INTERNAL_CACHE) + # Respect pip's PIP_NO_CACHE_DIR environment setting. if use_pip and not os.getenv("PIP_NO_CACHE_DIR"): pip_cache_dir = _get_pip_cache() if _PIP_VERSION >= _MINIMUM_PIP_VERSION else None @@ -68,11 +80,11 @@ def _get_cache_dir(custom_cache_dir: Path | None, *, use_pip: bool = True) -> Pa else: logger.warning( f"pip {_PIP_VERSION} doesn't support the `cache dir` subcommand, " - f"using {_PIP_AUDIT_INTERNAL_CACHE} instead" + f"using {pip_audit_cache_dir} instead" ) - return _PIP_AUDIT_INTERNAL_CACHE + return pip_audit_cache_dir else: - return _PIP_AUDIT_INTERNAL_CACHE + return pip_audit_cache_dir class _SafeFileCache(FileCache): diff --git a/pyproject.toml b/pyproject.toml index e7eff96c..52bd7181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "requests >= 2.31.0", "rich>=12.4", "toml>=0.10", + "platformdirs>=4.2.0" ] requires-python = ">=3.8" diff --git a/test/test_cache.py b/test/test_cache.py index 7da5c907..de2c52bf 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -1,12 +1,29 @@ +import importlib +import sys from pathlib import Path +import platformdirs import pretend # type: ignore +import pytest from packaging.version import Version +from pytest import MonkeyPatch import pip_audit._cache as cache from pip_audit._cache import _get_cache_dir, _get_pip_cache +def _patch_platformdirs(monkeypatch: MonkeyPatch, sys_platform: str) -> None: + """Utility function to patch `platformdirs` in order to test cross-platforms.""" + # Mocking OS host + monkeypatch.setattr(sys, "platform", sys_platform) + # We are forced to reload `platformdirs` to get the correct cache directory + # as cache definition is stored in the top level `__init__.py` file of the + # `platformdirs` package + importlib.reload(platformdirs) + if sys_platform == "win32": + monkeypatch.setenv("LOCALAPPDATA", "/tmp/AppData/Local") + + def test_get_cache_dir(monkeypatch): # When we supply a cache directory, always use that cache_dir = _get_cache_dir(Path("/tmp/foo/cache_dir")) @@ -26,22 +43,88 @@ def test_get_pip_cache(): assert cache_dir.stem == "http" -def test_get_cache_dir_do_not_use_pip(): +@pytest.mark.parametrize( + "sys_platform,expected", + [ + pytest.param( + "linux", + Path.home() / ".cache" / "pip-audit", + id="on Linux", + ), + pytest.param( + "win32", + Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache", + id="on Windows", + ), + pytest.param( + "darwin", + Path.home() / "Library" / "Caches" / "pip-audit", + id="on MacOS", + ), + ], +) +def test_get_cache_dir_do_not_use_pip(monkeypatch, sys_platform, expected): + # Check cross-platforms + _patch_platformdirs(monkeypatch, sys_platform) # Even with None, we never use the pip cache if we're told not to. cache_dir = _get_cache_dir(None, use_pip=False) - assert cache_dir == Path.home() / ".pip-audit-cache" - - -def test_get_cache_dir_pip_disabled_in_environment(monkeypatch): + assert cache_dir == expected + + +@pytest.mark.parametrize( + "sys_platform,expected", + [ + pytest.param( + "linux", + Path.home() / ".cache" / "pip-audit", + id="on Linux", + ), + pytest.param( + "win32", + Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache", + id="on Windows", + ), + pytest.param( + "darwin", + Path.home() / "Library" / "Caches" / "pip-audit", + id="on MacOS", + ), + ], +) +def test_get_cache_dir_pip_disabled_in_environment(monkeypatch, sys_platform, expected): monkeypatch.setenv("PIP_NO_CACHE_DIR", "1") + # Check cross-platforms + _patch_platformdirs(monkeypatch, sys_platform) # Even with use_pip=True, we avoid pip's cache if the environment tells us to. - assert _get_cache_dir(None, use_pip=True) == Path.home() / ".pip-audit-cache" - - -def test_get_cache_dir_old_pip(monkeypatch): + assert _get_cache_dir(None, use_pip=True) == expected + + +@pytest.mark.parametrize( + "sys_platform,expected", + [ + pytest.param( + "linux", + Path.home() / ".cache" / "pip-audit", + id="on Linux", + ), + pytest.param( + "win32", + Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache", + id="on Windows", + ), + pytest.param( + "darwin", + Path.home() / "Library" / "Caches" / "pip-audit", + id="on MacOS", + ), + ], +) +def test_get_cache_dir_old_pip(monkeypatch, sys_platform, expected): # Check the case where we have an old `pip` monkeypatch.setattr(cache, "_PIP_VERSION", Version("1.0.0")) + # Check cross-platforms + _patch_platformdirs(monkeypatch, sys_platform) # When we supply a cache directory, always use that cache_dir = _get_cache_dir(Path("/tmp/foo/cache_dir")) @@ -50,7 +133,7 @@ def test_get_cache_dir_old_pip(monkeypatch): # In this case, we can't query `pip` to figure out where its HTTP cache is # Instead, we use `~/.pip-audit-cache` cache_dir = _get_cache_dir(None) - assert cache_dir == Path.home() / ".pip-audit-cache" + assert cache_dir == expected def test_cache_warns_about_old_pip(monkeypatch, cache_dir): @@ -67,3 +150,13 @@ def test_cache_warns_about_old_pip(monkeypatch, cache_dir): # have an old `pip`, then we should expect a warning to be logged _get_cache_dir(None) assert len(logger.warning.calls) == 1 + + +def test_delete_legacy_cache_dir(monkeypatch, tmp_path): + legacy = tmp_path / "pip-audit-cache" + legacy.mkdir() + assert legacy.exists() + monkeypatch.setattr(cache, "_PIP_AUDIT_LEGACY_INTERNAL_CACHE", legacy) + + _get_cache_dir(None, use_pip=False) + assert not legacy.exists()