feat(cache): Implement unified caching subsystem with decorators, strategies, and management

- Added core caching functionality with `Cache` class supporting in-memory and file-based caching.
- Introduced `CacheManager` for high-level cache operations and statistics.
- Created various cache key generation strategies: `FilepathMethodStrategy`, `APIRequestStrategy`, `SimpleKeyStrategy`, and `CustomStrategy`.
- Developed decorators for easy method caching: `cached`, `cached_method`, `cached_api`, and `cached_property`.
- Implemented type definitions for cache entries and statistics.
- Added comprehensive tests for cache operations, strategies, and decorators to ensure functionality and backward compatibility.
This commit is contained in:
sha
2025-12-31 02:29:10 +00:00
parent 3fbf45083f
commit b50b9bc165
16 changed files with 1851 additions and 259 deletions
+257
View File
@@ -0,0 +1,257 @@
"""Tests for the unified cache subsystem."""
import pytest
from pathlib import Path
from renamer.cache import (
Cache,
CacheManager,
cached,
cached_method,
cached_api,
FilepathMethodStrategy,
APIRequestStrategy,
SimpleKeyStrategy,
CustomStrategy
)
class TestCacheBasicOperations:
"""Test basic cache operations."""
@pytest.fixture
def cache(self):
"""Create a cache instance for testing."""
return Cache()
@pytest.fixture
def manager(self, cache):
"""Create a cache manager for testing."""
return CacheManager(cache)
def test_set_and_get_object(self, cache):
"""Test storing and retrieving an object."""
cache.set_object("test_key", {"data": "value"}, ttl_seconds=3600)
result = cache.get_object("test_key")
assert result == {"data": "value"}
def test_cache_manager_stats(self, manager):
"""Test getting cache statistics."""
stats = manager.get_stats()
assert 'total_files' in stats
assert 'total_size_mb' in stats
assert 'memory_cache_entries' in stats
assert 'subdirs' in stats
class TestCacheStrategies:
"""Test cache key generation strategies."""
def test_filepath_method_strategy(self):
"""Test FilepathMethodStrategy generates correct keys."""
strategy = FilepathMethodStrategy()
key = strategy.generate_key(Path("/test/file.mkv"), "extract_title")
assert key.startswith("extractor_")
assert "extract_title" in key
def test_filepath_method_strategy_with_instance_id(self):
"""Test FilepathMethodStrategy with instance ID."""
strategy = FilepathMethodStrategy()
key = strategy.generate_key(
Path("/test/file.mkv"),
"extract_title",
instance_id="12345"
)
assert key.startswith("extractor_")
assert "12345" in key
assert "extract_title" in key
def test_api_request_strategy(self):
"""Test APIRequestStrategy generates correct keys."""
strategy = APIRequestStrategy()
key = strategy.generate_key("tmdb", "/movie/search", {"query": "test"})
assert key.startswith("api_tmdb_")
def test_api_request_strategy_no_params(self):
"""Test APIRequestStrategy without params."""
strategy = APIRequestStrategy()
key = strategy.generate_key("imdb", "/title/search")
assert key.startswith("api_imdb_")
def test_simple_key_strategy(self):
"""Test SimpleKeyStrategy generates correct keys."""
strategy = SimpleKeyStrategy()
key = strategy.generate_key("poster", "movie_123")
assert key == "poster_movie_123"
def test_simple_key_strategy_sanitizes_path_separators(self):
"""Test SimpleKeyStrategy sanitizes dangerous characters."""
strategy = SimpleKeyStrategy()
key = strategy.generate_key("poster", "path/to/file")
assert "/" not in key
assert key == "poster_path_to_file"
def test_custom_strategy(self):
"""Test CustomStrategy with custom function."""
def my_key_func(prefix, identifier):
return f"custom_{prefix}_{identifier}"
strategy = CustomStrategy(my_key_func)
key = strategy.generate_key("test", "123")
assert key == "custom_test_123"
class TestCacheDecorators:
"""Test cache decorators."""
@pytest.fixture
def cache(self):
"""Create a cache instance for testing."""
return Cache()
def test_cached_method_decorator(self, cache):
"""Test cached_method decorator caches results."""
call_count = 0
class TestExtractor:
def __init__(self, file_path):
self.file_path = file_path
self.cache = cache
@cached_method(ttl=3600)
def extract_title(self):
nonlocal call_count
call_count += 1
return "Test Movie"
extractor = TestExtractor(Path("/test/movie.mkv"))
# First call executes the method
result1 = extractor.extract_title()
assert result1 == "Test Movie"
assert call_count == 1
# Second call uses cache
result2 = extractor.extract_title()
assert result2 == "Test Movie"
assert call_count == 1 # Should still be 1 (cached)
def test_cached_method_without_cache_attribute(self):
"""Test cached_method executes without caching if no cache attribute."""
call_count = 0
class TestExtractor:
def __init__(self, file_path):
self.file_path = file_path
# No cache attribute!
@cached_method(ttl=3600)
def extract_title(self):
nonlocal call_count
call_count += 1
return "Test Movie"
extractor = TestExtractor(Path("/test/movie.mkv"))
# Both calls should execute since no cache
result1 = extractor.extract_title()
assert result1 == "Test Movie"
assert call_count == 1
result2 = extractor.extract_title()
assert result2 == "Test Movie"
assert call_count == 2 # Should increment (no caching)
def test_cached_method_different_instances(self, cache):
"""Test cached_method creates different cache keys for different files."""
call_count = 0
class TestExtractor:
def __init__(self, file_path):
self.file_path = file_path
self.cache = cache
@cached_method(ttl=3600)
def extract_title(self):
nonlocal call_count
call_count += 1
return f"Title for {self.file_path.name}"
extractor1 = TestExtractor(Path("/test/movie1.mkv"))
extractor2 = TestExtractor(Path("/test/movie2.mkv"))
result1 = extractor1.extract_title()
result2 = extractor2.extract_title()
assert result1 != result2
assert call_count == 2 # Both should execute (different files)
class TestCacheManager:
"""Test cache manager operations."""
@pytest.fixture
def cache(self):
"""Create a cache instance for testing."""
return Cache()
@pytest.fixture
def manager(self, cache):
"""Create a cache manager for testing."""
return CacheManager(cache)
def test_clear_by_prefix(self, cache, manager):
"""Test clearing cache by prefix."""
# Add some test data with recognized prefixes
cache.set_object("tmdb_movie_123", "data1", 3600)
cache.set_object("tmdb_movie_456", "data2", 3600)
cache.set_object("extractor_test_1", "data3", 3600)
# Clear only tmdb_ prefix
manager.clear_by_prefix("tmdb_")
# tmdb_ entries should be gone
assert cache.get_object("tmdb_movie_123") is None
assert cache.get_object("tmdb_movie_456") is None
# extractor_ entry should remain
assert cache.get_object("extractor_test_1") == "data3"
def test_clear_all(self, cache, manager):
"""Test clearing all cache."""
# Add some test data
cache.set_object("key1", "data1", 3600)
cache.set_object("key2", "data2", 3600)
# Clear all
manager.clear_all()
# All should be gone
assert cache.get_object("key1") is None
assert cache.get_object("key2") is None
def test_compact_cache(self, manager):
"""Test cache compaction."""
# Just verify it runs without error
manager.compact_cache()
class TestBackwardCompatibility:
"""Test backward compatibility with old import paths."""
def test_import_from_decorators(self):
"""Test importing from renamer.decorators still works."""
from renamer.decorators import cached_method
assert cached_method is not None
def test_import_cache_from_package(self):
"""Test importing Cache from renamer.cache package."""
from renamer.cache import Cache as PackageCache
assert PackageCache is not None
def test_create_cache_convenience_function(self):
"""Test the create_cache convenience function."""
from renamer.cache import create_cache
cache, manager = create_cache()
assert cache is not None
assert manager is not None
assert isinstance(manager, CacheManager)
+26 -17
View File
@@ -1,5 +1,6 @@
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
import json
@@ -17,7 +18,14 @@ class TestMediaInfoExtractor:
@pytest.fixture
def frame_class_cases(self):
"""Load test cases for frame class extraction"""
# Try the expected file first, fallback to the main frame class test file
cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json"
if not cases_file.exists():
cases_file = Path(__file__).parent / "test_mediainfo_frame_class.json"
if not cases_file.exists():
pytest.skip(f"Test case file not found: {cases_file}")
with open(cases_file, 'r') as f:
return json.load(f)
@@ -57,20 +65,21 @@ class TestMediaInfoExtractor:
# Text files don't have video tracks
assert is_3d is False
@pytest.mark.parametrize("case", [
pytest.param(case, id=case["testname"])
for case in json.load(open(Path(__file__).parent / "test_mediainfo_frame_class_cases.json"))
])
def test_extract_frame_class(self, case):
"""Test extracting frame class from various resolutions"""
# Create a mock extractor with the test resolution
extractor = MediaInfoExtractor.__new__(MediaInfoExtractor)
extractor.video_tracks = [{
'width': case["resolution"][0],
'height': case["resolution"][1],
'interlaced': 'Yes' if case["interlaced"] else None
}]
result = extractor.extract_frame_class()
print(f"Case: {case['testname']}, resolution: {case['resolution']}, expected: {case['expected_frame_class']}, got: {result}")
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"
def test_extract_frame_class_parametrized(self, frame_class_cases):
"""Test extracting frame class from various resolutions using fixture"""
for case in frame_class_cases:
# Create a mock extractor with the test resolution
extractor = MagicMock(spec=MediaInfoExtractor)
extractor.file_path = Path(f"test_{case['testname']}")
# Mock the video_tracks with proper attributes
mock_track = MagicMock()
mock_track.height = case["resolution"][1]
mock_track.width = case["resolution"][0]
mock_track.interlaced = 'Yes' if case["interlaced"] else 'No'
extractor.video_tracks = [mock_track]
# Call the actual method
result = MediaInfoExtractor.extract_frame_class(extractor)
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"
+5 -1
View File
@@ -9,8 +9,12 @@ import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
from pathlib import Path
test_cases = json.load(open('renamer/test/test_mediainfo_frame_class.json'))
# Load test cases from JSON file using context manager
test_cases_file = Path(__file__).parent / 'test_mediainfo_frame_class.json'
with open(test_cases_file, 'r') as f:
test_cases = json.load(f)
@pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases])
def test_frame_class_detection(test_case):