gramlot-py-server 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+ """Python server adapters for Gramlot: uvicorn, django, flask, fastapi and kajenn."""
@@ -0,0 +1,156 @@
1
+ # Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+ """Translate the Gramlot Host protocol into Django views and URLs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from secrets import token_urlsafe
9
+
10
+ from asgiref.sync import async_to_sync
11
+ from django.http import FileResponse, HttpResponse
12
+ from django.urls import path
13
+ from django.views.decorators.csrf import csrf_exempt
14
+ from gramlot.server import (
15
+ FileHost,
16
+ HostCapacity,
17
+ PageExpired,
18
+ PageNotFound,
19
+ SourceNotFound,
20
+ runtime_asset,
21
+ )
22
+
23
+ MAX_REQUEST_BYTES = 4096
24
+ OWNER_COOKIE = "gramlot_owner"
25
+ COMPANION_MEDIA_TYPES = {
26
+ ".css": "text/css; charset=utf-8",
27
+ "_aux.js": "text/javascript; charset=utf-8",
28
+ }
29
+
30
+
31
+ class Pages:
32
+ """Own a Gramlot ``FileHost`` and expose it at one Django URLconf mount point.
33
+
34
+ Include ``urls`` at the same ``mount_path`` supplied here: ``mount_path`` is
35
+ passed to ``open_page`` as the mount prefix of browser URLs. The cookie
36
+ associates browser requests with in-process page records; it is not
37
+ authentication.
38
+
39
+ GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
40
+ pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
41
+ there. Every other file of the folder is not served.
42
+
43
+ ``content_security_policy`` is the application's policy, sent as the
44
+ ``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
45
+ replaced by the nonce that ``open_page`` puts on the bootstrap script.
46
+ """
47
+
48
+ def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
49
+ max_pages: int = 1000, content_security_policy: str | None = None) -> None:
50
+ self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
51
+ self.content_security_policy = content_security_policy
52
+ self.host = FileHost(
53
+ pages,
54
+ runtime_url="/assets/gramlot.js",
55
+ main_url="/gramlot/main",
56
+ source_url="/gramlot/source",
57
+ close_url="/gramlot/close",
58
+ page_ttl=page_ttl,
59
+ max_pages=max_pages,
60
+ )
61
+ self.urls = [
62
+ path("assets/gramlot.js", self.asset),
63
+ path("gramlot/main", csrf_exempt(self.main)),
64
+ path("gramlot/source", csrf_exempt(self.source)),
65
+ path("gramlot/close", csrf_exempt(self.close)),
66
+ path("", self.page),
67
+ path("<path:page_path>", self.page),
68
+ ]
69
+
70
+ def asset(self, request):
71
+ if request.method not in ("GET", "HEAD"):
72
+ return HttpResponse(status=405)
73
+ response = FileResponse(runtime_asset().open("rb"), content_type="text/javascript")
74
+ response["Cache-Control"] = "no-cache"
75
+ return response
76
+
77
+ def page(self, request, page_path=""):
78
+ suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
79
+ if suffix is not None:
80
+ return self.companion(request, page_path, suffix)
81
+ if request.method not in ("GET", "HEAD"):
82
+ return HttpResponse(status=405)
83
+ owner = request.COOKIES.get(OWNER_COOKIE) or token_urlsafe(24)
84
+ try:
85
+ opened = async_to_sync(self.host.open_page)(page_path, owner=owner, prefix=self.mount_path)
86
+ except PageNotFound:
87
+ return HttpResponse("Page not found", status=404)
88
+ except HostCapacity:
89
+ return HttpResponse("Page capacity reached", status=503)
90
+ response = HttpResponse(opened.html, content_type="text/html; charset=utf-8")
91
+ response["Cache-Control"] = "no-store"
92
+ if self.content_security_policy is not None:
93
+ policy = self.content_security_policy.replace("{nonce}", opened.nonce)
94
+ response["Content-Security-Policy"] = policy
95
+ response.set_cookie(OWNER_COOKIE, owner, path=self.mount_path or "/", httponly=True, samesite="Lax")
96
+ return response
97
+
98
+ def companion(self, request, page_path, suffix):
99
+ """Serve the file of ``page_path`` when its real path is below the pages folder."""
100
+ if request.method not in ("GET", "HEAD"):
101
+ return HttpResponse(status=405)
102
+ root = self.host.pages_dir.resolve()
103
+ real = root.joinpath(*page_path.strip("/").split("/")).resolve()
104
+ if not (real.is_relative_to(root) and real.is_file()):
105
+ return HttpResponse("Not found", status=404)
106
+ body = b"" if request.method == "HEAD" else real.read_bytes()
107
+ response = HttpResponse(body, content_type=COMPANION_MEDIA_TYPES[suffix])
108
+ response["Cache-Control"] = "no-store"
109
+ return response
110
+
111
+ def main(self, request):
112
+ return self._operation(request, "main")
113
+
114
+ def source(self, request):
115
+ return self._operation(request, "source")
116
+
117
+ def close(self, request):
118
+ return self._operation(request, "close")
119
+
120
+ def _operation(self, request, operation):
121
+ if request.method != "POST":
122
+ return HttpResponse(status=405)
123
+ if request.content_type != "application/json":
124
+ return HttpResponse("Expected application/json", status=415)
125
+ if len(request.body) > MAX_REQUEST_BYTES:
126
+ return HttpResponse("Request too large", status=413)
127
+ try:
128
+ payload = json.loads(request.body)
129
+ except (UnicodeDecodeError, json.JSONDecodeError):
130
+ return HttpResponse("Invalid JSON request", status=400)
131
+ if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
132
+ return HttpResponse("Invalid JSON request", status=400)
133
+ if operation == "source" and not isinstance(payload.get("params", {}), dict):
134
+ return HttpResponse("Source params must be a dictionary", status=400)
135
+ owner = request.COOKIES.get(OWNER_COOKIE)
136
+ try:
137
+ if operation == "main":
138
+ result = async_to_sync(self.host.main)(payload["pageId"], owner=owner)
139
+ elif operation == "source":
140
+ result = async_to_sync(self.host.source)(
141
+ payload["pageId"], payload.get("method"), payload.get("params", {}),
142
+ owner=owner,
143
+ )
144
+ else:
145
+ self.host.close_page(payload["pageId"], owner=owner)
146
+ result = json.dumps({"ok": True})
147
+ except PageExpired:
148
+ return HttpResponse("Unknown page", status=404)
149
+ except SourceNotFound:
150
+ return HttpResponse("Unknown Source method", status=404)
151
+ response = HttpResponse(result, content_type="application/json")
152
+ response["Cache-Control"] = "no-store"
153
+ return response
154
+
155
+
156
+ __all__ = ["Pages"]
@@ -0,0 +1,191 @@
1
+ # Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+ """FastAPI integration for the Gramlot ``Host`` protocol."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ from pathlib import Path
9
+ from secrets import token_urlsafe
10
+
11
+ from fastapi import APIRouter, FastAPI, Request
12
+ from fastapi.responses import HTMLResponse, Response
13
+ from gramlot.server import (
14
+ FileHost,
15
+ HostCapacity,
16
+ PageExpired,
17
+ PageNotFound,
18
+ SourceNotFound,
19
+ runtime_asset,
20
+ )
21
+
22
+ MAX_REQUEST_BYTES = 4096
23
+ OWNER_COOKIE = "gramlot_owner"
24
+ COMPANION_MEDIA_TYPES = {
25
+ ".css": "text/css; charset=utf-8",
26
+ "_aux.js": "text/javascript; charset=utf-8",
27
+ }
28
+
29
+
30
+ class Pages:
31
+ """Own a Gramlot ``FileHost`` and its FastAPI route translations.
32
+
33
+ The routes are included at ``mount_path``, which is also passed to
34
+ ``open_page`` as the mount prefix of browser URLs.
35
+
36
+ GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
37
+ pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
38
+ there. Every other file of the folder is not served.
39
+
40
+ ``content_security_policy`` is the application's policy, sent as the
41
+ ``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
42
+ replaced by the nonce that ``open_page`` puts on the bootstrap script.
43
+ """
44
+
45
+ def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
46
+ max_pages: int = 1000, content_security_policy: str | None = None) -> None:
47
+ self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
48
+ self.content_security_policy = content_security_policy
49
+ self.host = FileHost(
50
+ pages,
51
+ runtime_url="/assets/gramlot.js",
52
+ main_url="/gramlot/main",
53
+ source_url="/gramlot/source",
54
+ close_url="/gramlot/close",
55
+ page_ttl=page_ttl,
56
+ max_pages=max_pages,
57
+ )
58
+
59
+ def mount(self, app: FastAPI) -> None:
60
+ router = APIRouter(prefix=self.mount_path)
61
+ router.add_api_route("/assets/gramlot.js", self.asset, methods=["GET", "HEAD"])
62
+ router.add_api_route("/gramlot/main", self.main, methods=["POST"])
63
+ router.add_api_route("/gramlot/source", self.source, methods=["POST"])
64
+ router.add_api_route("/gramlot/close", self.close, methods=["POST"])
65
+ router.add_api_route("/", self.page, methods=["GET", "HEAD"])
66
+ router.add_api_route("/{page_path:path}", self.page, methods=["GET", "HEAD"])
67
+ app.include_router(router)
68
+ app.router.add_event_handler("shutdown", self.shutdown)
69
+
70
+ async def shutdown(self) -> None:
71
+ self.host._pages.clear()
72
+
73
+ async def asset(self, request: Request) -> Response:
74
+ body = b"" if request.method == "HEAD" else runtime_asset().read_bytes()
75
+ return Response(body, media_type="text/javascript", headers={"Cache-Control": "no-cache"})
76
+
77
+ async def page(self, request: Request, page_path: str = "") -> Response:
78
+ suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
79
+ if suffix is not None:
80
+ return await self.companion(request, page_path, suffix)
81
+ if request.method != "GET":
82
+ return Response(status_code=405, headers={"Allow": "GET"})
83
+ owner = request.cookies.get(OWNER_COOKIE) or token_urlsafe(24)
84
+ try:
85
+ opened = await self.host.open_page(page_path, owner=owner, prefix=self.mount_path)
86
+ except PageNotFound:
87
+ return Response("Page not found", status_code=404)
88
+ except HostCapacity:
89
+ return Response("Page capacity reached", status_code=503)
90
+ headers = {"Cache-Control": "no-store"}
91
+ if self.content_security_policy is not None:
92
+ headers["Content-Security-Policy"] = self.content_security_policy.replace("{nonce}", opened.nonce)
93
+ response = HTMLResponse(opened.html, headers=headers)
94
+ response.set_cookie(
95
+ OWNER_COOKIE,
96
+ owner,
97
+ path=self.mount_path or "/",
98
+ httponly=True,
99
+ samesite="lax",
100
+ )
101
+ return response
102
+
103
+ async def companion(self, request: Request, page_path: str, suffix: str) -> Response:
104
+ """Serve the file of ``page_path`` when its real path is below the pages folder."""
105
+ root = self.host.pages_dir.resolve()
106
+ real = root.joinpath(*page_path.strip("/").split("/")).resolve()
107
+ if not (real.is_relative_to(root) and real.is_file()):
108
+ return Response("Not found", status_code=404)
109
+ body = b"" if request.method == "HEAD" else await asyncio.to_thread(real.read_bytes)
110
+ return Response(body, headers={"Content-Type": COMPANION_MEDIA_TYPES[suffix],
111
+ "Cache-Control": "no-store"})
112
+
113
+ async def main(self, request: Request) -> Response:
114
+ return await self._operation(request, "main")
115
+
116
+ async def source(self, request: Request) -> Response:
117
+ return await self._operation(request, "source")
118
+
119
+ async def close(self, request: Request) -> Response:
120
+ return await self._operation(request, "close")
121
+
122
+ async def _operation(self, request: Request, operation: str) -> Response:
123
+ if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json":
124
+ return Response("Expected application/json", status_code=415)
125
+ try:
126
+ raw = await self._body(request)
127
+ payload = json.loads(raw)
128
+ if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
129
+ raise ValueError
130
+ except RequestTooLarge:
131
+ return Response("Request too large", status_code=413)
132
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
133
+ return Response("Invalid JSON request", status_code=400)
134
+ owner = request.cookies.get(OWNER_COOKIE)
135
+ if operation == "source" and not isinstance(payload.get("params", {}), dict):
136
+ return Response("Source params must be a dictionary", status_code=400)
137
+ try:
138
+ if operation == "main":
139
+ result = await self.host.main(payload["pageId"], owner=owner)
140
+ elif operation == "source":
141
+ result = await self.host.source(
142
+ payload["pageId"], payload.get("method"), payload.get("params", {}), owner=owner
143
+ )
144
+ else:
145
+ self.host.close_page(payload["pageId"], owner=owner)
146
+ result = json.dumps({"ok": True})
147
+ except PageExpired:
148
+ return Response("Unknown page", status_code=404)
149
+ except SourceNotFound:
150
+ return Response("Unknown Source method", status_code=404)
151
+ return Response(result, media_type="application/json", headers={"Cache-Control": "no-store"})
152
+
153
+ @staticmethod
154
+ async def _body(request: Request) -> str:
155
+ length = request.headers.get("content-length")
156
+ if length and length.isdigit() and int(length) > MAX_REQUEST_BYTES:
157
+ raise RequestTooLarge
158
+ body = bytearray()
159
+ async for chunk in request.stream():
160
+ body.extend(chunk)
161
+ if len(body) > MAX_REQUEST_BYTES:
162
+ raise RequestTooLarge
163
+ return body.decode()
164
+
165
+
166
+ class RequestTooLarge(ValueError):
167
+ pass
168
+
169
+
170
+ class Application(FastAPI):
171
+ """Ready-made FastAPI application serving Gramlot pages."""
172
+
173
+ def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
174
+ max_pages: int = 1000, content_security_policy: str | None = None,
175
+ **fastapi_options) -> None:
176
+ super().__init__(**fastapi_options)
177
+ self.gramlot_pages = mount_pages(
178
+ self, pages, mount_path=mount_path, page_ttl=page_ttl, max_pages=max_pages,
179
+ content_security_policy=content_security_policy,
180
+ )
181
+
182
+
183
+ def mount_pages(app: FastAPI, pages: str | Path, **options) -> Pages:
184
+ """Mount the bounded Gramlot page protocol on an existing FastAPI app."""
185
+
186
+ integration = Pages(pages, **options)
187
+ integration.mount(app)
188
+ return integration
189
+
190
+
191
+ __all__ = ["Application", "Pages", "mount_pages"]
@@ -0,0 +1,159 @@
1
+ # Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
2
+ """Flask integration for the Gramlot ``Host`` protocol."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ from pathlib import Path
9
+ from secrets import token_urlsafe
10
+
11
+ from flask import Blueprint, Response, request
12
+ from gramlot.server import (
13
+ FileHost,
14
+ HostCapacity,
15
+ PageExpired,
16
+ PageNotFound,
17
+ SourceNotFound,
18
+ runtime_asset,
19
+ )
20
+
21
+ MAX_REQUEST_BYTES = 4096
22
+ OWNER_COOKIE = "gramlot_owner"
23
+ COMPANION_MEDIA_TYPES = {
24
+ ".css": "text/css; charset=utf-8",
25
+ "_aux.js": "text/javascript; charset=utf-8",
26
+ }
27
+
28
+
29
+ class Pages:
30
+ """Own a Gramlot ``FileHost`` and its WSGI route translations.
31
+
32
+ The blueprint is registered at ``mount_path``, which is also passed to
33
+ ``open_page`` as the mount prefix of browser URLs.
34
+
35
+ GET and HEAD serve a ``.css`` or ``_aux.js`` file whose real path is below the
36
+ pages folder: the companions of ``FileHost`` and ``Page.css`` files placed
37
+ there. Every other file of the folder is not served.
38
+
39
+ ``content_security_policy`` is the application's policy, sent as the
40
+ ``Content-Security-Policy`` header of each HTML page; ``{nonce}`` in it is
41
+ replaced by the nonce that ``open_page`` puts on the bootstrap script.
42
+ """
43
+
44
+ def __init__(self, pages: str | Path, *, mount_path: str = "", page_ttl: float = 1800,
45
+ max_pages: int = 1000, content_security_policy: str | None = None) -> None:
46
+ self.mount_path = "/" + mount_path.strip("/") if mount_path.strip("/") else ""
47
+ self.content_security_policy = content_security_policy
48
+ self.host = FileHost(
49
+ pages,
50
+ runtime_url="/assets/gramlot.js",
51
+ main_url="/gramlot/main",
52
+ source_url="/gramlot/source",
53
+ close_url="/gramlot/close",
54
+ page_ttl=page_ttl,
55
+ max_pages=max_pages,
56
+ )
57
+
58
+ def blueprint(self) -> Blueprint:
59
+ name = "gramlot_pages_" + (self.mount_path.strip("/") or "root").replace("/", "_")
60
+ blueprint = Blueprint(name, __name__, url_prefix=self.mount_path or None)
61
+ blueprint.add_url_rule("/assets/gramlot.js", "asset", self.asset, methods=["GET", "HEAD"])
62
+ blueprint.add_url_rule("/gramlot/main", "main", self.main, methods=["POST"])
63
+ blueprint.add_url_rule("/gramlot/source", "source", self.source, methods=["POST"])
64
+ blueprint.add_url_rule("/gramlot/close", "close", self.close, methods=["POST"])
65
+ blueprint.add_url_rule("/", "index", self.page, defaults={"page_path": ""})
66
+ blueprint.add_url_rule("/<path:page_path>", "page", self.page)
67
+ return blueprint
68
+
69
+ def asset(self) -> Response:
70
+ body = b"" if request.method == "HEAD" else runtime_asset().read_bytes()
71
+ return Response(body, mimetype="text/javascript", headers={"Cache-Control": "no-cache"})
72
+
73
+ def page(self, page_path="") -> Response:
74
+ suffix = next((suffix for suffix in COMPANION_MEDIA_TYPES if page_path.endswith(suffix)), None)
75
+ if suffix is not None:
76
+ return self.companion(page_path, suffix)
77
+ owner = request.cookies.get(OWNER_COOKIE) or token_urlsafe(24)
78
+ try:
79
+ opened = asyncio.run(self.host.open_page(page_path, owner=owner, prefix=self.mount_path))
80
+ except PageNotFound:
81
+ return Response("Page not found", status=404)
82
+ except HostCapacity:
83
+ return Response("Page capacity reached", status=503)
84
+ headers = {"Cache-Control": "no-store"}
85
+ if self.content_security_policy is not None:
86
+ headers["Content-Security-Policy"] = self.content_security_policy.replace("{nonce}", opened.nonce)
87
+ response = Response(opened.html, mimetype="text/html", headers=headers)
88
+ response.set_cookie(
89
+ OWNER_COOKIE,
90
+ owner,
91
+ path=self.mount_path or "/",
92
+ httponly=True,
93
+ samesite="Lax",
94
+ )
95
+ return response
96
+
97
+ def companion(self, page_path: str, suffix: str) -> Response:
98
+ """Serve the file of ``page_path`` when its real path is below the pages folder."""
99
+ root = self.host.pages_dir.resolve()
100
+ real = root.joinpath(*page_path.strip("/").split("/")).resolve()
101
+ if not (real.is_relative_to(root) and real.is_file()):
102
+ return Response("Not found", status=404)
103
+ body = b"" if request.method == "HEAD" else real.read_bytes()
104
+ return Response(body, content_type=COMPANION_MEDIA_TYPES[suffix],
105
+ headers={"Cache-Control": "no-store"})
106
+
107
+ def main(self) -> Response:
108
+ return self._operation("main")
109
+
110
+ def source(self) -> Response:
111
+ return self._operation("source")
112
+
113
+ def close(self) -> Response:
114
+ return self._operation("close")
115
+
116
+ def _operation(self, operation: str) -> Response:
117
+ if request.mimetype != "application/json":
118
+ return Response("Expected application/json", status=415)
119
+ if request.content_length is not None and request.content_length > MAX_REQUEST_BYTES:
120
+ return Response("Request too large", status=413)
121
+ raw = request.get_data(cache=False)
122
+ if len(raw) > MAX_REQUEST_BYTES:
123
+ return Response("Request too large", status=413)
124
+ try:
125
+ payload = json.loads(raw)
126
+ if not isinstance(payload, dict) or not isinstance(payload.get("pageId"), str):
127
+ raise ValueError
128
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
129
+ return Response("Invalid JSON request", status=400)
130
+ owner = request.cookies.get(OWNER_COOKIE)
131
+ if operation == "source" and not isinstance(payload.get("params", {}), dict):
132
+ return Response("Source params must be a dictionary", status=400)
133
+ try:
134
+ if operation == "main":
135
+ result = asyncio.run(self.host.main(payload["pageId"], owner=owner))
136
+ elif operation == "source":
137
+ result = asyncio.run(self.host.source(
138
+ payload["pageId"], payload.get("method"), payload.get("params", {}), owner=owner
139
+ ))
140
+ else:
141
+ self.host.close_page(payload["pageId"], owner=owner)
142
+ result = json.dumps({"ok": True})
143
+ except PageExpired:
144
+ return Response("Unknown page", status=404)
145
+ except SourceNotFound:
146
+ return Response("Unknown Source method", status=404)
147
+ return Response(result, mimetype="application/json", headers={"Cache-Control": "no-store"})
148
+
149
+
150
+ def mount_pages(app, pages: str | Path, **options) -> Pages:
151
+ """Mount the bounded Gramlot page protocol on an existing Flask app."""
152
+
153
+ integration = Pages(pages, **options)
154
+ app.register_blueprint(integration.blueprint())
155
+ app.extensions.setdefault("gramlot_pages", {})[integration.mount_path] = integration
156
+ return integration
157
+
158
+
159
+ __all__ = ["Pages", "mount_pages"]