From c7ed1a93a12d91fadd26ce26c3f739e59bc56852 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sat, 26 Sep 2026 12:54:39 +0000 Subject: [PATCH] fix(memory): match clear_pattern case-sensitively on every platform fnmatch.fnmatch runs both arguments through os.path.normcase, which lowercases on Windows, so cache:user:* also cleared cache:User:1 there. Redis matches case-sensitively everywhere. Use fnmatch.fnmatchcase. Document the two places where the memory glob syntax still differs from Redis: [!...] negation and bracket escapes instead of backslashes. Closes #179 --- CHANGELOG.md | 7 +++++++ docs/BACKENDS.md | 6 ++++++ fastapi_cachex/backends/memory.py | 4 +++- i18n/zh-TW/docs/BACKENDS.md | 2 ++ tests/backends/test_memory.py | 16 ++++++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 390e755..26c6739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,13 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. entry that expires while the route runs. ([#178](https://github.com/allen0099/FastAPI-CacheX/issues/178)) +- **`MemoryBackend.clear_pattern()` is case-sensitive on Windows.** It used + `fnmatch.fnmatch`, which folds case through `os.path.normcase` on Windows, so + `cache:user:*` also cleared `cache:User:1`. It now uses `fnmatch.fnmatchcase`, + like Redis on every platform. The backends docs list where the glob syntax + still differs from Redis. + ([#179](https://github.com/allen0099/FastAPI-CacheX/issues/179)) + ## [0.3.7] - 2026-09-25 ### Added diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 0da3990..887b7de 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -34,6 +34,12 @@ BackendProxy.set(backend) > The in-memory cache is not suitable for production with multiple processes. > Each process maintains its own separate cache. +`clear_pattern()` matches whole keys case-sensitively on every platform, like Redis. +The glob syntax is Python's `fnmatch`, which differs from Redis in two places: negate +a character class with `[!...]` (Redis uses `[^...]`), and escape a special character +by putting it in brackets, as in `[*]` (Redis also accepts `\*`). `*`, `?` and +`[abc]` behave the same on both. + ## Redis Install the extra with `uv add "fastapi-cachex[redis]"`. diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index bd99735..82d93ca 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -291,6 +291,8 @@ async def clear_pattern(self, pattern: str) -> int: Matches ``fnmatch`` against the whole key, which is what the Redis backend's SCAN does. Matching only the path component, as this used to, made the same call clear different things on different backends. + Matching is case-sensitive on every platform, as on Redis; plain + ``fnmatch.fnmatch`` folds case on Windows. Args: pattern: A glob pattern to match whole cache keys against @@ -298,7 +300,7 @@ async def clear_pattern(self, pattern: str) -> int: Returns: Number of cache entries cleared """ - cleared_count = await self._evict(lambda key: fnmatch.fnmatch(key, pattern)) + cleared_count = await self._evict(lambda key: fnmatch.fnmatchcase(key, pattern)) warn_if_path_shaped(pattern, cleared_count) logger.debug( "Memory cache CLEAR_PATTERN; pattern=%s removed=%s", pattern, cleared_count diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 67cdf2e..443d6f1 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -28,6 +28,8 @@ BackendProxy.set(backend) > [!NOTE] > 記憶體快取不適合用於多行程的正式環境。每個行程都各自維護獨立的快取。 +`clear_pattern()` 在所有平台上都以區分大小寫的方式比對完整的鍵,與 Redis 相同。萬用字元語法採用 Python 的 `fnmatch`,與 Redis 有兩處不同:否定字元類別要寫 `[!...]`(Redis 為 `[^...]`);跳脫特殊字元要放進中括號,例如 `[*]`(Redis 另外也接受 `\*`)。`*`、`?` 與 `[abc]` 在兩者上的行為相同。 + ## Redis {#redis} 以 `uv add "fastapi-cachex[redis]"` 安裝此 extra。 diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 02752c8..95e8820 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -1,4 +1,6 @@ import asyncio +import ntpath +import os import time from collections.abc import Awaitable from collections.abc import Callable @@ -285,6 +287,20 @@ async def test_memory_backend_clear_pattern_separator_less_keys( assert await memory_backend.get("cache:post:789") == value3 +@pytest.mark.asyncio +async def test_memory_backend_clear_pattern_is_case_sensitive_on_windows( + memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch +): + """fnmatch.fnmatch folds case through os.path.normcase on Windows (#179).""" + monkeypatch.setattr(os.path, "normcase", ntpath.normcase) + value = CacheEntry(fingerprint="e1", content=b"v1") + await memory_backend.set("cache:User:1", value) + + assert await memory_backend.clear_pattern("cache:user:*") == 0 + assert await memory_backend.get("cache:User:1") == value + assert await memory_backend.clear_pattern("cache:User:*") == 1 + + @pytest.mark.asyncio async def test_memory_backend_clear_path_with_colon_in_path( memory_backend: MemoryBackend,