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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. 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)