Files
EOS/tests/test_cache.py
T

1101 lines
41 KiB
Python
Raw Normal View History

import functools
2025-02-12 21:35:51 +01:00
import io
import json
import os
2025-02-12 21:35:51 +01:00
import pickle
import subprocess
import sys
2025-02-12 21:35:51 +01:00
import tempfile
import textwrap
2025-02-12 21:35:51 +01:00
from datetime import date, datetime, timedelta
from pathlib import Path
from time import sleep
from unittest.mock import MagicMock, patch
import cachebox
import pytest
from akkudoktoreos.core.cache import (
2025-10-28 02:50:31 +01:00
CacheEnergyManagementStore,
2025-02-12 21:35:51 +01:00
CacheFileRecord,
CacheFileStore,
_get_func_code_hash,
2025-10-28 02:50:31 +01:00
cache_energy_management,
2025-02-12 21:35:51 +01:00
cache_in_file,
)
from akkudoktoreos.utils.datetimeutil import (
Duration,
compare_datetimes,
to_datetime,
to_duration,
)
2025-02-12 21:35:51 +01:00
# ---------------------------------
# In-Memory Caching Functionality
# ---------------------------------
# Fixtures for testing
@pytest.fixture
2025-10-28 02:50:31 +01:00
def cache_energy_management_store():
"""Ensures CacheEnergyManagementStore is reset between tests."""
cache = CacheEnergyManagementStore()
CacheEnergyManagementStore().clear()
2025-02-12 21:35:51 +01:00
assert len(cache) == 0
return cache
2025-10-28 02:50:31 +01:00
class TestCacheEnergyManagementStore:
def test_cache_initialization(self, cache_energy_management_store):
"""Test that CacheEnergyManagementStore initializes with the correct properties."""
cache = CacheEnergyManagementStore()
2025-02-12 21:35:51 +01:00
assert isinstance(cache.cache, cachebox.LRUCache)
assert cache.maxsize == 100
assert len(cache) == 0
2025-10-28 02:50:31 +01:00
def test_singleton_behavior(self, cache_energy_management_store):
"""Test that CacheEnergyManagementStore is a singleton."""
cache1 = CacheEnergyManagementStore()
cache2 = CacheEnergyManagementStore()
2025-02-12 21:35:51 +01:00
assert cache1 is cache2
2025-10-28 02:50:31 +01:00
def test_cache_storage(self, cache_energy_management_store):
2025-02-12 21:35:51 +01:00
"""Test that items can be added and retrieved from the cache."""
2025-10-28 02:50:31 +01:00
cache = CacheEnergyManagementStore()
2025-02-12 21:35:51 +01:00
cache["key1"] = "value1"
assert cache["key1"] == "value1"
assert len(cache) == 1
2025-10-28 02:50:31 +01:00
def test_cache_getattr_invalid_method(self, cache_energy_management_store):
2025-02-12 21:35:51 +01:00
"""Test that accessing an invalid method raises an AttributeError."""
with pytest.raises(AttributeError):
2025-10-28 02:50:31 +01:00
CacheEnergyManagementStore().non_existent_method() # This should raise AttributeError
2025-02-12 21:35:51 +01:00
class TestCacheUntilUpdateDecorators:
2025-10-28 02:50:31 +01:00
def test_cachemethod_energy_management(self, cache_energy_management_store):
2025-11-13 15:13:37 +01:00
"""Test that cache_energy_management caches method results."""
2025-02-12 21:35:51 +01:00
class MyClass:
2025-11-13 15:13:37 +01:00
@cache_energy_management
2025-02-12 21:35:51 +01:00
def compute(self, value: int) -> int:
return value * 2
obj = MyClass()
# Call method and assert caching
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 0
assert CacheEnergyManagementStore.hit_count == 0
2025-02-12 21:35:51 +01:00
result1 = obj.compute(5)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 1
assert CacheEnergyManagementStore.hit_count == 0
2025-02-12 21:35:51 +01:00
result2 = obj.compute(5)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 1
assert CacheEnergyManagementStore.hit_count == 1
2025-02-12 21:35:51 +01:00
assert result1 == result2
@pytest.mark.parametrize("reverse", [False, True])
@pytest.mark.parametrize("use_kwargs", [False, True])
def test_methods_with_identical_arguments_keep_separate_results(
self, cache_energy_management_store, reverse, use_kwargs
):
calls = []
class Model:
@cache_energy_management
def fraction(self, value):
calls.append("fraction")
return value / 1000
@cache_energy_management
def energy(self, value):
calls.append("energy")
return value * 1000
model = Model()
cases = [(model.fraction, 0.005), (model.energy, 5000)]
if reverse:
cases.reverse()
for _ in range(2):
for method, expected in cases:
result = method(value=5) if use_kwargs else method(5)
assert result == expected
assert sorted(calls) == ["energy", "fraction"]
assert CacheEnergyManagementStore.miss_count == 2
assert CacheEnergyManagementStore.hit_count == 2
def test_distinct_closures_with_same_name_do_not_share_results(
self, cache_energy_management_store
):
def make_function(factor):
@cache_energy_management
def compute(value):
return value * factor
return compute
double = make_function(2)
triple = make_function(3)
assert double.__qualname__ == triple.__qualname__
assert double(4) == 8
assert triple(4) == 12
assert double(4) == 8
assert triple(4) == 12
assert CacheEnergyManagementStore.miss_count == 2
assert CacheEnergyManagementStore.hit_count == 2
2025-10-28 02:50:31 +01:00
def test_cache_energy_management(self, cache_energy_management_store):
"""Test that cache_energy_management caches function results."""
2025-02-12 21:35:51 +01:00
2025-10-28 02:50:31 +01:00
@cache_energy_management
2025-02-12 21:35:51 +01:00
def compute(value: int) -> int:
return value * 3
# Call function and assert caching
result1 = compute(4)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.last_event == cachebox.EVENT_MISS
2025-02-12 21:35:51 +01:00
result2 = compute(4)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.last_event == cachebox.EVENT_HIT
2025-02-12 21:35:51 +01:00
assert result1 == result2
2025-10-28 02:50:31 +01:00
def test_cache_with_different_arguments(self, cache_energy_management_store):
2025-02-12 21:35:51 +01:00
"""Test that caching works for different arguments."""
class MyClass:
2025-11-13 15:13:37 +01:00
@cache_energy_management
2025-02-12 21:35:51 +01:00
def compute(self, value: int) -> int:
return value * 2
obj = MyClass()
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 0
2025-02-12 21:35:51 +01:00
result1 = obj.compute(3)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.last_event == cachebox.EVENT_MISS
assert CacheEnergyManagementStore.miss_count == 1
2025-02-12 21:35:51 +01:00
result2 = obj.compute(5)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.last_event == cachebox.EVENT_MISS
assert CacheEnergyManagementStore.miss_count == 2
2025-02-12 21:35:51 +01:00
assert result1 == 6
assert result2 == 10
2025-10-28 02:50:31 +01:00
def test_cache_clearing(self, cache_energy_management_store):
2025-02-12 21:35:51 +01:00
"""Test that cache is cleared between EMS update cycles."""
class MyClass:
2025-11-13 15:13:37 +01:00
@cache_energy_management
2025-02-12 21:35:51 +01:00
def compute(self, value: int) -> int:
return value * 2
obj = MyClass()
obj.compute(5)
# Clear cache
2025-10-28 02:50:31 +01:00
CacheEnergyManagementStore().clear()
2025-02-12 21:35:51 +01:00
with pytest.raises(KeyError):
2025-10-28 02:50:31 +01:00
_ = CacheEnergyManagementStore()["<invalid>"]
2025-02-12 21:35:51 +01:00
2025-10-28 02:50:31 +01:00
def test_decorator_works_for_standalone_function(self, cache_energy_management_store):
"""Test that cache_energy_management works with standalone functions."""
2025-02-12 21:35:51 +01:00
2025-10-28 02:50:31 +01:00
@cache_energy_management
2025-02-12 21:35:51 +01:00
def add(a: int, b: int) -> int:
return a + b
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 0
assert CacheEnergyManagementStore.hit_count == 0
2025-02-12 21:35:51 +01:00
result1 = add(1, 2)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 1
assert CacheEnergyManagementStore.hit_count == 0
2025-02-12 21:35:51 +01:00
result2 = add(1, 2)
2025-10-28 02:50:31 +01:00
assert CacheEnergyManagementStore.miss_count == 1
assert CacheEnergyManagementStore.hit_count == 1
2025-02-12 21:35:51 +01:00
assert result1 == result2
# -----------------------------
# CacheFileStore
# -----------------------------
@pytest.fixture
def temp_store_file():
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
yield Path(temp_file.file.name)
# temp_file.unlink()
@pytest.fixture
def cache_file_store(temp_store_file, monkeypatch):
2025-02-12 21:35:51 +01:00
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
2025-02-12 21:35:51 +01:00
cache = CacheFileStore()
# Patch the _cache_file method to return the temp file
monkeypatch.setattr(
cache,
"_store_file",
lambda: temp_store_file,
)
2025-02-12 21:35:51 +01:00
cache.clear(clear_all=True)
assert len(cache._store) == 0
2025-02-12 21:35:51 +01:00
return cache
class TestCacheFileStore:
def test_generate_cache_file_key(self, cache_file_store):
"""Test cache file key generation based on URL and date."""
key = "http://example.com"
# Provide until date - assure until_dt is used.
until_dt = to_datetime("2024-10-01")
cache_file_key, cache_file_until_dt, ttl_duration = (
cache_file_store._generate_cache_file_key(key=key, until_datetime=until_dt)
)
assert cache_file_key is not None
assert compare_datetimes(cache_file_until_dt, until_dt).equal
# Provide until date again - assure same key is generated.
cache_file_key1, cache_file_until_dt1, ttl_duration1 = (
cache_file_store._generate_cache_file_key(key=key, until_datetime=until_dt)
)
assert cache_file_key1 == cache_file_key
assert compare_datetimes(cache_file_until_dt1, until_dt).equal
# Provide no until date - assure today EOD is used.
no_until_dt = to_datetime().end_of("day")
cache_file_key, cache_file_until_dt, ttl_duration = (
cache_file_store._generate_cache_file_key(key)
)
assert cache_file_key is not None
assert compare_datetimes(cache_file_until_dt, no_until_dt).equal
# Provide with_ttl - assure until_dt is used.
until_dt = to_datetime().add(hours=1)
cache_file_key, cache_file_until_dt, ttl_duration = (
cache_file_store._generate_cache_file_key(key, with_ttl="1 hour")
)
assert cache_file_key is not None
assert compare_datetimes(cache_file_until_dt, until_dt).approximately_equal
assert ttl_duration == to_duration("1 hour")
# Provide with_ttl again - assure same key is generated.
until_dt = to_datetime().add(hours=1)
cache_file_key1, cache_file_until_dt1, ttl_duration1 = (
cache_file_store._generate_cache_file_key(key=key, with_ttl="1 hour")
)
assert cache_file_key1 == cache_file_key
assert compare_datetimes(cache_file_until_dt1, until_dt).approximately_equal
assert ttl_duration1 == to_duration("1 hour")
# Provide different with_ttl - assure different key is generated.
until_dt = to_datetime().add(hours=1, minutes=1)
cache_file_key2, cache_file_until_dt2, ttl_duration2 = (
cache_file_store._generate_cache_file_key(key=key, with_ttl="1 hour 1 minute")
)
assert cache_file_key2 != cache_file_key
assert compare_datetimes(cache_file_until_dt2, until_dt).approximately_equal
assert ttl_duration2 == to_duration("1 hour 1 minute")
def test_get_file_path(self, cache_file_store):
"""Test get file path from cache file object."""
cache_file = cache_file_store.create("test_file", mode="w+", suffix=".txt")
file_path = cache_file_store._get_file_path(cache_file)
assert file_path is not None
def test_until_datetime_by_options(self, cache_file_store):
"""Test until datetime calculation based on options."""
now = to_datetime()
# Test with until_datetime
result, ttl_duration = cache_file_store._until_datetime_by_options(until_datetime=now)
assert result == now
assert ttl_duration is None
# -- From now on we expect a until_datetime in one hour
ttl_duration_expected: Duration | None = to_duration("1 hour")
2025-02-12 21:35:51 +01:00
# Test with with_ttl as timedelta
until_datetime_expected = to_datetime().add(hours=1)
ttl = timedelta(hours=1)
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl)
assert compare_datetimes(result, until_datetime_expected).approximately_equal
assert ttl_duration == ttl_duration_expected
# Test with with_ttl as int (seconds)
until_datetime_expected = to_datetime().add(hours=1)
ttl_seconds = 3600
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl_seconds)
assert compare_datetimes(result, until_datetime_expected).approximately_equal
assert ttl_duration == ttl_duration_expected
# Test with with_ttl as string ("1 hour")
until_datetime_expected = to_datetime().add(hours=1)
ttl_string = "1 hour"
result, ttl_duration = cache_file_store._until_datetime_by_options(with_ttl=ttl_string)
assert compare_datetimes(result, until_datetime_expected).approximately_equal
assert ttl_duration == ttl_duration_expected
# -- From now on we expect a until_datetime today at end of day
until_datetime_expected = to_datetime().end_of("day")
ttl_duration_expected = None
# Test default case (end of today)
result, ttl_duration = cache_file_store._until_datetime_by_options()
assert compare_datetimes(result, until_datetime_expected).equal
assert ttl_duration == ttl_duration_expected
# -- From now on we expect a until_datetime in one day at end of day
until_datetime_expected = to_datetime().add(days=1).end_of("day")
assert ttl_duration == ttl_duration_expected
# Test with until_date as date
until_date = date.today() + timedelta(days=1)
result, ttl_duration = cache_file_store._until_datetime_by_options(until_date=until_date)
assert compare_datetimes(result, until_datetime_expected).equal
assert ttl_duration == ttl_duration_expected
# -- Test with multiple options (until_datetime takes precedence)
specific_datetime = to_datetime().add(days=2)
result, ttl_duration = cache_file_store._until_datetime_by_options(
until_date=to_datetime().add(days=1).date(),
until_datetime=specific_datetime,
with_ttl=ttl,
)
assert compare_datetimes(result, specific_datetime).equal
assert ttl_duration is None
# Test with invalid inputs
with pytest.raises(ValueError):
cache_file_store._until_datetime_by_options(until_date="invalid-date")
with pytest.raises(ValueError):
cache_file_store._until_datetime_by_options(with_ttl="invalid-ttl")
with pytest.raises(ValueError):
cache_file_store._until_datetime_by_options(until_datetime="invalid-datetime")
def test_create_cache_file(self, cache_file_store):
"""Test the creation of a cache file and ensure it is stored correctly."""
# Create a cache file for today's date
cache_file = cache_file_store.create("test_file", mode="w+", suffix=".txt")
# Check that the file exists in the store and is a file-like object
assert cache_file is not None
assert hasattr(cache_file, "name")
assert cache_file.name.endswith(".txt")
# Write some data to the file
cache_file.seek(0)
cache_file.write("Test data")
cache_file.seek(0) # Reset file pointer
assert cache_file.read() == "Test data"
def test_get_cache_file(self, cache_file_store):
"""Test retrieving an existing cache file by key."""
# Create a cache file and write data to it
cache_file = cache_file_store.create("test_file", mode="w+")
cache_file.seek(0)
cache_file.write("Test data")
cache_file.seek(0)
# Retrieve the cache file and verify the data
retrieved_file = cache_file_store.get("test_file")
assert retrieved_file is not None
retrieved_file.seek(0)
assert retrieved_file.read() == "Test data"
def test_set_custom_file_object(self, cache_file_store):
"""Test setting a custom file-like object (BytesIO or StringIO) in the store."""
# Create a BytesIO object and set it into the cache
file_obj = io.BytesIO(b"Binary data")
cache_file_store.set("binary_file", file_obj)
# Retrieve the file from the store
retrieved_file = cache_file_store.get("binary_file")
assert isinstance(retrieved_file, io.BytesIO)
retrieved_file.seek(0)
assert retrieved_file.read() == b"Binary data"
def test_delete_cache_file(self, cache_file_store):
"""Test deleting a cache file from the store."""
# Create multiple cache files
cache_file1 = cache_file_store.create("file1")
assert hasattr(cache_file1, "name")
cache_file2 = cache_file_store.create("file2")
assert hasattr(cache_file2, "name")
# Ensure the files are in the store
assert cache_file_store.get("file1") is cache_file1
assert cache_file_store.get("file2") is cache_file2
# Delete cache files
cache_file_store.delete("file1")
cache_file_store.delete("file2")
# Ensure the store is empty
assert cache_file_store.get("file1") is None
assert cache_file_store.get("file2") is None
def test_clear_all_cache_files(self, cache_file_store):
"""Test clearing all cache files from the store."""
# Create multiple cache files
cache_file1 = cache_file_store.create("file1")
assert hasattr(cache_file1, "name")
cache_file2 = cache_file_store.create("file2")
assert hasattr(cache_file2, "name")
# Ensure the files are in the store
assert cache_file_store.get("file1") is cache_file1
assert cache_file_store.get("file2") is cache_file2
current_store = cache_file_store.current_store()
assert current_store != {}
# Clear all cache files
cache_file_store.clear(clear_all=True)
# Ensure the store is empty
assert cache_file_store.get("file1") is None
assert cache_file_store.get("file2") is None
current_store = cache_file_store.current_store()
assert current_store == {}
def test_clear_cache_files_by_date(self, cache_file_store):
"""Test clearing cache files from the store by date."""
# Create multiple cache files
cache_file1 = cache_file_store.create("file1")
assert hasattr(cache_file1, "name")
cache_file2 = cache_file_store.create("file2")
assert hasattr(cache_file2, "name")
# Ensure the files are in the store
assert cache_file_store.get("file1") is cache_file1
assert cache_file_store.get("file2") is cache_file2
# Clear cache files that are older than today
cache_file_store.clear(before_datetime=to_datetime().start_of("day"))
# Ensure the files are in the store
assert cache_file_store.get("file1") is cache_file1
assert cache_file_store.get("file2") is cache_file2
# Clear cache files that are older than tomorrow
cache_file_store.clear(before_datetime=datetime.now() + timedelta(days=1))
# Ensure the store is empty
assert cache_file_store.get("file1") is None
assert cache_file_store.get("file2") is None
def test_cache_file_with_date(self, cache_file_store):
"""Test creating and retrieving cache files with a specific date."""
# Use a specific date for cache file creation
specific_date = datetime(2023, 10, 10)
cache_file = cache_file_store.create("dated_file", mode="w+", until_date=specific_date)
# Write data to the cache file
cache_file.write("Dated data")
cache_file.seek(0)
# Retrieve the cache file with the specific date
retrieved_file = cache_file_store.get("dated_file", until_date=specific_date)
assert retrieved_file is not None
retrieved_file.seek(0)
assert retrieved_file.read() == "Dated data"
def test_recreate_existing_cache_file(self, cache_file_store):
"""Test creating a cache file with an existing key does not overwrite the existing file."""
# Create a cache file
cache_file = cache_file_store.create("test_file", mode="w+")
cache_file.write("Original data")
cache_file.seek(0)
# Attempt to recreate the same file (should return the existing one)
new_file = cache_file_store.create("test_file")
assert new_file is cache_file # Should be the same object
new_file.seek(0)
assert new_file.read() == "Original data" # Data should be preserved
# Assure cache file store is a singleton
cache_file_store2 = CacheFileStore()
new_file = cache_file_store2.get("test_file")
assert new_file is cache_file # Should be the same object
def test_cache_file_store_is_singleton(self, cache_file_store):
"""Test re-creating a cache store provides the same store."""
# Create a cache file
cache_file = cache_file_store.create("test_file", mode="w+")
cache_file.write("Original data")
cache_file.seek(0)
# Assure cache file store is a singleton
cache_file_store2 = CacheFileStore()
new_file = cache_file_store2.get("test_file")
assert new_file is cache_file # Should be the same object
def test_cache_file_store_save_store(self, cache_file_store):
# Creating a sample cache record
cache_file = MagicMock()
cache_file.name = "cache_file_path"
cache_file.mode = "wb+"
cache_record = CacheFileRecord(
cache_file=cache_file, until_datetime=to_datetime(), ttl_duration=None
)
cache_file_store._store = {"test_key": cache_record}
# Save the store to the file
cache_file_store.save_store()
# Verify the file content
with cache_file_store._store_file().open("r", encoding="utf-8", newline=None) as f:
2025-02-12 21:35:51 +01:00
store_loaded = json.load(f)
assert "test_key" in store_loaded
assert store_loaded["test_key"]["cache_file"] == "cache_file_path"
assert store_loaded["test_key"]["mode"] == "wb+"
assert store_loaded["test_key"]["until_datetime"] == to_datetime(
cache_record.until_datetime, as_string=True
)
assert store_loaded["test_key"]["ttl_duration"] is None
def test_cache_file_store_load_store(self, cache_file_store):
# Creating a sample cache record and save it to the file
cache_record = {
"test_key": {
"cache_file": "cache_file_path",
"mode": "wb+",
"until_datetime": to_datetime(as_string=True),
"ttl_duration": None,
}
}
with cache_file_store._store_file().open("w", encoding="utf-8", newline="\n") as f:
2025-02-12 21:35:51 +01:00
json.dump(cache_record, f, indent=4)
# Mock the open function to return a MagicMock for the cache file
with patch("builtins.open", new_callable=MagicMock) as mock_open:
mock_open.return_value.name = "cache_file_path"
mock_open.return_value.mode = "wb+"
# Load the store from the file
cache_file_store.load_store()
# Verify the loaded store
assert "test_key" in cache_file_store._store
loaded_record = cache_file_store._store["test_key"]
assert loaded_record.cache_file.name == "cache_file_path"
assert loaded_record.cache_file.mode == "wb+"
assert loaded_record.until_datetime == to_datetime(
cache_record["test_key"]["until_datetime"]
)
assert loaded_record.ttl_duration is None
class TestCacheFileDecorators:
def test_cache_in_file_decorator_caches_function_result(self, cache_file_store):
"""Test that the cache_in_file decorator caches a function result."""
# Clear store to assure it is empty
cache_file_store.clear(clear_all=True)
assert len(cache_file_store._store) == 0
# Define a simple function to decorate
@cache_in_file(mode="w+")
def my_function(until_date=None):
return "Some expensive computation result"
# Call the decorated function (should store result in cache)
result = my_function(until_date=datetime.now() + timedelta(days=1))
assert result == "Some expensive computation result"
# Assert that the create method was called to store the result
assert len(cache_file_store._store) == 1
# Check if the result was written to the cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert cache_file is not None
# Assert correct content was written to the file
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == "Some expensive computation result"
def test_cache_in_file_decorator_uses_cache(self, cache_file_store):
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
# Clear store to assure it is empty
cache_file_store.clear(clear_all=True)
assert len(cache_file_store._store) == 0
# Define a simple function to decorate
@cache_in_file(mode="w+")
def my_function(until_date=None):
return "New result"
# Call the decorated function (should store result in cache)
result = my_function(until_date=to_datetime().add(days=1))
assert result == "New result"
# Assert result was written to cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert cache_file is not None
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result
# Modify cache file
result2 = "Cached result"
cache_file.seek(0)
cache_file.write(result2)
# Call the decorated function again (should get result from cache)
result = my_function(until_date=to_datetime().add(days=1))
assert result == result2
def test_cache_in_file_decorator_forces_update_data(self, cache_file_store):
"""Test that the cache_in_file decorator reuses cached file on subsequent calls."""
# Clear store to assure it is empty
cache_file_store.clear(clear_all=True)
assert len(cache_file_store._store) == 0
# Define a simple function to decorate
@cache_in_file(mode="w+")
def my_function(until_date=None):
return "New result"
until_date = to_datetime().add(days=1).date()
# Call the decorated function (should store result in cache)
result1 = "New result"
result = my_function(until_date=until_date)
assert result == result1
# Assert result was written to cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert cache_file is not None
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result
# Modify cache file
result2 = "Cached result"
cache_file.seek(0)
cache_file.write(result2)
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result2
# Call the decorated function again with force update (should get result from function)
result = my_function(until_date=until_date, force_update=True) # type: ignore[call-arg]
assert result == result1
# Assure result was written to the same cache file
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result1
def test_cache_in_file_handles_ttl_in_call(self, cache_file_store):
"""Test that the cache_infile decorator handles the with_ttl parameter in decorator."""
2025-02-12 21:35:51 +01:00
# Define a simple function to decorate
@cache_in_file(mode="w+")
def my_function():
return "New result"
# Call the decorated function
result1 = my_function(with_ttl="1 second") # type: ignore[call-arg]
assert result1 == "New result"
assert len(cache_file_store._store) == 1
key = list(cache_file_store._store.keys())[0]
# Assert result was written to cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert cache_file is not None
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result1
# Modify cache file
result2 = "Cached result"
cache_file.seek(0)
cache_file.write(result2)
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result2
# Call the decorated function again
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result2
assert result == result2
# Wait one second to let the cache time out
sleep(2)
# Call again - cache should be timed out
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
assert result == result1
def test_cache_in_file_handles_ttl_in_decorator(self, cache_file_store):
"""Test that the cache_infile decorator handles the with_ttl parameter in call."""
# Define a simple function to decorate
@cache_in_file(mode="w+", with_ttl="1 second")
def my_function():
return "New result"
# Call the decorated function
result1 = my_function()
assert result1 == "New result"
assert len(cache_file_store._store) == 1
key = list(cache_file_store._store.keys())[0]
# Assert result was written to cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert cache_file is not None
2025-02-12 21:35:51 +01:00
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result1
# Modify cache file
result2 = "Cached result"
cache_file.seek(0)
cache_file.write(result2)
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result2
# Call the decorated function again
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
cache_file.seek(0) # Move to the start of the file
assert cache_file.read() == result2
assert result == result2
# Wait one second to let the cache time out
sleep(2)
# Call again - cache should be timed out
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
assert result == result1
def test_cache_in_file_handles_bytes_return(self, cache_file_store):
"""Test that the cache_infile decorator handles bytes returned from the function."""
# Clear store to assure it is empty
cache_file_store.clear(clear_all=True)
assert len(cache_file_store._store) == 0
# Define a function that returns bytes
@cache_in_file()
def my_function(until_date=None) -> bytes:
return b"Some binary data"
# Call the decorated function
result = my_function(until_date=datetime.now() + timedelta(days=1))
# Check if the binary data was written to the cache file
key = next(iter(cache_file_store._store))
cache_file = cache_file_store._store[key].cache_file
assert len(cache_file_store._store) == 1
assert cache_file is not None
cache_file.seek(0)
result1 = pickle.load(cache_file)
assert result1 == result
# Access cache
result = my_function(until_date=datetime.now() + timedelta(days=1))
assert len(cache_file_store._store) == 1
assert cache_file_store._store[key].cache_file is not None
assert result1 == result
def test_cache_in_file_distinguishes_different_function_bodies(self, cache_file_store):
"""Cache key must change when the function's code changes."""
cache_file_store.clear(clear_all=True)
@cache_in_file(mode="w+")
def f(x=None, until_date=None):
return x + 100
@cache_in_file(mode="w+")
def g(x=None, until_date=None):
return x + 200
until = to_datetime().add(days=1)
assert f(x=1, until_date=until) == 101
assert g(x=1, until_date=until) == 201
assert len(cache_file_store._store) == 2
def test_force_update_combined_with_until_date(self, cache_file_store):
cache_file_store.clear(clear_all=True)
@cache_in_file(mode="w+")
def my_function(until_date=None):
return "New result"
until = to_datetime().add(days=1).date()
my_function(until_date=until)
key = next(iter(cache_file_store._store))
cf = cache_file_store._store[key].cache_file
cf.seek(0)
cf.write("Cached result")
# force_update must win even though until_date comes first in the call.
assert my_function(until_date=until, force_update=True) == "New result" # type: ignore[call-arg]
def test_call_time_control_parameters_apply_to_the_call_only(self, cache_file_store):
"""A force_update given once does not stick to later calls."""
cache_file_store.clear(clear_all=True)
calls: list[int] = []
@cache_in_file()
def f():
calls.append(1)
return len(calls)
f()
f(force_update=True) # type: ignore[call-arg]
f()
f()
assert len(calls) == 2
def test_call_time_expiry_does_not_replace_the_decorator_expiry(self, cache_file_store):
cache_file_store.clear(clear_all=True)
@cache_in_file(with_ttl="1 hour")
def f():
return "result"
f(until_datetime=to_datetime().add(days=2)) # type: ignore[call-arg]
cache_file_store.clear(clear_all=True)
f()
(record,) = cache_file_store._store.values()
assert record.ttl_duration == to_duration("1 hour")
def test_call_time_expiry_precedence(self, cache_file_store):
"""until_datetime takes precedence over with_ttl and until_date."""
cache_file_store.clear(clear_all=True)
@cache_in_file()
def f():
return "result"
until = to_datetime().add(days=3)
f(until_date=to_datetime().add(days=1).date(), with_ttl="1 hour", until_datetime=until) # type: ignore[call-arg]
(record,) = cache_file_store._store.values()
assert compare_datetimes(record.until_datetime, until).equal
assert record.ttl_duration is None
def test_read_failure_recreates_the_cache_file_once(self, cache_file_store):
"""A broken cache file is recreated, and caching works again afterwards."""
cache_file_store.clear(clear_all=True)
calls: list[int] = []
@cache_in_file()
def f():
calls.append(1)
return "result"
f()
(record,) = cache_file_store._store.values()
record.cache_file.seek(0)
record.cache_file.truncate()
record.cache_file.write(b"not a pickle")
assert f() == "result"
assert f() == "result"
assert len(calls) == 2
def test_var_positional_arguments_are_part_of_the_key(self, cache_file_store):
cache_file_store.clear(clear_all=True)
@cache_in_file()
def f(*items):
return sum(items)
assert f(1) == 1
assert f(2) == 2
def test_arguments_of_a_wrapped_function_are_part_of_the_key(self, cache_file_store):
cache_file_store.clear(clear_all=True)
def passthrough(func):
@functools.wraps(func)
def inner(*args, **kwargs):
return func(*args, **kwargs)
return inner
@cache_in_file()
@passthrough
def f(x):
return x * 10
assert f(1) == 10
assert f(2) == 20
def test_positional_and_keyword_arguments_give_the_same_key(self, cache_file_store):
cache_file_store.clear(clear_all=True)
calls: list[int] = []
@cache_in_file()
def f(x):
calls.append(x)
return x
f(1)
f(x=1)
assert calls == [1]
def test_ignored_keyword_arguments_still_reach_the_function(self, cache_file_store):
cache_file_store.clear(clear_all=True)
received: list[dict] = []
@cache_in_file(ignore_params=["token"])
def f(**options):
received.append(options)
return options["a"]
f(a=1, token="secret-1")
f(a=1, token="secret-2") # same key: token is ignored
assert received == [{"a": 1, "token": "secret-1"}]
class TestFuncCodeHash:
def test_hash_is_stable(self):
from akkudoktoreos.core.cache import _get_func_code_hash
def f(x):
return x + 1
assert _get_func_code_hash(f) == _get_func_code_hash(f)
def test_hash_changes_with_logic(self):
from akkudoktoreos.core.cache import _get_func_code_hash
def f(x):
return x + 1
def g(x):
return x + 2
assert _get_func_code_hash(f) != _get_func_code_hash(g)
def test_hash_changes_with_constant(self):
from akkudoktoreos.core.cache import _get_func_code_hash
def f(x):
return x + 100
def g(x):
return x + 200
assert _get_func_code_hash(f) != _get_func_code_hash(g)
@staticmethod
def compile_function(source: str):
"""Compile ``def f`` from source, so compared functions share the qualified name."""
namespace: dict = {"__name__": "cache_probe"}
exec(textwrap.dedent(source), namespace) # noqa: S102
return namespace["f"]
@pytest.mark.parametrize(
("source_a", "source_b"),
[
# Called method (co_names)
("def f(s): return s.upper()", "def f(s): return s.lower()"),
# Global function (co_names)
("def f(x): return str(x)", "def f(x): return hex(x)"),
# Positional default
("def f(x, factor=5): return x * factor", "def f(x, factor=6): return x * factor"),
# Keyword-only default
("def f(x, *, factor=5): return x * factor", "def f(x, *, factor=6): return x * factor"),
# Constant in a nested function
("def f(x): return list(map(lambda v: v + 1, x))", "def f(x): return list(map(lambda v: v + 2, x))"),
],
)
def test_hash_distinguishes_functions(self, source_a, source_b):
assert _get_func_code_hash(self.compile_function(source_a)) != _get_func_code_hash(
self.compile_function(source_b)
)
def test_hash_covers_captured_values(self):
def make(n):
def inner(x):
return x + n
return inner
assert _get_func_code_hash(make(1)) != _get_func_code_hash(make(2))
assert _get_func_code_hash(make(1)) == _get_func_code_hash(make(1))
def test_hash_unwraps_functools_wraps(self):
def passthrough(func):
@functools.wraps(func)
def inner(*args, **kwargs):
return func(*args, **kwargs)
return inner
def plain(x):
return 1
wrapped_1 = passthrough(plain)
wrapped_2 = passthrough(self.compile_function("def f(x): return 2"))
assert _get_func_code_hash(wrapped_1) == _get_func_code_hash(plain)
assert _get_func_code_hash(wrapped_1) != _get_func_code_hash(wrapped_2)
def test_hash_is_the_same_in_every_process(self, tmp_path):
"""The file cache outlives the process: the hash must not depend on addresses or the hash seed."""
(tmp_path / "cacheprobe.py").write_text(
textwrap.dedent(
"""
def with_lambda(x):
return sorted(x, key=lambda v: -v)
def with_genexpr(x):
return sum(v * v for v in x)
def with_set_literal(x):
return x in {"alpha", "beta", "gamma", "delta", "epsilon"}
def with_set_default(x, allowed=frozenset({"alpha", "beta", "gamma"})):
return x in allowed
def make(n):
def inner(x):
return x in {"alpha", "beta"} and n
return inner
with_closure = make(3)
"""
),
encoding="utf-8",
)
script = (
"import cacheprobe as m\n"
"from akkudoktoreos.core.cache import _get_func_code_hash as h\n"
"for name in ('with_lambda', 'with_genexpr', 'with_set_literal',\n"
" 'with_set_default', 'with_closure'):\n"
" print(name, h(getattr(m, name)))\n"
)
def hashes(seed: str) -> str:
env = {**os.environ, "PYTHONHASHSEED": seed, "PYTHONPATH": str(tmp_path)}
result = subprocess.run(
[sys.executable, "-c", script],
env=env,
cwd=tmp_path,
capture_output=True,
text=True,
check=True,
timeout=120,
)
return result.stdout
first = hashes("1")
assert len(set(first.split()[1::2])) == 5
assert hashes("2") == first