fluxfast 0.1.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.
- fluxfast-0.1.0/.gitignore +17 -0
- fluxfast-0.1.0/LICENSE +21 -0
- fluxfast-0.1.0/PKG-INFO +64 -0
- fluxfast-0.1.0/README.md +42 -0
- fluxfast-0.1.0/pyproject.toml +47 -0
- fluxfast-0.1.0/src/fluxfast/__init__.py +83 -0
- fluxfast-0.1.0/src/fluxfast/app.py +131 -0
- fluxfast-0.1.0/src/fluxfast/cache.py +126 -0
- fluxfast-0.1.0/src/fluxfast/cli.py +246 -0
- fluxfast-0.1.0/src/fluxfast/engine.py +127 -0
- fluxfast-0.1.0/src/fluxfast/errors.py +33 -0
- fluxfast-0.1.0/src/fluxfast/headers.py +117 -0
- fluxfast-0.1.0/src/fluxfast/mutation.py +160 -0
- fluxfast-0.1.0/src/fluxfast/page.py +29 -0
- fluxfast-0.1.0/src/fluxfast/protocol.py +50 -0
- fluxfast-0.1.0/src/fluxfast/resource.py +55 -0
- fluxfast-0.1.0/src/fluxfast/router.py +269 -0
- fluxfast-0.1.0/src/fluxfast/scope.py +78 -0
- fluxfast-0.1.0/src/fluxfast/serialization.py +31 -0
- fluxfast-0.1.0/src/fluxfast/timing.py +28 -0
- fluxfast-0.1.0/tests/conftest.py +11 -0
- fluxfast-0.1.0/tests/test_cache.py +111 -0
- fluxfast-0.1.0/tests/test_cli.py +93 -0
- fluxfast-0.1.0/tests/test_integration.py +365 -0
- fluxfast-0.1.0/tests/test_mutations.py +106 -0
- fluxfast-0.1.0/tests/test_parallel.py +118 -0
- fluxfast-0.1.0/tests/test_protocol.py +113 -0
- fluxfast-0.1.0/tests/test_resources.py +66 -0
- fluxfast-0.1.0/tests/test_scope.py +88 -0
- fluxfast-0.1.0/uv.lock +411 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.pyc
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
coverage/
|
|
10
|
+
benchmark-results/
|
|
11
|
+
playwright-report/
|
|
12
|
+
test-results/
|
|
13
|
+
tests/browser/frontend/.next/
|
|
14
|
+
tests/browser/frontend/AGENTS.md
|
|
15
|
+
tests/browser/frontend/CLAUDE.md
|
|
16
|
+
tests/browser/frontend/next-env.d.ts
|
|
17
|
+
tests/browser/frontend/src/.fluxfast/
|
fluxfast-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FluxFast Contributors
|
|
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.
|
fluxfast-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fluxfast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance server-driven application runtime for FastAPI
|
|
5
|
+
Project-URL: Homepage, https://github.com/El37628/FluxFast
|
|
6
|
+
Project-URL: Repository, https://github.com/El37628/FluxFast
|
|
7
|
+
Project-URL: Issues, https://github.com/El37628/FluxFast/issues
|
|
8
|
+
Author: FluxFast Contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: anyio>=4.0.0
|
|
13
|
+
Requires-Dist: fastapi<1.0.0,>=0.141.1
|
|
14
|
+
Requires-Dist: pydantic>=2.9.0
|
|
15
|
+
Requires-Dist: starlette<2.0.0,>=1.3.1
|
|
16
|
+
Requires-Dist: uvicorn<1.0.0,>=0.30.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: httpx2<3.0.0,>=2.0.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# FluxFast for FastAPI
|
|
24
|
+
|
|
25
|
+
The Python package provides FastAPI page routes, scoped versioned resources, a
|
|
26
|
+
bounded in-memory cache, concurrent loader resolution, mutation helpers, 422
|
|
27
|
+
mapping, redirects, and the `fluxfast/1` protocol.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install fluxfast
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fastapi import FastAPI
|
|
35
|
+
from fluxfast import FluxFast, Page, resource, scope
|
|
36
|
+
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
flux = FluxFast(app)
|
|
39
|
+
|
|
40
|
+
@flux.page("/")
|
|
41
|
+
async def home():
|
|
42
|
+
return Page(
|
|
43
|
+
"home/index",
|
|
44
|
+
resources=[
|
|
45
|
+
resource("news", load_news, scope=scope.public(), ttl=60),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Reusable data must declare an appropriate public, user, tenant, or custom scope.
|
|
51
|
+
See the repository protocol and caching documentation for wire and invalidation
|
|
52
|
+
semantics.
|
|
53
|
+
|
|
54
|
+
For a Next.js consumer, install its backend and run both development processes
|
|
55
|
+
under one supervisor:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m pip install -e backend
|
|
59
|
+
fluxfast dev backend.app.main:app
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The CLI starts FastAPI on an available internal loopback port and Next on the
|
|
63
|
+
browser-facing development port. The companion Next config uses the injected
|
|
64
|
+
server-only address, so browser requests stay same-origin.
|
fluxfast-0.1.0/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# FluxFast for FastAPI
|
|
2
|
+
|
|
3
|
+
The Python package provides FastAPI page routes, scoped versioned resources, a
|
|
4
|
+
bounded in-memory cache, concurrent loader resolution, mutation helpers, 422
|
|
5
|
+
mapping, redirects, and the `fluxfast/1` protocol.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install fluxfast
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fluxfast import FluxFast, Page, resource, scope
|
|
14
|
+
|
|
15
|
+
app = FastAPI()
|
|
16
|
+
flux = FluxFast(app)
|
|
17
|
+
|
|
18
|
+
@flux.page("/")
|
|
19
|
+
async def home():
|
|
20
|
+
return Page(
|
|
21
|
+
"home/index",
|
|
22
|
+
resources=[
|
|
23
|
+
resource("news", load_news, scope=scope.public(), ttl=60),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Reusable data must declare an appropriate public, user, tenant, or custom scope.
|
|
29
|
+
See the repository protocol and caching documentation for wire and invalidation
|
|
30
|
+
semantics.
|
|
31
|
+
|
|
32
|
+
For a Next.js consumer, install its backend and run both development processes
|
|
33
|
+
under one supervisor:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install -e backend
|
|
37
|
+
fluxfast dev backend.app.main:app
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The CLI starts FastAPI on an available internal loopback port and Next on the
|
|
41
|
+
browser-facing development port. The companion Next config uses the injected
|
|
42
|
+
server-only address, so browser requests stay same-origin.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fluxfast"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "High-performance server-driven application runtime for FastAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "FluxFast Contributors" }]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi>=0.141.1,<1.0.0",
|
|
16
|
+
"pydantic>=2.9.0",
|
|
17
|
+
"anyio>=4.0.0",
|
|
18
|
+
"starlette>=1.3.1,<2.0.0",
|
|
19
|
+
"uvicorn>=0.30.0,<1.0.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/El37628/FluxFast"
|
|
24
|
+
Repository = "https://github.com/El37628/FluxFast"
|
|
25
|
+
Issues = "https://github.com/El37628/FluxFast/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
fluxfast = "fluxfast.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"httpx2>=2.0.0,<3.0.0",
|
|
33
|
+
"pytest>=8.0.0",
|
|
34
|
+
"ruff>=0.3.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/fluxfast"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
target-version = "py311"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
ignore = ["B008"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""FluxFast — high-performance server-driven application runtime for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from .app import FluxFast
|
|
4
|
+
from .cache import CachedResource, MemoryResourceCache, ResourceCacheBackend
|
|
5
|
+
from .errors import (
|
|
6
|
+
FluxFastError,
|
|
7
|
+
PageNotFoundError,
|
|
8
|
+
ProtocolError,
|
|
9
|
+
ResourceError,
|
|
10
|
+
ScopeError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .mutation import (
|
|
14
|
+
InvalidateResource,
|
|
15
|
+
MutationResult,
|
|
16
|
+
append_item,
|
|
17
|
+
flux_external_redirect,
|
|
18
|
+
flux_redirect,
|
|
19
|
+
invalidate_resource,
|
|
20
|
+
merge_object,
|
|
21
|
+
mutation,
|
|
22
|
+
remove_item,
|
|
23
|
+
replace_item,
|
|
24
|
+
replace_resource,
|
|
25
|
+
)
|
|
26
|
+
from .page import Page
|
|
27
|
+
from .protocol import (
|
|
28
|
+
PROTOCOL_MEDIA_TYPE,
|
|
29
|
+
PROTOCOL_VERSION,
|
|
30
|
+
ErrorDetail,
|
|
31
|
+
ErrorEnvelope,
|
|
32
|
+
MutationEnvelope,
|
|
33
|
+
MutationPayload,
|
|
34
|
+
PageDescriptor,
|
|
35
|
+
PageEnvelope,
|
|
36
|
+
ResourceWireRecord,
|
|
37
|
+
)
|
|
38
|
+
from .resource import ResourceLoader, ResourceSpec, resource
|
|
39
|
+
from .router import FluxRouter
|
|
40
|
+
from .scope import CacheScope, ScopeType, scope
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"PROTOCOL_MEDIA_TYPE",
|
|
46
|
+
"PROTOCOL_VERSION",
|
|
47
|
+
"CacheScope",
|
|
48
|
+
"CachedResource",
|
|
49
|
+
"ErrorDetail",
|
|
50
|
+
"ErrorEnvelope",
|
|
51
|
+
"FluxFast",
|
|
52
|
+
"FluxFastError",
|
|
53
|
+
"FluxRouter",
|
|
54
|
+
"InvalidateResource",
|
|
55
|
+
"MemoryResourceCache",
|
|
56
|
+
"MutationEnvelope",
|
|
57
|
+
"MutationPayload",
|
|
58
|
+
"MutationResult",
|
|
59
|
+
"Page",
|
|
60
|
+
"PageDescriptor",
|
|
61
|
+
"PageEnvelope",
|
|
62
|
+
"PageNotFoundError",
|
|
63
|
+
"ProtocolError",
|
|
64
|
+
"ResourceCacheBackend",
|
|
65
|
+
"ResourceError",
|
|
66
|
+
"ResourceLoader",
|
|
67
|
+
"ResourceSpec",
|
|
68
|
+
"ResourceWireRecord",
|
|
69
|
+
"ScopeError",
|
|
70
|
+
"ScopeType",
|
|
71
|
+
"ValidationError",
|
|
72
|
+
"append_item",
|
|
73
|
+
"flux_external_redirect",
|
|
74
|
+
"flux_redirect",
|
|
75
|
+
"invalidate_resource",
|
|
76
|
+
"merge_object",
|
|
77
|
+
"mutation",
|
|
78
|
+
"remove_item",
|
|
79
|
+
"replace_item",
|
|
80
|
+
"replace_resource",
|
|
81
|
+
"resource",
|
|
82
|
+
"scope",
|
|
83
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""FluxFast FastAPI application integration and lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.exceptions import RequestValidationError
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from .cache import MemoryResourceCache, ResourceCacheBackend
|
|
11
|
+
from .errors import FluxFastError, ProtocolError, ResourceError
|
|
12
|
+
from .headers import HEADER_FLUXFAST, HEADER_PROTOCOL, is_fluxfast_request
|
|
13
|
+
from .protocol import PROTOCOL_MEDIA_TYPE, PROTOCOL_VERSION, ErrorDetail, ErrorEnvelope
|
|
14
|
+
from .router import FluxRouter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FluxFast:
|
|
18
|
+
"""Main application integrator configuring FastAPI with FluxFast support."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
app: FastAPI,
|
|
23
|
+
cache: ResourceCacheBackend | None = None,
|
|
24
|
+
debug: bool = False,
|
|
25
|
+
):
|
|
26
|
+
self.app = app
|
|
27
|
+
self.cache = cache if cache is not None else MemoryResourceCache()
|
|
28
|
+
self.debug = debug
|
|
29
|
+
|
|
30
|
+
# Attach cache to FastAPI app state
|
|
31
|
+
self.app.state.fluxfast_cache = self.cache
|
|
32
|
+
self.app.state.fluxfast_debug = self.debug
|
|
33
|
+
|
|
34
|
+
# Setup exception handlers
|
|
35
|
+
self._setup_exception_handlers()
|
|
36
|
+
|
|
37
|
+
# Shared router
|
|
38
|
+
self.router = FluxRouter()
|
|
39
|
+
|
|
40
|
+
def _setup_exception_handlers(self) -> None:
|
|
41
|
+
@self.app.exception_handler(RequestValidationError)
|
|
42
|
+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
43
|
+
# Map Pydantic validation errors to structured dictionary
|
|
44
|
+
field_errors: dict[str, list[str]] = {}
|
|
45
|
+
for error in exc.errors():
|
|
46
|
+
loc = error.get("loc", ())
|
|
47
|
+
# Extract field name (skip 'body', 'query', etc. if nested)
|
|
48
|
+
field_name = str(loc[-1]) if loc else "general"
|
|
49
|
+
msg = error.get("msg", "Invalid value")
|
|
50
|
+
if field_name not in field_errors:
|
|
51
|
+
field_errors[field_name] = []
|
|
52
|
+
field_errors[field_name].append(msg)
|
|
53
|
+
|
|
54
|
+
if is_fluxfast_request(request):
|
|
55
|
+
envelope = ErrorEnvelope(
|
|
56
|
+
protocol=PROTOCOL_VERSION,
|
|
57
|
+
error=ErrorDetail(
|
|
58
|
+
type="ValidationError",
|
|
59
|
+
message="Request validation failed",
|
|
60
|
+
details=field_errors,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
return JSONResponse(
|
|
64
|
+
status_code=422,
|
|
65
|
+
content=envelope.model_dump(mode="json"),
|
|
66
|
+
media_type=PROTOCOL_MEDIA_TYPE,
|
|
67
|
+
headers={
|
|
68
|
+
HEADER_FLUXFAST: "1",
|
|
69
|
+
HEADER_PROTOCOL: "1",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Fallback standard response for non-Flux requests
|
|
74
|
+
return JSONResponse(
|
|
75
|
+
status_code=422,
|
|
76
|
+
content={"detail": exc.errors()},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@self.app.exception_handler(FluxFastError)
|
|
80
|
+
async def fluxfast_exception_handler(request: Request, exc: FluxFastError) -> JSONResponse:
|
|
81
|
+
status_code = 409 if isinstance(exc, ProtocolError) else 500
|
|
82
|
+
public_message = exc.message
|
|
83
|
+
public_details = exc.details
|
|
84
|
+
if isinstance(exc, ResourceError) and not self.debug:
|
|
85
|
+
public_message = "A resource could not be resolved"
|
|
86
|
+
public_details = None
|
|
87
|
+
|
|
88
|
+
envelope = ErrorEnvelope(
|
|
89
|
+
protocol=PROTOCOL_VERSION,
|
|
90
|
+
error=ErrorDetail(
|
|
91
|
+
type=type(exc).__name__,
|
|
92
|
+
message=public_message,
|
|
93
|
+
details=public_details,
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
return JSONResponse(
|
|
97
|
+
status_code=status_code,
|
|
98
|
+
content=envelope.model_dump(mode="json"),
|
|
99
|
+
media_type=PROTOCOL_MEDIA_TYPE,
|
|
100
|
+
headers={HEADER_FLUXFAST: "1", HEADER_PROTOCOL: "1"},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def page(self, path: str, *args: Any, **kwargs: Any) -> Callable[..., Any]:
|
|
104
|
+
"""Define a page directly on the integrated FastAPI application."""
|
|
105
|
+
register = self.router.page(path, *args, **kwargs)
|
|
106
|
+
|
|
107
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
108
|
+
route_count = len(self.router.routes)
|
|
109
|
+
registered = register(func)
|
|
110
|
+
self.app.router.routes.extend(self.router.routes[route_count:])
|
|
111
|
+
return registered
|
|
112
|
+
|
|
113
|
+
return decorator
|
|
114
|
+
|
|
115
|
+
def mutation(
|
|
116
|
+
self,
|
|
117
|
+
path: str,
|
|
118
|
+
methods: list[str] | None = None,
|
|
119
|
+
*args: Any,
|
|
120
|
+
**kwargs: Any,
|
|
121
|
+
) -> Callable[..., Any]:
|
|
122
|
+
"""Define a mutation directly on the integrated FastAPI application."""
|
|
123
|
+
register = self.router.mutation(path, methods, *args, **kwargs)
|
|
124
|
+
|
|
125
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
126
|
+
route_count = len(self.router.routes)
|
|
127
|
+
registered = register(func)
|
|
128
|
+
self.app.router.routes.extend(self.router.routes[route_count:])
|
|
129
|
+
return registered
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Server-side in-memory resource caching backend and interface."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class CachedResource:
|
|
13
|
+
"""Represents a cached resource entry."""
|
|
14
|
+
|
|
15
|
+
version: str
|
|
16
|
+
value: Any
|
|
17
|
+
expires_at: float
|
|
18
|
+
tags: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_expired(self) -> bool:
|
|
22
|
+
return time.monotonic() >= self.expires_at
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResourceCacheBackend(Protocol):
|
|
26
|
+
"""Abstract interface for server resource caches."""
|
|
27
|
+
|
|
28
|
+
async def get(self, key: str) -> CachedResource | None: ...
|
|
29
|
+
|
|
30
|
+
async def set(
|
|
31
|
+
self,
|
|
32
|
+
key: str,
|
|
33
|
+
resource: CachedResource,
|
|
34
|
+
ttl: float,
|
|
35
|
+
) -> None: ...
|
|
36
|
+
|
|
37
|
+
async def delete(self, key: str) -> None: ...
|
|
38
|
+
|
|
39
|
+
async def invalidate_tag(self, tag: str) -> None: ...
|
|
40
|
+
|
|
41
|
+
async def clear(self) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MemoryResourceCache:
|
|
45
|
+
"""Async-safe, bounded, TTL-aware LRU in-memory resource cache."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, max_entries: int = 10000):
|
|
48
|
+
if max_entries <= 0:
|
|
49
|
+
raise ValueError("max_entries must be greater than zero")
|
|
50
|
+
self.max_entries = max_entries
|
|
51
|
+
self._cache: OrderedDict[str, CachedResource] = OrderedDict()
|
|
52
|
+
self._tag_index: dict[str, set[str]] = {}
|
|
53
|
+
self._lock = anyio.Lock()
|
|
54
|
+
|
|
55
|
+
async def get(self, key: str) -> CachedResource | None:
|
|
56
|
+
async with self._lock:
|
|
57
|
+
entry = self._cache.get(key)
|
|
58
|
+
if entry is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Check expiration
|
|
62
|
+
if entry.is_expired:
|
|
63
|
+
self._remove_internal(key)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Mark as recently used (LRU)
|
|
67
|
+
self._cache.move_to_end(key)
|
|
68
|
+
return entry
|
|
69
|
+
|
|
70
|
+
async def set(self, key: str, resource: CachedResource, ttl: float) -> None:
|
|
71
|
+
async with self._lock:
|
|
72
|
+
if key in self._cache:
|
|
73
|
+
self._remove_internal(key)
|
|
74
|
+
|
|
75
|
+
if ttl <= 0:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Evict LRU if full
|
|
79
|
+
while len(self._cache) >= self.max_entries:
|
|
80
|
+
oldest_key, oldest_entry = self._cache.popitem(last=False)
|
|
81
|
+
self._cleanup_tags(oldest_key, oldest_entry.tags)
|
|
82
|
+
|
|
83
|
+
stored_resource = replace(
|
|
84
|
+
resource,
|
|
85
|
+
expires_at=time.monotonic() + ttl,
|
|
86
|
+
)
|
|
87
|
+
self._cache[key] = stored_resource
|
|
88
|
+
|
|
89
|
+
# Index tags
|
|
90
|
+
for tag in stored_resource.tags:
|
|
91
|
+
if tag not in self._tag_index:
|
|
92
|
+
self._tag_index[tag] = set()
|
|
93
|
+
self._tag_index[tag].add(key)
|
|
94
|
+
|
|
95
|
+
async def delete(self, key: str) -> None:
|
|
96
|
+
async with self._lock:
|
|
97
|
+
self._remove_internal(key)
|
|
98
|
+
|
|
99
|
+
async def invalidate_tag(self, tag: str) -> None:
|
|
100
|
+
async with self._lock:
|
|
101
|
+
keys = self._tag_index.pop(tag, set())
|
|
102
|
+
for key in keys:
|
|
103
|
+
if key in self._cache:
|
|
104
|
+
self._remove_internal(key)
|
|
105
|
+
|
|
106
|
+
async def clear(self) -> None:
|
|
107
|
+
async with self._lock:
|
|
108
|
+
self._cache.clear()
|
|
109
|
+
self._tag_index.clear()
|
|
110
|
+
|
|
111
|
+
def _remove_internal(self, key: str) -> None:
|
|
112
|
+
entry = self._cache.pop(key, None)
|
|
113
|
+
if entry:
|
|
114
|
+
self._cleanup_tags(key, entry.tags)
|
|
115
|
+
|
|
116
|
+
def _cleanup_tags(self, key: str, tags: tuple[str, ...] | None = None) -> None:
|
|
117
|
+
if tags is None:
|
|
118
|
+
# Clean from all tag index sets
|
|
119
|
+
for tag_keys in self._tag_index.values():
|
|
120
|
+
tag_keys.discard(key)
|
|
121
|
+
else:
|
|
122
|
+
for tag in tags:
|
|
123
|
+
if tag in self._tag_index:
|
|
124
|
+
self._tag_index[tag].discard(key)
|
|
125
|
+
if not self._tag_index[tag]:
|
|
126
|
+
del self._tag_index[tag]
|