vercel-cache 0.7.0__tar.gz

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,130 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ # C extensions
6
+ *.so
7
+
8
+ # Distribution / packaging
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstallerhave
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ .benchmarks/
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ .ruff_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Scrapy
57
+ .scrapy
58
+
59
+ # Sphinx documentation
60
+ docs/_build/
61
+
62
+ # PyBuilder
63
+ target/
64
+
65
+ # Jupyter Notebook
66
+ .ipynb_checkpoints
67
+
68
+ # IPython
69
+ profile_default/
70
+ ipython_config.py
71
+
72
+ # pyenv
73
+ .python-version
74
+
75
+ # pipenv
76
+ Pipfile.lock
77
+
78
+ # poetry
79
+ poetry.lock
80
+
81
+ # PDM
82
+ pdm.lock
83
+ .pdm.toml
84
+
85
+ # Hatch
86
+ .hatch/
87
+
88
+ # pyright/mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # pyre
94
+ .pyre/
95
+
96
+ # pytype
97
+ .pytype/
98
+
99
+ # Caches
100
+ __pypackages__/
101
+
102
+ # Editor/project files
103
+ .DS_Store
104
+ .idea/
105
+ .vscode/
106
+ .zed/
107
+ *.swp
108
+ *.swo
109
+
110
+ # Virtual environments
111
+ .env
112
+ .venv
113
+ .venv.*
114
+ env/
115
+ venv/
116
+ **/.env
117
+ **/.venv
118
+ **/.venv.*
119
+ **/env/
120
+ **/venv/
121
+ ENV/
122
+ env.bak/
123
+ venv.bak/
124
+
125
+ # dotenv anywhere
126
+ **/.env
127
+ **/.env.*
128
+ **/*.env
129
+
130
+ .claude
@@ -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.
@@ -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,7 @@
1
+ # Cache
2
+
3
+ `vercel.cache` exposes runtime cache helpers for Vercel Python applications.
4
+
5
+ Use `get_cache()` for synchronous code and `vercel.cache.aio.get_cache()` for
6
+ async code. When runtime cache environment variables are unavailable, cache
7
+ operations fall back to an in-memory cache.
@@ -0,0 +1,116 @@
1
+ """Hatch metadata hook for publish-time workspace dependency bounds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+ from packaging.requirements import Requirement
11
+
12
+ try:
13
+ import tomllib
14
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
15
+ import tomli as tomllib # type: ignore[no-redef]
16
+
17
+
18
+ class WorkspaceDependenciesMetadataHook(MetadataHookInterface):
19
+ """Generate package dependencies from the repo-owned dependency table."""
20
+
21
+ def update(self, metadata: dict[str, Any]) -> None:
22
+ """Populate dynamic dependencies for Hatchling metadata generation."""
23
+ pyproject = _load_pyproject(Path(self.root))
24
+ release = pyproject.get("tool", {}).get("vercel", {}).get("release", {})
25
+ dependency_table = release.get("dependencies", {})
26
+ workspace_sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {})
27
+ workspace_names = {
28
+ name for name, source in workspace_sources.items() if source.get("workspace") is True
29
+ }
30
+ workspace_root = _find_workspace_root(Path(self.root))
31
+
32
+ metadata["dependencies"] = [
33
+ _rewrite_dependency(requirement, workspace_names, workspace_root)
34
+ for requirement in dependency_table.get("dependencies", [])
35
+ ]
36
+
37
+
38
+ def _load_pyproject(path: Path) -> dict[str, Any]:
39
+ with (path / "pyproject.toml").open("rb") as fp:
40
+ return tomllib.load(fp)
41
+
42
+
43
+ def _find_workspace_root(start: Path) -> Path | None:
44
+ for path in [start, *start.parents]:
45
+ pyproject = path / "pyproject.toml"
46
+ if not pyproject.exists():
47
+ continue
48
+ data = _load_pyproject(path)
49
+ if "workspace" in data.get("tool", {}).get("uv", {}):
50
+ return path
51
+ return None
52
+
53
+
54
+ def _rewrite_dependency(
55
+ requirement: str,
56
+ workspace_names: set[str],
57
+ workspace_root: Path | None,
58
+ ) -> str:
59
+ parsed = Requirement(requirement)
60
+ normalized = parsed.name.lower().replace("_", "-")
61
+ if normalized not in workspace_names or workspace_root is None:
62
+ return requirement
63
+ return _with_lower_bound(parsed, _read_workspace_version(workspace_root, normalized))
64
+
65
+
66
+ def _with_lower_bound(requirement: Requirement, version: str) -> str:
67
+ extras = f"[{','.join(sorted(requirement.extras))}]" if requirement.extras else ""
68
+ specifiers = [
69
+ str(specifier) for specifier in requirement.specifier if specifier.operator != ">="
70
+ ]
71
+ specifier_text = ",".join([f">={version}", *specifiers])
72
+ marker = f" ; {requirement.marker}" if requirement.marker else ""
73
+ return f"{requirement.name}{extras}{specifier_text}{marker}"
74
+
75
+
76
+ def _read_workspace_version(workspace_root: Path, package_name: str) -> str:
77
+ for pattern in ("src/*/pyproject.toml", "integrations/*/pyproject.toml"):
78
+ for pyproject_path in workspace_root.glob(pattern):
79
+ version = _version_from_pyproject(pyproject_path, package_name)
80
+ if version is not None:
81
+ return version
82
+ raise RuntimeError(f"unknown workspace dependency {package_name!r}")
83
+
84
+
85
+ def _version_from_pyproject(pyproject_path: Path, package_name: str) -> str | None:
86
+ data = _load_pyproject(pyproject_path.parent)
87
+ if data.get("project", {}).get("name") != package_name:
88
+ return None
89
+ version_path = pyproject_path.parent / data["tool"]["hatch"]["version"]["path"]
90
+ module = ast.parse(version_path.read_text(encoding="utf-8"), filename=str(version_path))
91
+ for node in module.body:
92
+ value = _version_value(node)
93
+ if value is None:
94
+ continue
95
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
96
+ return value.value
97
+ raise RuntimeError(f"could not find __version__ in {version_path}")
98
+
99
+
100
+ def _version_value(node: ast.stmt) -> ast.expr | None:
101
+ if isinstance(node, ast.Assign) and any(
102
+ isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets
103
+ ):
104
+ return node.value
105
+ if (
106
+ isinstance(node, ast.AnnAssign)
107
+ and isinstance(node.target, ast.Name)
108
+ and node.target.id == "__version__"
109
+ ):
110
+ return node.value
111
+ return None
112
+
113
+
114
+ def get_metadata_hook() -> type[MetadataHookInterface]:
115
+ """Return the hook class used by Hatchling's custom hook loader."""
116
+ return WorkspaceDependenciesMetadataHook
@@ -0,0 +1,29 @@
1
+ """Load the shared Vercel Hatch metadata hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.util import module_from_spec, spec_from_file_location
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+
11
+
12
+ def get_metadata_hook() -> type[MetadataHookInterface]:
13
+ """Return the shared workspace dependency metadata hook."""
14
+ return _load_shared_hook().get_metadata_hook()
15
+
16
+
17
+ def _load_shared_hook() -> ModuleType:
18
+ root = Path(__file__).resolve().parent
19
+ candidates = [root / "../../scripts/hatch_build.py", root / "_vercel_hatch_build.py"]
20
+ for candidate in candidates:
21
+ path = candidate.resolve()
22
+ if path.exists():
23
+ spec = spec_from_file_location("_vercel_hatch_build", path)
24
+ if spec is None or spec.loader is None:
25
+ raise RuntimeError(f"could not load Hatch hook from {path}")
26
+ module = module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ return module
29
+ raise RuntimeError("could not find shared Vercel Hatch metadata hook")
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vercel-cache"
7
+ dynamic = ["version", "dependencies"]
8
+ description = "Runtime cache helpers for Vercel Python applications"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "LICENSE.*"]
13
+
14
+ [tool.hatch.metadata.hooks.custom]
15
+ path = "hatch_build.py"
16
+
17
+ [tool.vercel.release.dependencies]
18
+ dependencies = [
19
+ "httpx>=0.27.0,<1",
20
+ "vercel-headers>=0.6.0",
21
+ "vercel-internal-telemetry>=0.6.0",
22
+ ]
23
+
24
+ [tool.uv.sources]
25
+ vercel-headers = { workspace = true }
26
+ vercel-internal-telemetry = { workspace = true }
27
+
28
+ [tool.hatch.version]
29
+ path = "vercel/cache/version.py"
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ force-include = { "../../scripts/hatch_build.py" = "/_vercel_hatch_build.py" }
33
+ include = [
34
+ "/vercel/cache/**/*.py",
35
+ "/vercel/cache/py.typed",
36
+ "/README.md",
37
+ "/pyproject.toml",
38
+ "/hatch_build.py",
39
+ "/LICENSE",
40
+ ]
41
+ exclude = [
42
+ "/**/__pycache__",
43
+ ]
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ dev-mode-dirs = ["."]
47
+ only-include = [
48
+ "/vercel/cache",
49
+ ]
50
+ exclude = [
51
+ "/**/__pycache__",
52
+ ]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ pythonpath = ["."]
57
+ addopts = "--no-header --capture=tee-sys"
58
+ asyncio_mode = "auto"
59
+
60
+ [tool.poe]
61
+ include = "../../scripts/poe/poe.toml"
62
+ verbosity = -1
63
+
64
+ [tool.poe.tasks.typecheck]
65
+ cmd = "$MYPY"
@@ -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
+ ]
@@ -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()
@@ -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
+ )
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)
@@ -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: ...
@@ -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"