pgwidgets-python 0.1.3__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.
- pgwidgets/__init__.py +19 -0
- pgwidgets/async_/__init__.py +24 -0
- pgwidgets/async_/application.py +660 -0
- pgwidgets/async_/widget.py +171 -0
- pgwidgets/defs.py +11 -0
- pgwidgets/sync/__init__.py +25 -0
- pgwidgets/sync/application.py +796 -0
- pgwidgets/sync/widget.py +166 -0
- pgwidgets_python-0.1.3.dist-info/METADATA +121 -0
- pgwidgets_python-0.1.3.dist-info/RECORD +13 -0
- pgwidgets_python-0.1.3.dist-info/WHEEL +5 -0
- pgwidgets_python-0.1.3.dist-info/licenses/LICENSE.md +29 -0
- pgwidgets_python-0.1.3.dist-info/top_level.txt +1 -0
pgwidgets/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pgwidgets — Python bindings for the pgwidgets JavaScript widget library.
|
|
3
|
+
|
|
4
|
+
Usage (synchronous):
|
|
5
|
+
from pgwidgets.sync import Application
|
|
6
|
+
app = Application()
|
|
7
|
+
W = app.get_widgets()
|
|
8
|
+
top = W.TopLevel(title="Hello", resizable=True)
|
|
9
|
+
...
|
|
10
|
+
app.run()
|
|
11
|
+
|
|
12
|
+
Usage (asynchronous):
|
|
13
|
+
from pgwidgets.async_ import Application
|
|
14
|
+
app = Application()
|
|
15
|
+
W = app.get_widgets()
|
|
16
|
+
top = await W.TopLevel(title="Hello", resizable=True)
|
|
17
|
+
...
|
|
18
|
+
await app.run()
|
|
19
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous pgwidgets API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from pgwidgets.async_ import Application
|
|
6
|
+
|
|
7
|
+
app = Application()
|
|
8
|
+
|
|
9
|
+
@app.on_connect
|
|
10
|
+
async def setup(session):
|
|
11
|
+
W = session.get_widgets()
|
|
12
|
+
top = await W.TopLevel(title="Hello", resizable=True)
|
|
13
|
+
await top.resize(400, 300)
|
|
14
|
+
btn = await W.Button("Click me")
|
|
15
|
+
await btn.on("activated", my_handler)
|
|
16
|
+
await top.set_widget(btn)
|
|
17
|
+
await top.show()
|
|
18
|
+
|
|
19
|
+
await app.run()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pgwidgets.async_.application import Application, Session
|
|
23
|
+
|
|
24
|
+
__all__ = ["Application", "Session"]
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous Application and Session classes.
|
|
3
|
+
|
|
4
|
+
Application — starts a WebSocket server and HTTP file server, manages
|
|
5
|
+
session lifecycle.
|
|
6
|
+
|
|
7
|
+
Session — one per browser connection, owns the widget tree and
|
|
8
|
+
callbacks for that connection.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import mimetypes
|
|
15
|
+
import traceback
|
|
16
|
+
from http.server import SimpleHTTPRequestHandler
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import websockets
|
|
20
|
+
|
|
21
|
+
from pgwidgets_js import get_static_path, get_remote_html
|
|
22
|
+
from pgwidgets.defs import WIDGETS
|
|
23
|
+
from pgwidgets.async_.widget import Widget, build_all_widget_classes
|
|
24
|
+
|
|
25
|
+
_CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Namespace:
|
|
29
|
+
"""Holds widget factory methods as attributes (W.Button, W.Label, etc.)."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Session:
|
|
34
|
+
"""
|
|
35
|
+
A single browser connection with its own widget tree (async).
|
|
36
|
+
|
|
37
|
+
Each browser tab that connects gets its own Session. The session
|
|
38
|
+
owns the widget map, callback registry, and message-ID counter for
|
|
39
|
+
that connection.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
app : Application
|
|
44
|
+
The owning Application.
|
|
45
|
+
ws : websockets.WebSocketServerProtocol
|
|
46
|
+
The WebSocket connection for this session.
|
|
47
|
+
session_id : int
|
|
48
|
+
Unique session identifier.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, app, ws, session_id):
|
|
52
|
+
self._app = app
|
|
53
|
+
self._ws = ws
|
|
54
|
+
self._id = session_id
|
|
55
|
+
|
|
56
|
+
self._next_id = 1
|
|
57
|
+
self._next_wid = 1
|
|
58
|
+
self._pending = {} # msg id -> Future
|
|
59
|
+
self._callbacks = {} # "wid:action" -> handler fn
|
|
60
|
+
self._widget_map = {} # wid -> Widget instance
|
|
61
|
+
|
|
62
|
+
self._widget_classes = app._widget_classes
|
|
63
|
+
self._transfers = {} # transfer_id -> transfer state dict
|
|
64
|
+
|
|
65
|
+
# Per-session lock (for "per_session" and "serialized" modes).
|
|
66
|
+
self._cb_lock = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def id(self):
|
|
70
|
+
"""Unique session identifier."""
|
|
71
|
+
return self._id
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def app(self):
|
|
75
|
+
"""The Application this session belongs to."""
|
|
76
|
+
return self._app
|
|
77
|
+
|
|
78
|
+
# -- Message handling --
|
|
79
|
+
|
|
80
|
+
def _handle_message(self, data):
|
|
81
|
+
msg = json.loads(data)
|
|
82
|
+
if isinstance(msg, list):
|
|
83
|
+
for m in msg:
|
|
84
|
+
self._handle_one(m)
|
|
85
|
+
else:
|
|
86
|
+
self._handle_one(msg)
|
|
87
|
+
|
|
88
|
+
def _handle_one(self, msg):
|
|
89
|
+
msg_type = msg.get("type")
|
|
90
|
+
|
|
91
|
+
if msg_type in ("result", "error"):
|
|
92
|
+
msg_id = msg.get("id")
|
|
93
|
+
future = self._pending.pop(msg_id, None)
|
|
94
|
+
if future and not future.done():
|
|
95
|
+
if msg_type == "error":
|
|
96
|
+
future.set_exception(RuntimeError(msg["error"]))
|
|
97
|
+
else:
|
|
98
|
+
future.set_result(msg)
|
|
99
|
+
|
|
100
|
+
elif msg_type == "file-chunk":
|
|
101
|
+
self._handle_file_chunk(msg)
|
|
102
|
+
|
|
103
|
+
elif msg_type == "callback":
|
|
104
|
+
# If the payload has a transfer_id, stash the metadata —
|
|
105
|
+
# the end callback fires after all chunks arrive.
|
|
106
|
+
if (msg.get("args")
|
|
107
|
+
and isinstance(msg["args"][0], dict)
|
|
108
|
+
and "transfer_id" in msg["args"][0]):
|
|
109
|
+
payload = msg["args"][0]
|
|
110
|
+
tid = payload["transfer_id"]
|
|
111
|
+
action = msg["action"]
|
|
112
|
+
self._transfers[tid] = {
|
|
113
|
+
"wid": msg["wid"],
|
|
114
|
+
"action": action,
|
|
115
|
+
"payload": payload,
|
|
116
|
+
"file_data": {}, # file_index -> [chunk, ...]
|
|
117
|
+
"num_chunks": {}, # file_index -> expected count
|
|
118
|
+
}
|
|
119
|
+
# Fire a start callback with metadata (no file data).
|
|
120
|
+
if action == "drop-end":
|
|
121
|
+
self._dispatch_callback(
|
|
122
|
+
msg["wid"], "drop-start", payload)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
self._dispatch_callback(
|
|
126
|
+
msg["wid"], msg["action"], *msg.get("args", []))
|
|
127
|
+
|
|
128
|
+
def _handle_file_chunk(self, msg):
|
|
129
|
+
"""Handle a file-chunk message: buffer data and fire callbacks."""
|
|
130
|
+
tid = msg["transfer_id"]
|
|
131
|
+
transfer = self._transfers.get(tid)
|
|
132
|
+
if transfer is None:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
fi = msg["file_index"]
|
|
136
|
+
fc = msg["file_count"]
|
|
137
|
+
if fi not in transfer["file_data"]:
|
|
138
|
+
transfer["file_data"][fi] = []
|
|
139
|
+
transfer["num_chunks"][fi] = msg["num_chunks"]
|
|
140
|
+
transfer["file_data"][fi].append(msg["data"])
|
|
141
|
+
|
|
142
|
+
# Check if all files have received all their chunks.
|
|
143
|
+
all_complete = (
|
|
144
|
+
len(transfer["num_chunks"]) == fc
|
|
145
|
+
and all(
|
|
146
|
+
len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
|
|
147
|
+
for i in range(fc)
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Compute byte-level progress from file sizes and chunk counts.
|
|
152
|
+
files_meta = transfer["payload"]["files"]
|
|
153
|
+
transferred_bytes = 0
|
|
154
|
+
total_bytes = 0
|
|
155
|
+
for i, fmeta in enumerate(files_meta):
|
|
156
|
+
fsize = fmeta.get("size", 0)
|
|
157
|
+
total_bytes += fsize
|
|
158
|
+
nc = transfer["num_chunks"].get(i)
|
|
159
|
+
if nc:
|
|
160
|
+
received = len(transfer["file_data"].get(i, []))
|
|
161
|
+
transferred_bytes += fsize * received // nc
|
|
162
|
+
|
|
163
|
+
progress_info = {
|
|
164
|
+
"transfer_id": tid,
|
|
165
|
+
"file_index": fi,
|
|
166
|
+
"chunk_index": msg["chunk_index"],
|
|
167
|
+
"num_chunks": msg["num_chunks"],
|
|
168
|
+
"transferred_bytes": transferred_bytes,
|
|
169
|
+
"total_bytes": total_bytes,
|
|
170
|
+
"complete": all_complete,
|
|
171
|
+
}
|
|
172
|
+
# Map original action to its progress callback name.
|
|
173
|
+
action = transfer["action"]
|
|
174
|
+
progress_action = ("drop-progress" if action == "drop-end"
|
|
175
|
+
else "progress")
|
|
176
|
+
self._dispatch_callback(
|
|
177
|
+
transfer["wid"], progress_action, progress_info)
|
|
178
|
+
|
|
179
|
+
if all_complete:
|
|
180
|
+
# Reassemble file data and fire the original callback.
|
|
181
|
+
payload = transfer["payload"]
|
|
182
|
+
for i, file_meta in enumerate(payload["files"]):
|
|
183
|
+
file_meta["data"] = "".join(
|
|
184
|
+
transfer["file_data"].get(i, []))
|
|
185
|
+
del self._transfers[tid]
|
|
186
|
+
self._dispatch_callback(
|
|
187
|
+
transfer["wid"], action, payload)
|
|
188
|
+
|
|
189
|
+
def _dispatch_callback(self, wid, action, *args):
|
|
190
|
+
"""Dispatch a callback through the configured concurrency mode."""
|
|
191
|
+
key = f"{wid}:{action}"
|
|
192
|
+
handler = self._callbacks.get(key)
|
|
193
|
+
if not handler:
|
|
194
|
+
return
|
|
195
|
+
cb_args = (wid, *args)
|
|
196
|
+
mode = self._app._concurrency
|
|
197
|
+
if mode == "concurrent":
|
|
198
|
+
asyncio.ensure_future(handler(*cb_args))
|
|
199
|
+
elif mode == "per_session":
|
|
200
|
+
asyncio.ensure_future(
|
|
201
|
+
self._serialized_dispatch(
|
|
202
|
+
handler, cb_args, self._cb_lock))
|
|
203
|
+
else: # serialized
|
|
204
|
+
asyncio.ensure_future(
|
|
205
|
+
self._serialized_dispatch(
|
|
206
|
+
handler, cb_args, self._app._cb_lock))
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
async def _serialized_dispatch(handler, args, lock):
|
|
210
|
+
"""Run handler under a lock for serialized execution."""
|
|
211
|
+
async with lock:
|
|
212
|
+
try:
|
|
213
|
+
result = handler(*args)
|
|
214
|
+
if hasattr(result, "__await__"):
|
|
215
|
+
await result
|
|
216
|
+
except Exception:
|
|
217
|
+
traceback.print_exc()
|
|
218
|
+
|
|
219
|
+
async def _send(self, msg):
|
|
220
|
+
"""Send a message and wait for the result."""
|
|
221
|
+
msg_id = self._next_id
|
|
222
|
+
self._next_id += 1
|
|
223
|
+
msg["id"] = msg_id
|
|
224
|
+
loop = asyncio.get_event_loop()
|
|
225
|
+
future = loop.create_future()
|
|
226
|
+
self._pending[msg_id] = future
|
|
227
|
+
await self._ws.send(json.dumps(msg))
|
|
228
|
+
return await future
|
|
229
|
+
|
|
230
|
+
def _alloc_wid(self):
|
|
231
|
+
wid = self._next_wid
|
|
232
|
+
self._next_wid += 1
|
|
233
|
+
return wid
|
|
234
|
+
|
|
235
|
+
# -- Low-level widget API --
|
|
236
|
+
|
|
237
|
+
async def _create(self, js_class, *args):
|
|
238
|
+
"""Create a JS widget and return its wid."""
|
|
239
|
+
wid = self._alloc_wid()
|
|
240
|
+
resolved = [self._resolve_arg(a) for a in args]
|
|
241
|
+
await self._send({
|
|
242
|
+
"type": "create",
|
|
243
|
+
"wid": wid,
|
|
244
|
+
"class": js_class,
|
|
245
|
+
"args": resolved,
|
|
246
|
+
})
|
|
247
|
+
return wid
|
|
248
|
+
|
|
249
|
+
async def _call(self, wid, method, *args):
|
|
250
|
+
"""Call a method on a JS widget."""
|
|
251
|
+
result = await self._send({
|
|
252
|
+
"type": "call",
|
|
253
|
+
"wid": wid,
|
|
254
|
+
"method": method,
|
|
255
|
+
"args": list(args),
|
|
256
|
+
})
|
|
257
|
+
return result.get("value")
|
|
258
|
+
|
|
259
|
+
async def _listen(self, wid, action, handler):
|
|
260
|
+
"""Register a callback listener."""
|
|
261
|
+
key = f"{wid}:{action}"
|
|
262
|
+
self._callbacks[key] = handler
|
|
263
|
+
await self._send({
|
|
264
|
+
"type": "listen",
|
|
265
|
+
"wid": wid,
|
|
266
|
+
"action": action,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
async def _unlisten(self, wid, action):
|
|
270
|
+
"""Remove a callback listener."""
|
|
271
|
+
key = f"{wid}:{action}"
|
|
272
|
+
self._callbacks.pop(key, None)
|
|
273
|
+
await self._send({
|
|
274
|
+
"type": "unlisten",
|
|
275
|
+
"wid": wid,
|
|
276
|
+
"action": action,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
def _resolve_arg(self, arg):
|
|
280
|
+
"""Convert Widget instances to wire refs in outgoing args."""
|
|
281
|
+
if isinstance(arg, Widget):
|
|
282
|
+
return {"__wid__": arg.wid}
|
|
283
|
+
if isinstance(arg, list):
|
|
284
|
+
return [self._resolve_arg(a) for a in arg]
|
|
285
|
+
if isinstance(arg, dict):
|
|
286
|
+
return {k: self._resolve_arg(v) for k, v in arg.items()}
|
|
287
|
+
return arg
|
|
288
|
+
|
|
289
|
+
def _resolve_return(self, val):
|
|
290
|
+
"""Convert wire refs back to Widget instances in return values."""
|
|
291
|
+
if isinstance(val, dict) and "__wid__" in val:
|
|
292
|
+
wid = val["__wid__"]
|
|
293
|
+
return self._widget_map.get(wid, val)
|
|
294
|
+
if isinstance(val, list):
|
|
295
|
+
return [self._resolve_return(v) for v in val]
|
|
296
|
+
return val
|
|
297
|
+
|
|
298
|
+
# -- Widget factory --
|
|
299
|
+
|
|
300
|
+
def get_widgets(self):
|
|
301
|
+
"""Return a namespace with async factory methods for all widget types.
|
|
302
|
+
|
|
303
|
+
Usage:
|
|
304
|
+
Widgets = session.get_widgets()
|
|
305
|
+
btn = await Widgets.Button("Click me")
|
|
306
|
+
"""
|
|
307
|
+
ns = _Namespace()
|
|
308
|
+
session = self
|
|
309
|
+
|
|
310
|
+
for js_class, cls in self._widget_classes.items():
|
|
311
|
+
defn = WIDGETS[js_class]
|
|
312
|
+
|
|
313
|
+
def make_factory(js_cls, widget_cls, widget_defn):
|
|
314
|
+
async def factory(*args, **kwargs):
|
|
315
|
+
pos_names = widget_defn.get("args", [])
|
|
316
|
+
opt_names = widget_defn.get("options", [])
|
|
317
|
+
|
|
318
|
+
js_args = list(args[:len(pos_names)])
|
|
319
|
+
|
|
320
|
+
for i, val in enumerate(args[len(pos_names):]):
|
|
321
|
+
if i < len(opt_names):
|
|
322
|
+
kwargs[opt_names[i]] = val
|
|
323
|
+
|
|
324
|
+
options = {}
|
|
325
|
+
for k in list(kwargs.keys()):
|
|
326
|
+
if k in opt_names:
|
|
327
|
+
options[k] = kwargs.pop(k)
|
|
328
|
+
|
|
329
|
+
if options:
|
|
330
|
+
js_args.append(options)
|
|
331
|
+
|
|
332
|
+
wid = await session._create(js_cls, *js_args)
|
|
333
|
+
widget = widget_cls(session, wid, js_cls)
|
|
334
|
+
session._widget_map[wid] = widget
|
|
335
|
+
|
|
336
|
+
for k, v in kwargs.items():
|
|
337
|
+
setter = f"set_{k}"
|
|
338
|
+
if hasattr(widget, setter):
|
|
339
|
+
await getattr(widget, setter)(v)
|
|
340
|
+
else:
|
|
341
|
+
raise TypeError(
|
|
342
|
+
f"{js_cls}() got unexpected keyword "
|
|
343
|
+
f"argument '{k}'")
|
|
344
|
+
|
|
345
|
+
return widget
|
|
346
|
+
|
|
347
|
+
factory.__name__ = js_cls
|
|
348
|
+
factory.__qualname__ = js_cls
|
|
349
|
+
return factory
|
|
350
|
+
|
|
351
|
+
setattr(ns, js_class, make_factory(js_class, cls, defn))
|
|
352
|
+
|
|
353
|
+
return ns
|
|
354
|
+
|
|
355
|
+
async def make_timer(self, duration=0):
|
|
356
|
+
"""Create a Timer (non-visual) and return its widget wrapper."""
|
|
357
|
+
ns = self.get_widgets()
|
|
358
|
+
return await ns.Timer(duration=duration)
|
|
359
|
+
|
|
360
|
+
async def close(self):
|
|
361
|
+
"""Close this session's WebSocket connection."""
|
|
362
|
+
if self._ws is not None:
|
|
363
|
+
await self._ws.close()
|
|
364
|
+
|
|
365
|
+
def __repr__(self):
|
|
366
|
+
return f"<Session id={self._id}>"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class Application:
|
|
370
|
+
"""
|
|
371
|
+
Main entry point for an async pgwidgets application.
|
|
372
|
+
|
|
373
|
+
Creates a WebSocket server for widget commands and optionally an HTTP
|
|
374
|
+
server to serve the JS/CSS assets. Each browser connection gets its
|
|
375
|
+
own Session.
|
|
376
|
+
|
|
377
|
+
Parameters
|
|
378
|
+
----------
|
|
379
|
+
ws_port : int
|
|
380
|
+
WebSocket server port (default 9500).
|
|
381
|
+
http_port : int
|
|
382
|
+
HTTP file server port (default 9501). Ignored if http_server=False.
|
|
383
|
+
host : str
|
|
384
|
+
Bind address (default '127.0.0.1').
|
|
385
|
+
http_server : bool
|
|
386
|
+
Whether to start the built-in HTTP server (default True).
|
|
387
|
+
Set to False if you are serving the pgwidgets static files
|
|
388
|
+
from your own HTTP/HTTPS server (e.g. FastAPI, aiohttp, nginx).
|
|
389
|
+
concurrency_handling : str
|
|
390
|
+
How widget callbacks are dispatched. One of:
|
|
391
|
+
|
|
392
|
+
``"per_session"`` (default)
|
|
393
|
+
Each session gets its own asyncio.Lock. Callbacks within
|
|
394
|
+
a session are serialized, but different sessions' callbacks
|
|
395
|
+
can interleave at await points.
|
|
396
|
+
``"serialized"``
|
|
397
|
+
All callbacks from all sessions are serialized under a
|
|
398
|
+
single global asyncio.Lock.
|
|
399
|
+
``"concurrent"``
|
|
400
|
+
Callbacks are dispatched freely via ensure_future with no
|
|
401
|
+
serialization.
|
|
402
|
+
max_sessions : int or None
|
|
403
|
+
Maximum number of concurrent sessions (default 1).
|
|
404
|
+
Set to None for unlimited. When the limit is reached, new
|
|
405
|
+
connections are held until an existing session disconnects.
|
|
406
|
+
logger : logging.Logger or None
|
|
407
|
+
Logger for status messages. If None (default), a null logger
|
|
408
|
+
is used and no output is produced.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
|
|
412
|
+
http_server=True, concurrency_handling="per_session",
|
|
413
|
+
max_sessions=1, logger=None):
|
|
414
|
+
if concurrency_handling not in _CONCURRENCY_MODES:
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"concurrency_handling must be one of "
|
|
417
|
+
f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
|
|
418
|
+
self._host = host
|
|
419
|
+
self._ws_port = ws_port
|
|
420
|
+
self._http_port = http_port
|
|
421
|
+
self._use_http_server = http_server
|
|
422
|
+
self._concurrency = concurrency_handling
|
|
423
|
+
self._max_sessions = max_sessions
|
|
424
|
+
|
|
425
|
+
if logger is None:
|
|
426
|
+
logger = logging.getLogger("pgwidgets")
|
|
427
|
+
logger.addHandler(logging.NullHandler())
|
|
428
|
+
self._logger = logger
|
|
429
|
+
|
|
430
|
+
self._favicon_path = Path(get_static_path()) / "icons" / "pgicon.svg"
|
|
431
|
+
|
|
432
|
+
self._sessions = {} # session_id -> Session
|
|
433
|
+
self._next_session_id = 1
|
|
434
|
+
self._on_connect = None # user callback: fn(session)
|
|
435
|
+
self._on_disconnect = None # user callback: fn(session)
|
|
436
|
+
self._session_semaphore = None # initialized in start()
|
|
437
|
+
self._cb_lock = None # for "serialized" mode
|
|
438
|
+
|
|
439
|
+
self._run_future = None # set in run(), cancelled by close()
|
|
440
|
+
self._httpd = None # HTTP server instance
|
|
441
|
+
|
|
442
|
+
# build widget classes once, shared by all sessions
|
|
443
|
+
self._widget_classes = build_all_widget_classes()
|
|
444
|
+
|
|
445
|
+
def on_connect(self, handler):
|
|
446
|
+
"""Register a callback invoked when a new session is created.
|
|
447
|
+
|
|
448
|
+
The handler receives one argument: the Session object.
|
|
449
|
+
Handler can be sync or async. Use it to build the UI::
|
|
450
|
+
|
|
451
|
+
@app.on_connect
|
|
452
|
+
async def setup(session):
|
|
453
|
+
Widgets = session.get_widgets()
|
|
454
|
+
top = await Widgets.TopLevel(title="Hello")
|
|
455
|
+
await top.show()
|
|
456
|
+
|
|
457
|
+
Can also be used as a decorator.
|
|
458
|
+
"""
|
|
459
|
+
self._on_connect = handler
|
|
460
|
+
return handler
|
|
461
|
+
|
|
462
|
+
def on_disconnect(self, handler):
|
|
463
|
+
"""Register a callback invoked when a session disconnects.
|
|
464
|
+
|
|
465
|
+
The handler receives one argument: the Session object.
|
|
466
|
+
Handler can be sync or async. Can also be used as a decorator.
|
|
467
|
+
"""
|
|
468
|
+
self._on_disconnect = handler
|
|
469
|
+
return handler
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def sessions(self):
|
|
473
|
+
"""Dict of active sessions (session_id -> Session)."""
|
|
474
|
+
return dict(self._sessions)
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def url(self):
|
|
478
|
+
if self._use_http_server:
|
|
479
|
+
return f"http://{self._host}:{self._http_port}/"
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def static_path(self):
|
|
484
|
+
"""Path to the pgwidgets static files directory."""
|
|
485
|
+
return get_static_path()
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def remote_html(self):
|
|
489
|
+
"""Path to the remote.html connector page."""
|
|
490
|
+
return get_remote_html()
|
|
491
|
+
|
|
492
|
+
def set_favicon(self, path):
|
|
493
|
+
"""Set a custom favicon for the built-in HTTP server.
|
|
494
|
+
|
|
495
|
+
Call this before start() to override the default pgwidgets icon.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
path : str or Path
|
|
500
|
+
Path to an image file (SVG, PNG, ICO, etc.).
|
|
501
|
+
"""
|
|
502
|
+
self._favicon_path = Path(path)
|
|
503
|
+
|
|
504
|
+
# -- WebSocket handling --
|
|
505
|
+
|
|
506
|
+
async def _ws_handler(self, ws):
|
|
507
|
+
# If max_sessions is set, wait for a slot.
|
|
508
|
+
if self._session_semaphore is not None:
|
|
509
|
+
await self._session_semaphore.acquire()
|
|
510
|
+
|
|
511
|
+
# Allocate session.
|
|
512
|
+
session_id = self._next_session_id
|
|
513
|
+
self._next_session_id += 1
|
|
514
|
+
|
|
515
|
+
session = Session(self, ws, session_id)
|
|
516
|
+
|
|
517
|
+
# Set up per-session lock if needed.
|
|
518
|
+
if self._concurrency == "per_session":
|
|
519
|
+
session._cb_lock = asyncio.Lock()
|
|
520
|
+
|
|
521
|
+
# Init handshake: reset the browser to a clean slate.
|
|
522
|
+
await ws.send(json.dumps({"type": "init", "id": 0}))
|
|
523
|
+
await ws.recv() # wait for ack
|
|
524
|
+
|
|
525
|
+
self._sessions[session_id] = session
|
|
526
|
+
self._logger.info(f"Session {session_id} connected.")
|
|
527
|
+
|
|
528
|
+
# Launch on_connect as a concurrent task so it runs alongside
|
|
529
|
+
# the message loop below — on_connect sends widget commands
|
|
530
|
+
# whose responses must be read by the message loop.
|
|
531
|
+
if self._on_connect:
|
|
532
|
+
result = self._on_connect(session)
|
|
533
|
+
if hasattr(result, "__await__"):
|
|
534
|
+
asyncio.ensure_future(result)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
async for message in ws:
|
|
538
|
+
session._handle_message(message)
|
|
539
|
+
finally:
|
|
540
|
+
self._sessions.pop(session_id, None)
|
|
541
|
+
|
|
542
|
+
self._logger.info(f"Session {session_id} disconnected.")
|
|
543
|
+
|
|
544
|
+
if self._on_disconnect:
|
|
545
|
+
result = self._on_disconnect(session)
|
|
546
|
+
if hasattr(result, "__await__"):
|
|
547
|
+
await result
|
|
548
|
+
|
|
549
|
+
if self._session_semaphore is not None:
|
|
550
|
+
self._session_semaphore.release()
|
|
551
|
+
|
|
552
|
+
# -- HTTP server --
|
|
553
|
+
|
|
554
|
+
async def _start_http_server(self):
|
|
555
|
+
"""Start a simple HTTP server to serve the JS/CSS assets."""
|
|
556
|
+
static_path = str(get_static_path())
|
|
557
|
+
remote_html = get_remote_html()
|
|
558
|
+
favicon_path = self._favicon_path
|
|
559
|
+
ws_host = self._host
|
|
560
|
+
ws_port = self._ws_port
|
|
561
|
+
|
|
562
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
563
|
+
def __init__(self, *a, **kw):
|
|
564
|
+
super().__init__(*a, directory=static_path, **kw)
|
|
565
|
+
|
|
566
|
+
def do_GET(self):
|
|
567
|
+
if self.path == "/" or self.path == "/index.html":
|
|
568
|
+
html = remote_html.read_text(encoding="utf-8")
|
|
569
|
+
inject = (
|
|
570
|
+
f'<script>window.PGWIDGETS_WS_URL'
|
|
571
|
+
f' = "ws://{ws_host}:{ws_port}";</script>\n')
|
|
572
|
+
html = html.replace("<head>",
|
|
573
|
+
"<head>\n" + inject, 1)
|
|
574
|
+
body = html.encode("utf-8")
|
|
575
|
+
self.send_response(200)
|
|
576
|
+
self.send_header("Content-Type",
|
|
577
|
+
"text/html; charset=utf-8")
|
|
578
|
+
self.send_header("Content-Length", str(len(body)))
|
|
579
|
+
self.end_headers()
|
|
580
|
+
self.wfile.write(body)
|
|
581
|
+
return
|
|
582
|
+
if self.path == "/favicon.svg" or self.path == "/favicon.ico":
|
|
583
|
+
if favicon_path and favicon_path.is_file():
|
|
584
|
+
mime, _ = mimetypes.guess_type(str(favicon_path))
|
|
585
|
+
self.send_response(200)
|
|
586
|
+
self.send_header("Content-Type",
|
|
587
|
+
mime or "image/svg+xml")
|
|
588
|
+
self.end_headers()
|
|
589
|
+
self.wfile.write(favicon_path.read_bytes())
|
|
590
|
+
else:
|
|
591
|
+
self.send_error(404)
|
|
592
|
+
return
|
|
593
|
+
super().do_GET()
|
|
594
|
+
|
|
595
|
+
def log_message(self, format, *args):
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
loop = asyncio.get_event_loop()
|
|
599
|
+
import http.server
|
|
600
|
+
self._httpd = http.server.HTTPServer(
|
|
601
|
+
(self._host, self._http_port), Handler)
|
|
602
|
+
await loop.run_in_executor(None, self._httpd.serve_forever)
|
|
603
|
+
|
|
604
|
+
# -- Main loop --
|
|
605
|
+
|
|
606
|
+
async def start(self):
|
|
607
|
+
"""Start the WebSocket server (and HTTP server if enabled).
|
|
608
|
+
|
|
609
|
+
Call this after construction and any customisation. Subclasses
|
|
610
|
+
can override to add extra setup before or after the servers start.
|
|
611
|
+
"""
|
|
612
|
+
if self._max_sessions is not None:
|
|
613
|
+
self._session_semaphore = asyncio.Semaphore(
|
|
614
|
+
self._max_sessions)
|
|
615
|
+
|
|
616
|
+
if self._concurrency == "serialized":
|
|
617
|
+
self._cb_lock = asyncio.Lock()
|
|
618
|
+
|
|
619
|
+
if self._use_http_server:
|
|
620
|
+
self._logger.info(f"Open {self.url} in a browser to connect.")
|
|
621
|
+
asyncio.ensure_future(self._start_http_server())
|
|
622
|
+
self._logger.info(
|
|
623
|
+
f"WebSocket on ws://{self._host}:{self._ws_port}")
|
|
624
|
+
|
|
625
|
+
self._ws_server = await websockets.serve(
|
|
626
|
+
self._ws_handler, self._host, self._ws_port)
|
|
627
|
+
|
|
628
|
+
async def close(self):
|
|
629
|
+
"""Close all sessions and shut down the application.
|
|
630
|
+
|
|
631
|
+
Causes run() to return so the program can exit cleanly.
|
|
632
|
+
"""
|
|
633
|
+
# Close all active sessions.
|
|
634
|
+
for session in list(self._sessions.values()):
|
|
635
|
+
await session.close()
|
|
636
|
+
|
|
637
|
+
# Stop the WebSocket server.
|
|
638
|
+
if hasattr(self, '_ws_server'):
|
|
639
|
+
self._ws_server.close()
|
|
640
|
+
await self._ws_server.wait_closed()
|
|
641
|
+
|
|
642
|
+
# Stop the HTTP server (runs in a thread).
|
|
643
|
+
if self._httpd is not None:
|
|
644
|
+
self._httpd.shutdown()
|
|
645
|
+
|
|
646
|
+
# Cancel the run() future so run() returns.
|
|
647
|
+
if self._run_future is not None and not self._run_future.done():
|
|
648
|
+
self._run_future.cancel()
|
|
649
|
+
|
|
650
|
+
async def run(self):
|
|
651
|
+
"""Start servers and run forever. Ctrl-C to exit."""
|
|
652
|
+
await self.start()
|
|
653
|
+
self._run_future = asyncio.get_event_loop().create_future()
|
|
654
|
+
try:
|
|
655
|
+
await self._run_future
|
|
656
|
+
except asyncio.CancelledError:
|
|
657
|
+
pass
|
|
658
|
+
finally:
|
|
659
|
+
await self.close()
|
|
660
|
+
self._logger.info("Shutting down.")
|