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.
@@ -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.
@@ -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.
@@ -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]