Approach: implement a decorator factory timed_lru_cache(maxsize=128) that:- Builds deterministic cache keys by serializing args + kwargs via json.dumps(..., sort_keys=True).- Falls back to a safe, explicit fallback for non-JSON-serializable objects (with clear security caveats).- Tracks LRU eviction with collections.OrderedDict.- Measures execution time (time.perf_counter) and stores last runtime on the cached entry.python
import json
import time
import functools
from collections import OrderedDict
def _make_key(args, kwargs):
"""
Deterministic key: try JSON with sort_keys. If that fails, use repr fallback.
Using repr is less deterministic across runs/versions but avoids insecure pickle.
"""
try:
key = json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True, default=lambda o: repr(o), separators=(",", ":"))
return key
except (TypeError, ValueError):
# As a last resort; note: repr may vary and isn't collision-proof
return repr((args, tuple(sorted(kwargs.items()))))
def timed_lru_cache(maxsize=128):
def decorator(func):
cache = OrderedDict() # key -> (result, elapsed_seconds)
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = _make_key(args, kwargs)
if key in cache:
# Move to end => most recently used
result, elapsed = cache.pop(key)
cache[key] = (result, elapsed)
wrapper.hits += 1
return result
wrapper.misses += 1
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
cache[key] = (result, elapsed)
# enforce maxsize
if len(cache) > maxsize:
cache.popitem(last=False) # evict least recently used
return result
# attach stats
wrapper.hits = 0
wrapper.misses = 0
wrapper.cache = cache # expose for inspection
wrapper.get_last_time = lambda *a, **k: cache.get(_make_key(a, k), (None, None))[1]
return wrapper
return decorator
Key points:- Uses deterministic JSON serialization where possible (sort_keys=True) so equivalent dicts/lists map to same key.- Falls back to repr for objects that aren't JSON-serializable; this is less robust.Time & space complexity:- Cache lookup/insertion: amortized O(1) using OrderedDict.- Serialization cost: O(size of args) per call; can dominate for large inputs.Edge cases and limitations:- Mutable arguments: if caller mutates a list after caching, cached result might become inconsistent. Prefer immutables or deep-copy inputs before using cache.- Non-deterministic repr: repr-based fallbacks can change across versions or produce collisions.- Serialization cost: heavy for large tensors/arrays; for ML models prefer hashing based on ids/checksums (e.g., stable hash of numpy arrays) instead of full serialization.- Thread-safety: this implementation is not thread-safe; use threading.Lock around cache accesses in concurrent contexts.Security considerations:- Avoid pickle for key serialization when inputs come from untrusted sources—pickle can execute arbitrary code. JSON is safer but won't handle all types.- Be cautious exposing repr strings or serialized forms in logs (may leak sensitive data).- If you must support complex objects, implement explicit safe serializers (e.g., for numpy arrays use .tobytes() + a hash) and whitelist types.Example usage:python
@timed_lru_cache(maxsize=64)
def expensive_transform(data):
# data can be a list/dict; serialization will create the cache key
time.sleep(0.1) # simulate work
return sum(data)
print(expensive_transform([1,2,3])) # computes, caches
print(expensive_transform([1,2,3])) # cache hit, faster
print("hits", expensive_transform.hits, "misses", expensive_transform.misses)
print("last call time (cached):", expensive_transform.get_last_time([1,2,3]))
Alternatives:- For ML payloads (numpy, tensors), compute a deterministic hash (e.g., SHA256 of bytes + shape + dtype) rather than serializing full content to reduce cost and avoid collisions/security pitfalls.- Use functools.lru_cache when arguments are hashable; combine with custom wrappers that map non-hashable args to hashable tokens.