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,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()