vercel-cache 0.7.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,12 @@
1
+ from .cache_build import RuntimeCacheError
2
+ from .purge import dangerously_delete_by_tag, invalidate_by_tag
3
+ from .runtime_cache import AsyncRuntimeCache, RuntimeCache, get_cache
4
+
5
+ __all__ = [
6
+ "RuntimeCache",
7
+ "RuntimeCacheError",
8
+ "AsyncRuntimeCache",
9
+ "get_cache",
10
+ "invalidate_by_tag",
11
+ "dangerously_delete_by_tag",
12
+ ]
vercel/cache/aio.py ADDED
@@ -0,0 +1,19 @@
1
+ from collections.abc import Callable
2
+
3
+ from .runtime_cache import AsyncRuntimeCache
4
+
5
+
6
+ def get_cache(
7
+ *,
8
+ key_hash_function: Callable[[str], str] | None = None,
9
+ namespace: str | None = None,
10
+ namespace_separator: str | None = None,
11
+ ) -> AsyncRuntimeCache:
12
+ return AsyncRuntimeCache(
13
+ key_hash_function=key_hash_function,
14
+ namespace=namespace,
15
+ namespace_separator=namespace_separator,
16
+ )
17
+
18
+
19
+ __all__ = ["get_cache", "AsyncRuntimeCache"]
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable, Mapping, Sequence
5
+
6
+ import httpx
7
+
8
+ from vercel.internal.telemetry import track
9
+
10
+ from .types import AsyncCache, Cache
11
+
12
+ HEADERS_VERCEL_CACHE_STATE = "x-vercel-cache-state"
13
+ HEADERS_VERCEL_REVALIDATE = "x-vercel-revalidate"
14
+ HEADERS_VERCEL_CACHE_TAGS = "x-vercel-cache-tags"
15
+ HEADERS_VERCEL_CACHE_ITEM_NAME = "x-vercel-cache-item-name"
16
+
17
+ # Use no keep-alive for async clients to avoid lingering background tasks
18
+ ASYNC_CLIENT_LIMITS = httpx.Limits(max_keepalive_connections=0)
19
+ DEFAULT_TIMEOUT = 30.0
20
+
21
+
22
+ class RuntimeCacheError(RuntimeError):
23
+ """Raised when strict Runtime Cache operations fail."""
24
+
25
+
26
+ class BuildCache(Cache):
27
+ def __init__(
28
+ self,
29
+ *,
30
+ endpoint: str,
31
+ headers: Mapping[str, str],
32
+ on_error: Callable[[Exception], None] | None = None,
33
+ strict: bool = False,
34
+ ) -> None:
35
+ self._endpoint = endpoint.rstrip("/") + "/"
36
+ self._headers = dict(headers)
37
+ self._on_error = on_error
38
+ self._strict = strict
39
+ self._client = httpx.Client(timeout=httpx.Timeout(30.0))
40
+
41
+ def get(self, key: str):
42
+ try:
43
+ r = self._client.get(self._endpoint + key, headers=self._headers)
44
+ if r.status_code == 404:
45
+ # Track cache miss
46
+ track("cache_get", hit=False)
47
+ return None
48
+ if r.status_code == 200:
49
+ cache_state = r.headers.get(HEADERS_VERCEL_CACHE_STATE)
50
+ if cache_state and cache_state.lower() != "fresh":
51
+ r.close()
52
+ # Track cache miss (stale)
53
+ track("cache_get", hit=False)
54
+ return None
55
+ # Track cache hit
56
+ track("cache_get", hit=True)
57
+ return r.json()
58
+ raise RuntimeCacheError(f"Failed to get cache: {r.status_code} {r.reason_phrase}")
59
+ except Exception as e:
60
+ if self._on_error:
61
+ self._on_error(e)
62
+ if self._strict:
63
+ raise
64
+ return None
65
+
66
+ def set(
67
+ self,
68
+ key: str,
69
+ value: object,
70
+ options: dict | None = None,
71
+ ) -> None:
72
+ try:
73
+ optional_headers: dict[str, str] = {}
74
+ if options and (ttl := options.get("ttl")):
75
+ optional_headers[HEADERS_VERCEL_REVALIDATE] = str(ttl)
76
+ if options and (tags := options.get("tags")):
77
+ if tags:
78
+ optional_headers[HEADERS_VERCEL_CACHE_TAGS] = ",".join(tags)
79
+ if options and (name := options.get("name")):
80
+ optional_headers[HEADERS_VERCEL_CACHE_ITEM_NAME] = name
81
+
82
+ r = self._client.post(
83
+ self._endpoint + key,
84
+ headers={**self._headers, **optional_headers},
85
+ content=json.dumps(value),
86
+ )
87
+ if r.status_code != 200:
88
+ raise RuntimeCacheError(f"Failed to set cache: {r.status_code} {r.reason_phrase}")
89
+ # Track telemetry
90
+ track(
91
+ "cache_set",
92
+ ttl_seconds=options.get("ttl") if options else None,
93
+ has_tags=bool(options and options.get("tags")),
94
+ )
95
+ except Exception as e:
96
+ if self._on_error:
97
+ self._on_error(e)
98
+ if self._strict:
99
+ raise
100
+
101
+ def delete(self, key: str) -> None:
102
+ try:
103
+ r = self._client.delete(self._endpoint + key, headers=self._headers)
104
+ if r.status_code != 200:
105
+ raise RuntimeCacheError(
106
+ f"Failed to delete cache: {r.status_code} {r.reason_phrase}"
107
+ )
108
+ except Exception as e:
109
+ if self._on_error:
110
+ self._on_error(e)
111
+ if self._strict:
112
+ raise
113
+
114
+ def expire_tag(self, tag: str | Sequence[str]) -> None:
115
+ try:
116
+ tags = ",".join(tag) if isinstance(tag, (list, tuple, set)) else tag
117
+ r = self._client.post(
118
+ f"{self._endpoint}revalidate",
119
+ params={"tags": tags},
120
+ headers=self._headers,
121
+ )
122
+ if r.status_code != 200:
123
+ raise RuntimeCacheError(
124
+ f"Failed to revalidate tag: {r.status_code} {r.reason_phrase}"
125
+ )
126
+ except Exception as e:
127
+ if self._on_error:
128
+ self._on_error(e)
129
+ if self._strict:
130
+ raise
131
+
132
+ def __contains__(self, key: str) -> bool:
133
+ try:
134
+ r = self._client.get(self._endpoint + key, headers=self._headers)
135
+ try:
136
+ if r.status_code == 404:
137
+ return False
138
+ if r.status_code == 200:
139
+ cache_state = r.headers.get(HEADERS_VERCEL_CACHE_STATE)
140
+ # Consider present only when fresh
141
+ if cache_state and cache_state.lower() != "fresh":
142
+ return False
143
+ return True
144
+ return False
145
+ finally:
146
+ # Ensure the response is closed regardless of outcome
147
+ r.close()
148
+ except Exception as e:
149
+ if self._on_error:
150
+ self._on_error(e)
151
+ return False
152
+
153
+ def __getitem__(self, key: str):
154
+ if key in self:
155
+ return self.get(key)
156
+ raise KeyError(key)
157
+
158
+
159
+ class AsyncBuildCache(AsyncCache):
160
+ def __init__(
161
+ self,
162
+ *,
163
+ endpoint: str,
164
+ headers: Mapping[str, str],
165
+ on_error: Callable[[Exception], None] | None = None,
166
+ ) -> None:
167
+ self._endpoint = endpoint.rstrip("/") + "/"
168
+ self._headers = dict(headers)
169
+ self._on_error = on_error
170
+
171
+ async def get(self, key: str):
172
+ try:
173
+ async with httpx.AsyncClient(
174
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT), limits=ASYNC_CLIENT_LIMITS
175
+ ) as client:
176
+ r = await client.get(self._endpoint + key, headers=self._headers)
177
+ if r.status_code == 404:
178
+ await r.aclose()
179
+ # Track cache miss
180
+ try:
181
+ track("cache_get", hit=False)
182
+ except Exception:
183
+ pass
184
+ return None
185
+ if r.status_code == 200:
186
+ cache_state = r.headers.get(HEADERS_VERCEL_CACHE_STATE)
187
+ if cache_state and cache_state.lower() != "fresh":
188
+ await r.aclose()
189
+ # Track cache miss (stale)
190
+ try:
191
+ track("cache_get", hit=False)
192
+ except Exception:
193
+ pass
194
+ return None
195
+ data = r.json()
196
+ await r.aclose()
197
+ # Track cache hit
198
+ try:
199
+ track("cache_get", hit=True)
200
+ except Exception:
201
+ pass
202
+ return data
203
+ await r.aclose()
204
+ raise RuntimeError(f"Failed to get cache: {r.status_code} {r.reason_phrase}")
205
+ except Exception as e:
206
+ if self._on_error:
207
+ self._on_error(e)
208
+ return None
209
+
210
+ async def set(
211
+ self,
212
+ key: str,
213
+ value: object,
214
+ options: dict | None = None,
215
+ ) -> None:
216
+ try:
217
+ optional_headers: dict[str, str] = {}
218
+ if options and (ttl := options.get("ttl")):
219
+ optional_headers[HEADERS_VERCEL_REVALIDATE] = str(ttl)
220
+ if options and (tags := options.get("tags")):
221
+ if tags:
222
+ optional_headers[HEADERS_VERCEL_CACHE_TAGS] = ",".join(tags)
223
+ if options and (name := options.get("name")):
224
+ optional_headers[HEADERS_VERCEL_CACHE_ITEM_NAME] = name
225
+
226
+ async with httpx.AsyncClient(
227
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT), limits=ASYNC_CLIENT_LIMITS
228
+ ) as client:
229
+ r = await client.post(
230
+ self._endpoint + key,
231
+ headers={**self._headers, **optional_headers},
232
+ content=json.dumps(value),
233
+ )
234
+ if r.status_code != 200:
235
+ await r.aclose()
236
+ raise RuntimeError(f"Failed to set cache: {r.status_code} {r.reason_phrase}")
237
+ await r.aclose()
238
+ # Track telemetry
239
+ track(
240
+ "cache_set",
241
+ ttl_seconds=options.get("ttl") if options else None,
242
+ has_tags=bool(options and options.get("tags")),
243
+ )
244
+ except Exception as e:
245
+ if self._on_error:
246
+ self._on_error(e)
247
+
248
+ async def delete(self, key: str) -> None:
249
+ try:
250
+ async with httpx.AsyncClient(
251
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT), limits=ASYNC_CLIENT_LIMITS
252
+ ) as client:
253
+ r = await client.delete(self._endpoint + key, headers=self._headers)
254
+ if r.status_code != 200:
255
+ await r.aclose()
256
+ raise RuntimeError(f"Failed to delete cache: {r.status_code} {r.reason_phrase}")
257
+ await r.aclose()
258
+ except Exception as e:
259
+ if self._on_error:
260
+ self._on_error(e)
261
+
262
+ async def expire_tag(self, tag: str | Sequence[str]) -> None:
263
+ try:
264
+ tags = ",".join(tag) if isinstance(tag, (list, tuple, set)) else tag
265
+ async with httpx.AsyncClient(
266
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT), limits=ASYNC_CLIENT_LIMITS
267
+ ) as client:
268
+ r = await client.post(
269
+ f"{self._endpoint}revalidate",
270
+ params={"tags": tags},
271
+ headers=self._headers,
272
+ )
273
+ if r.status_code != 200:
274
+ await r.aclose()
275
+ raise RuntimeError(
276
+ f"Failed to revalidate tag: {r.status_code} {r.reason_phrase}"
277
+ )
278
+ await r.aclose()
279
+ except Exception as e:
280
+ if self._on_error:
281
+ self._on_error(e)
282
+
283
+ async def contains(self, key: str) -> bool:
284
+ try:
285
+ async with httpx.AsyncClient(
286
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT), limits=ASYNC_CLIENT_LIMITS
287
+ ) as client:
288
+ r = await client.get(self._endpoint + key, headers=self._headers)
289
+ if r.status_code == 404:
290
+ await r.aclose()
291
+ return False
292
+ if r.status_code == 200:
293
+ cache_state = r.headers.get(HEADERS_VERCEL_CACHE_STATE)
294
+ if cache_state and cache_state.lower() != "fresh":
295
+ await r.aclose()
296
+ return False
297
+ await r.aclose()
298
+ return True
299
+ await r.aclose()
300
+ return False
301
+ except Exception as e:
302
+ if self._on_error:
303
+ self._on_error(e)
304
+ return False
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from vercel.internal.telemetry import track
6
+
7
+ from .types import AsyncCache, Cache
8
+
9
+
10
+ class InMemoryCache(Cache):
11
+ def __init__(self) -> None:
12
+ self._cache: dict[str, dict] = {}
13
+
14
+ def get(self, key: str):
15
+ entry = self._cache.get(key)
16
+ if not entry:
17
+ # Track cache miss
18
+ track("cache_get", hit=False)
19
+ return None
20
+ ttl = entry.get("ttl")
21
+ if (
22
+ ttl is not None
23
+ and entry["last_modified"] + ttl * 1000 < __import__("time").time() * 1000
24
+ ):
25
+ self.delete(key)
26
+ # Track cache miss (expired)
27
+ track("cache_get", hit=False)
28
+ return None
29
+ # Track cache hit
30
+ track("cache_get", hit=True)
31
+ return entry["value"]
32
+
33
+ def set(self, key: str, value: object, options: dict | None = None) -> None:
34
+ from time import time
35
+
36
+ opts = options or {}
37
+ ttl = opts.get("ttl")
38
+ tags = set(opts.get("tags", []))
39
+ self._cache[key] = {
40
+ "value": value,
41
+ "tags": tags,
42
+ "last_modified": int(time() * 1000),
43
+ "ttl": ttl,
44
+ }
45
+ # Track telemetry
46
+ track("cache_set", ttl_seconds=ttl, has_tags=len(tags) > 0)
47
+
48
+ def delete(self, key: str) -> None:
49
+ self._cache.pop(key, None)
50
+
51
+ def expire_tag(self, tag: str | Sequence[str]) -> None:
52
+ tags = {tag} if isinstance(tag, str) else set(tag)
53
+ to_delete = []
54
+ for k, entry in self._cache.items():
55
+ entry_tags = entry.get("tags", set())
56
+ if any(t in entry_tags for t in tags):
57
+ to_delete.append(k)
58
+ for k in to_delete:
59
+ self._cache.pop(k, None)
60
+
61
+ def __contains__(self, key: str) -> bool:
62
+ entry = self._cache.get(key)
63
+ if not entry:
64
+ return False
65
+ ttl = entry.get("ttl")
66
+ if (
67
+ ttl is not None
68
+ and entry["last_modified"] + ttl * 1000 < __import__("time").time() * 1000
69
+ ):
70
+ # Expired entries should not be considered present
71
+ self.delete(key)
72
+ return False
73
+ return True
74
+
75
+ def __getitem__(self, key: str):
76
+ if key in self:
77
+ return self.get(key)
78
+ raise KeyError(key)
79
+
80
+
81
+ class AsyncInMemoryCache(AsyncCache):
82
+ def __init__(self, delegate: InMemoryCache | None = None) -> None:
83
+ # Reuse the synchronous implementation under the hood and expose async API
84
+ self.cache = delegate or InMemoryCache()
85
+
86
+ async def get(self, key: str):
87
+ return self.cache.get(key)
88
+
89
+ async def set(self, key: str, value: object, options: dict | None = None) -> None:
90
+ self.cache.set(key, value, options)
91
+
92
+ async def delete(self, key: str) -> None:
93
+ self.cache.delete(key)
94
+
95
+ async def expire_tag(self, tag: str | Sequence[str]) -> None:
96
+ self.cache.expire_tag(tag)
97
+
98
+ async def contains(self, key: str) -> bool:
99
+ return key in self.cache
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Mapping
4
+ from contextvars import ContextVar
5
+ from dataclasses import dataclass
6
+
7
+ from vercel.headers import get_headers as _get_headers, set_headers as _set_headers
8
+
9
+ from .types import PurgeAPI
10
+
11
+ _cv_wait_until: ContextVar[Callable[[Awaitable[object]], None] | None] = ContextVar(
12
+ "vercel_wait_until", default=None
13
+ )
14
+ _cv_cache: ContextVar[object | None] = ContextVar("vercel_cache", default=None)
15
+ _cv_async_cache: ContextVar[object | None] = ContextVar("vercel_async_cache", default=None)
16
+ _cv_purge: ContextVar[PurgeAPI | None] = ContextVar("vercel_purge", default=None)
17
+
18
+
19
+ class _Unset: ...
20
+
21
+
22
+ UNSET = _Unset()
23
+
24
+
25
+ @dataclass
26
+ class _ContextSnapshot:
27
+ wait_until: Callable[[Awaitable[object]], None] | None
28
+ cache: object | None
29
+ async_cache: object | None
30
+ purge: PurgeAPI | None
31
+ headers: Mapping[str, str] | None
32
+
33
+
34
+ def get_context() -> _ContextSnapshot:
35
+ return _ContextSnapshot(
36
+ wait_until=_cv_wait_until.get(),
37
+ cache=_cv_cache.get(),
38
+ async_cache=_cv_async_cache.get(),
39
+ purge=_cv_purge.get(),
40
+ headers=_get_headers(),
41
+ )
42
+
43
+
44
+ def set_context(
45
+ *,
46
+ wait_until: Callable[[Awaitable[object]], None] | None | _Unset = UNSET,
47
+ cache: object | None | _Unset = UNSET,
48
+ async_cache: object | None | _Unset = UNSET,
49
+ purge: PurgeAPI | None | _Unset = UNSET,
50
+ headers: Mapping[str, str] | None | _Unset = UNSET,
51
+ ) -> None:
52
+ if not isinstance(wait_until, _Unset):
53
+ _cv_wait_until.set(wait_until)
54
+ if not isinstance(cache, _Unset):
55
+ _cv_cache.set(cache)
56
+ if not isinstance(async_cache, _Unset):
57
+ _cv_async_cache.set(async_cache)
58
+ if not isinstance(purge, _Unset):
59
+ _cv_purge.set(purge)
60
+ if not isinstance(headers, _Unset):
61
+ _set_headers(headers)
62
+
63
+
64
+ def set_headers(headers: Mapping[str, str] | None) -> None:
65
+ _set_headers(headers)
66
+
67
+
68
+ def get_headers() -> Mapping[str, str] | None:
69
+ return _get_headers()
vercel/cache/purge.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .context import get_context
6
+
7
+
8
+ def invalidate_by_tag(tag: str | list[str]) -> Any:
9
+ api = get_context().purge
10
+ if api is None:
11
+ return None
12
+ return api.invalidate_by_tag(tag)
13
+
14
+
15
+ def dangerously_delete_by_tag(
16
+ tag: str | list[str],
17
+ *,
18
+ revalidation_deadline_seconds: int | None = None,
19
+ ) -> Any:
20
+ api = get_context().purge
21
+ if api is None:
22
+ return None
23
+ return api.dangerously_delete_by_tag(
24
+ tag,
25
+ revalidation_deadline_seconds=revalidation_deadline_seconds,
26
+ )
vercel/cache/py.typed ADDED
File without changes
@@ -0,0 +1,204 @@
1
+ import json
2
+ import os
3
+ from collections.abc import Callable, Sequence
4
+ from typing import Literal, cast, overload
5
+
6
+ from .cache_build import AsyncBuildCache, BuildCache
7
+ from .cache_in_memory import AsyncInMemoryCache, InMemoryCache
8
+ from .context import get_context
9
+ from .types import AsyncCache, Cache
10
+ from .utils import create_key_transformer
11
+
12
+ _in_memory_cache_instance: InMemoryCache | None = None
13
+ _async_in_memory_cache_instance: AsyncInMemoryCache | None = None
14
+ _build_cache_instance: BuildCache | None = None
15
+ _async_build_cache_instance: AsyncBuildCache | None = None
16
+ _warned_cache_unavailable = False
17
+
18
+
19
+ class RuntimeCache(Cache):
20
+ def __init__(
21
+ self,
22
+ *,
23
+ key_hash_function: Callable[[str], str] | None = None,
24
+ namespace: str | None = None,
25
+ namespace_separator: str | None = None,
26
+ strict: bool = False,
27
+ ) -> None:
28
+ # Transform keys to match get_cache behavior
29
+ self._make_key = create_key_transformer(key_hash_function, namespace, namespace_separator)
30
+ self._strict = strict
31
+
32
+ def get(self, key: str):
33
+ return resolve_cache(sync=True, strict=self._strict).get(self._make_key(key))
34
+
35
+ def set(self, key: str, value: object, options: dict | None = None):
36
+ cache = resolve_cache(sync=True, strict=self._strict)
37
+ return cache.set(self._make_key(key), value, options)
38
+
39
+ def delete(self, key: str):
40
+ return resolve_cache(sync=True, strict=self._strict).delete(self._make_key(key))
41
+
42
+ def expire_tag(self, tag: str | Sequence[str]):
43
+ # Tag invalidation is not namespaced/hashed by design
44
+ return resolve_cache(sync=True, strict=self._strict).expire_tag(tag)
45
+
46
+ def __contains__(self, key: str) -> bool:
47
+ # Delegate membership to the underlying cache implementation with transformed key
48
+ return self._make_key(key) in resolve_cache(sync=True, strict=self._strict)
49
+
50
+ def __getitem__(self, key: str):
51
+ if key in self:
52
+ return self.get(key)
53
+ raise KeyError(key)
54
+
55
+
56
+ class AsyncRuntimeCache(AsyncCache):
57
+ def __init__(
58
+ self,
59
+ *,
60
+ key_hash_function: Callable[[str], str] | None = None,
61
+ namespace: str | None = None,
62
+ namespace_separator: str | None = None,
63
+ ) -> None:
64
+ self._make_key = create_key_transformer(key_hash_function, namespace, namespace_separator)
65
+
66
+ async def get(self, key: str):
67
+ return await resolve_cache(sync=False).get(self._make_key(key))
68
+
69
+ async def set(self, key: str, value: object, options: dict | None = None):
70
+ return await resolve_cache(sync=False).set(self._make_key(key), value, options)
71
+
72
+ async def delete(self, key: str):
73
+ return await resolve_cache(sync=False).delete(self._make_key(key))
74
+
75
+ async def expire_tag(self, tag: str | Sequence[str]):
76
+ return await resolve_cache(sync=False).expire_tag(tag)
77
+
78
+ async def contains(self, key: str) -> bool:
79
+ return await resolve_cache(sync=False).contains(self._make_key(key))
80
+
81
+
82
+ @overload
83
+ def get_cache(
84
+ *,
85
+ key_hash_function: Callable[[str], str] | None = ...,
86
+ namespace: str | None = ...,
87
+ namespace_separator: str | None = ...,
88
+ sync: Literal[True] = ...,
89
+ ) -> RuntimeCache: ...
90
+
91
+
92
+ @overload
93
+ def get_cache(
94
+ *,
95
+ key_hash_function: Callable[[str], str] | None = ...,
96
+ namespace: str | None = ...,
97
+ namespace_separator: str | None = ...,
98
+ sync: Literal[False],
99
+ ) -> AsyncRuntimeCache: ...
100
+
101
+
102
+ def get_cache(
103
+ *,
104
+ key_hash_function: Callable[[str], str] | None = None,
105
+ namespace: str | None = None,
106
+ namespace_separator: str | None = None,
107
+ sync: bool = True,
108
+ ) -> RuntimeCache | AsyncRuntimeCache:
109
+ if sync:
110
+ return RuntimeCache(
111
+ key_hash_function=key_hash_function,
112
+ namespace=namespace,
113
+ namespace_separator=namespace_separator,
114
+ )
115
+ return AsyncRuntimeCache(
116
+ key_hash_function=key_hash_function,
117
+ namespace=namespace,
118
+ namespace_separator=namespace_separator,
119
+ )
120
+
121
+
122
+ def _get_cache_implementation(
123
+ debug: bool = False,
124
+ sync: bool = True,
125
+ strict: bool = False,
126
+ ) -> Cache | AsyncCache:
127
+ global _in_memory_cache_instance, _async_in_memory_cache_instance
128
+ global _build_cache_instance, _async_build_cache_instance, _warned_cache_unavailable
129
+
130
+ # Prepare a single shared InMemoryCache backing store and an async wrapper over it
131
+ if _in_memory_cache_instance is None:
132
+ _in_memory_cache_instance = InMemoryCache()
133
+ if _async_in_memory_cache_instance is None:
134
+ _async_in_memory_cache_instance = AsyncInMemoryCache(delegate=_in_memory_cache_instance)
135
+
136
+ # Disable build cache via env
137
+ if os.getenv("RUNTIME_CACHE_DISABLE_BUILD_CACHE") == "true":
138
+ if debug:
139
+ print("Using InMemoryCache as build cache is disabled")
140
+ return _in_memory_cache_instance if sync else _async_in_memory_cache_instance
141
+
142
+ endpoint = os.getenv("RUNTIME_CACHE_ENDPOINT")
143
+ headers = os.getenv("RUNTIME_CACHE_HEADERS")
144
+
145
+ if debug:
146
+ print(
147
+ "Runtime cache environment variables:",
148
+ {"RUNTIME_CACHE_ENDPOINT": endpoint, "RUNTIME_CACHE_HEADERS": headers},
149
+ )
150
+
151
+ if not endpoint or not headers:
152
+ if not _warned_cache_unavailable:
153
+ print("Runtime Cache unavailable in this environment. Falling back to in-memory cache.")
154
+ _warned_cache_unavailable = True
155
+ return _in_memory_cache_instance if sync else _async_in_memory_cache_instance # type: ignore[return-value]
156
+
157
+ # Build cache clients
158
+ try:
159
+ parsed_headers = json.loads(headers)
160
+ if not isinstance(parsed_headers, dict):
161
+ raise ValueError("RUNTIME_CACHE_HEADERS must be a JSON object")
162
+ except Exception as e:
163
+ print("Failed to parse RUNTIME_CACHE_HEADERS:", e)
164
+ return _in_memory_cache_instance if sync else _async_in_memory_cache_instance # type: ignore[return-value]
165
+
166
+ if sync:
167
+ if _build_cache_instance is None:
168
+ _build_cache_instance = BuildCache(
169
+ endpoint=endpoint,
170
+ headers=parsed_headers,
171
+ on_error=lambda e: print(e),
172
+ )
173
+ if not strict:
174
+ return _build_cache_instance
175
+ return BuildCache(endpoint=endpoint, headers=parsed_headers, strict=True)
176
+ else:
177
+ if _async_build_cache_instance is None:
178
+ _async_build_cache_instance = AsyncBuildCache(
179
+ endpoint=endpoint,
180
+ headers=parsed_headers,
181
+ on_error=lambda e: print(e),
182
+ )
183
+ return _async_build_cache_instance
184
+
185
+
186
+ @overload
187
+ def resolve_cache(sync: Literal[True] = ..., strict: bool = ...) -> Cache: ...
188
+
189
+
190
+ @overload
191
+ def resolve_cache(sync: Literal[False], strict: bool = ...) -> AsyncCache: ...
192
+
193
+
194
+ def resolve_cache(sync: bool = True, strict: bool = False) -> Cache | AsyncCache:
195
+ ctx = get_context()
196
+ if sync:
197
+ cache = getattr(ctx, "cache", None)
198
+ if cache is not None:
199
+ return cast(Cache, cache)
200
+ else:
201
+ cache = getattr(ctx, "async_cache", None)
202
+ if cache is not None:
203
+ return cast(AsyncCache, cache)
204
+ return _get_cache_implementation(os.getenv("SUSPENSE_CACHE_DEBUG") == "true", sync, strict)
vercel/cache/types.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any, Protocol
5
+
6
+
7
+ class Cache(Protocol):
8
+ def delete(self, key: str) -> None: ...
9
+
10
+ def get(self, key: str) -> object | None: ...
11
+
12
+ def __contains__(self, key: str) -> bool: ...
13
+
14
+ def set(
15
+ self,
16
+ key: str,
17
+ value: object,
18
+ options: dict | None = None,
19
+ ) -> None: ...
20
+
21
+ def expire_tag(self, tag: str | Sequence[str]) -> None: ...
22
+
23
+
24
+ class AsyncCache(Protocol):
25
+ async def delete(self, key: str) -> None: ...
26
+
27
+ async def get(self, key: str) -> object | None: ...
28
+
29
+ async def set(
30
+ self,
31
+ key: str,
32
+ value: object,
33
+ options: dict | None = None,
34
+ ) -> None: ...
35
+
36
+ async def expire_tag(self, tag: str | Sequence[str]) -> None: ...
37
+
38
+ async def contains(self, key: str) -> bool: ...
39
+
40
+
41
+ class PurgeAPI(Protocol):
42
+ """Protocol for the purge API object."""
43
+
44
+ def invalidate_by_tag(self, tag: str | list[str]) -> Any:
45
+ """Invalidate cache entries by tag."""
46
+ ...
47
+
48
+ def dangerously_delete_by_tag(
49
+ self,
50
+ tag: str | list[str],
51
+ *,
52
+ revalidation_deadline_seconds: int | None = None,
53
+ ) -> Any:
54
+ """Dangerously delete cache entries by tag."""
55
+ ...
56
+
57
+
58
+ class AsyncPurgeAPI(Protocol):
59
+ async def invalidate_by_tag(self, tag: str | list[str]) -> Any: ...
60
+
61
+ async def dangerously_delete_by_tag(
62
+ self,
63
+ tag: str | list[str],
64
+ *,
65
+ revalidation_deadline_seconds: int | None = None,
66
+ ) -> Any: ...
vercel/cache/utils.py ADDED
@@ -0,0 +1,27 @@
1
+ from collections.abc import Callable
2
+
3
+ _DEFAULT_NAMESPACE_SEPARATOR = "$"
4
+
5
+
6
+ def default_key_hash_function(key: str) -> str:
7
+ # Mirror TS defaultKeyHashFunction: djb2 xor variant, 32-bit unsigned hex
8
+ h = 5381
9
+ for ch in key:
10
+ h = ((h * 33) ^ ord(ch)) & 0xFFFFFFFF
11
+ return format(h, "x")
12
+
13
+
14
+ def create_key_transformer(
15
+ key_fn: Callable[[str], str] | None,
16
+ ns: str | None,
17
+ sep: str | None,
18
+ ) -> Callable[[str], str]:
19
+ key_fn = key_fn or default_key_hash_function
20
+ sep = sep or _DEFAULT_NAMESPACE_SEPARATOR
21
+
22
+ def make(key: str) -> str:
23
+ if not ns:
24
+ return key_fn(key)
25
+ return f"{ns}{sep}{key_fn(key)}"
26
+
27
+ return make
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: vercel-cache
3
+ Version: 0.7.0
4
+ Summary: Runtime cache helpers for Vercel Python applications
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx<1,>=0.27.0
9
+ Requires-Dist: vercel-headers>=0.7.0
10
+ Requires-Dist: vercel-internal-telemetry>=0.7.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Cache
14
+
15
+ `vercel.cache` exposes runtime cache helpers for Vercel Python applications.
16
+
17
+ Use `get_cache()` for synchronous code and `vercel.cache.aio.get_cache()` for
18
+ async code. When runtime cache environment variables are unavailable, cache
19
+ operations fall back to an in-memory cache.
@@ -0,0 +1,15 @@
1
+ vercel/cache/__init__.py,sha256=dFyvI06ZlFo0UU7sRgLkhFw5r-j3hRSF3chW3Yitans,337
2
+ vercel/cache/aio.py,sha256=c4WdnoujC_7J4DFMnO5yxZAjEou2DE79Mqn-Bm2VeQQ,474
3
+ vercel/cache/cache_build.py,sha256=GK_ERyv0JUfS26Zjf0C6xBjxPGwoDz8cp3DeF0S8CwU,11282
4
+ vercel/cache/cache_in_memory.py,sha256=SuFF8atNBLa34Sq_hfx-UsgGD1-3XGDZ7JYEUNH8k5A,3053
5
+ vercel/cache/context.py,sha256=jJ2PSqHjIuSk3M28LHemOkavzckS96iXHcmHQlYHt58,2007
6
+ vercel/cache/purge.py,sha256=ynd0ojiOGj2rS9jiKb_9ENW1Wu7zGClhM68QgfFeR-w,585
7
+ vercel/cache/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ vercel/cache/runtime_cache.py,sha256=3_fD3RtpLDHnwEyYJOc83JbRs7x7QNmRqsscv-8zsMc,7258
9
+ vercel/cache/types.py,sha256=ZKvT4ugXpsWJD6ryH53HxTxGILl1WjpxYSa1_L1oSZI,1562
10
+ vercel/cache/utils.py,sha256=UubbjcwMLtWF9TsH0OfIFM5lutCwHFf3FyoApHzFXuQ,679
11
+ vercel/cache/version.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
12
+ vercel_cache-0.7.0.dist-info/METADATA,sha256=4m2OXQ9rOj6ny2TfM-BjlPs6BZpDYI8VUbKWlUGEm_A,631
13
+ vercel_cache-0.7.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ vercel_cache-0.7.0.dist-info/licenses/LICENSE,sha256=ZhFC5TwxPSu14bBV9cCjkAFFD_G14nuJ3EvH3ppjUso,1069
15
+ vercel_cache-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.