fastapi-alcazaba 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+ from typing import Any, ClassVar
3
+
4
+ from fastapi_alcazaba.cache import (
5
+ DEFAULT_VARY_HEADERS,
6
+ Cache,
7
+ CacheOptions,
8
+ get_cache,
9
+ normalise_headers,
10
+ warn_unsigned,
11
+ )
12
+ from fastapi_alcazaba.coder import Coder, JsonCoder
13
+ from fastapi_alcazaba.errors import (
14
+ BackendUnavailable,
15
+ UnsignedEntriesWarning,
16
+ UnstableCacheKeyWarning,
17
+ )
18
+ from fastapi_alcazaba.key_builder import default_key_builder
19
+ from fastapi_alcazaba.metrics import Metrics
20
+ from fastapi_alcazaba.resilience import BackendGuard
21
+ from fastapi_alcazaba.types import Backend, KeyBuilder
22
+
23
+ try:
24
+ __version__ = version("fastapi-alcazaba")
25
+ except PackageNotFoundError: # running from a source checkout that isn't installed
26
+ __version__ = "0.0.0"
27
+
28
+ __all__ = [
29
+ "DEFAULT_VARY_HEADERS",
30
+ "Backend",
31
+ "BackendUnavailable",
32
+ "Cache",
33
+ "CacheOptions",
34
+ "Coder",
35
+ "FastAPICache",
36
+ "JsonCoder",
37
+ "KeyBuilder",
38
+ "UnsignedEntriesWarning",
39
+ "UnstableCacheKeyWarning",
40
+ "default_key_builder",
41
+ "get_cache",
42
+ "normalise_headers",
43
+ "warn_unsigned",
44
+ ]
45
+
46
+
47
+ class FastAPICache:
48
+ """The global cache that `@cache` uses: a Cache configured once with `init()`. The API of
49
+ fastapi-cache2; for several caches, or one per app, create Cache instances instead."""
50
+
51
+ _instance: ClassVar[Cache | None] = None
52
+
53
+ @classmethod
54
+ def init(cls, backend: Backend, *args: Any, **kwargs: Any) -> None:
55
+ """Configure the global cache, with the arguments of `Cache`; later calls are ignored
56
+ until `reset()`."""
57
+ if cls._instance is None:
58
+ cls._instance = Cache(backend, *args, **kwargs)
59
+
60
+ @classmethod
61
+ def reset(cls) -> None:
62
+ cls._instance = None
63
+
64
+ @classmethod
65
+ def instance(cls) -> Cache:
66
+ if cls._instance is None:
67
+ raise RuntimeError(
68
+ "FastAPICache.init() hasn't been called (or decorate with a Cache's cached())"
69
+ )
70
+ return cls._instance
71
+
72
+ @classmethod
73
+ def get_backend(cls) -> Backend:
74
+ return cls.instance().backend
75
+
76
+ @classmethod
77
+ def get_prefix(cls) -> str:
78
+ return cls.instance().prefix
79
+
80
+ @classmethod
81
+ def get_expire(cls) -> int | None:
82
+ return cls._instance.expire if cls._instance is not None else None
83
+
84
+ @classmethod
85
+ def get_coder(cls) -> type[Coder]:
86
+ return cls.instance().coder
87
+
88
+ @classmethod
89
+ def get_key_builder(cls) -> KeyBuilder:
90
+ return cls.instance().key_builder
91
+
92
+ @classmethod
93
+ def get_cache_status_header(cls) -> str:
94
+ return cls.instance().cache_status_header
95
+
96
+ @classmethod
97
+ def get_enable(cls) -> bool:
98
+ return cls._instance.enable if cls._instance is not None else True
99
+
100
+ @classmethod
101
+ def get_options(cls) -> CacheOptions:
102
+ return cls._instance.options if cls._instance is not None else CacheOptions()
103
+
104
+ @classmethod
105
+ def get_guard(cls) -> BackendGuard:
106
+ return cls.instance().guard
107
+
108
+ @classmethod
109
+ def get_metrics(cls) -> Metrics:
110
+ return cls._instance.metrics if cls._instance is not None else Metrics(enabled=False)
111
+
112
+ @classmethod
113
+ def tag_key(cls, tag: str) -> str:
114
+ return cls.instance().tag_key(tag)
115
+
116
+ @classmethod
117
+ async def invalidate_tags(cls, *tags: str) -> int:
118
+ """Delete every entry stored with any of `tags` (see `@cache(tags=...)`)."""
119
+ return await cls.instance().invalidate_tags(*tags)
120
+
121
+ @classmethod
122
+ async def clear(cls, namespace: str | None = None, key: str | None = None) -> int:
123
+ """Delete the entry `key` (a full cache key), or every entry of `namespace`, or of the
124
+ whole prefix when neither is given."""
125
+ return await cls.instance().clear(namespace, key)
@@ -0,0 +1,42 @@
1
+ from fastapi_alcazaba.backends import inmemory, tiered
2
+ from fastapi_alcazaba.types import Backend
3
+
4
+ __all__ = ["Backend", "inmemory", "tiered"]
5
+
6
+ # import each backend in turn and add to __all__. This syntax
7
+ # is explicitly supported by type checkers, while more dynamic
8
+ # syntax would not be recognised.
9
+ try:
10
+ from fastapi_alcazaba.backends import dynamodb
11
+ except ImportError:
12
+ pass
13
+ else:
14
+ __all__ += ["dynamodb"]
15
+
16
+ try:
17
+ from fastapi_alcazaba.backends import memcached
18
+ except ImportError:
19
+ pass
20
+ else:
21
+ __all__ += ["memcached"]
22
+
23
+ try:
24
+ from fastapi_alcazaba.backends import redis
25
+ except ImportError:
26
+ pass
27
+ else:
28
+ __all__ += ["redis"]
29
+
30
+ try:
31
+ from fastapi_alcazaba.backends import valkey
32
+ except ImportError:
33
+ pass
34
+ else:
35
+ __all__ += ["valkey"]
36
+
37
+ try:
38
+ from fastapi_alcazaba.backends import glide
39
+ except ImportError:
40
+ pass
41
+ else:
42
+ __all__ += ["glide"]
@@ -0,0 +1,138 @@
1
+ """Shared implementation of the Redis and Valkey backends: their clients have the same API."""
2
+
3
+ import re
4
+ import secrets
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from fastapi_alcazaba.types import Backend
9
+
10
+ _GLOB_SPECIAL = re.compile(r"([\\*?\[\]])")
11
+
12
+ # Keys and values always travel as KEYS/ARGV, never inside the script source.
13
+ RELEASE_LOCK_SCRIPT = """
14
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
15
+ return redis.call('DEL', KEYS[1])
16
+ end
17
+ return 0
18
+ """
19
+
20
+ # KEYS[1]: tag set; ARGV[1]: entry key; ARGV[2]: its expiry in seconds, 0 for none. The set lives as
21
+ # long as its longest-lived entry: it stays persistent once an entry without expiry joins.
22
+ ADD_TAG_SCRIPT = """
23
+ local existed = redis.call('EXISTS', KEYS[1]) == 1
24
+ redis.call('SADD', KEYS[1], ARGV[1])
25
+ local expire = tonumber(ARGV[2])
26
+ if expire == 0 then
27
+ redis.call('PERSIST', KEYS[1])
28
+ elseif not existed then
29
+ redis.call('EXPIRE', KEYS[1], expire)
30
+ else
31
+ local current = redis.call('TTL', KEYS[1])
32
+ if current ~= -1 and current < expire then
33
+ redis.call('EXPIRE', KEYS[1], expire)
34
+ end
35
+ end
36
+ return 1
37
+ """
38
+
39
+
40
+ def escape_glob(text: str) -> str:
41
+ """Escape the metacharacters of a SCAN MATCH pattern, so that a namespace like "a*" or "a[b]"
42
+ only matches itself."""
43
+ return _GLOB_SPECIAL.sub(r"\\\1", text)
44
+
45
+
46
+ def pending_tag_key(tag: str) -> str:
47
+ """Where a tag set is moved while it's invalidated. Same hash slot as the tag in a cluster:
48
+ the tag keys carry a {hash tag}."""
49
+ return f"{tag}:invalidating:{secrets.token_hex(8)}"
50
+
51
+
52
+ def is_missing_key_error(exc: Exception) -> bool:
53
+ return "no such key" in str(exc).lower()
54
+
55
+
56
+ class RedisLikeBackend(Backend):
57
+ """Backend for redis-py style clients (standalone or cluster).
58
+
59
+ `clear(namespace=...)` walks the namespace with SCAN and deletes in batches with UNLINK.
60
+ fastapi-cache2 ran a Lua script with KEYS, which blocks the server while it walks every key,
61
+ and interpolated the namespace into the script source: a namespace coming from user input
62
+ could run arbitrary commands (e.g. "x') redis.call('FLUSHALL') --").
63
+ """
64
+
65
+ supports_locks = True
66
+ supports_tags = True
67
+
68
+ def __init__(self, client: Any, *, is_cluster: bool, scan_count: int = 500) -> None:
69
+ if scan_count < 1:
70
+ raise ValueError("scan_count must be at least 1")
71
+ self._client = client
72
+ self.is_cluster = is_cluster
73
+ self.scan_count = scan_count
74
+
75
+ async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
76
+ async with self._client.pipeline(transaction=not self.is_cluster) as pipe:
77
+ ttl, value = await pipe.ttl(key).get(key).execute()
78
+ if value is None:
79
+ return 0, None
80
+ return int(ttl), value
81
+
82
+ async def get(self, key: str) -> bytes | None:
83
+ value: bytes | None = await self._client.get(key)
84
+ return value
85
+
86
+ async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
87
+ await self._client.set(key, value, ex=expire or None)
88
+
89
+ async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
90
+ if namespace:
91
+ return await self._delete_matching(f"{escape_glob(namespace)}:*")
92
+ if key:
93
+ return int(await self._client.delete(key))
94
+ return 0
95
+
96
+ async def acquire_lock(self, key: str, token: str, ttl: float) -> bool:
97
+ return bool(await self._client.set(key, token, nx=True, px=max(1, int(ttl * 1000))))
98
+
99
+ async def release_lock(self, key: str, token: str) -> None:
100
+ await self._client.eval(RELEASE_LOCK_SCRIPT, 1, key, token)
101
+
102
+ async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
103
+ for tag in tags:
104
+ await self._client.eval(ADD_TAG_SCRIPT, 1, tag, key, str(expire or 0))
105
+
106
+ async def invalidate_tags(self, tags: Sequence[str]) -> int:
107
+ deleted = 0
108
+ for tag in tags:
109
+ # Moving the set first means that entries tagged while this runs land in a new set and
110
+ # aren't lost; they belong to the next invalidation.
111
+ pending = pending_tag_key(tag)
112
+ try:
113
+ await self._client.rename(tag, pending)
114
+ except Exception as exc:
115
+ if not is_missing_key_error(exc):
116
+ raise
117
+ continue
118
+ members = list(await self._client.smembers(pending))
119
+ if members:
120
+ deleted += await self._unlink(members)
121
+ await self._client.unlink(pending)
122
+ return deleted
123
+
124
+ async def _delete_matching(self, pattern: str) -> int:
125
+ deleted = 0
126
+ batch: list[bytes] = []
127
+ async for found in self._client.scan_iter(match=pattern, count=self.scan_count):
128
+ batch.append(found)
129
+ if len(batch) >= self.scan_count:
130
+ deleted += await self._unlink(batch)
131
+ batch = []
132
+ if batch:
133
+ deleted += await self._unlink(batch)
134
+ return deleted
135
+
136
+ async def _unlink(self, keys: list[bytes]) -> int:
137
+ # The cluster clients of redis-py and valkey-py split a multi-key UNLINK by hash slot.
138
+ return int(await self._client.unlink(*keys))
@@ -0,0 +1,92 @@
1
+ import time
2
+ from typing import TYPE_CHECKING
3
+
4
+ from aiobotocore.client import AioBaseClient
5
+ from aiobotocore.session import AioSession, get_session
6
+
7
+ from fastapi_alcazaba.types import Backend
8
+
9
+ if TYPE_CHECKING:
10
+ from types_aiobotocore_dynamodb import DynamoDBClient
11
+ else:
12
+ DynamoDBClient = AioBaseClient
13
+
14
+
15
+ class DynamoBackend(Backend):
16
+ """
17
+ Amazon DynamoDB backend provider
18
+
19
+ This backend requires an existing table within your AWS environment to be passed during
20
+ backend init. If ttl is going to be used, this needs to be manually enabled on the table
21
+ using the `ttl` key. Dynamo will take care of deleting outdated objects, but this is not
22
+ instant so don't be alarmed when they linger around for a bit.
23
+
24
+ As with all AWS clients, credentials will be taken from the environment. Check the AWS SDK
25
+ for more information.
26
+
27
+ Usage:
28
+ >> dynamodb = DynamoBackend(table_name="your-cache", region="eu-west-1")
29
+ >> await dynamodb.init()
30
+ >> FastAPICache.init(dynamodb)
31
+ """
32
+
33
+ def __init__(
34
+ self, table_name: str, region: str | None = None, *, endpoint_url: str | None = None
35
+ ) -> None:
36
+ """`endpoint_url` points to DynamoDB Local, LocalStack or a VPC endpoint."""
37
+ self.session: AioSession = get_session()
38
+ self.table_name = table_name
39
+ self.region = region
40
+ self.endpoint_url = endpoint_url
41
+ self.client: DynamoDBClient | None = None
42
+
43
+ async def init(self) -> None:
44
+ self.client = await self.session.create_client(
45
+ "dynamodb", region_name=self.region, endpoint_url=self.endpoint_url
46
+ ).__aenter__()
47
+
48
+ async def close(self) -> None:
49
+ if self.client is not None:
50
+ await self.client.__aexit__(None, None, None)
51
+ self.client = None
52
+
53
+ def _get_client(self) -> DynamoDBClient:
54
+ if self.client is None:
55
+ raise RuntimeError("DynamoBackend.init() must be awaited first")
56
+ return self.client
57
+
58
+ async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
59
+ response = await self._get_client().get_item(
60
+ TableName=self.table_name, Key={"key": {"S": key}}
61
+ )
62
+ item = response.get("Item")
63
+ if item is None:
64
+ return 0, None
65
+ value = item.get("value", {}).get("B")
66
+ ttl = item.get("ttl", {}).get("N")
67
+ if value is None:
68
+ return 0, None
69
+ if not ttl:
70
+ return -1, value
71
+ # DynamoDB deletes expired items eventually, not right away: check it ourselves.
72
+ remaining = int(ttl) - int(time.time())
73
+ return (remaining, value) if remaining > 0 else (0, None)
74
+
75
+ async def get(self, key: str) -> bytes | None:
76
+ return (await self.get_with_ttl(key))[1]
77
+
78
+ async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
79
+ item: dict[str, dict[str, str | bytes]] = {"key": {"S": key}, "value": {"B": value}}
80
+ if expire:
81
+ item["ttl"] = {"N": str(int(time.time()) + expire)}
82
+ await self._get_client().put_item(TableName=self.table_name, Item=item)
83
+
84
+ async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
85
+ if namespace:
86
+ raise NotImplementedError("DynamoDB can't delete a namespace without a table scan")
87
+ if key:
88
+ response = await self._get_client().delete_item(
89
+ TableName=self.table_name, Key={"key": {"S": key}}, ReturnValues="ALL_OLD"
90
+ )
91
+ return 1 if response.get("Attributes") else 0
92
+ return 0
@@ -0,0 +1,117 @@
1
+ from collections.abc import Sequence
2
+
3
+ from glide import (
4
+ Batch,
5
+ ClusterBatch,
6
+ ClusterScanCursor,
7
+ ConditionalChange,
8
+ ExpirySet,
9
+ ExpiryType,
10
+ GlideClient,
11
+ GlideClusterClient,
12
+ Script,
13
+ )
14
+
15
+ from fastapi_alcazaba.backends._redis_common import (
16
+ ADD_TAG_SCRIPT,
17
+ RELEASE_LOCK_SCRIPT,
18
+ escape_glob,
19
+ is_missing_key_error,
20
+ pending_tag_key,
21
+ )
22
+ from fastapi_alcazaba.types import Backend
23
+
24
+ _RELEASE_LOCK = Script(RELEASE_LOCK_SCRIPT) # type: ignore[no-untyped-call]
25
+ _ADD_TAG = Script(ADD_TAG_SCRIPT) # type: ignore[no-untyped-call]
26
+
27
+
28
+ class GlideBackend(Backend):
29
+ """Valkey (or Redis) through Valkey GLIDE, the official client with a Rust core:
30
+ `GlideClient` or `GlideClusterClient`, created with `await GlideClient.create(config)`.
31
+
32
+ Same behaviour as RedisBackend: SCAN + UNLINK to clear a namespace, distributed locks and tags.
33
+ """
34
+
35
+ supports_locks = True
36
+ supports_tags = True
37
+
38
+ def __init__(self, client: GlideClient | GlideClusterClient, *, scan_count: int = 500) -> None:
39
+ if scan_count < 1:
40
+ raise ValueError("scan_count must be at least 1")
41
+ self.client = client
42
+ self.scan_count = scan_count
43
+ self._is_cluster = isinstance(client, GlideClusterClient)
44
+
45
+ async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
46
+ # TTL and GET in one round trip (a non-atomic batch, i.e. a pipeline).
47
+ batch = ClusterBatch(is_atomic=False) if self._is_cluster else Batch(is_atomic=False)
48
+ batch.ttl(key)
49
+ batch.get(key)
50
+ replies = await self.client.exec(batch, raise_on_error=True)
51
+ ttl, value = replies if replies is not None else (0, None)
52
+ if value is None:
53
+ return 0, None
54
+ return int(ttl), value
55
+
56
+ async def get(self, key: str) -> bytes | None:
57
+ return await self.client.get(key)
58
+
59
+ async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
60
+ expiry = ExpirySet(ExpiryType.SEC, expire) if expire else None
61
+ await self.client.set(key, value, expiry=expiry)
62
+
63
+ async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
64
+ if namespace:
65
+ return await self._delete_matching(f"{escape_glob(namespace)}:*")
66
+ if key:
67
+ return await self.client.delete([key])
68
+ return 0
69
+
70
+ async def acquire_lock(self, key: str, token: str, ttl: float) -> bool:
71
+ result = await self.client.set(
72
+ key,
73
+ token,
74
+ conditional_set=ConditionalChange.ONLY_IF_DOES_NOT_EXIST,
75
+ expiry=ExpirySet(ExpiryType.MILLSEC, max(1, int(ttl * 1000))),
76
+ )
77
+ return result is not None
78
+
79
+ async def release_lock(self, key: str, token: str) -> None:
80
+ await self.client.invoke_script(_RELEASE_LOCK, keys=[key], args=[token])
81
+
82
+ async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
83
+ for tag in tags:
84
+ await self.client.invoke_script(_ADD_TAG, keys=[tag], args=[key, str(expire or 0)])
85
+
86
+ async def invalidate_tags(self, tags: Sequence[str]) -> int:
87
+ deleted = 0
88
+ for tag in tags:
89
+ pending = pending_tag_key(tag)
90
+ try:
91
+ await self.client.rename(tag, pending)
92
+ except Exception as exc:
93
+ if not is_missing_key_error(exc):
94
+ raise
95
+ continue
96
+ members = list(await self.client.smembers(pending))
97
+ if members:
98
+ deleted += await self.client.unlink(list(members))
99
+ await self.client.unlink([pending])
100
+ return deleted
101
+
102
+ async def _delete_matching(self, pattern: str) -> int:
103
+ deleted = 0
104
+ if isinstance(self.client, GlideClusterClient):
105
+ cursor = ClusterScanCursor() # type: ignore[no-untyped-call]
106
+ while not cursor.is_finished():
107
+ cursor, keys = await self.client.scan(cursor, match=pattern, count=self.scan_count) # type: ignore[assignment]
108
+ if keys:
109
+ deleted += await self.client.unlink(keys)
110
+ return deleted
111
+ position: bytes = b"0"
112
+ while True:
113
+ position, keys = await self.client.scan(position, match=pattern, count=self.scan_count) # type: ignore[assignment]
114
+ if keys:
115
+ deleted += await self.client.unlink(keys)
116
+ if position == b"0":
117
+ return deleted
@@ -0,0 +1,95 @@
1
+ import math
2
+ import time
3
+ from collections import OrderedDict
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from fastapi_alcazaba.types import Backend
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class Value:
12
+ data: bytes
13
+ expires_at: float | None # time.monotonic(); None: no expiry
14
+
15
+
16
+ class InMemoryBackend(Backend):
17
+ """Cache in the memory of the process: for development, tests and single-process apps.
18
+
19
+ Each instance has its own store, bounded to `max_size` entries (the least recently used are
20
+ evicted). fastapi-cache2 shared one dict between all instances and never evicted, so varying
21
+ a query string grew it until the process ran out of memory.
22
+ """
23
+
24
+ supports_tags = True
25
+
26
+ def __init__(self, max_size: int | None = 10_000) -> None:
27
+ if max_size is not None and max_size < 1:
28
+ raise ValueError("max_size must be at least 1 (or None for no limit)")
29
+ self.max_size = max_size
30
+ self._store: OrderedDict[str, Value] = OrderedDict()
31
+ self._tags: dict[str, set[str]] = {}
32
+ self._tags_of: dict[str, set[str]] = {} # entry key -> its tags
33
+
34
+ def _delete(self, key: str) -> bool:
35
+ if self._store.pop(key, None) is None:
36
+ return False
37
+ for tag in self._tags_of.pop(key, ()):
38
+ members = self._tags.get(tag)
39
+ if members is not None:
40
+ members.discard(key)
41
+ if not members:
42
+ del self._tags[tag]
43
+ return True
44
+
45
+ def _get(self, key: str) -> Value | None:
46
+ value = self._store.get(key)
47
+ if value is None:
48
+ return None
49
+ if value.expires_at is not None and value.expires_at <= time.monotonic():
50
+ self._delete(key)
51
+ return None
52
+ self._store.move_to_end(key)
53
+ return value
54
+
55
+ async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
56
+ value = self._get(key)
57
+ if value is None:
58
+ return 0, None
59
+ if value.expires_at is None:
60
+ return -1, value.data
61
+ return max(1, math.ceil(value.expires_at - time.monotonic())), value.data
62
+
63
+ async def get(self, key: str) -> bytes | None:
64
+ value = self._get(key)
65
+ return None if value is None else value.data
66
+
67
+ async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
68
+ expires_at = time.monotonic() + expire if expire else None
69
+ self._store[key] = Value(value, expires_at)
70
+ self._store.move_to_end(key)
71
+ if self.max_size is not None:
72
+ while len(self._store) > self.max_size:
73
+ self._delete(next(iter(self._store)))
74
+
75
+ async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
76
+ if namespace:
77
+ prefix = f"{namespace}:"
78
+ matching = [k for k in self._store if k.startswith(prefix)]
79
+ for k in matching:
80
+ self._delete(k)
81
+ return len(matching)
82
+ if key:
83
+ return int(self._delete(key))
84
+ return 0
85
+
86
+ async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
87
+ if key not in self._store:
88
+ return
89
+ for tag in tags:
90
+ self._tags.setdefault(tag, set()).add(key)
91
+ self._tags_of.setdefault(key, set()).update(tags)
92
+
93
+ async def invalidate_tags(self, tags: Sequence[str]) -> int:
94
+ keys = set().union(*(self._tags.get(tag, set()) for tag in tags))
95
+ return sum(self._delete(key) for key in keys)
@@ -0,0 +1,49 @@
1
+ import hashlib
2
+ import time
3
+
4
+ from aiomcache import Client
5
+
6
+ from fastapi_alcazaba.types import Backend
7
+
8
+ # Beyond 30 days memcached reads exptime as a Unix timestamp, not as seconds from now.
9
+ _MAX_RELATIVE_EXPTIME = 30 * 24 * 3600
10
+ _MAX_KEY_LENGTH = 250
11
+
12
+
13
+ def memcached_key(key: str) -> bytes:
14
+ """memcached keys are at most 250 bytes of printable ASCII without spaces: anything else is
15
+ replaced by its SHA-256 (the keys Alcazaba builds always fit)."""
16
+ raw = key.encode()
17
+ if len(raw) <= _MAX_KEY_LENGTH and all(33 <= byte <= 126 for byte in raw):
18
+ return raw
19
+ return b"fastapi_alcazaba:sha256:" + hashlib.sha256(raw).hexdigest().encode()
20
+
21
+
22
+ class MemcachedBackend(Backend):
23
+ def __init__(self, mcache: Client) -> None:
24
+ self.mcache = mcache
25
+
26
+ async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
27
+ value = await self.get(key)
28
+ # memcached can't tell the remaining time to live (fastapi-cache2 always said 3600).
29
+ return (0, None) if value is None else (-1, value)
30
+
31
+ async def get(self, key: str) -> bytes | None:
32
+ value: bytes | None = await self.mcache.get(memcached_key(key))
33
+ return value
34
+
35
+ async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
36
+ exptime = expire or 0
37
+ if exptime > _MAX_RELATIVE_EXPTIME:
38
+ exptime += int(time.time())
39
+ await self.mcache.set(memcached_key(key), value, exptime=exptime)
40
+
41
+ async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
42
+ if namespace:
43
+ # flush_all (upstream PR #477) would also wipe every other application's data.
44
+ raise NotImplementedError(
45
+ "memcached can't list the keys of a namespace; delete them one by one with `key`"
46
+ )
47
+ if key:
48
+ return int(await self.mcache.delete(memcached_key(key)))
49
+ return 0
@@ -0,0 +1,15 @@
1
+ from redis.asyncio.client import Redis
2
+ from redis.asyncio.cluster import RedisCluster
3
+
4
+ from fastapi_alcazaba.backends._redis_common import RedisLikeBackend
5
+
6
+
7
+ class RedisBackend(RedisLikeBackend):
8
+ """Redis, or any server speaking its protocol (Valkey, KeyDB, Dragonfly...), through redis-py.
9
+
10
+ Create the client without `decode_responses=True`: cached values are bytes.
11
+ """
12
+
13
+ def __init__(self, redis: Redis | RedisCluster, *, scan_count: int = 500) -> None:
14
+ super().__init__(redis, is_cluster=isinstance(redis, RedisCluster), scan_count=scan_count)
15
+ self.redis = redis