import os import shutil from glob import glob from typing import Callable, List, Tuple import pytest from tests.lib import PipTestEnvironment, TestPipResult @pytest.fixture def cache_dir(script: PipTestEnvironment) -> str: result = script.run( "python", "-c", "from pip._internal.locations import USER_CACHE_DIR;print(USER_CACHE_DIR)", ) return result.stdout.strip() @pytest.fixture def http_cache_dir(cache_dir: str) -> str: return os.path.normcase(os.path.join(cache_dir, "http")) @pytest.fixture def wheel_cache_dir(cache_dir: str) -> str: return os.path.normcase(os.path.join(cache_dir, "wheels")) @pytest.fixture def http_cache_files(http_cache_dir: str) -> List[str]: destination = os.path.join(http_cache_dir, "arbitrary", "pathname") if not os.path.exists(destination): return [] filenames = glob(os.path.join(destination, "*")) files = [] for filename in filenames: files.append(os.path.join(destination, filename)) return files @pytest.fixture def wheel_cache_files(wheel_cache_dir: str) -> List[str]: destination = os.path.join(wheel_cache_dir, "arbitrary", "pathname") if not os.path.exists(destination): return [] filenames = glob(os.path.join(destination, "*.whl")) files = [] for filename in filenames: files.append(os.path.join(destination, filename)) return files @pytest.fixture def populate_http_cache(http_cache_dir: str) -> List[Tuple[str, str]]: destination = os.path.join(http_cache_dir, "arbitrary", "pathname") os.makedirs(destination) files = [ ("aaaaaaaaa", os.path.join(destination, "aaaaaaaaa")), ("bbbbbbbbb", os.path.join(destination, "bbbbbbbbb")), ("ccccccccc", os.path.join(destination, "ccccccccc")), ] for _name, filename in files: with open(filename, "w"): pass return files @pytest.fixture def populate_wheel_cache(wheel_cache_dir: str) -> List[Tuple[str, str]]: destination = os.path.join(wheel_cache_dir, "arbitrary", "pathname") os.makedirs(destination) files = [ ("yyy-1.2.3", os.path.join(destination, "yyy-1.2.3-py3-none-any.whl")), ("zzz-4.5.6", os.path.join(destination, "zzz-4.5.6-py3-none-any.whl")), ("zzz-4.5.7", os.path.join(destination, "zzz-4.5.7-py3-none-any.whl")), ("zzz-7.8.9", os.path.join(destination, "zzz-7.8.9-py3-none-any.whl")), ] for _name, filename in files: with open(filename, "w"): pass return files @pytest.fixture def empty_wheel_cache(wheel_cache_dir: str) -> None: if os.path.exists(wheel_cache_dir): shutil.rmtree(wheel_cache_dir) def list_matches_wheel(wheel_name: str, result: TestPipResult) -> bool: """Returns True if any line in `result`, which should be the output of a `pip cache list` call, matches `wheel_name`. E.g., If wheel_name is `foo-1.2.3` it searches for a line starting with `- foo-1.2.3-py3-none-any.whl `.""" lines = result.stdout.splitlines() expected = f" - {wheel_name}-py3-none-any.whl " return any(map(lambda line: line.startswith(expected), lines)) def list_matches_wheel_abspath(wheel_name: str, result: TestPipResult) -> bool: """Returns True if any line in `result`, which should be the output of a `pip cache list --format=abspath` call, is a valid path and belongs to `wheel_name`. E.g., If wheel_name is `foo-1.2.3` it searches for a line starting with `foo-1.2.3-py3-none-any.whl`.""" lines = result.stdout.splitlines() expected = f"{wheel_name}-py3-none-any.whl" return any( map( lambda line: ( os.path.basename(line).startswith(expected) and os.path.exists(line) ), lines, ) ) RemoveMatches = Callable[[str, TestPipResult], bool] @pytest.fixture def remove_matches_http(http_cache_dir: str) -> RemoveMatches: """Returns True if any line in `result`, which should be the output of a `pip cache purge` call, matches `http_filename`. E.g., If http_filename is `aaaaaaaaa`, it searches for a line equal to `Removed /arbitrary/pathname/aaaaaaaaa`. """ def _remove_matches_http(http_filename: str, result: TestPipResult) -> bool: lines = result.stdout.splitlines() # The "/arbitrary/pathname/" bit is an implementation detail of how # the `populate_http_cache` fixture is implemented. path = os.path.join( http_cache_dir, "arbitrary", "pathname", http_filename, ) expected = f"Removed {path}" return expected in lines return _remove_matches_http @pytest.fixture def remove_matches_wheel(wheel_cache_dir: str) -> RemoveMatches: """Returns True if any line in `result`, which should be the output of a `pip cache remove`/`pip cache purge` call, matches `wheel_name`. E.g., If wheel_name is `foo-1.2.3`, it searches for a line equal to `Removed /arbitrary/pathname/foo-1.2.3-py3-none-any.whl`. """ def _remove_matches_wheel(wheel_name: str, result: TestPipResult) -> bool: lines = result.stdout.splitlines() wheel_filename = f"{wheel_name}-py3-none-any.whl" # The "/arbitrary/pathname/" bit is an implementation detail of how # the `populate_wheel_cache` fixture is implemented. path = os.path.join( wheel_cache_dir, "arbitrary", "pathname", wheel_filename, ) expected = f"Removed {path}" return expected in lines return _remove_matches_wheel def test_cache_dir(script: PipTestEnvironment, cache_dir: str) -> None: result = script.pip("cache", "dir") assert os.path.normcase(cache_dir) == result.stdout.strip() def test_cache_dir_too_many_args(script: PipTestEnvironment, cache_dir: str) -> None: result = script.pip("cache", "dir", "aaa", expect_error=True) assert result.stdout == "" # This would be `result.stderr == ...`, but pip prints deprecation # warnings on Python 2.7, so we check if the _line_ is in stderr. assert "ERROR: Too many arguments" in result.stderr.splitlines() @pytest.mark.usefixtures("populate_http_cache", "populate_wheel_cache") def test_cache_info( script: PipTestEnvironment, http_cache_dir: str, wheel_cache_dir: str, wheel_cache_files: List[str], ) -> None: result = script.pip("cache", "info") assert f"Package index page cache location: {http_cache_dir}" in result.stdout assert f"Locally built wheels location: {wheel_cache_dir}" in result.stdout num_wheels = len(wheel_cache_files) assert f"Number of locally built wheels: {num_wheels}" in result.stdout @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list(script: PipTestEnvironment) -> None: """Running `pip cache list` should return exactly what the populate_wheel_cache fixture adds.""" result = script.pip("cache", "list") assert list_matches_wheel("yyy-1.2.3", result) assert list_matches_wheel("zzz-4.5.6", result) assert list_matches_wheel("zzz-4.5.7", result) assert list_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list_abspath(script: PipTestEnvironment) -> None: """Running `pip cache list --format=abspath` should return full paths of exactly what the populate_wheel_cache fixture adds.""" result = script.pip("cache", "list", "--format=abspath") assert list_matches_wheel_abspath("yyy-1.2.3", result) assert list_matches_wheel_abspath("zzz-4.5.6", result) assert list_matches_wheel_abspath("zzz-4.5.7", result) assert list_matches_wheel_abspath("zzz-7.8.9", result) @pytest.mark.usefixtures("empty_wheel_cache") def test_cache_list_with_empty_cache(script: PipTestEnvironment) -> None: """Running `pip cache list` with an empty cache should print "No locally built wheels cached." and exit.""" result = script.pip("cache", "list") assert result.stdout == "No locally built wheels cached.\n" @pytest.mark.usefixtures("empty_wheel_cache") def test_cache_list_with_empty_cache_abspath(script: PipTestEnvironment) -> None: """Running `pip cache list --format=abspath` with an empty cache should not print anything and exit.""" result = script.pip("cache", "list", "--format=abspath") assert result.stdout.strip() == "" @pytest.mark.usefixtures("empty_wheel_cache") def test_cache_purge_with_empty_cache(script: PipTestEnvironment) -> None: """Running `pip cache purge` with an empty cache should print a warning and exit without an error code.""" result = script.pip("cache", "purge", allow_stderr_warning=True) assert result.stderr == "WARNING: No matching packages\n" assert result.stdout == "Files removed: 0\n" @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_remove_with_bad_pattern(script: PipTestEnvironment) -> None: """Running `pip cache remove` with a bad pattern should print a warning and exit without an error code.""" result = script.pip("cache", "remove", "aaa", allow_stderr_warning=True) assert result.stderr == 'WARNING: No matching packages for pattern "aaa"\n' assert result.stdout == "Files removed: 0\n" def test_cache_list_too_many_args(script: PipTestEnvironment) -> None: """Passing `pip cache list` too many arguments should cause an error.""" script.pip("cache", "list", "aaa", "bbb", expect_error=True) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list_name_match(script: PipTestEnvironment) -> None: """Running `pip cache list zzz` should list zzz-4.5.6, zzz-4.5.7, zzz-7.8.9, but nothing else.""" result = script.pip("cache", "list", "zzz", "--verbose") assert not list_matches_wheel("yyy-1.2.3", result) assert list_matches_wheel("zzz-4.5.6", result) assert list_matches_wheel("zzz-4.5.7", result) assert list_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list_name_match_abspath(script: PipTestEnvironment) -> None: """Running `pip cache list zzz --format=abspath` should list paths of zzz-4.5.6, zzz-4.5.7, zzz-7.8.9, but nothing else.""" result = script.pip("cache", "list", "zzz", "--format=abspath", "--verbose") assert not list_matches_wheel_abspath("yyy-1.2.3", result) assert list_matches_wheel_abspath("zzz-4.5.6", result) assert list_matches_wheel_abspath("zzz-4.5.7", result) assert list_matches_wheel_abspath("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list_name_and_version_match(script: PipTestEnvironment) -> None: """Running `pip cache list zzz-4.5.6` should list zzz-4.5.6, but nothing else.""" result = script.pip("cache", "list", "zzz-4.5.6", "--verbose") assert not list_matches_wheel("yyy-1.2.3", result) assert list_matches_wheel("zzz-4.5.6", result) assert not list_matches_wheel("zzz-4.5.7", result) assert not list_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_list_name_and_version_match_abspath(script: PipTestEnvironment) -> None: """Running `pip cache list zzz-4.5.6 --format=abspath` should list path of zzz-4.5.6, but nothing else.""" result = script.pip("cache", "list", "zzz-4.5.6", "--format=abspath", "--verbose") assert not list_matches_wheel_abspath("yyy-1.2.3", result) assert list_matches_wheel_abspath("zzz-4.5.6", result) assert not list_matches_wheel_abspath("zzz-4.5.7", result) assert not list_matches_wheel_abspath("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_remove_no_arguments(script: PipTestEnvironment) -> None: """Running `pip cache remove` with no arguments should cause an error.""" script.pip("cache", "remove", expect_error=True) def test_cache_remove_too_many_args(script: PipTestEnvironment) -> None: """Passing `pip cache remove` too many arguments should cause an error.""" script.pip("cache", "remove", "aaa", "bbb", expect_error=True) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_remove_name_match( script: PipTestEnvironment, remove_matches_wheel: RemoveMatches ) -> None: """Running `pip cache remove zzz` should remove zzz-4.5.6 and zzz-7.8.9, but nothing else.""" result = script.pip("cache", "remove", "zzz", "--verbose") assert not remove_matches_wheel("yyy-1.2.3", result) assert remove_matches_wheel("zzz-4.5.6", result) assert remove_matches_wheel("zzz-4.5.7", result) assert remove_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_wheel_cache") def test_cache_remove_name_and_version_match( script: PipTestEnvironment, remove_matches_wheel: RemoveMatches ) -> None: """Running `pip cache remove zzz-4.5.6` should remove zzz-4.5.6, but nothing else.""" result = script.pip("cache", "remove", "zzz-4.5.6", "--verbose") assert not remove_matches_wheel("yyy-1.2.3", result) assert remove_matches_wheel("zzz-4.5.6", result) assert not remove_matches_wheel("zzz-4.5.7", result) assert not remove_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_http_cache", "populate_wheel_cache") def test_cache_purge( script: PipTestEnvironment, remove_matches_http: RemoveMatches, remove_matches_wheel: RemoveMatches, ) -> None: """Running `pip cache purge` should remove all cached http files and wheels.""" result = script.pip("cache", "purge", "--verbose") assert remove_matches_http("aaaaaaaaa", result) assert remove_matches_http("bbbbbbbbb", result) assert remove_matches_http("ccccccccc", result) assert remove_matches_wheel("yyy-1.2.3", result) assert remove_matches_wheel("zzz-4.5.6", result) assert remove_matches_wheel("zzz-4.5.7", result) assert remove_matches_wheel("zzz-7.8.9", result) @pytest.mark.usefixtures("populate_http_cache", "populate_wheel_cache") def test_cache_purge_too_many_args( script: PipTestEnvironment, http_cache_files: List[str], wheel_cache_files: List[str], ) -> None: """Running `pip cache purge aaa` should raise an error and remove no cached http files or wheels.""" result = script.pip("cache", "purge", "aaa", "--verbose", expect_error=True) assert result.stdout == "" # This would be `result.stderr == ...`, but pip prints deprecation # warnings on Python 2.7, so we check if the _line_ is in stderr. assert "ERROR: Too many arguments" in result.stderr.splitlines() # Make sure nothing was deleted. for filename in http_cache_files + wheel_cache_files: assert os.path.exists(filename) @pytest.mark.parametrize("command", ["info", "list", "remove", "purge"]) def test_cache_abort_when_no_cache_dir( script: PipTestEnvironment, command: str ) -> None: """Running any pip cache command when cache is disabled should abort and log an informative error""" result = script.pip("cache", command, "--no-cache-dir", expect_error=True) assert result.stdout == "" assert ( "ERROR: pip cache commands can not function" " since cache is disabled." in result.stderr.splitlines() )