poetry/tests/utils/test_cache.py

367 lines
11 KiB
Python

from __future__ import annotations
import concurrent.futures
import shutil
import traceback
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TypeVar
import pytest
from packaging.tags import Tag
from poetry.core.packages.utils.link import Link
from poetry.utils.cache import ArtifactCache
from poetry.utils.cache import FileCache
from poetry.utils.env import MockEnv
if TYPE_CHECKING:
from typing import Any
from pytest_mock import MockerFixture
from tests.conftest import Config
from tests.types import FixtureDirGetter
T = TypeVar("T")
@pytest.fixture
def repository_cache_dir(config: Config) -> Path:
return config.repository_cache_directory
@pytest.fixture
def poetry_file_cache(repository_cache_dir: Path) -> FileCache[Any]:
return FileCache(repository_cache_dir / "cache")
def test_cache_validates(repository_cache_dir: Path) -> None:
with pytest.raises(ValueError) as e:
FileCache(repository_cache_dir / "cache", hash_type="unknown")
assert str(e.value) == "FileCache.hash_type is unknown value: 'unknown'."
def test_cache_get_put_has(repository_cache_dir: Path) -> None:
cache: FileCache[Any] = FileCache(repository_cache_dir / "cache")
cache.put("key1", "value")
cache.put("key2", {"a": ["json-encoded", "value"]})
assert cache.get("key1") == "value"
assert cache.get("key2") == {"a": ["json-encoded", "value"]}
assert cache.has("key1")
assert cache.has("key2")
assert not cache.has("key3")
def test_cache_forget(repository_cache_dir: Path) -> None:
cache: FileCache[Any] = FileCache(repository_cache_dir / "cache")
cache.put("key1", "value")
cache.put("key2", "value")
assert cache.has("key1")
assert cache.has("key2")
cache.forget("key1")
assert not cache.has("key1")
assert cache.has("key2")
def test_cache_flush(repository_cache_dir: Path) -> None:
cache: FileCache[Any] = FileCache(repository_cache_dir / "cache")
cache.put("key1", "value")
cache.put("key2", "value")
assert cache.has("key1")
assert cache.has("key2")
cache.flush()
assert not cache.has("key1")
assert not cache.has("key2")
def test_cache_remember(repository_cache_dir: Path, mocker: MockerFixture) -> None:
cache: FileCache[Any] = FileCache(repository_cache_dir / "cache")
method = mocker.Mock(return_value="value2")
cache.put("key1", "value1")
assert cache.remember("key1", method) == "value1"
method.assert_not_called()
assert cache.remember("key2", method) == "value2"
method.assert_called()
def test_cache_get_limited_minutes(
repository_cache_dir: Path, mocker: MockerFixture
) -> None:
cache: FileCache[Any] = FileCache(repository_cache_dir / "cache")
start_time = 1111111111
mocker.patch("time.time", return_value=start_time)
cache.put("key1", "value", minutes=5)
cache.put("key2", "value", minutes=5)
assert cache.get("key1") is not None
assert cache.get("key2") is not None
mocker.patch("time.time", return_value=start_time + 5 * 60 + 1)
# check to make sure that the cache deletes for has() and get()
assert not cache.has("key1")
assert cache.get("key2") is None
def test_missing_cache_file(poetry_file_cache: FileCache[Any]) -> None:
poetry_file_cache.put("key1", "value")
key1_path = (
poetry_file_cache.path
/ "81/74/09/96/87/a2/66/21/8174099687a26621f4e2cdd7cc03b3dacedb3fb962255b1aafd033cabe831530"
)
assert key1_path.exists()
key1_path.unlink() # corrupt cache by removing a key file
assert poetry_file_cache.get("key1") is None
def test_missing_cache_path(poetry_file_cache: FileCache[Any]) -> None:
poetry_file_cache.put("key1", "value")
key1_partial_path = poetry_file_cache.path / "81/74/09/96/87/a2/"
assert key1_partial_path.exists()
shutil.rmtree(
key1_partial_path
) # corrupt cache by removing a subdirectory containing a key file
assert poetry_file_cache.get("key1") is None
@pytest.mark.parametrize(
"corrupt_payload",
[
"", # empty file
b"\x00", # null
"99999999", # truncated file
'999999a999"value"', # corrupt lifetime
b'9999999999"va\xd8\x00"', # invalid unicode
"fil3systemFa!led", # garbage file
],
)
def test_detect_corrupted_cache_key_file(
corrupt_payload: str | bytes, poetry_file_cache: FileCache[Any]
) -> None:
poetry_file_cache.put("key1", "value")
key1_path = (
poetry_file_cache.path
/ "81/74/09/96/87/a2/66/21/8174099687a26621f4e2cdd7cc03b3dacedb3fb962255b1aafd033cabe831530"
)
assert key1_path.exists()
# original content: 9999999999"value"
write_modes = {str: "w", bytes: "wb"}
with open(key1_path, write_modes[type(corrupt_payload)]) as f:
f.write(corrupt_payload) # write corrupt data
assert poetry_file_cache.get("key1") is None
def test_get_cache_directory_for_link(tmp_path: Path) -> None:
cache = ArtifactCache(cache_dir=tmp_path)
directory = cache.get_cache_directory_for_link(
Link("https://files.python-poetry.org/poetry-1.1.0.tar.gz")
)
expected = Path(
f"{tmp_path.as_posix()}/11/4f/a8/"
"1c89d75547e4967082d30a28360401c82c83b964ddacee292201bf85f2"
)
assert directory == expected
@pytest.mark.parametrize("subdirectory", [None, "subdir"])
def test_get_cache_directory_for_git(tmp_path: Path, subdirectory: str | None) -> None:
cache = ArtifactCache(cache_dir=tmp_path)
directory = cache.get_cache_directory_for_git(
url="https://github.com/demo/demo.git", ref="123456", subdirectory=subdirectory
)
if subdirectory:
expected = Path(
f"{tmp_path.as_posix()}/53/08/33/"
"7851e5806669aa15ab0c555b13bd5523978057323c6a23a9cee18ec51c"
)
else:
expected = Path(
f"{tmp_path.as_posix()}/61/14/30/"
"7c57f8fd71e4eee40b18893b9b586cba45177f15e300f4fb8b14ccc933"
)
assert directory == expected
def test_get_cached_archives(fixture_dir: FixtureDirGetter) -> None:
distributions = fixture_dir("distributions")
cache = ArtifactCache(cache_dir=Path())
archives = cache._get_cached_archives(distributions)
assert archives
assert set(archives) == set(distributions.glob("*.whl")) | set(
distributions.glob("*.tar.gz")
)
@pytest.mark.parametrize(
("link", "strict", "available_packages"),
[
(
"https://files.python-poetry.org/demo-0.1.0.tar.gz",
True,
[
Path("/cache/demo-0.1.0-py2.py3-none-any"),
Path("/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl"),
Path("/cache/demo-0.1.0-cp37-cp37-macosx_10_15_x86_64.whl"),
],
),
(
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
[],
),
],
)
def test_get_not_found_cached_archive_for_link(
mocker: MockerFixture,
link: str,
strict: bool,
available_packages: list[Path],
) -> None:
env = MockEnv(
version_info=(3, 8, 3),
marker_env={"interpreter_name": "cpython", "interpreter_version": "3.8.3"},
supported_tags=[
Tag("cp38", "cp38", "macosx_10_15_x86_64"),
Tag("py3", "none", "any"),
],
)
cache = ArtifactCache(cache_dir=Path())
mocker.patch.object(
cache,
"_get_cached_archives",
return_value=available_packages,
)
archive = cache.get_cached_archive_for_link(Link(link), strict=strict, env=env)
assert archive is None
@pytest.mark.parametrize(
("link", "cached", "strict"),
[
(
"https://files.python-poetry.org/demo-0.1.0.tar.gz",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
),
(
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
),
(
"https://files.python-poetry.org/demo-0.1.0.tar.gz",
"/cache/demo-0.1.0.tar.gz",
True,
),
(
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
True,
),
],
)
def test_get_found_cached_archive_for_link(
mocker: MockerFixture,
link: str,
cached: str,
strict: bool,
) -> None:
env = MockEnv(
version_info=(3, 8, 3),
marker_env={"interpreter_name": "cpython", "interpreter_version": "3.8.3"},
supported_tags=[
Tag("cp38", "cp38", "macosx_10_15_x86_64"),
Tag("py3", "none", "any"),
],
)
cache = ArtifactCache(cache_dir=Path())
mocker.patch.object(
cache,
"_get_cached_archives",
return_value=[
Path("/cache/demo-0.1.0-py2.py3-none-any"),
Path("/cache/demo-0.1.0.tar.gz"),
Path("/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl"),
Path("/cache/demo-0.1.0-cp37-cp37-macosx_10_15_x86_64.whl"),
],
)
archive = cache.get_cached_archive_for_link(Link(link), strict=strict, env=env)
assert Path(cached) == archive
def test_get_cached_archive_for_link_no_race_condition(
tmp_path: Path, mocker: MockerFixture
) -> None:
cache = ArtifactCache(cache_dir=tmp_path)
link = Link("https://files.python-poetry.org/demo-0.1.0.tar.gz")
def replace_file(_: str, dest: Path) -> None:
dest.unlink(missing_ok=True)
# write some data (so it takes a while) to provoke possible race conditions
dest.write_text("a" * 2**20)
download_mock = mocker.Mock(side_effect=replace_file)
with concurrent.futures.ThreadPoolExecutor() as executor:
tasks = []
for _ in range(4):
tasks.append(
executor.submit(
cache.get_cached_archive_for_link,
link,
strict=True,
download_func=download_mock,
)
)
concurrent.futures.wait(tasks)
results = set()
for task in tasks:
try:
results.add(task.result())
except Exception:
pytest.fail(traceback.format_exc())
assert results == {cache.get_cache_directory_for_link(link) / link.filename}
download_mock.assert_called_once()
def test_get_cached_archive_for_git() -> None:
"""Smoke test that checks that no assertion is raised."""
cache = ArtifactCache(cache_dir=Path())
archive = cache.get_cached_archive_for_git("url", "ref", "subdirectory", MockEnv())
assert archive is None