blob: cdb3ebe70bb0a5f17c50107f3cc2bcb53b06d245 [file] [edit]
import os
from shutil import rmtree
from tempfile import gettempdir
from threading import RLock
from time import sleep
import pytest
from platformdirs import user_cache_dir
from requests_cache.backends import FileCache, FileDict, LRUFileDict
from requests_cache.backends.filesystem import LRUDict
from requests_cache.serializers import (
SERIALIZERS,
SerializerPipeline,
Stage,
json_serializer,
safe_pickle_serializer,
utf8_serializer,
yaml_serializer,
)
from tests.conftest import CACHE_NAME, HTTPBIN_FORMATS, HTTPBIN_METHODS
from tests.integration.base_cache_test import BaseCacheTest
from tests.integration.base_storage_test import BaseStorageTest
# Handle optional dependencies if they're not installed,
# so any skipped tests will explicitly be shown in pytest output
TEST_SERIALIZERS = SERIALIZERS.copy()
try:
TEST_SERIALIZERS['safe_pickle'] = safe_pickle_serializer(secret_key='hunter2')
except ImportError:
TEST_SERIALIZERS['safe_pickle'] = 'safe_pickle_placeholder'
def _valid_serializer(serializer) -> bool:
return isinstance(serializer, (SerializerPipeline, Stage))
class TestFileDict(BaseStorageTest):
storage_class = FileDict
picklable = True
@classmethod
def teardown_class(cls):
rmtree(CACHE_NAME, ignore_errors=True)
def init_cache(self, index=0, clear=True, **kwargs) -> FileDict:
cache = self.storage_class(f'{CACHE_NAME}_{index}', use_temp=True, **kwargs)
if clear:
cache.clear()
return cache
def test_use_cache_dir(self):
relative_path = self.storage_class(CACHE_NAME).cache_dir
cache_dir_path = self.storage_class(CACHE_NAME, use_cache_dir=True).cache_dir
assert not str(relative_path).startswith(user_cache_dir())
assert str(cache_dir_path).startswith(user_cache_dir())
def test_use_temp(self):
relative_path = self.storage_class(CACHE_NAME).cache_dir
temp_path = self.storage_class(CACHE_NAME, use_temp=True).cache_dir
assert not str(relative_path).startswith(gettempdir())
assert str(temp_path).startswith(gettempdir())
def test_custom_extension(self):
cache = self.init_cache(extension='dat')
cache['key'] = 'value'
assert cache._key2path('key').suffix == '.dat'
def test_size(self):
"""Check that size updates with bytes added/updated/removed."""
cache = self.init_cache(plaintext=True)
assert cache.size() == 0
# Create a 1KB file
file_content = '0' * 1024
cache['key'] = file_content
assert cache.size() == 1026 # +2 for UTF-8 BOM
cache.clear()
assert cache.size() == 0
class TestLRUFileDict(TestFileDict):
"""Test the LRUFileDict with the same tests as the FileDict."""
storage_class = LRUFileDict
def init_cache(
self,
index=0,
clear=True,
max_cache_bytes=10000,
block_bytes=1,
plaintext=False,
**kwargs,
) -> LRUFileDict:
"""Initialize a LRUFileDict with common test parameters.
Args:
shared_cache_name: If provided, use this exact cache name (for multi-process simulation).
If None, create a unique cache name for isolation.
"""
if plaintext:
kwargs['serializer'] = None
cache = self.storage_class(
f'{CACHE_NAME}_{index}',
use_temp=True,
max_cache_bytes=max_cache_bytes,
block_bytes=block_bytes,
**kwargs,
)
if clear:
cache.clear()
return cache
@pytest.mark.parametrize('block_bytes', [0, -1, 101])
def test_init__invalid_block_bytes(self, block_bytes):
"""This block size is invalid."""
with pytest.raises(ValueError):
self.init_cache(
max_file_bytes=100,
max_cache_bytes=100,
block_bytes=block_bytes,
)
def test_init__invalid_max_file_size(self):
"""Cannot have file size greater than maximum cache size."""
with pytest.raises(ValueError):
self.init_cache(
max_file_bytes=1000,
max_cache_bytes=100,
block_bytes=100,
)
def test_init__max_file_bytes_defaults_to_max_size(self):
"""The argument is initialized properly."""
lfd = self.init_cache(max_cache_bytes=100, block_bytes=100)
assert lfd.max_file_bytes == lfd.max_cache_bytes == 100
def test_init__shared_lock(self):
"""Test passing the lock parameter."""
lock = RLock()
cache1 = self.init_cache(lock=lock)
cache2 = self.init_cache(lock=lock)
cache3 = self.init_cache()
assert cache1.lock is cache2.lock
assert cache1.lock is not cache3.lock
def test_multiple_cache_instances(self):
"""Multiple cache instances should see the same files."""
lfd = self.init_cache()
lfd2 = self.init_cache()
# Adding items
lfd['key'] = 'value'
assert 'key' in lfd
assert 'key' in lfd2
# Replacing items
lfd['key'] = 'new_value'
assert lfd2['key'] == 'new_value'
# Removing items
del lfd['key']
assert 'key' not in lfd2
@pytest.mark.parametrize('max_cache_bytes', [1000, 10000])
def test_do_not_store_files_too_big(self, max_cache_bytes: int):
"""If a file is too big, it should not be cached at all."""
lfd = self.init_cache(max_cache_bytes=max_cache_bytes)
file_content = '0' * (max_cache_bytes + 1)
lfd['key'] = file_content
assert 'key' not in lfd.keys()
@pytest.mark.parametrize('files_on_disk', [3, 100])
def test_evict_oldest_files(self, files_on_disk: int):
"""Check that the files are added up to the size.
After adding, the least recently used files are removed.
"""
max_cache_bytes = 1000
lfd = self.init_cache(max_cache_bytes=max_cache_bytes, plaintext=True)
file_content = '0' * (max_cache_bytes // files_on_disk)
for i in range(files_on_disk):
lfd[f'key_{i}'] = file_content
assert len(list(lfd.paths())) == files_on_disk, (
f'Expecting {files_on_disk} files with size '
f'{len(file_content)}, got {len(list(lfd.paths()))}: '
f'{sorted(lfd.paths())}'
)
assert len(lfd) == files_on_disk
lfd['new_file'] = file_content
assert len(list(lfd.paths())) == files_on_disk, 'One file should be dropped.'
assert 'key_0' not in lfd.keys(), 'File key_0 should be dropped because it is the last one.'
def test_evict__already_deleted(self):
"""Test behahior when a file is being evicted but has already been (manually) deleted on disk"""
lfd = self.init_cache(max_cache_bytes=1000, plaintext=True)
lfd['key_0'] = '0' * 500
sleep(0.01)
lfd['key_1'] = '0' * 500
# Manually delete file, so LRU index is now out of sync
os.unlink(lfd._key2path('key_0'))
assert 'key_0' not in lfd.keys()
# Adding this key should trigger eviction
lfd['key_2'] = '0' * 500
assert len(lfd) == 2
assert len(lfd.lru_index) == 2
assert 'key_0' not in lfd.lru_index
def test_sync_index(self):
"""With sync_index=True, the LRU index should be updated on init for any manual file changes."""
lfd = self.init_cache(max_cache_bytes=1000, plaintext=True)
lfd['key_0'] = '0' * 100
sleep(0.01)
lfd['key_1'] = '0' * 100
# Manually delete a file outside of cache interface
os.unlink(lfd._key2path('key_0'))
# Index should be updated on init
lfd = self.init_cache(max_cache_bytes=1000, sync_index=True, plaintext=True, clear=False)
lfd._evict(100)
assert len(lfd) == 1
assert len(lfd.lru_index) == 1
def test_large_file_evicts_all(self):
"""If we add a big file, we should delete everything."""
lfd = self.init_cache(max_cache_bytes=1000, plaintext=True)
for i in range(100):
lfd[f'key_{i}'] = '1' * 10
lfd['big_file'] = '0' * 1000
assert lfd.keys() == ['big_file']
def test_size(self):
"""Check that size updates with bytes added/updated/removed."""
cache = self.init_cache(plaintext=True)
assert cache.size() == 0
assert len(cache.keys()) == 0
assert len(cache) == 0
# Adding items
cache['key'] = 'value'
assert cache.size() == len('value')
cache['key2'] = 'value2'
assert cache.size() == len('value') + len('value2')
# Replacing items
cache['key'] = 'new_value'
assert cache.size() == len('new_value') + len('value2')
# Removing items
del cache['key2']
assert cache.size() == len('new_value')
@pytest.mark.parametrize(
('block_bytes', 'file_size', 'expected_size'),
[
(1, 10, 10),
(10, 9, 10),
(10, 10, 10),
(10, 11, 20),
(10, 12, 20),
(1024, 3, 1024),
(100, 1111, 1200),
],
)
def test_get_size_on_disk(self, block_bytes, file_size, expected_size):
"""Compute the block size."""
cache = self.init_cache(block_bytes=block_bytes)
assert cache._get_size_on_disk(file_size) == expected_size
assert cache._get_size_on_disk(-file_size) == -expected_size
@pytest.mark.parametrize('block_bytes', [1, 10, 128])
def test_block_alignment(self, block_bytes: int):
"""File size should be aligned with the block on the file system, and be taken into account
when adding, replacing, and deleting items.
"""
block_lfd = self.init_cache(block_bytes=block_bytes, plaintext=True)
# Adding items
block_lfd['file'] = '0' * 100
assert block_lfd.size() % block_lfd.block_bytes == 0
assert block_lfd.size() == block_lfd._get_size_on_disk(100)
# Replacing items
block_lfd['file'] = '0' * 123
assert block_lfd.size() % block_lfd.block_bytes == 0
assert block_lfd.size() == block_lfd._get_size_on_disk(123)
# Deleting items
del block_lfd['file']
block_lfd['key1'] = '2' * 100
size_1 = block_lfd.size()
block_lfd['key2'] = '2' * 123
size_1_2 = block_lfd.size()
assert size_1_2 > size_1
assert size_1_2 % block_lfd.block_bytes == 0
del block_lfd['key1']
size_2 = size_1_2 - size_1
assert block_lfd.size() == size_2
assert size_2 % block_lfd.block_bytes == 0
del block_lfd['key2']
assert block_lfd.size() == 0
@pytest.mark.parametrize('max_cache_bytes', [5, 6, 7, 8, 9])
def test_clear_with_unaligned_blocks(self, max_cache_bytes):
lfd = self.init_cache(
max_file_bytes=5,
max_cache_bytes=max_cache_bytes,
block_bytes=5,
)
lfd.clear()
lfd['x'] = '123'
assert lfd.size() == 5
lfd['y'] = ''
assert lfd.size() == 5
assert 'x' not in lfd, 'x must have been deleted'
class TestLRUDict:
def init_cache(self, **kwargs):
cache = LRUDict(CACHE_NAME, table_name='lru', use_temp=True, **kwargs)
cache.clear()
return cache
@classmethod
def teardown_class(cls):
try:
os.unlink(f'{CACHE_NAME}.sqlite')
except Exception:
pass
def test_get_set(self):
cache = self.init_cache()
cache['key1'] = 100
cache['key2'] = 200
cache['key3'] = 300
assert cache['key1'] == 100
assert cache['key2'] == 200
assert cache['key3'] == 300
with pytest.raises(KeyError):
_ = cache['nonexistent']
def test_delete(self):
cache = self.init_cache()
cache['key'] = 0
del cache['key']
with pytest.raises(KeyError):
_ = cache['key']
def test_count(self):
cache = self.init_cache()
assert cache.count() == 0
cache['key1'] = 100
assert cache.count() == 1
cache['key2'] = 200
cache['key3'] = 300
assert cache.count() == 3
del cache['key1']
assert cache.count() == 2
def test_clear(self):
cache = self.init_cache()
cache['key1'] = 100
cache['key2'] = 200
cache['key3'] = 300
assert len(cache) == 3
assert cache.total_size() == 600
cache.clear()
assert len(cache) == 0
assert cache.total_size() == 0
def test_get_lru(self):
cache = self.init_cache()
assert cache.get_lru(total_size=1) == []
cache['key0'] = 0
cache['key1'] = 100
cache['key2'] = 200
cache['key3'] = 300
sleep(0.001)
cache.update_access_time('key1')
# Order should be (from least to most recent): key0, key2, key3, key1
assert cache.get_lru(total_size=1) == ['key0', 'key2']
assert cache.get_lru(total_size=200) == ['key0', 'key2']
assert cache.get_lru(total_size=201) == ['key0', 'key2', 'key3']
assert cache.get_lru(total_size=500) == ['key0', 'key2', 'key3']
assert cache.get_lru(total_size=501) == ['key0', 'key2', 'key3', 'key1']
assert cache.get_lru(total_size=600) == ['key0', 'key2', 'key3', 'key1']
assert cache.get_lru(total_size=700) == ['key0', 'key2', 'key3', 'key1']
@pytest.mark.parametrize(
('sort_key', 'reversed_order', 'limit', 'expected'),
[
# Sort by access_time (default)
('access_time', False, None, ['key1', 'key2', 'key3']),
('access_time', True, None, ['key3', 'key2', 'key1']),
('access_time', False, 2, ['key1', 'key2']),
('access_time', True, 2, ['key3', 'key2']),
# Sort by size
('size', False, None, ['key1', 'key2', 'key3']),
('size', True, None, ['key3', 'key2', 'key1']),
('size', False, 2, ['key1', 'key2']),
# Sort by key (alphabetical)
('key', False, None, ['key1', 'key2', 'key3']),
('key', True, None, ['key3', 'key2', 'key1']),
],
)
def test_sorted(self, sort_key, reversed_order, limit, expected):
cache = self.init_cache()
# Add items with different sizes and access times
cache['key1'] = 100
sleep(0.001)
cache['key2'] = 200
sleep(0.001)
cache['key3'] = 300
result = list(cache.sorted(key=sort_key, reversed=reversed_order, limit=limit))
assert result == expected
def test_sorted__invalid_key(self):
cache = self.init_cache()
cache['key1'] = 100
with pytest.raises(ValueError, match='Invalid sort key: invalid'):
list(cache.sorted(key='invalid'))
def test_total_size(self):
"""Test that total cache size is tracked correctly"""
cache = self.init_cache()
assert cache.total_size() == 0
# Create
cache['key1'] = 100
assert cache.total_size() == 100
cache['key2'] = 200
assert cache.total_size() == 300
# Update
cache['key1'] = 150
assert cache.total_size() == 350
# Delete
del cache['key1']
assert cache.total_size() == 200
def test_update_access_time(self):
cache = self.init_cache()
cache['key1'] = 100
cache['key2'] = 200
cache['key3'] = 300
assert list(cache.sorted()) == ['key1', 'key2', 'key3']
sleep(0.001)
cache.update_access_time('key1')
assert list(cache.sorted()) == ['key2', 'key3', 'key1']
def test_update_access_time__keyerror(self):
cache = self.init_cache()
with pytest.raises(KeyError):
cache.update_access_time('nonexistent')
class TestFileCache(BaseCacheTest):
backend_class = FileCache
init_kwargs = {'use_temp': True}
@pytest.mark.parametrize('serializer', TEST_SERIALIZERS.values())
@pytest.mark.parametrize('method', HTTPBIN_METHODS)
@pytest.mark.parametrize('field', ['params', 'data', 'json'])
def test_all_methods(self, field, method, serializer):
"""Test all relevant combinations of methods X data fields X serializers"""
if not _valid_serializer(serializer):
pytest.skip(f'Dependencies not installed for {serializer}')
super().test_all_methods(field, method, serializer)
@pytest.mark.parametrize('serializer', TEST_SERIALIZERS.values())
@pytest.mark.parametrize('response_format', HTTPBIN_FORMATS)
def test_all_response_formats(self, response_format, serializer):
"""Test all relevant combinations of response formats X serializers"""
if not _valid_serializer(serializer):
pytest.skip(f'Dependencies not installed for {serializer}')
serializer.set_decode_content(False)
super().test_all_response_formats(response_format, serializer)
@pytest.mark.parametrize('serializer', [json_serializer, yaml_serializer])
@pytest.mark.parametrize('response_format', HTTPBIN_FORMATS)
def test_all_response_formats__no_decode_content(self, response_format, serializer):
"""Test with decode_content=True for text-based serialization formats"""
if not _valid_serializer(serializer):
pytest.skip(f'Dependencies not installed for {serializer}')
serializer.set_decode_content(True)
self.test_all_response_formats(response_format, serializer)
@pytest.mark.parametrize('serializer_name', SERIALIZERS.keys())
def test_paths(self, serializer_name):
if not isinstance(SERIALIZERS[serializer_name], SerializerPipeline):
pytest.skip(f'Dependencies not installed for {serializer_name}')
session = self.init_session(serializer=serializer_name)
num_files = 20
for i in range(num_files):
session.cache.responses[f'key_{i}'] = {f'value_{i}': i}
expected_extension = serializer_name.replace('pickle', 'pkl')
if 'json' in serializer_name:
expected_extension = 'json'
assert len(list(session.cache.paths())) == num_files
for path in session.cache.paths():
assert path.is_file()
assert path.suffix == f'.{expected_extension}'
# Redirects db should be in the same directory as response files
assert session.cache.redirects.db_path.parent == session.cache.responses.cache_dir
@pytest.mark.parametrize('max_cache_bytes', [1000, 10000])
@pytest.mark.parametrize('files_on_disk', [3, 100])
def test_drop_oldest_files(self, max_cache_bytes, files_on_disk):
"""Check that the files are added up to the size.
After adding, the first ones are dropped.
"""
file_content = '0' * (max_cache_bytes // files_on_disk - 2) # remove UTF8 BOM
session = self.init_session(max_cache_bytes=max_cache_bytes, block_bytes=1)
for i in range(files_on_disk):
session.cache.responses[f'key_{i}'] = file_content
assert len(list(session.cache.paths())) == files_on_disk, (
f'Expecting {files_on_disk} files with size {len(file_content)}, got {len(list(session.cache.paths()))}'
)
assert len(list(session.cache.paths())) == files_on_disk
session.cache.responses['new_file'] = file_content
assert len(list(session.cache.paths())) == files_on_disk, 'One file should be dropped.'
assert 'key_0' not in session.cache.responses.keys(), (
'File key_0 should be dropped because it is the last one.'
)
@pytest.mark.parametrize('max_size_on_disk', [1000, 10000])
def test_do_not_store_files_too_big(self, max_size_on_disk):
"""If a file is too big, it should not be cached at all."""
file_content = '0' * (max_size_on_disk + 1)
session = self.init_session(max_cache_bytes=max_size_on_disk, block_bytes=1)
session.cache.responses['key'] = file_content
assert 'key' not in session.cache.responses.keys()
def test_size(self):
"""Check that the total bytes are summed up if files are added."""
session = self.init_session(max_cache_bytes=100, block_bytes=1, serializer=utf8_serializer)
start_bytes = session.cache.responses.size()
session.cache.responses['key_1'] = 'value_1'
assert session.cache.responses.size() == start_bytes + len('value_1')