composeui 0.1.0__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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The headers every response carries, whatever route or middleware answers it.
|
|
2
|
+
|
|
3
|
+
- Scripts: the page runs only the app's own script files, so text that ever got past
|
|
4
|
+
the escaping (`Markup`) can't run as a script, inline or evaluated. Inline style
|
|
5
|
+
attributes stay allowed: the renderers lay out with them, and they run nothing.
|
|
6
|
+
- Clickjacking: another site could show the app in a hidden frame, so the user's clicks
|
|
7
|
+
reach it from the app's own page, which the origin check (`origin.py`) lets through. So
|
|
8
|
+
no page of the app may be shown in a frame.
|
|
9
|
+
- No MIME sniffing: a browser keeps to the content type each response says it is, so an
|
|
10
|
+
uploaded or served file can't run as a script or a page it doesn't say it is.
|
|
11
|
+
- Referrer: the app's addresses aren't sent to other sites, and its own requests still
|
|
12
|
+
carry them, which the origin check falls back to.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from starlette.datastructures import MutableHeaders
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: scripts stay local; maps alone fetch raster tiles from OSM, without credentials
|
|
26
|
+
POLICY = (
|
|
27
|
+
"default-src 'self'; script-src 'self'; style-src 'self';"
|
|
28
|
+
" style-src-attr 'unsafe-inline'; img-src 'self' data:; object-src 'none';"
|
|
29
|
+
" connect-src 'self' https://tile.openstreetmap.org;"
|
|
30
|
+
" base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SecurityHeadersMiddleware:
|
|
35
|
+
"""Add the content, framing, sniffing and referrer headers to every response."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
38
|
+
self.app = app
|
|
39
|
+
|
|
40
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
41
|
+
if scope["type"] != "http":
|
|
42
|
+
await self.app(scope, receive, send)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
async def send_with_headers(message: Message) -> None:
|
|
46
|
+
if message["type"] == "http.response.start":
|
|
47
|
+
headers = MutableHeaders(scope=message)
|
|
48
|
+
# beside a route's own (an icon's), which can then only be stricter
|
|
49
|
+
headers.append("Content-Security-Policy", POLICY)
|
|
50
|
+
# the older framing header, for browsers without `frame-ancestors`
|
|
51
|
+
headers["X-Frame-Options"] = "DENY"
|
|
52
|
+
# kept when a route sets them (e.g. an icon's)
|
|
53
|
+
headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
54
|
+
headers.setdefault("Referrer-Policy", "same-origin")
|
|
55
|
+
await send(message)
|
|
56
|
+
|
|
57
|
+
await self.app(scope, receive, send_with_headers)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The image files the views name as icons, served at an address naming their content.
|
|
2
|
+
|
|
3
|
+
Only a file a renderer has asked an address for is served: the views name them, from
|
|
4
|
+
the app's code (`ActionView.icon` is never loaded from a study), so a request can't
|
|
5
|
+
name a file of its own. One registry for the process: every session runs the same code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
#: the file types served, by suffix: images a browser shows in an `<img>` or a mask
|
|
16
|
+
_IMAGE_SUFFIXES = frozenset({".svg", ".png", ".gif", ".jpg", ".jpeg", ".webp", ".ico"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _IconFile(NamedTuple):
|
|
20
|
+
mtime_ns: int
|
|
21
|
+
#: its name in its address: its content's digest, then its suffix
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_lock = threading.Lock()
|
|
26
|
+
_by_path: dict[Path, _IconFile] = {}
|
|
27
|
+
_by_name: dict[str, Path] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def icon_url(icon: str) -> str | None:
|
|
31
|
+
"""Get the address serving the image file at `icon`; None if it isn't one.
|
|
32
|
+
|
|
33
|
+
A relative path is from the current directory, as Qt reads it. The address changes
|
|
34
|
+
with the file's content, so a browser can keep it as long as it likes.
|
|
35
|
+
"""
|
|
36
|
+
path = Path(icon).resolve()
|
|
37
|
+
if path.suffix.lower() not in _IMAGE_SUFFIXES:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
41
|
+
with _lock:
|
|
42
|
+
known = _by_path.get(path)
|
|
43
|
+
if known is None or known.mtime_ns != mtime_ns:
|
|
44
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
45
|
+
if known is not None:
|
|
46
|
+
_by_name.pop(known.name, None)
|
|
47
|
+
known = _IconFile(mtime_ns, f"{digest}{path.suffix.lower()}")
|
|
48
|
+
_by_path[path] = known
|
|
49
|
+
_by_name[known.name] = path
|
|
50
|
+
except OSError: # no such file: no icon, as on Qt
|
|
51
|
+
return None
|
|
52
|
+
return f"/icon/{known.name}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def icon_path(name: str) -> Path | None:
|
|
56
|
+
"""Get the file served as `name` (see `icon_url`); None unless a view named it."""
|
|
57
|
+
with _lock:
|
|
58
|
+
return _by_name.get(name)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Refuse a request body larger than any upload the app accepts, before it is all read.
|
|
2
|
+
|
|
3
|
+
The routes check an upload's size once they have it (`MAX_STATE_SIZE`, `MAX_FILE_SIZE`),
|
|
4
|
+
but Starlette parses a whole multipart body first, spooling its files to temporary files
|
|
5
|
+
with no limit: an upload of many gigabytes would fill the disk before that check runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from starlette.datastructures import Headers
|
|
13
|
+
from starlette.exceptions import HTTPException
|
|
14
|
+
from starlette.responses import PlainTextResponse
|
|
15
|
+
|
|
16
|
+
from composeui.webcore.session import MAX_FILE_SIZE, MAX_STATE_SIZE
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
20
|
+
|
|
21
|
+
#: largest request body accepted, in bytes: the largest upload, and room around it for the
|
|
22
|
+
#: form's other fields, so a file just too large still gets the route's own message
|
|
23
|
+
MAX_BODY_SIZE = max(MAX_STATE_SIZE, MAX_FILE_SIZE) + 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BodySizeLimitMiddleware:
|
|
27
|
+
"""Answer 413 to a request whose body is larger than `max_size` bytes."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, app: ASGIApp, max_size: int = MAX_BODY_SIZE) -> None:
|
|
30
|
+
self.app = app
|
|
31
|
+
self.max_size = max_size
|
|
32
|
+
|
|
33
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
34
|
+
if scope["type"] != "http":
|
|
35
|
+
await self.app(scope, receive, send)
|
|
36
|
+
return
|
|
37
|
+
length = Headers(scope=scope).get("content-length")
|
|
38
|
+
if length is not None and length.isdigit() and int(length) > self.max_size:
|
|
39
|
+
response = PlainTextResponse("Request body too large", status_code=413)
|
|
40
|
+
await response(scope, receive, send)
|
|
41
|
+
return
|
|
42
|
+
received = 0
|
|
43
|
+
|
|
44
|
+
async def limited_receive() -> Message:
|
|
45
|
+
# a body without a length (chunked), or longer than it said
|
|
46
|
+
nonlocal received
|
|
47
|
+
message = await receive()
|
|
48
|
+
if message["type"] == "http.request":
|
|
49
|
+
received += len(message.get("body", b""))
|
|
50
|
+
if received > self.max_size:
|
|
51
|
+
# raised in the route reading the body: Starlette answers it
|
|
52
|
+
raise HTTPException(413, "Request body too large")
|
|
53
|
+
return message
|
|
54
|
+
|
|
55
|
+
await self.app(scope, limited_receive, send)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Refuse requests that change a session from another origin (cross-site request forgery).
|
|
2
|
+
|
|
3
|
+
The session cookie is `SameSite=Lax`, but "same site"
|
|
4
|
+
ignores the port and spans sibling subdomains: a page on `localhost:9000` still sends it
|
|
5
|
+
to an app on `localhost:8000`. So every unsafe request must prove it comes from the app's
|
|
6
|
+
own pages, as OWASP's Fetch Metadata defense asks: `Sec-Fetch-Site`, else `Origin`, else
|
|
7
|
+
`Referer`, else refused.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from starlette.datastructures import URL, Headers
|
|
16
|
+
from starlette.responses import PlainTextResponse
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
20
|
+
|
|
21
|
+
#: methods that only read: every request changing a session uses another one
|
|
22
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_same_origin(scope: Scope) -> bool:
|
|
26
|
+
"""Tell whether the browser says the request comes from a page of this origin.
|
|
27
|
+
|
|
28
|
+
A browser sets these headers itself: no page can forge them. A request without any
|
|
29
|
+
is refused: it isn't evidence of a same-origin one (an old browser, or a proxy
|
|
30
|
+
stripping them).
|
|
31
|
+
"""
|
|
32
|
+
headers = Headers(scope=scope)
|
|
33
|
+
site = headers.get("sec-fetch-site")
|
|
34
|
+
if site is not None:
|
|
35
|
+
return site == "same-origin"
|
|
36
|
+
url = URL(scope=scope)
|
|
37
|
+
own = f"{url.scheme}://{url.netloc}"
|
|
38
|
+
origin = headers.get("origin")
|
|
39
|
+
if origin is not None:
|
|
40
|
+
return origin == own # "null" (e.g. a sandboxed frame) is never ours
|
|
41
|
+
referer = headers.get("referer")
|
|
42
|
+
if referer is not None:
|
|
43
|
+
parts = urlsplit(referer)
|
|
44
|
+
return f"{parts.scheme}://{parts.netloc}" == own
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SameOriginMiddleware:
|
|
49
|
+
"""Answer 403 to an unsafe request (e.g. a POST) that another origin sent."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
52
|
+
self.app = app
|
|
53
|
+
|
|
54
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
55
|
+
if (
|
|
56
|
+
scope["type"] == "http"
|
|
57
|
+
and scope["method"] not in SAFE_METHODS
|
|
58
|
+
and not is_same_origin(scope)
|
|
59
|
+
):
|
|
60
|
+
response = PlainTextResponse("Cross-origin request refused", status_code=403)
|
|
61
|
+
await response(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from string import Template
|
|
6
|
+
|
|
7
|
+
from markupsafe import Markup, escape
|
|
8
|
+
|
|
9
|
+
_STATIC = Path(__file__).parent / "static"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _static_url(name: str) -> str:
|
|
13
|
+
"""Get the address of a static file, naming its content.
|
|
14
|
+
|
|
15
|
+
A new version of the file has a new address, so a browser never keeps using a copy it
|
|
16
|
+
cached of an older one (e.g. a stylesheet without the classes a new page uses).
|
|
17
|
+
"""
|
|
18
|
+
digest = hashlib.sha256((_STATIC / name).read_bytes()).hexdigest()[:16]
|
|
19
|
+
return f"/static/{name}?v={digest}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# no `data-theme`: daisyUI then follows the browser's light/dark preference
|
|
23
|
+
_PAGE = Template("""<!DOCTYPE html>
|
|
24
|
+
<html lang="$language">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<title>$title</title>
|
|
29
|
+
$icon_link
|
|
30
|
+
<!-- no copies of past pages: going back or forward reloads the page from the server,
|
|
31
|
+
whose state they would contradict, at the address of a page of the app. Nothing
|
|
32
|
+
htmx would run or style inline, which the page's policy refuses (`headers.py`). -->
|
|
33
|
+
<meta name="htmx-config" content='{"historyCacheSize": 0, "refreshOnHistoryMiss": true,
|
|
34
|
+
"allowEval": false, "allowScriptTags": false, "includeIndicatorStyles": false}' />
|
|
35
|
+
<script src="$htmx_url"></script>
|
|
36
|
+
<!-- built from css/app.css by `make css`: no CDN, so the page works offline -->
|
|
37
|
+
<link href="$css_url" rel="stylesheet" type="text/css" />
|
|
38
|
+
<script src="$page_url" data-uplot="$uplot_url" data-cally="$cally_url"
|
|
39
|
+
data-deckgl="$deckgl_url"></script>
|
|
40
|
+
</head>
|
|
41
|
+
|
|
42
|
+
<body class="bg-base-200 min-h-screen">
|
|
43
|
+
<div id="app">
|
|
44
|
+
$content
|
|
45
|
+
</div>
|
|
46
|
+
$page_marker
|
|
47
|
+
$pollers
|
|
48
|
+
<form id="$save_form" method="post" action="/state/download" hidden></form>
|
|
49
|
+
</body>
|
|
50
|
+
|
|
51
|
+
</html>
|
|
52
|
+
""")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: read once: the files only change with a new version of the package
|
|
56
|
+
_STATIC_URLS = {
|
|
57
|
+
# htmx 2.0.10 (https://github.com/bigskysoftware/htmx); `make web-deps` updates these files
|
|
58
|
+
"htmx_url": _static_url("htmx.min.js"),
|
|
59
|
+
# Tailwind and daisyUI 5.7.47 classes, daisyUI's license in daisyui.LICENSE
|
|
60
|
+
"css_url": _static_url("app.css"),
|
|
61
|
+
# a file, not inline: a policy can then allow the page's own scripts by address
|
|
62
|
+
"page_url": _static_url("page.js"),
|
|
63
|
+
# uPlot 1.6.32 (https://github.com/leeoniya/uPlot), its stylesheet in css/uPlot.min.css,
|
|
64
|
+
# its license in uPlot.LICENSE
|
|
65
|
+
"uplot_url": _static_url("uPlot.iife.min.js"),
|
|
66
|
+
# Cally 0.9.2 (https://github.com/WickyNilliams/cally), styled by daisyUI's `cally`
|
|
67
|
+
"cally_url": _static_url("cally.js"),
|
|
68
|
+
# deck.gl 9.2.6 (https://github.com/visgl/deck.gl), its license in deckgl.LICENSE
|
|
69
|
+
"deckgl_url": _static_url("deckgl.min.js"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def page_marker(language: str, title: str, *, oob: bool = False) -> Markup:
|
|
74
|
+
"""Get the element telling the page's script its `<html lang>` and `<title>`."""
|
|
75
|
+
swap = Markup(' hx-swap-oob="true"') if oob else Markup()
|
|
76
|
+
return Markup('<div id="cui-page" data-language="{}" data-title="{}"{} hidden></div>').format(
|
|
77
|
+
language, title, swap
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: id of the form Save and Save As post, their button naming the action (`trigger`)
|
|
82
|
+
SAVE_FORM_ID = "cui-save"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: milliseconds the page waits, while jobs run, before asking for what they changed
|
|
86
|
+
JOBS_POLL_MS = 300
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def jobs_poller(*, is_running: bool, oob: bool = False) -> Markup:
|
|
90
|
+
"""Get the element asking the server for what the jobs changed, while they run.
|
|
91
|
+
|
|
92
|
+
No response carries what a job's result changes (see `RenderTarget.pending`): while
|
|
93
|
+
the main view's jobs run, this element fetches itself every `JOBS_POLL_MS`, which
|
|
94
|
+
brings the changes along, until it comes back without a fetch once they are over.
|
|
95
|
+
"""
|
|
96
|
+
swap = Markup(' hx-swap-oob="true"') if oob else Markup()
|
|
97
|
+
poll = (
|
|
98
|
+
Markup(' hx-get="/jobs" hx-trigger="load delay:{}ms" hx-swap="outerHTML"').format(
|
|
99
|
+
JOBS_POLL_MS
|
|
100
|
+
)
|
|
101
|
+
if is_running
|
|
102
|
+
else Markup()
|
|
103
|
+
)
|
|
104
|
+
return Markup('<div id="cui-jobs"{}{} hidden></div>').format(poll, swap)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: milliseconds between two asks, in a page of a ``--reload`` server, whether it restarted
|
|
108
|
+
RELOAD_POLL_MS = 1000
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reload_poller(instance: str | None) -> Markup:
|
|
112
|
+
"""Get the element asking a ``--reload`` server whether it restarted; none without.
|
|
113
|
+
|
|
114
|
+
It asks every `RELOAD_POLL_MS`, naming `instance`, the server's process that served
|
|
115
|
+
the page. Another process answers with ``HX-Refresh``: the page reloads, with the
|
|
116
|
+
changed code and its session restored. An ask that fails while the server restarts is
|
|
117
|
+
asked again.
|
|
118
|
+
"""
|
|
119
|
+
if instance is None:
|
|
120
|
+
return Markup()
|
|
121
|
+
return Markup(
|
|
122
|
+
'<div id="cui-reload" hx-get="/reload?instance={}" hx-trigger="every {}ms"'
|
|
123
|
+
' hx-swap="none" hidden></div>'
|
|
124
|
+
).format(instance, RELOAD_POLL_MS)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_page(
|
|
128
|
+
title: str, content: Markup, language: str, *, pollers: Markup, icon: str | None = None
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Get the app's page showing `content`, with the browser tab's `icon` at its address.
|
|
131
|
+
|
|
132
|
+
`pollers` are the elements asking the server for news: `jobs_poller`'s, and
|
|
133
|
+
`reload_poller`'s.
|
|
134
|
+
"""
|
|
135
|
+
return _PAGE.substitute(
|
|
136
|
+
{
|
|
137
|
+
"title": escape(title),
|
|
138
|
+
"icon_link": Markup('<link rel="icon" href="{}" />').format(icon) if icon else "",
|
|
139
|
+
"content": content,
|
|
140
|
+
"language": escape(language),
|
|
141
|
+
"page_marker": page_marker(language, title),
|
|
142
|
+
"pollers": pollers,
|
|
143
|
+
"save_form": SAVE_FORM_ID,
|
|
144
|
+
**_STATIC_URLS,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from starlette.concurrency import run_in_threadpool
|
|
7
|
+
from starlette.datastructures import UploadFile
|
|
8
|
+
from starlette.responses import (
|
|
9
|
+
FileResponse,
|
|
10
|
+
HTMLResponse,
|
|
11
|
+
PlainTextResponse,
|
|
12
|
+
RedirectResponse,
|
|
13
|
+
Response,
|
|
14
|
+
StreamingResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from composeui.core import chartimage
|
|
18
|
+
from composeui.webcore.icons import icon_path
|
|
19
|
+
from composeui.webcore.session import MAX_FILE_SIZE, MAX_STATE_SIZE
|
|
20
|
+
from composeui.webcore.webrenderer import UnsupportedEventError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from starlette.requests import Request
|
|
24
|
+
|
|
25
|
+
from composeui.webcore.session import SessionStore, WebSession
|
|
26
|
+
|
|
27
|
+
#: Every page and fragment reflects live server state: a browser reusing a cached copy
|
|
28
|
+
#: (or restoring the fields of one) would show values the server no longer has, e.g.
|
|
29
|
+
#: after a server restart.
|
|
30
|
+
_NO_STORE = {"Cache-Control": "no-store"}
|
|
31
|
+
#: Answer to a widget request whose session is gone (expired, or the server restarted):
|
|
32
|
+
#: htmx reloads the page, which starts a fresh session, instead of the request failing.
|
|
33
|
+
_RELOAD = {**_NO_STORE, "HX-Refresh": "true"}
|
|
34
|
+
#: An icon's address names its content (see `icons.py`), so it never changes; an SVG
|
|
35
|
+
#: opened on its own runs no script and loads nothing.
|
|
36
|
+
_ICON_HEADERS = {
|
|
37
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
38
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
39
|
+
"X-Content-Type-Options": "nosniff",
|
|
40
|
+
}
|
|
41
|
+
#: key of the session id in Starlette's signed session cookie
|
|
42
|
+
SESSION_KEY = "composeui_session"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _store(request: Request) -> SessionStore:
|
|
46
|
+
store: SessionStore = request.app.state.sessions
|
|
47
|
+
return store
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _session(request: Request) -> WebSession | None:
|
|
51
|
+
return _store(request).get(request.session.get(SESSION_KEY))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _session_or_new(request: Request) -> WebSession | None:
|
|
55
|
+
"""Get the request's session, or a new one; None while the server has too many."""
|
|
56
|
+
session = _session(request)
|
|
57
|
+
if session is None:
|
|
58
|
+
created = _store(request).create()
|
|
59
|
+
if created is None:
|
|
60
|
+
return None
|
|
61
|
+
session_id, session = created
|
|
62
|
+
request.session[SESSION_KEY] = session_id
|
|
63
|
+
return session
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reload_instance(request: Request) -> str | None:
|
|
67
|
+
"""Get the id of this process of a ``--reload`` server; None for another server."""
|
|
68
|
+
instance: str | None = request.app.state.reload_instance
|
|
69
|
+
return instance
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _too_many_sessions() -> Response:
|
|
73
|
+
return PlainTextResponse(
|
|
74
|
+
"Too many sessions are open: try again later.", status_code=503, headers=_NO_STORE
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def homepage(request: Request) -> Response:
|
|
79
|
+
"""Show the app; one with pages is sent to its first page's address."""
|
|
80
|
+
session = _session_or_new(request)
|
|
81
|
+
if session is None:
|
|
82
|
+
return _too_many_sessions()
|
|
83
|
+
path = session.home_path()
|
|
84
|
+
if path is not None:
|
|
85
|
+
return RedirectResponse(path, status_code=303, headers=_NO_STORE)
|
|
86
|
+
return HTMLResponse(session.page_html(_reload_instance(request)), headers=_NO_STORE)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def page(request: Request) -> Response:
|
|
90
|
+
"""Show the app on the page of the address's route (`/<route>`).
|
|
91
|
+
|
|
92
|
+
An address no page has is not found, rather than sent to a page: a browser asks
|
|
93
|
+
for addresses of its own (e.g. `/favicon.ico`), which must not navigate.
|
|
94
|
+
"""
|
|
95
|
+
session = _session_or_new(request)
|
|
96
|
+
if session is None:
|
|
97
|
+
return _too_many_sessions()
|
|
98
|
+
if not session.open_route(request.path_params["route"]):
|
|
99
|
+
return PlainTextResponse("Not Found", status_code=404, headers=_NO_STORE)
|
|
100
|
+
return HTMLResponse(session.page_html(_reload_instance(request)), headers=_NO_STORE)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def reload_poll(request: Request) -> Response:
|
|
104
|
+
"""Tell a page of a ``--reload`` server whether another process of it served the page.
|
|
105
|
+
|
|
106
|
+
Then its answer reloads the page (``HX-Refresh``), which runs the changed code. Only
|
|
107
|
+
a read: the next request restores the session (`SessionStore`). A server without
|
|
108
|
+
``--reload`` reloads the page too: a tab a ``--reload`` server served, left open on
|
|
109
|
+
its address, would else ask it every second, for ever.
|
|
110
|
+
"""
|
|
111
|
+
instance = _reload_instance(request)
|
|
112
|
+
if instance is not None and request.query_params.get("instance") == instance:
|
|
113
|
+
return Response(status_code=204, headers=_NO_STORE)
|
|
114
|
+
return Response(headers=_RELOAD)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def widget_event(request: Request) -> Response:
|
|
118
|
+
"""Dispatch a browser event to a widget; respond with every fragment it changed."""
|
|
119
|
+
session = _session(request)
|
|
120
|
+
if session is None:
|
|
121
|
+
return Response(headers=_RELOAD)
|
|
122
|
+
form = await request.form()
|
|
123
|
+
fields = {name: value for name, value in form.items() if isinstance(value, str)}
|
|
124
|
+
event = fields.pop("event", "")
|
|
125
|
+
widget_id = request.path_params["widget_id"]
|
|
126
|
+
try:
|
|
127
|
+
# view callbacks are plain sync code: keep them off the event loop
|
|
128
|
+
result = await run_in_threadpool(session.handle_event, widget_id, event, fields)
|
|
129
|
+
except UnsupportedEventError as e:
|
|
130
|
+
return PlainTextResponse(str(e), status_code=400)
|
|
131
|
+
if result is None:
|
|
132
|
+
return PlainTextResponse("Unknown widget", status_code=404)
|
|
133
|
+
return _widget_response(request, session, *result)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _widget_response(
|
|
137
|
+
request: Request, session: WebSession, body: str, path: str | None
|
|
138
|
+
) -> Response:
|
|
139
|
+
"""Answer a widget request with the fragments it changed, `body`, and its page."""
|
|
140
|
+
if session.closed:
|
|
141
|
+
# e.g. Exit: end the session; the page it lands on starts a fresh one
|
|
142
|
+
_store(request).drop(request.session.get(SESSION_KEY))
|
|
143
|
+
return Response(headers={**_NO_STORE, "HX-Redirect": "/"})
|
|
144
|
+
# a navigation puts the new page's address in the browser's history
|
|
145
|
+
headers = _NO_STORE if path is None else {**_NO_STORE, "HX-Push-Url": path}
|
|
146
|
+
return HTMLResponse(body, headers=headers)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def widget_file(request: Request) -> Response:
|
|
150
|
+
"""Hand an uploaded file to a `PickFileAction`; respond as `widget_event` does."""
|
|
151
|
+
session = _session(request)
|
|
152
|
+
if session is None:
|
|
153
|
+
return Response(headers=_RELOAD)
|
|
154
|
+
form = await request.form(max_files=1)
|
|
155
|
+
upload = form.get("file")
|
|
156
|
+
if not isinstance(upload, UploadFile):
|
|
157
|
+
return PlainTextResponse("No file uploaded", status_code=400)
|
|
158
|
+
# one byte over the limit is enough to know the file is too large
|
|
159
|
+
content = await upload.read(MAX_FILE_SIZE + 1)
|
|
160
|
+
widget_id = request.path_params["widget_id"]
|
|
161
|
+
try:
|
|
162
|
+
result = await run_in_threadpool(
|
|
163
|
+
session.pick_file, widget_id, upload.filename or "", content
|
|
164
|
+
)
|
|
165
|
+
except UnsupportedEventError as e:
|
|
166
|
+
return PlainTextResponse(str(e), status_code=400)
|
|
167
|
+
if result is None:
|
|
168
|
+
return PlainTextResponse("Unknown widget", status_code=404)
|
|
169
|
+
return _widget_response(request, session, *result)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def widget_fragment(request: Request) -> Response:
|
|
173
|
+
"""Return a widget's current fragment (e.g. a status bar whose message timed out)."""
|
|
174
|
+
session = _session(request)
|
|
175
|
+
if session is None:
|
|
176
|
+
return Response(headers=_RELOAD)
|
|
177
|
+
body = session.fragment(request.path_params["widget_id"])
|
|
178
|
+
if body is None:
|
|
179
|
+
return PlainTextResponse("Unknown widget", status_code=404)
|
|
180
|
+
return HTMLResponse(body, headers=_NO_STORE)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def chart_image(request: Request) -> Response:
|
|
184
|
+
"""Download a chart's image as a report's figure, drawn by matplotlib at its zoom.
|
|
185
|
+
|
|
186
|
+
A plain link, like the state's download. Without matplotlib (the `report` extra), not
|
|
187
|
+
found: the page draws its PNG itself.
|
|
188
|
+
"""
|
|
189
|
+
session = _session(request)
|
|
190
|
+
if session is None:
|
|
191
|
+
return RedirectResponse("/", status_code=303)
|
|
192
|
+
if not chartimage.is_available():
|
|
193
|
+
return PlainTextResponse("Not Found", status_code=404)
|
|
194
|
+
query = dict(request.query_params)
|
|
195
|
+
image_format = query.pop("format", "pdf").lower()
|
|
196
|
+
widget_id = request.path_params["widget_id"]
|
|
197
|
+
try:
|
|
198
|
+
# matplotlib takes a while: off the event loop
|
|
199
|
+
result = await run_in_threadpool(session.chart_image, widget_id, image_format, query)
|
|
200
|
+
except ValueError as error:
|
|
201
|
+
return PlainTextResponse(str(error), status_code=400)
|
|
202
|
+
if result is None:
|
|
203
|
+
return PlainTextResponse("Unknown widget", status_code=404)
|
|
204
|
+
filename, body = result
|
|
205
|
+
headers = {**_NO_STORE, "Content-Disposition": _attachment(filename)}
|
|
206
|
+
return Response(body, media_type=chartimage.MEDIA_TYPES[image_format], headers=headers)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _attachment(filename: str) -> str:
|
|
210
|
+
"""Get the `Content-Disposition` of a download named `filename`."""
|
|
211
|
+
# the name's own letters, which a header's latin-1 may not have, and a fallback
|
|
212
|
+
ascii_name = filename.encode("ascii", "replace").decode().replace("?", "_")
|
|
213
|
+
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def table_csv(request: Request) -> Response:
|
|
217
|
+
"""Download a large table's rows shown as CSV, streamed a window of rows at a time.
|
|
218
|
+
|
|
219
|
+
A plain link, like a chart's image: it only reads.
|
|
220
|
+
"""
|
|
221
|
+
session = _session(request)
|
|
222
|
+
if session is None:
|
|
223
|
+
return RedirectResponse("/", status_code=303)
|
|
224
|
+
result = session.table_csv(request.path_params["widget_id"])
|
|
225
|
+
if result is None:
|
|
226
|
+
return PlainTextResponse("Unknown widget", status_code=404)
|
|
227
|
+
filename, chunks = result
|
|
228
|
+
headers = {**_NO_STORE, "Content-Disposition": _attachment(filename)}
|
|
229
|
+
return StreamingResponse(chunks, media_type="text/csv; charset=utf-8", headers=headers)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def jobs_poll(request: Request) -> Response:
|
|
233
|
+
"""Return the jobs' poller and what they changed, which it fetches while they run."""
|
|
234
|
+
session = _session(request)
|
|
235
|
+
if session is None:
|
|
236
|
+
return Response(headers=_RELOAD)
|
|
237
|
+
return HTMLResponse(session.poll_jobs(), headers=_NO_STORE)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def state_download(request: Request) -> Response:
|
|
241
|
+
"""Download the session's state as a file, the web side of `MainView.save_state`.
|
|
242
|
+
|
|
243
|
+
A plain link, not htmx (htmx can't start downloads); without a session, the
|
|
244
|
+
browser is sent to the page, which starts one.
|
|
245
|
+
"""
|
|
246
|
+
session = _session(request)
|
|
247
|
+
if session is None:
|
|
248
|
+
return RedirectResponse("/", status_code=303)
|
|
249
|
+
return _state_file(session)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def state_save(request: Request) -> Response:
|
|
253
|
+
"""Fire Save's own `triggered`, then download the state as `state_download` does.
|
|
254
|
+
|
|
255
|
+
A form's post rather than a link: the action changes the session, which a GET must
|
|
256
|
+
not (a link from another site would fire it).
|
|
257
|
+
"""
|
|
258
|
+
session = _session(request)
|
|
259
|
+
if session is None:
|
|
260
|
+
return RedirectResponse("/", status_code=303)
|
|
261
|
+
trigger = (await request.form()).get("trigger")
|
|
262
|
+
if isinstance(trigger, str):
|
|
263
|
+
await run_in_threadpool(session.trigger_action, trigger)
|
|
264
|
+
return _state_file(session)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _state_file(session: WebSession) -> Response:
|
|
268
|
+
filename, body, media_type = session.save_state()
|
|
269
|
+
headers = {**_NO_STORE, "Content-Disposition": f'attachment; filename="{filename}"'}
|
|
270
|
+
return Response(body, media_type=media_type, headers=headers)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def state_upload(request: Request) -> Response:
|
|
274
|
+
"""Load an uploaded state file, the web side of `MainView.load_state`.
|
|
275
|
+
|
|
276
|
+
A load can change any part of the tree, so instead of collecting fragments the page
|
|
277
|
+
reloads; a rejected file leaves the state alone and says why in the status bar.
|
|
278
|
+
"""
|
|
279
|
+
session = _session(request)
|
|
280
|
+
if session is None:
|
|
281
|
+
return Response(headers=_RELOAD)
|
|
282
|
+
form = await request.form(max_files=1)
|
|
283
|
+
upload = form.get("file")
|
|
284
|
+
if not isinstance(upload, UploadFile):
|
|
285
|
+
return PlainTextResponse("No file uploaded", status_code=400)
|
|
286
|
+
trigger = form.get("trigger")
|
|
287
|
+
if isinstance(trigger, str):
|
|
288
|
+
# e.g. Open's own `triggered`, before the file is loaded
|
|
289
|
+
await run_in_threadpool(session.trigger_action, trigger)
|
|
290
|
+
# one byte over the limit is enough to know the file is too large
|
|
291
|
+
content = await upload.read(MAX_STATE_SIZE + 1)
|
|
292
|
+
status_bar = await run_in_threadpool(session.load_state, upload.filename or "", content)
|
|
293
|
+
if status_bar is None:
|
|
294
|
+
return Response(headers=_RELOAD)
|
|
295
|
+
return HTMLResponse(status_bar, headers=_NO_STORE)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def icon(request: Request) -> Response:
|
|
299
|
+
"""Serve an icon file a view named; any other name is not found."""
|
|
300
|
+
path = icon_path(request.path_params["name"])
|
|
301
|
+
if path is None or not path.is_file():
|
|
302
|
+
return PlainTextResponse("Not Found", status_code=404)
|
|
303
|
+
return FileResponse(path, headers=_ICON_HEADERS)
|