From fffacb18d6b9648dac014d0fe120b1e086631dc2 Mon Sep 17 00:00:00 2001 From: AhmedGoudaa Date: Thu, 25 Dec 2025 16:28:31 +0400 Subject: [PATCH 1/2] fix key fn and add new configure fn --- CHANGELOG.md | 10 ++ README.md | 56 +++++++++- pyproject.toml | 2 +- src/advanced_caching/__init__.py | 2 +- src/advanced_caching/decorators.py | 172 ++++++++++++++++++++++++----- tests/test_configured_cache.py | 65 +++++++++++ tests/test_correctness.py | 56 ++++++++++ tests/test_key_generation.py | 158 ++++++++++++++++++++++++++ 8 files changed, 486 insertions(+), 35 deletions(-) create mode 100644 tests/test_configured_cache.py create mode 100644 tests/test_key_generation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4388c8..a1aac23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2025-12-25 + +### Fixed +- **Key Generation Bug**: Fixed an issue where `TTLCache` and `SWRCache` would fail to correctly generate cache keys when using named placeholders (e.g., `"user:{id}"`) if the function was called with positional arguments. +- **Performance**: Optimized cache key generation logic to avoid expensive signature binding on every call, using a fast-path for common patterns and efficient argument merging for complex cases. + +### Added +- `configure()` class method on all decorators to easily create pre-configured cache instances (e.g., `MyCache = TTLCache.configure(cache=RedisCache(...))`). + ## [0.2.0] - 2025-12-23 ### Changed @@ -16,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `AsyncTTLCache`, `AsyncStaleWhileRevalidateCache`, `AsyncBackgroundCache` classes (aliased to `TTLCache`, `SWRCache`, `BGCache`). +- `configure()` class method on all decorators to easily create pre-configured cache instances (e.g., `MyCache = TTLCache.configure(cache=RedisCache(...))`). - `SharedAsyncScheduler` for managing async background jobs. - `pytest-asyncio` configuration in `pyproject.toml`. diff --git a/README.md b/README.md index 726d8b9..4b93bbd 100644 --- a/README.md +++ b/README.md @@ -73,19 +73,34 @@ def load_inventory() -> list[dict]: @BGCache.register_loader("inventory_async", interval_seconds=300) async def load_inventory_async() -> list[dict]: return await warehouse_api.get_all_items() + +# Configured Cache (Reusable Backend) +# Create a decorator pre-wired with a specific cache (e.g., Redis) +RedisTTL = TTLCache.configure(cache=RedisCache(redis_client)) + +@RedisTTL.cached("user:{}", ttl=300) +async def get_user_redis(user_id: int): + return await db.fetch(user_id) ``` --- ## Key Templates -* `"user:{}"` → first positional argument -* `"user:{user_id}"` → named argument -* Custom: +The library supports smart key generation that handles both positional and keyword arguments seamlessly. -```python -key=lambda *a, **k: f"user:{k.get('user_id', a[0])}" -``` +* **Positional Placeholder**: `"user:{}"` + * Uses the first argument, whether passed positionally or as a keyword. + * Example: `get_user(123)` or `get_user(user_id=123)` -> `"user:123"` + +* **Named Placeholder**: `"user:{user_id}"` + * Resolves `user_id` from keyword arguments OR positional arguments (by inspecting the function signature). + * Example: `def get_user(user_id): ...` called as `get_user(123)` -> `"user:123"` + +* **Custom Function**: + * For complex logic, pass a callable. + * Example1 for kw/args with default values use : `key=lambda *a, **k: f"user:{k.get('user_id', a[0])}"` + * Example2 fns with no defaults use : `key=lambda user_id: f"user:{user_id}"` --- @@ -214,6 +229,35 @@ db_host = load_config_map().get("db", {}).get("host") --- +## Advanced Configuration + +To avoid repeating complex cache configurations (like HybridCache setup) in every decorator, you can create a pre-configured cache instance. + +```python +from advanced_caching import SWRCache, HybridCache, InMemCache, RedisCache + +# 1. Define your cache factory +def create_hybrid_cache(): + return HybridCache( + l1_cache=InMemCache(), + l2_cache=RedisCache(redis_client), + l1_ttl=300, + l2_ttl=3600 + ) + +# 2. Create a configured decorator +MySWRCache = SWRCache.configure(cache=create_hybrid_cache) + +# 3. Use it cleanly +@MySWRCache.cached("users:{}", ttl=300) +def get_users(code: str): + return db.get_users(code) +``` + +This works for `TTLCache`, `SWRCache`, and `BGCache`. + +--- + ### Custom Storage Implement the `CacheStorage` protocol. diff --git a/pyproject.toml b/pyproject.toml index 0d82cd7..717e4e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "advanced-caching" -version = "0.2.0" +version = "0.2.1" description = "Production-ready composable caching with TTL, SWR, and background refresh patterns for Python." readme = "README.md" requires-python = ">=3.10" diff --git a/src/advanced_caching/__init__.py b/src/advanced_caching/__init__.py index 4382b27..7f45bc2 100644 --- a/src/advanced_caching/__init__.py +++ b/src/advanced_caching/__init__.py @@ -4,7 +4,7 @@ Expose storage backends, decorators, and scheduler utilities under `advanced_caching`. """ -__version__ = "0.2.0" +__version__ = "0.2.1" from .storage import ( InMemCache, diff --git a/src/advanced_caching/decorators.py b/src/advanced_caching/decorators.py index ec2efeb..6773379 100644 --- a/src/advanced_caching/decorators.py +++ b/src/advanced_caching/decorators.py @@ -10,10 +10,11 @@ from __future__ import annotations import asyncio +import inspect import logging import time from datetime import datetime, timedelta -from typing import Callable, TypeVar +from typing import Callable, TypeVar, Any from apscheduler.triggers.interval import IntervalTrigger @@ -28,56 +29,84 @@ # Helper to normalize cache key builders for all decorators. -def _create_key_fn(key: str | Callable[..., str]) -> Callable[..., str]: +def _create_smart_key_fn( + key: str | Callable[..., str], func: Callable[..., Any] +) -> Callable[..., str]: + # If the key is already a function (e.g., lambda u: f"user:{u}"), return it directly. if callable(key): return key # type: ignore[assignment] template = key + # Optimization: Static key (e.g., "global_config") + # If there are no placeholders, we don't need to format anything. if "{" not in template: def key_fn(*args, **kwargs) -> str: + # Always return the static string, ignoring arguments. return template return key_fn - if ( - template.count("{}") == 1 - and template.count("{") == 1 - and template.count("}") == 1 - ): + # Optimization: Simple positional key "prefix:{}" (e.g., "user:{}") + # This is a very common pattern, so we optimize it to avoid full string formatting. + if template.count("{}") == 1 and template.count("{") == 1: prefix, suffix = template.split("{}", 1) def key_fn(*args, **kwargs) -> str: + # If positional args are provided (e.g., get_user(123)), use the first one. if args: - return prefix + str(args[0]) + suffix + return f"{prefix}{args[0]}{suffix}" + # If keyword args are provided (e.g., get_user(user_id=123)), use the first value. + # This supports the case where a positional placeholder is used but the function is called with kwargs. if kwargs: - if len(kwargs) == 1: - return prefix + str(next(iter(kwargs.values()))) + suffix - return template + # Fallback for single kwarg usage with positional template + return f"{prefix}{next(iter(kwargs.values()))}{suffix}" + # If no arguments are provided, return the raw template (e.g., "user:{}"). return template return key_fn + # General case: Named placeholders (e.g., "user:{id}") or complex positional (e.g., "{}:{}" or "{0}") + # We need to inspect the function signature to map positional arguments to parameter names. + sig = inspect.signature(func) + param_names = list(sig.parameters.keys()) + + # Pre-compute defaults to handle cases where arguments are omitted but have default values. + # e.g., def func(a=1): ... with key="{a}" + defaults = { + k: v.default + for k, v in sig.parameters.items() + if v.default is not inspect.Parameter.empty + } + def key_fn(*args, **kwargs) -> str: + # Fast merge of arguments to support named placeholders. + # 1. Start with defaults (e.g., {'a': 1}) + merged = defaults.copy() if defaults else {} + + # 2. Map positional args to names (e.g., func(2) -> {'a': 2}) + # This allows us to use named placeholders even when the function is called positionally. if args: - try: - return template.format(args[0]) - except Exception: - try: - return template.format(*args) - except Exception: - return template + merged.update(zip(param_names, args)) + + # 3. Update with explicit kwargs (e.g., func(a=3) -> {'a': 3}) if kwargs: + merged.update(kwargs) + + try: + # Try formatting with named arguments (e.g., "user:{id}".format(id=123)) + return template.format(**merged) + except (KeyError, ValueError, IndexError): + # Fallback: Try raw positional args (for "{}" templates or mixed usage) + # e.g., "user:{}".format(123) if named formatting failed. try: - return template.format(**kwargs) + return template.format(*args) except Exception: - if len(kwargs) == 1: - try: - return template.format(next(iter(kwargs.values()))) - except Exception: - return template + # If formatting fails entirely, return the raw template to avoid crashing. return template - return template + except Exception: + # Catch-all for other formatting errors. + return template return key_fn @@ -104,6 +133,33 @@ async def get_user(user_id): return await db.fetch_user(user_id) """ + @classmethod + def configure( + cls, cache: CacheStorage | Callable[[], CacheStorage] + ) -> type[AsyncTTLCache]: + """ + Create a configured version of TTLCache with a default cache backend. + + Example: + MyCache = TTLCache.configure(cache=RedisCache(...)) + @MyCache.cached("key", ttl=60) + def func(): ... + """ + + class ConfiguredTTLCache(cls): + @classmethod + def cached( + cls_inner, + key: str | Callable[..., str], + ttl: int, + cache: CacheStorage | Callable[[], CacheStorage] | None = None, + ) -> Callable[[Callable[..., T]], Callable[..., T]]: + # Use the configured cache if none is provided + return cls.cached(key, ttl, cache=cache or cls_inner._configured_cache) + + ConfiguredTTLCache._configured_cache = cache # type: ignore + return ConfiguredTTLCache + @classmethod def cached( cls, @@ -119,10 +175,11 @@ def cached( ttl: Time-to-live in seconds cache: Optional cache backend (defaults to InMemCache) """ - key_fn = _create_key_fn(key) + # key_fn creation moved inside decorator to access func signature cache_factory = normalize_cache_factory(cache, default_factory=InMemCache) def decorator(func: Callable[..., T]) -> Callable[..., T]: + key_fn = _create_smart_key_fn(key, func) cache_obj = cache_factory() cache_get_entry = cache_obj.get_entry cache_set = cache_obj.set @@ -183,6 +240,35 @@ class AsyncStaleWhileRevalidateCache: Supports both sync and async functions. """ + @classmethod + def configure( + cls, cache: CacheStorage | Callable[[], CacheStorage] + ) -> type[AsyncStaleWhileRevalidateCache]: + """ + Create a configured version of SWRCache with a default cache backend. + """ + + class ConfiguredSWRCache(cls): + @classmethod + def cached( + cls_inner, + key: str | Callable[..., str], + ttl: int, + stale_ttl: int = 0, + cache: CacheStorage | Callable[[], CacheStorage] | None = None, + enable_lock: bool = True, + ) -> Callable[[Callable[..., T]], Callable[..., T]]: + return cls.cached( + key, + ttl, + stale_ttl=stale_ttl, + cache=cache or cls_inner._configured_cache, + enable_lock=enable_lock, + ) + + ConfiguredSWRCache._configured_cache = cache # type: ignore + return ConfiguredSWRCache + @classmethod def cached( cls, @@ -192,10 +278,11 @@ def cached( cache: CacheStorage | Callable[[], CacheStorage] | None = None, enable_lock: bool = True, ) -> Callable[[Callable[..., T]], Callable[..., T]]: - key_fn = _create_key_fn(key) + # key_fn creation moved inside decorator to access func signature cache_factory = normalize_cache_factory(cache, default_factory=InMemCache) def decorator(func: Callable[..., T]) -> Callable[..., T]: + key_fn = _create_smart_key_fn(key, func) cache_obj = cache_factory() get_entry = cache_obj.get_entry set_entry = cache_obj.set_entry @@ -360,6 +447,37 @@ def shutdown(cls, wait: bool = True) -> None: SharedAsyncScheduler.shutdown(wait) SharedScheduler.shutdown(wait) + @classmethod + def configure( + cls, cache: CacheStorage | Callable[[], CacheStorage] + ) -> type[AsyncBackgroundCache]: + """ + Create a configured version of BGCache with a default cache backend. + """ + + class ConfiguredBGCache(cls): + @classmethod + def register_loader( + cls_inner, + key: str, + interval_seconds: int, + ttl: int | None = None, + run_immediately: bool = True, + on_error: Callable[[Exception], None] | None = None, + cache: CacheStorage | Callable[[], CacheStorage] | None = None, + ) -> Callable[[Callable[[], T]], Callable[[], T]]: + return cls.register_loader( + key, + interval_seconds, + ttl=ttl, + run_immediately=run_immediately, + on_error=on_error, + cache=cache or cls_inner._configured_cache, + ) + + ConfiguredBGCache._configured_cache = cache # type: ignore + return ConfiguredBGCache + @classmethod def register_loader( cls, diff --git a/tests/test_configured_cache.py b/tests/test_configured_cache.py new file mode 100644 index 0000000..0a77342 --- /dev/null +++ b/tests/test_configured_cache.py @@ -0,0 +1,65 @@ +import pytest +import time +from advanced_caching import TTLCache, SWRCache, BGCache, InMemCache + + +def test_configured_ttl_cache(): + cache = InMemCache() + MyTTL = TTLCache.configure(cache=cache) + + call_count = 0 + + @MyTTL.cached("key", ttl=60) + def func(): + nonlocal call_count + call_count += 1 + return 1 + + assert func() == 1 + assert call_count == 1 + assert cache.exists("key") + + # Should hit cache + assert func() == 1 + assert call_count == 1 + + +def test_configured_swr_cache(): + cache = InMemCache() + MySWR = SWRCache.configure(cache=cache) + + call_count = 0 + + @MySWR.cached("swr", ttl=60) + def func(): + nonlocal call_count + call_count += 1 + return 2 + + assert func() == 2 + assert call_count == 1 + assert cache.exists("swr") + + # Should hit cache + assert func() == 2 + assert call_count == 1 + + +def test_configured_bg_cache(): + cache = InMemCache() + MyBG = BGCache.configure(cache=cache) + + call_count = 0 + + @MyBG.register_loader("bg", interval_seconds=60, run_immediately=True) + def func(): + nonlocal call_count + call_count += 1 + return 3 + + # First call might trigger load if run_immediately=True logic works synchronously for sync functions + # In decorators.py, sync wrapper checks cache, if miss, calls loader. + + assert func() == 3 + assert call_count >= 1 + assert cache.exists("bg") diff --git a/tests/test_correctness.py b/tests/test_correctness.py index 35b53f7..b6eeeec 100644 --- a/tests/test_correctness.py +++ b/tests/test_correctness.py @@ -557,6 +557,62 @@ async def load_i18n(lang: str, region: str | None = None) -> dict: assert r2 == {"hello": "Hello in en-US"} assert calls["n"] == 1 + async def test_ttl_named_template_with_positional_arg(self): + """Test named placeholder with positional argument in TTLCache.""" + calls = {"n": 0} + + @TTLCache.cached("user:{user_id}", ttl=60) + async def get_user(user_id: int): + calls["n"] += 1 + return {"id": user_id} + + # Call with positional arg + assert (await get_user(1)) == {"id": 1} + assert (await get_user(1)) == {"id": 1} + assert calls["n"] == 1 + + # Call with different positional arg + assert (await get_user(2)) == {"id": 2} + assert calls["n"] == 2 + + async def test_swr_named_template_with_positional_arg(self): + """Test named placeholder with positional argument in SWRCache.""" + calls = {"n": 0} + + @SWRCache.cached("item:{item_id}", ttl=60) + async def get_item(item_id: int): + calls["n"] += 1 + return {"id": item_id} + + # Call with positional arg + assert (await get_item(10)) == {"id": 10} + assert (await get_item(10)) == {"id": 10} + assert calls["n"] == 1 + + async def test_multiple_named_placeholders_mixed_args(self): + """Test multiple named placeholders with mixed positional and keyword args.""" + calls = {"n": 0} + + @TTLCache.cached("u:{uid}:g:{gid}", ttl=60) + async def get_data(uid: int, gid: int): + calls["n"] += 1 + return f"{uid}-{gid}" + + # Positional + Keyword + assert (await get_data(1, gid=2)) == "1-2" + assert (await get_data(1, gid=2)) == "1-2" + assert calls["n"] == 1 + + # All Positional + assert (await get_data(3, 4)) == "3-4" + assert (await get_data(3, 4)) == "3-4" + assert calls["n"] == 2 + + # All Keyword + assert (await get_data(uid=5, gid=6)) == "5-6" + assert (await get_data(uid=5, gid=6)) == "5-6" + assert calls["n"] == 3 + class TestStorageEdgeCases: """Edge cases for storage backends to improve coverage.""" diff --git a/tests/test_key_generation.py b/tests/test_key_generation.py new file mode 100644 index 0000000..3c65c7e --- /dev/null +++ b/tests/test_key_generation.py @@ -0,0 +1,158 @@ +import pytest +from advanced_caching.decorators import _create_smart_key_fn + + +class TestSmartKeyGeneration: + """ + Unit tests for _create_smart_key_fn to ensure robust cache key generation. + """ + + def test_static_key(self): + """Test static key without placeholders.""" + + def func(a, b): + pass + + key_fn = _create_smart_key_fn("static-key", func) + assert key_fn(1, 2) == "static-key" + assert key_fn(a=1, b=2) == "static-key" + + def test_callable_key(self): + """Test when key is already a callable.""" + + def func(a): + pass + + def my_key_gen(a): + return f"custom:{a}" + + key_fn = _create_smart_key_fn(my_key_gen, func) + assert key_fn(1) == "custom:1" + + def test_simple_positional_optimization(self): + """Test the optimized path for single '{}' placeholder.""" + + def func(user_id): + pass + + key_fn = _create_smart_key_fn("user:{}", func) + + # Positional arg + assert key_fn(123) == "user:123" + + # Single keyword arg (fallback behavior) + assert key_fn(user_id=456) == "user:456" + + # No args (returns template) + assert key_fn() == "user:{}" + + def test_named_placeholder_kwargs(self): + """Test named placeholder with keyword arguments.""" + + def func(user_id): + pass + + key_fn = _create_smart_key_fn("user:{user_id}", func) + assert key_fn(user_id=123) == "user:123" + + def test_named_placeholder_positional(self): + """Test named placeholder with positional arguments (mapped via signature).""" + + def func(user_id, other): + pass + + key_fn = _create_smart_key_fn("user:{user_id}", func) + assert key_fn(123, "ignore") == "user:123" + + def test_named_placeholder_defaults(self): + """Test named placeholder using default values.""" + + def func(user_id=999): + pass + + key_fn = _create_smart_key_fn("user:{user_id}", func) + + # Use default + assert key_fn() == "user:999" + + # Override default + assert key_fn(123) == "user:123" + + def test_mixed_args_and_kwargs(self): + """Test named placeholders with mixed positional and keyword args.""" + + def func(a, b, c): + pass + + key_fn = _create_smart_key_fn("{a}:{b}:{c}", func) + + # a=1 (pos), b=2 (pos), c=3 (kw) + assert key_fn(1, 2, c=3) == "1:2:3" + + def test_fallback_to_raw_positional(self): + """Test fallback to raw positional formatting when named formatting fails.""" + + # This happens when template uses {} but function has named args, + # or when optimization check fails (e.g. multiple {}) + def func(a, b): + pass + + key_fn = _create_smart_key_fn("{}:{}", func) + assert key_fn(1, 2) == "1:2" + + def test_missing_argument_returns_template(self): + """Test that missing required arguments returns the raw template.""" + + def func(a, b): + pass + + key_fn = _create_smart_key_fn("key:{a}", func) + + # 'a' is missing from args/kwargs and has no default + # format() raises KeyError, fallback format(*args) raises IndexError/ValueError + # Should return template + assert key_fn(b=2) == "key:{a}" + + def test_extra_kwargs_in_template(self): + """Test template using kwargs that aren't in function signature (if **kwargs used).""" + + def func(a, **kwargs): + pass + + key_fn = _create_smart_key_fn("{a}:{extra}", func) + + assert key_fn(1, extra="value") == "1:value" + + def test_complex_positional_no_optimization(self): + """Test multiple positional placeholders (bypasses optimization).""" + + def func(a, b): + pass + + key_fn = _create_smart_key_fn("prefix:{}-suffix:{}", func) + assert key_fn(1, 2) == "prefix:1-suffix:2" + + def test_format_specifiers(self): + """Test that format specifiers in template work.""" + + def func(price): + pass + + key_fn = _create_smart_key_fn("price:{price:.2f}", func) + assert key_fn(12.3456) == "price:12.35" + + def test_object_str_representation(self): + """Test that objects are correctly converted to string in key.""" + + class User: + def __init__(self, id): + self.id = id + + def __str__(self): + return f"User({self.id})" + + def func(user): + pass + + key_fn = _create_smart_key_fn("obj:{user}", func) + assert key_fn(User(42)) == "obj:User(42)" From d228e00ae48b8193b95938f05ddf526310daa60a Mon Sep 17 00:00:00 2001 From: AhmedGoudaa Date: Thu, 25 Dec 2025 16:30:19 +0400 Subject: [PATCH 2/2] fix key fn and add new configure fn --- src/advanced_caching/decorators.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/advanced_caching/decorators.py b/src/advanced_caching/decorators.py index 6773379..202b566 100644 --- a/src/advanced_caching/decorators.py +++ b/src/advanced_caching/decorators.py @@ -175,7 +175,6 @@ def cached( ttl: Time-to-live in seconds cache: Optional cache backend (defaults to InMemCache) """ - # key_fn creation moved inside decorator to access func signature cache_factory = normalize_cache_factory(cache, default_factory=InMemCache) def decorator(func: Callable[..., T]) -> Callable[..., T]: @@ -278,7 +277,6 @@ def cached( cache: CacheStorage | Callable[[], CacheStorage] | None = None, enable_lock: bool = True, ) -> Callable[[Callable[..., T]], Callable[..., T]]: - # key_fn creation moved inside decorator to access func signature cache_factory = normalize_cache_factory(cache, default_factory=InMemCache) def decorator(func: Callable[..., T]) -> Callable[..., T]: