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.
- vercel_cache-0.7.0/.gitignore +130 -0
- vercel_cache-0.7.0/LICENSE +21 -0
- vercel_cache-0.7.0/PKG-INFO +19 -0
- vercel_cache-0.7.0/README.md +7 -0
- vercel_cache-0.7.0/_vercel_hatch_build.py +116 -0
- vercel_cache-0.7.0/hatch_build.py +29 -0
- vercel_cache-0.7.0/pyproject.toml +65 -0
- vercel_cache-0.7.0/vercel/cache/__init__.py +12 -0
- vercel_cache-0.7.0/vercel/cache/aio.py +19 -0
- vercel_cache-0.7.0/vercel/cache/cache_build.py +304 -0
- vercel_cache-0.7.0/vercel/cache/cache_in_memory.py +99 -0
- vercel_cache-0.7.0/vercel/cache/context.py +69 -0
- vercel_cache-0.7.0/vercel/cache/purge.py +26 -0
- vercel_cache-0.7.0/vercel/cache/py.typed +0 -0
- vercel_cache-0.7.0/vercel/cache/runtime_cache.py +204 -0
- vercel_cache-0.7.0/vercel/cache/types.py +66 -0
- vercel_cache-0.7.0/vercel/cache/utils.py +27 -0
- vercel_cache-0.7.0/vercel/cache/version.py +1 -0
|
@@ -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"
|