diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d659e6..6472e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.42 (2026-06-07) + +### Added (cache — @cacheable condition / unless) + +`@cacheable` / `cache` now accept Spring-style predicates (Pythonic callables): + +- **`condition`** — a predicate over the call arguments; when it returns ``False`` caching + is bypassed entirely (the function runs, nothing is read from or written to the cache). +- **`unless`** — a predicate over the *result*; when it returns ``True`` the result is + returned but not stored (e.g. skip caching empty/None results). + ## v26.06.41 (2026-06-07) ### Added (context — injectable ApplicationEventPublisher + arbitrary domain events) diff --git a/README.md b/README.md index 5be5d28..3f5c22d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.41 + Version: 26.06.42 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 2be9360..d4bf699 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.41" +version = "26.6.42" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 2029e2c..dd7d9fa 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.41" +__version__ = "26.06.42" diff --git a/src/pyfly/cache/decorators.py b/src/pyfly/cache/decorators.py index 07f1d54..db3d48b 100644 --- a/src/pyfly/cache/decorators.py +++ b/src/pyfly/cache/decorators.py @@ -63,6 +63,9 @@ def cache( backend: CacheAdapter, key: str, ttl: timedelta | None = None, + *, + condition: Callable[..., bool] | None = None, + unless: Callable[[Any], bool] | None = None, ) -> Callable[[F], F]: """Cache the return value of an async function. @@ -74,6 +77,11 @@ def cache( backend: Cache adapter to use. key: Key template with {param} placeholders. ttl: Optional time-to-live for cached entries. + condition: Predicate over the call arguments (same signature as *func*); when it + returns ``False`` caching is bypassed entirely (the function runs, nothing is + read from or written to the cache). Spring's ``@Cacheable(condition=...)``. + unless: Predicate over the *result*; when it returns ``True`` the result is returned + but NOT stored. Spring's ``@Cacheable(unless=...)``. """ def decorator(func: F) -> F: @@ -81,6 +89,10 @@ def decorator(func: F) -> F: @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: + # condition=False -> bypass the cache entirely. + if condition is not None and not condition(*args, **kwargs): + return await func(*args, **kwargs) + resolved_key = _resolve_key(func, key, args, kwargs) # Check cache. A present-but-None entry is a hit (null caching / @@ -91,9 +103,10 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if await backend.exists(resolved_key): return None - # Execute and cache + # Execute and (unless excluded) cache. result = await func(*args, **kwargs) - await backend.put(resolved_key, result, ttl=ttl) + if unless is None or not unless(result): + await backend.put(resolved_key, result, ttl=ttl) return result return wrapper # type: ignore[return-value] @@ -105,17 +118,23 @@ def cacheable( backend: CacheAdapter, key: str, ttl: timedelta | None = None, + *, + condition: Callable[..., bool] | None = None, + unless: Callable[[Any], bool] | None = None, ) -> Callable[[F], F]: """Cache the return value, skip execution on cache hit. - Equivalent to :func:`cache`. + Equivalent to :func:`cache`, with Spring-style ``condition`` (bypass caching) and + ``unless`` (don't store certain results) predicates. Args: backend: Cache adapter to use. key: Key template with {param} placeholders. ttl: Optional time-to-live for cached entries. + condition: Predicate over the call arguments; ``False`` bypasses the cache. + unless: Predicate over the result; ``True`` returns it without storing. """ - return cache(backend=backend, key=key, ttl=ttl) + return cache(backend=backend, key=key, ttl=ttl, condition=condition, unless=unless) def cache_evict( diff --git a/tests/cache/test_cacheable_condition_unless.py b/tests/cache/test_cacheable_condition_unless.py new file mode 100644 index 0000000..2574d68 --- /dev/null +++ b/tests/cache/test_cacheable_condition_unless.py @@ -0,0 +1,73 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""@cacheable condition / unless predicates (v26.06.42).""" + +from __future__ import annotations + +import pytest + +from pyfly.cache.adapters import InMemoryCache +from pyfly.cache.decorators import cacheable + + +@pytest.mark.asyncio +async def test_condition_false_bypasses_cache() -> None: + backend = InMemoryCache() + calls = {"n": 0} + + @cacheable(backend=backend, key="x:{n}", condition=lambda n: n > 0) + async def compute(n: int) -> int: + calls["n"] += 1 + return n * 10 + + # n=0 -> condition False -> bypass: executes every time, nothing cached + assert await compute(0) == 0 + assert await compute(0) == 0 + assert calls["n"] == 2 + assert await backend.exists("x:0") is False + + +@pytest.mark.asyncio +async def test_condition_true_caches() -> None: + backend = InMemoryCache() + calls = {"n": 0} + + @cacheable(backend=backend, key="x:{n}", condition=lambda n: n > 0) + async def compute(n: int) -> int: + calls["n"] += 1 + return n * 10 + + assert await compute(5) == 50 + assert await compute(5) == 50 # cache hit + assert calls["n"] == 1 + + +@pytest.mark.asyncio +async def test_unless_excludes_result_from_cache() -> None: + backend = InMemoryCache() + calls = {"n": 0} + + # Don't cache "empty" results (None / 0). + @cacheable(backend=backend, key="x:{n}", unless=lambda result: not result) + async def compute(n: int) -> int: + calls["n"] += 1 + return n + + assert await compute(0) == 0 + assert await compute(0) == 0 # unless(0)->True -> not stored -> recomputed + assert calls["n"] == 2 + + assert await compute(7) == 7 + assert await compute(7) == 7 # unless(7)->False -> stored -> cache hit + assert calls["n"] == 3 diff --git a/uv.lock b/uv.lock index 21cf538..da64b5a 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.41" +version = "26.6.42" source = { editable = "." } dependencies = [ { name = "pydantic" },