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,436 @@
|
|
|
1
|
+
"""One `MainView` per browser session, kept in memory.
|
|
2
|
+
|
|
3
|
+
A session is identified by a random id stored in Starlette's signed session cookie.
|
|
4
|
+
Every tab of a browser shares that cookie, so they share one session (and one app
|
|
5
|
+
state), the way two windows on the same document would; different browsers or users
|
|
6
|
+
get their own. State lives in this process only: a restart starts every session over,
|
|
7
|
+
except with ``--reload``, whose server keeps them across its restarts (`SessionStore`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
21
|
+
|
|
22
|
+
from composeui.core.model import InvalidStateError
|
|
23
|
+
from composeui.core.registry import RendererType
|
|
24
|
+
from composeui.core.reload import load_reloaded_state, save_reloaded_state
|
|
25
|
+
from composeui.core.study import has_data, open_study, save_study
|
|
26
|
+
from composeui.webcore.page import jobs_poller, page_marker
|
|
27
|
+
from composeui.webcore.webrenderer import (
|
|
28
|
+
RenderTarget,
|
|
29
|
+
WebActionRenderer,
|
|
30
|
+
WebChartRenderer,
|
|
31
|
+
WebFilePathInputRenderer,
|
|
32
|
+
WebMainRenderer,
|
|
33
|
+
WebSourceTableRenderer,
|
|
34
|
+
collect_dirty,
|
|
35
|
+
handle_event,
|
|
36
|
+
page_path,
|
|
37
|
+
render_dirty,
|
|
38
|
+
rendering_into,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Iterator, Mapping
|
|
43
|
+
|
|
44
|
+
from composeui.core.model import Model
|
|
45
|
+
from composeui.core.view import MainView, Router
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: largest state file accepted from an upload, in bytes
|
|
49
|
+
MAX_STATE_SIZE = 5 * 1024 * 1024
|
|
50
|
+
#: largest a study's archive may unpack to, in bytes (its database compresses well)
|
|
51
|
+
MAX_UNPACKED_SIZE = 20 * MAX_STATE_SIZE
|
|
52
|
+
#: largest file a `PickFileAction` or a file path field accepts from an upload, in bytes
|
|
53
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def safe_filename(name: str) -> str:
|
|
57
|
+
"""Reduce `name` to letters, digits, `.`, `_` and `-`, safe in a response header."""
|
|
58
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "state"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WebSession(RenderTarget):
|
|
62
|
+
"""A session's view tree, rendered once and then driven by its browser's requests."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, main_view: MainView, model: Model | None = None) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.main_view = main_view
|
|
67
|
+
# held here: the signals giving it to callbacks only keep a weak reference
|
|
68
|
+
self.model = model
|
|
69
|
+
#: where the file path fields' uploads are kept, made by the first one
|
|
70
|
+
self._uploads: Path | None = None
|
|
71
|
+
with self.lock, rendering_into(self):
|
|
72
|
+
main_view.render(RendererType.WEB)
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Release the session's model (e.g. its database file) and its uploads; it's dropped."""
|
|
76
|
+
with self.lock:
|
|
77
|
+
if self.model is not None:
|
|
78
|
+
self.model.close()
|
|
79
|
+
if self._uploads is not None:
|
|
80
|
+
shutil.rmtree(self._uploads, ignore_errors=True)
|
|
81
|
+
self._uploads = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def closed(self) -> bool:
|
|
85
|
+
"""Whether the app asked to close (`MainView.close()`, e.g. the Exit action)."""
|
|
86
|
+
renderer = self.main_view.renderer
|
|
87
|
+
return isinstance(renderer, WebMainRenderer) and renderer.is_closed
|
|
88
|
+
|
|
89
|
+
def trigger_action(self, widget_id: str) -> None:
|
|
90
|
+
"""Fire the `triggered` of one of this session's actions; ignore any other id."""
|
|
91
|
+
with self.lock:
|
|
92
|
+
renderer = self.renderers.get(widget_id)
|
|
93
|
+
if isinstance(renderer, WebActionRenderer):
|
|
94
|
+
# its fragments are dropped: the caller answers with a download or a reload
|
|
95
|
+
handle_event(renderer, "triggered", {})
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def _router(self) -> Router | None:
|
|
99
|
+
renderer = self.main_view.renderer
|
|
100
|
+
return renderer.router if isinstance(renderer, WebMainRenderer) else None
|
|
101
|
+
|
|
102
|
+
def home_path(self) -> str | None:
|
|
103
|
+
"""Get the address of the first page; None for an app without pages."""
|
|
104
|
+
router = self._router
|
|
105
|
+
if router is None or len(router.pages) == 0:
|
|
106
|
+
return None
|
|
107
|
+
return page_path(router.pages[0].route)
|
|
108
|
+
|
|
109
|
+
def open_route(self, route: str) -> bool:
|
|
110
|
+
"""Show the page on `route`, as its address asks; False if no page has it."""
|
|
111
|
+
with self.lock, collect_dirty(): # the whole page is sent: drop the fragments
|
|
112
|
+
router = self._router
|
|
113
|
+
if router is None or router.page(route) is None:
|
|
114
|
+
return False
|
|
115
|
+
router.navigate(route)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def page_html(self, reload_instance: str | None = None) -> str:
|
|
119
|
+
"""Get the whole page; with `reload_instance`, it reloads once its server restarts."""
|
|
120
|
+
with self.lock:
|
|
121
|
+
assert isinstance(self.main_view.renderer, WebMainRenderer) # rendered when built
|
|
122
|
+
self.take_pending() # the whole page shows them
|
|
123
|
+
return self.main_view.renderer.page_html(reload_instance)
|
|
124
|
+
|
|
125
|
+
def save_to(self, directory: Path) -> None:
|
|
126
|
+
"""Save the session's study, its file and language, for `open_saved` to open."""
|
|
127
|
+
with self.lock:
|
|
128
|
+
save_reloaded_state(self.main_view, self.model, directory)
|
|
129
|
+
|
|
130
|
+
def open_saved(self, directory: Path) -> None:
|
|
131
|
+
"""Open what `save_to` saved, if it still fits the main view (else it's dropped)."""
|
|
132
|
+
with self.lock, collect_dirty(): # the whole page is sent next: drop the fragments
|
|
133
|
+
load_reloaded_state(self.main_view, self.model, directory)
|
|
134
|
+
|
|
135
|
+
def handle_event(
|
|
136
|
+
self, widget_id: str, event: str, form: Mapping[str, str]
|
|
137
|
+
) -> tuple[str, str | None] | None:
|
|
138
|
+
"""Apply a browser event to one of this session's widgets; None if it isn't one.
|
|
139
|
+
|
|
140
|
+
Returns the fragments the event changed, with those changed outside any request
|
|
141
|
+
since the last response, and the address of the page it navigated to, if it did
|
|
142
|
+
(e.g. a toolbar link, or an app's own `navigate`).
|
|
143
|
+
"""
|
|
144
|
+
with self.lock:
|
|
145
|
+
renderer = self.renderers.get(widget_id)
|
|
146
|
+
if renderer is None:
|
|
147
|
+
return None
|
|
148
|
+
return self._respond(functools.partial(renderer.dispatch, event, form))
|
|
149
|
+
|
|
150
|
+
def pick_file(
|
|
151
|
+
self, widget_id: str, filename: str, content: bytes
|
|
152
|
+
) -> tuple[str, str | None] | None:
|
|
153
|
+
"""Hand an uploaded file to one of this session's `PickFileAction`s or path fields.
|
|
154
|
+
|
|
155
|
+
None if `widget_id` is neither. The file is a copy under its own name: for an
|
|
156
|
+
action, gone once its callbacks have run; for a file path field, whose text keeps
|
|
157
|
+
its path, kept until the session ends or the field's next upload. One of a type
|
|
158
|
+
they don't offer, or too large, is refused in the status bar. Returns what
|
|
159
|
+
`handle_event` does.
|
|
160
|
+
"""
|
|
161
|
+
name = safe_filename(Path(filename).name)
|
|
162
|
+
with self.lock:
|
|
163
|
+
renderer = self.renderers.get(widget_id)
|
|
164
|
+
if not isinstance(renderer, (WebActionRenderer, WebFilePathInputRenderer)):
|
|
165
|
+
return None
|
|
166
|
+
types = renderer.file_types
|
|
167
|
+
error = None
|
|
168
|
+
if len(content) > MAX_FILE_SIZE:
|
|
169
|
+
error = "the file is too large"
|
|
170
|
+
elif types and Path(name).suffix.lower() not in types:
|
|
171
|
+
error = f"expected a {' or '.join(types)} file"
|
|
172
|
+
if error is not None:
|
|
173
|
+
message = f"Can't open {filename!r}: {error}."
|
|
174
|
+
return self._respond(
|
|
175
|
+
functools.partial(self.main_view.status_bar.show_message, message)
|
|
176
|
+
)
|
|
177
|
+
if isinstance(renderer, WebFilePathInputRenderer):
|
|
178
|
+
path = self._upload_directory(widget_id) / name
|
|
179
|
+
path.write_bytes(content)
|
|
180
|
+
return self._respond(functools.partial(renderer.pick_file, path))
|
|
181
|
+
with tempfile.TemporaryDirectory(prefix="composeui-") as directory:
|
|
182
|
+
path = Path(directory, name)
|
|
183
|
+
path.write_bytes(content)
|
|
184
|
+
return self._respond(functools.partial(renderer.pick_file, path))
|
|
185
|
+
|
|
186
|
+
def _upload_directory(self, widget_id: str) -> Path:
|
|
187
|
+
"""Get an empty directory for a field's upload: the field's last one is removed."""
|
|
188
|
+
if self._uploads is None:
|
|
189
|
+
self._uploads = Path(tempfile.mkdtemp(prefix="composeui-"))
|
|
190
|
+
directory = self._uploads / widget_id
|
|
191
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
192
|
+
directory.mkdir()
|
|
193
|
+
return directory
|
|
194
|
+
|
|
195
|
+
def _respond(self, apply: Callable[[], None]) -> tuple[str, str | None]:
|
|
196
|
+
"""Run `apply` against the tree and return what changed, as `handle_event` does."""
|
|
197
|
+
with self.lock:
|
|
198
|
+
router = self._router
|
|
199
|
+
route = None if router is None else router.current_route
|
|
200
|
+
main_view = self.main_view
|
|
201
|
+
language, title = main_view.language, main_view.title
|
|
202
|
+
jobs_running = main_view.jobs.is_running
|
|
203
|
+
with collect_dirty() as dirty:
|
|
204
|
+
apply()
|
|
205
|
+
# after: the event may hand changes over with `call_on_main_thread` too
|
|
206
|
+
body = render_dirty({**self.take_pending(), **dirty})
|
|
207
|
+
if main_view.title != title and isinstance(main_view.renderer, WebMainRenderer):
|
|
208
|
+
body += main_view.renderer.brand_html(oob=True)
|
|
209
|
+
if (main_view.language, main_view.title) != (language, title):
|
|
210
|
+
body += page_marker(main_view.language, main_view.title, oob=True)
|
|
211
|
+
if main_view.jobs.is_running != jobs_running:
|
|
212
|
+
body += jobs_poller(is_running=main_view.jobs.is_running, oob=True)
|
|
213
|
+
if router is None or router.current_route == route:
|
|
214
|
+
return body, None
|
|
215
|
+
return body, page_path(router.current_route)
|
|
216
|
+
|
|
217
|
+
def save_state(self) -> tuple[str, bytes, str]:
|
|
218
|
+
"""Return a download name, the saved study (`save_study`'s) and its media type."""
|
|
219
|
+
with self.lock, tempfile.TemporaryDirectory(prefix="composeui-") as directory:
|
|
220
|
+
main_view = self.main_view
|
|
221
|
+
if main_view.state_filepath is not None:
|
|
222
|
+
name = main_view.state_filepath.name
|
|
223
|
+
else:
|
|
224
|
+
name = f"{main_view.title.lower()}{main_view.extension}"
|
|
225
|
+
state_filepath = main_view.state_filepath
|
|
226
|
+
path = Path(directory, "study")
|
|
227
|
+
save_study(main_view, self.model, path)
|
|
228
|
+
# the browser's file is wherever it saves the download, not this scratch one
|
|
229
|
+
main_view.state_filepath = state_filepath
|
|
230
|
+
media_type = "application/zip" if has_data(self.model) else "application/json"
|
|
231
|
+
return safe_filename(name), path.read_bytes(), media_type
|
|
232
|
+
|
|
233
|
+
def load_state(self, filename: str, content: bytes) -> str | None:
|
|
234
|
+
"""Load an uploaded state file; on failure, return the status bar saying why."""
|
|
235
|
+
with self.lock, collect_dirty() as dirty:
|
|
236
|
+
error = self._load_state(filename, content)
|
|
237
|
+
if error is not None:
|
|
238
|
+
self.main_view.status_bar.show_message(f"Can't open {filename!r}: {error}.")
|
|
239
|
+
return render_dirty(dirty)
|
|
240
|
+
self.main_view.state_filepath = Path(Path(filename).name)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def _load_state(self, filename: str, content: bytes) -> str | None:
|
|
244
|
+
extension = self.main_view.extension
|
|
245
|
+
if Path(filename).suffix != extension:
|
|
246
|
+
return f"expected a {extension} file"
|
|
247
|
+
if len(content) > MAX_STATE_SIZE:
|
|
248
|
+
return "the file is too large"
|
|
249
|
+
with tempfile.TemporaryDirectory(prefix="composeui-") as directory:
|
|
250
|
+
path = Path(directory, "study")
|
|
251
|
+
path.write_bytes(content)
|
|
252
|
+
try:
|
|
253
|
+
open_study(self.main_view, self.model, path, max_size=MAX_UNPACKED_SIZE)
|
|
254
|
+
except InvalidStateError as error:
|
|
255
|
+
return str(error)
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def fragment(self, widget_id: str) -> str | None:
|
|
259
|
+
"""Render one of this session's widgets as it is now; None if it isn't one.
|
|
260
|
+
|
|
261
|
+
The fragments changed outside any request come along too.
|
|
262
|
+
"""
|
|
263
|
+
with self.lock:
|
|
264
|
+
renderer = self.renderers.get(widget_id)
|
|
265
|
+
if renderer is None:
|
|
266
|
+
return None
|
|
267
|
+
pending = self.take_pending()
|
|
268
|
+
pending.pop(widget_id, None) # swapped in already
|
|
269
|
+
return renderer.to_html() + render_dirty(pending)
|
|
270
|
+
|
|
271
|
+
def chart_image(
|
|
272
|
+
self, widget_id: str, image_format: str, query: Mapping[str, str]
|
|
273
|
+
) -> tuple[str, bytes] | None:
|
|
274
|
+
"""Draw the image of one of this session's charts; None if it isn't one.
|
|
275
|
+
|
|
276
|
+
Raises `ValueError` for a format or a range it can't draw.
|
|
277
|
+
"""
|
|
278
|
+
with self.lock:
|
|
279
|
+
renderer = self.renderers.get(widget_id)
|
|
280
|
+
if not isinstance(renderer, WebChartRenderer):
|
|
281
|
+
return None
|
|
282
|
+
return renderer.image(image_format, query)
|
|
283
|
+
|
|
284
|
+
def table_csv(self, widget_id: str) -> tuple[str, Iterator[str]] | None:
|
|
285
|
+
"""Get the file name and the CSV, in pieces, of one of this session's large tables.
|
|
286
|
+
|
|
287
|
+
None if it isn't one.
|
|
288
|
+
|
|
289
|
+
The rows shown at the call (`SourceTableView.csv_chunks`), each piece read under the
|
|
290
|
+
session's lock: the session's other requests go on between two pieces.
|
|
291
|
+
"""
|
|
292
|
+
with self.lock:
|
|
293
|
+
renderer = self.renderers.get(widget_id)
|
|
294
|
+
if not isinstance(renderer, WebSourceTableRenderer):
|
|
295
|
+
return None
|
|
296
|
+
filename, chunks = renderer.csv_file()
|
|
297
|
+
|
|
298
|
+
def locked() -> Iterator[str]:
|
|
299
|
+
while True:
|
|
300
|
+
with self.lock:
|
|
301
|
+
chunk = next(chunks, None)
|
|
302
|
+
if chunk is None:
|
|
303
|
+
return
|
|
304
|
+
yield chunk
|
|
305
|
+
|
|
306
|
+
return filename, locked()
|
|
307
|
+
|
|
308
|
+
def poll_jobs(self) -> str:
|
|
309
|
+
"""Render the jobs' poller as it is now, and what changed outside any request.
|
|
310
|
+
|
|
311
|
+
The poller fetches this while the main view's jobs run (see `jobs_poller`).
|
|
312
|
+
"""
|
|
313
|
+
with self.lock:
|
|
314
|
+
pending = render_dirty(self.take_pending())
|
|
315
|
+
return jobs_poller(is_running=self.main_view.jobs.is_running) + pending
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
#: a session id, as `SessionStore.create` makes them: also the name of its saved directory
|
|
319
|
+
_SESSION_ID = re.compile(r"[A-Za-z0-9_-]{43}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class SessionStore:
|
|
323
|
+
"""The live sessions by id, each dropped once idle for longer than `ttl` seconds.
|
|
324
|
+
|
|
325
|
+
Expiry is checked whenever the store is used, so an idle server keeps its sessions
|
|
326
|
+
until the next request. A dropped session's `StatusBar` timer may still fire; it
|
|
327
|
+
then clears a message on a tree nothing displays any more, which is harmless.
|
|
328
|
+
|
|
329
|
+
With `saved`, a ``--reload`` server's directory, `save_all` saves the live sessions
|
|
330
|
+
there as the server stops. The server started next opens one there when its browser
|
|
331
|
+
comes back, under the same id: its cookie, signed with the same key, still names it.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
build_session: Callable[[], tuple[MainView, Model | None]],
|
|
337
|
+
*,
|
|
338
|
+
ttl: float,
|
|
339
|
+
max_sessions: int,
|
|
340
|
+
saved: Path | None = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
self._build_session = build_session
|
|
343
|
+
self._ttl = ttl
|
|
344
|
+
self._max_sessions = max_sessions
|
|
345
|
+
self._saved = saved
|
|
346
|
+
self._sessions: dict[str, tuple[WebSession, float]] = {}
|
|
347
|
+
#: sessions being built, outside the lock: they count against `max_sessions` too
|
|
348
|
+
self._building = 0
|
|
349
|
+
self._lock = threading.Lock()
|
|
350
|
+
|
|
351
|
+
def get(self, session_id: Any) -> WebSession | None:
|
|
352
|
+
"""Return the live session with this id (from a cookie, so of any type), or None."""
|
|
353
|
+
with self._lock:
|
|
354
|
+
self._drop_expired()
|
|
355
|
+
entry = self._sessions.get(session_id) if isinstance(session_id, str) else None
|
|
356
|
+
if entry is None:
|
|
357
|
+
return self._restore(session_id)
|
|
358
|
+
session = entry[0]
|
|
359
|
+
self._sessions[session_id] = (session, time.monotonic())
|
|
360
|
+
return session
|
|
361
|
+
|
|
362
|
+
def create(self) -> tuple[str, WebSession] | None:
|
|
363
|
+
"""Build a new session; None while `max_sessions` are live.
|
|
364
|
+
|
|
365
|
+
A full store refuses new sessions rather than dropping old ones: requests without
|
|
366
|
+
a cookie, which anyone can send, would otherwise push out the users' own sessions
|
|
367
|
+
and their unsaved work.
|
|
368
|
+
"""
|
|
369
|
+
with self._lock:
|
|
370
|
+
self._drop_expired()
|
|
371
|
+
if len(self._sessions) + self._building >= self._max_sessions:
|
|
372
|
+
return None
|
|
373
|
+
self._building += 1
|
|
374
|
+
try:
|
|
375
|
+
# built outside the store's lock: building runs app code, which may be slow
|
|
376
|
+
session = WebSession(*self._build_session())
|
|
377
|
+
session_id = secrets.token_urlsafe(32)
|
|
378
|
+
with self._lock:
|
|
379
|
+
self._sessions[session_id] = (session, time.monotonic())
|
|
380
|
+
finally:
|
|
381
|
+
with self._lock:
|
|
382
|
+
self._building -= 1
|
|
383
|
+
return session_id, session
|
|
384
|
+
|
|
385
|
+
def save_all(self) -> None:
|
|
386
|
+
"""Save every live session into `saved`, as a ``--reload`` server's process stops."""
|
|
387
|
+
assert self._saved is not None # only such a server's lifespan calls it
|
|
388
|
+
with self._lock:
|
|
389
|
+
sessions = [
|
|
390
|
+
(session_id, session) for session_id, (session, _) in self._sessions.items()
|
|
391
|
+
]
|
|
392
|
+
for session_id, session in sessions:
|
|
393
|
+
directory = self._saved / session_id
|
|
394
|
+
directory.mkdir(exist_ok=True)
|
|
395
|
+
session.save_to(directory)
|
|
396
|
+
|
|
397
|
+
def _restore(self, session_id: Any) -> WebSession | None:
|
|
398
|
+
"""Open the session the previous server saved under `session_id`; None if none.
|
|
399
|
+
|
|
400
|
+
Called with the lock held. The session is built under it too, unlike `create`'s,
|
|
401
|
+
so two requests of its browser can't both open it: only a ``--reload`` server,
|
|
402
|
+
for its developer, saves sessions.
|
|
403
|
+
"""
|
|
404
|
+
if (
|
|
405
|
+
self._saved is None
|
|
406
|
+
or not isinstance(session_id, str)
|
|
407
|
+
# its directory's name: never a path elsewhere
|
|
408
|
+
or _SESSION_ID.fullmatch(session_id) is None
|
|
409
|
+
or len(self._sessions) + self._building >= self._max_sessions
|
|
410
|
+
):
|
|
411
|
+
return None
|
|
412
|
+
directory = self._saved / session_id
|
|
413
|
+
if not directory.is_dir():
|
|
414
|
+
return None
|
|
415
|
+
session = WebSession(*self._build_session())
|
|
416
|
+
session.open_saved(directory)
|
|
417
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
418
|
+
self._sessions[session_id] = (session, time.monotonic())
|
|
419
|
+
return session
|
|
420
|
+
|
|
421
|
+
def drop(self, session_id: Any) -> None:
|
|
422
|
+
with self._lock:
|
|
423
|
+
entry = self._sessions.pop(session_id, None)
|
|
424
|
+
if entry is not None:
|
|
425
|
+
entry[0].close()
|
|
426
|
+
|
|
427
|
+
def _drop_expired(self) -> None:
|
|
428
|
+
now = time.monotonic()
|
|
429
|
+
expired = [
|
|
430
|
+
session_id
|
|
431
|
+
for session_id, (_, last_seen) in self._sessions.items()
|
|
432
|
+
if now - last_seen >= self._ttl
|
|
433
|
+
]
|
|
434
|
+
for session_id in expired:
|
|
435
|
+
session, _ = self._sessions.pop(session_id)
|
|
436
|
+
session.close()
|