pgwidgets-python 0.2.3__tar.gz → 0.3.0__tar.gz
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_python-0.2.3 → pgwidgets_python-0.3.0}/PKG-INFO +1 -1
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/WhatsNew.rst +70 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/architecture.rst +3 -1
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/callbacks.rst +19 -13
- pgwidgets_python-0.3.0/docs/utilities.rst +81 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/widgets.rst +6 -0
- pgwidgets_python-0.3.0/examples/flask-multi-process/README.md +72 -0
- pgwidgets_python-0.3.0/examples/flask-multi-process/server.py +203 -0
- pgwidgets_python-0.3.0/examples/flask-multi-process/user_app.py +230 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/__init__.py +2 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/application.py +144 -27
- pgwidgets_python-0.3.0/pgwidgets/buffer.py +125 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/method_types.py +34 -2
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/application.py +183 -28
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/PKG-INFO +1 -1
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/SOURCES.txt +4 -0
- pgwidgets_python-0.2.3/docs/utilities.rst +0 -40
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.flake8 +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.github/workflows/tests.yml +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.gitignore +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.readthedocs.yaml +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/LICENSE.md +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/README.md +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/Makefile +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/async.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/index.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/sync.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/async.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/conf.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/extras.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/getting-started.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/index.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/subclassing.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/sync.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/web-servers.rst +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/README.md +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/all_widgets.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/all_widgets_async.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_async.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_sync.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_treeview.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/_json.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/Widgets.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/__init__.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/widget.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/callbacks.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/defs.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/extras/__init__.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/extras/file_browser.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/Widgets.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/__init__.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/widget.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/requires.txt +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/top_level.txt +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pyproject.toml +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/setup.cfg +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/test_pg2.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/__init__.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_defs.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_extras.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_protocol.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_reconstruct.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_session.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_stateful.py +0 -0
- {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_widget_classes.py +0 -0
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
What's New
|
|
2
2
|
==========
|
|
3
3
|
|
|
4
|
+
Recent changes — since ``v0.2.3``
|
|
5
|
+
---------------------------------
|
|
6
|
+
|
|
7
|
+
Chunked binary transport (both directions)
|
|
8
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
9
|
+
|
|
10
|
+
``Session`` gained ``_send_binary_chunked(wid, method, args, data,
|
|
11
|
+
chunk_size=512KB, shape=None, dtype=None)`` and a matching
|
|
12
|
+
incoming-binary path. Large payloads automatically use the
|
|
13
|
+
chunked transport — :meth:`Image.set_binary_image` (and the
|
|
14
|
+
reconstruction replay of binary state) auto-pick chunked above a
|
|
15
|
+
1 MiB threshold. ``Session._handle_file_chunk`` was rewritten as
|
|
16
|
+
``_handle_binary_chunk`` to consume the new binary-chunk
|
|
17
|
+
envelope: chunks store by ``chunk_index`` (robust to ordering)
|
|
18
|
+
and reassemble as ``bytes``.
|
|
19
|
+
|
|
20
|
+
**Pre-1.0 API break**: drop-end / FileDialog ``activated``
|
|
21
|
+
handlers now receive each file's ``data`` field as raw ``bytes``
|
|
22
|
+
(was a ``"data:<mime>;base64,…"`` string). Each file dict now
|
|
23
|
+
also carries an ``encoding`` field (currently ``"bytes"``;
|
|
24
|
+
reserved for future ``"base64"``).
|
|
25
|
+
|
|
26
|
+
:class:`pgwidgets.Buffer` for typed-array delivery
|
|
27
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
28
|
+
|
|
29
|
+
New :class:`pgwidgets.Buffer` wraps raw bytes with ``shape`` and
|
|
30
|
+
``dtype`` metadata. When passed to a binary-aware method, the
|
|
31
|
+
bytes ride the chunked transport and the receiver on the JS side
|
|
32
|
+
gets a typed array of the right shape (``Uint8Array``,
|
|
33
|
+
``Float32Array``, …) without the per-method casting that used to
|
|
34
|
+
be necessary::
|
|
35
|
+
|
|
36
|
+
from pgwidgets import Buffer
|
|
37
|
+
pixels = ... # 2048 * 2048 * 4 bytes
|
|
38
|
+
viewer.load_buffer(
|
|
39
|
+
Buffer(pixels, shape=(2048, 2048, 4), dtype="uint8"),
|
|
40
|
+
[2048, 2048], cache)
|
|
41
|
+
|
|
42
|
+
Supported dtypes: ``uint8`` / ``uint16`` / ``uint32`` / ``int8``
|
|
43
|
+
/ ``int16`` / ``int32`` / ``float32`` / ``float64``.
|
|
44
|
+
Construction validates that ``len(data) == prod(shape) *
|
|
45
|
+
itemsize``.
|
|
46
|
+
|
|
47
|
+
``Application(ws_sock=…)`` for race-free port allocation
|
|
48
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
49
|
+
|
|
50
|
+
:class:`Application` accepts an optional pre-bound TCP socket via
|
|
51
|
+
``ws_sock=``. When provided, ``Application`` adopts the socket
|
|
52
|
+
directly (hands it to ``websockets.serve(sock=...)``) instead of
|
|
53
|
+
binding ``(host, ws_port)`` itself, and reads ``ws_port`` back
|
|
54
|
+
from the socket for logging. The motivation is TOCTOU-free port
|
|
55
|
+
allocation: the caller can bind in advance, hand the socket in,
|
|
56
|
+
and never release the port between "find" and "use" — no other
|
|
57
|
+
process can grab it in the gap. The existing ``ws_port=`` API
|
|
58
|
+
is unchanged.
|
|
59
|
+
|
|
60
|
+
Flask multi-process demo
|
|
61
|
+
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
62
|
+
|
|
63
|
+
New ``examples/flask-multi-process/`` example: one pgwidgets
|
|
64
|
+
``Application`` per visitor, each in its own OS process with its
|
|
65
|
+
own WebSocket port. Demonstrates the new ``ws_sock=`` parameter
|
|
66
|
+
(child binds, never releases), idle reaping via a grace timer on
|
|
67
|
+
``app.on_disconnect`` that handles the subtle case where
|
|
68
|
+
``app.on_connect`` doesn't fire on session-reconnect (the wrapper
|
|
69
|
+
also hooks ``session.add_connection``), and serving pgwidgets-js's
|
|
70
|
+
static assets from the pip-installed package rather than a CDN.
|
|
71
|
+
|
|
72
|
+
----
|
|
73
|
+
|
|
4
74
|
Recent changes — since ``v0.2.1``
|
|
5
75
|
---------------------------------
|
|
6
76
|
|
|
@@ -68,7 +68,9 @@ All messages are JSON objects with a ``type`` field.
|
|
|
68
68
|
- ``{"type": "error", "id": 1, "error": "..."}`` -- method error.
|
|
69
69
|
- ``{"type": "callback", "wid": 1, "action": "activated", "args": [...]}`` --
|
|
70
70
|
user interaction.
|
|
71
|
-
- ``{"type": "
|
|
71
|
+
- ``{"type": "binary-chunk", ...}`` -- one chunk of a chunked binary
|
|
72
|
+
transfer (used in both directions; file uploads, large image
|
|
73
|
+
payloads, etc.; see :doc:`callbacks`).
|
|
72
74
|
|
|
73
75
|
Session Model
|
|
74
76
|
-------------
|
|
@@ -127,9 +127,10 @@ The protocol works as follows:
|
|
|
127
127
|
metadata (names, sizes, MIME types) but no file data.
|
|
128
128
|
2. The framework fires a **drop-start** callback with the metadata so you
|
|
129
129
|
can show progress UI.
|
|
130
|
-
3. The browser sends ``
|
|
130
|
+
3. The browser sends ``binary-chunk`` JSON headers, each followed by a raw
|
|
131
|
+
binary WebSocket frame carrying the chunk's bytes (no base64 inflation).
|
|
131
132
|
4. The framework fires **drop-progress** callbacks with transfer status.
|
|
132
|
-
5. When all chunks arrive, the framework reassembles the
|
|
133
|
+
5. When all chunks arrive, the framework reassembles the bytes and fires the
|
|
133
134
|
**drop-end** callback with the complete payload.
|
|
134
135
|
|
|
135
136
|
drop-start
|
|
@@ -175,22 +176,28 @@ drop-end
|
|
|
175
176
|
~~~~~~~~
|
|
176
177
|
|
|
177
178
|
Fires when all file data has been received. The handler receives the full
|
|
178
|
-
payload
|
|
179
|
+
payload; each file dict carries:
|
|
180
|
+
|
|
181
|
+
- ``name`` (str) — original filename
|
|
182
|
+
- ``size`` (int) — byte size
|
|
183
|
+
- ``type`` (str) — MIME type from the browser (e.g. ``"image/png"``)
|
|
184
|
+
- ``encoding`` (str) — wire format of ``data``. Currently always
|
|
185
|
+
``"bytes"`` (``data`` is raw bytes); reserved for ``"base64"`` if a
|
|
186
|
+
future sender chooses to deliver the file body without binary
|
|
187
|
+
reassembly.
|
|
188
|
+
- ``data`` (bytes or None) — file contents. ``None`` on read error;
|
|
189
|
+
in that case an additional ``error`` field carries the message.
|
|
179
190
|
|
|
180
191
|
.. code-block:: python
|
|
181
192
|
|
|
182
|
-
import base64
|
|
183
|
-
|
|
184
193
|
def on_drop(payload):
|
|
185
194
|
for f in payload["files"]:
|
|
195
|
+
if f["encoding"] != "bytes":
|
|
196
|
+
continue # currently only "bytes" is emitted
|
|
186
197
|
name = f["name"]
|
|
187
198
|
size = f["size"]
|
|
188
|
-
mime = f["type"]
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# Decode the file content
|
|
192
|
-
b64 = data_uri.split(",", 1)[1]
|
|
193
|
-
content = base64.b64decode(b64)
|
|
199
|
+
mime = f["type"] # e.g. "image/png"
|
|
200
|
+
content = f["data"] # bytes
|
|
194
201
|
print(f"Received {name}: {len(content)} bytes")
|
|
195
202
|
|
|
196
203
|
widget.on("drop-end", on_drop)
|
|
@@ -210,8 +217,7 @@ Example: File Drop Zone
|
|
|
210
217
|
|
|
211
218
|
def on_drop(payload):
|
|
212
219
|
f = payload["files"][0]
|
|
213
|
-
|
|
214
|
-
text = base64.b64decode(b64).decode("utf-8", errors="replace")
|
|
220
|
+
text = f["data"].decode("utf-8", errors="replace")
|
|
215
221
|
textarea.set_text(text)
|
|
216
222
|
drop_label.set_text(f"Loaded: {f['name']}")
|
|
217
223
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Utilities
|
|
2
|
+
=========
|
|
3
|
+
|
|
4
|
+
The ``Widget`` base class exposes a few static utility methods that are
|
|
5
|
+
useful when working with local files on the server side.
|
|
6
|
+
|
|
7
|
+
.. code-block:: python
|
|
8
|
+
|
|
9
|
+
from pgwidgets.sync.widget import Widget # or pgwidgets.async_.widget
|
|
10
|
+
|
|
11
|
+
to_data_uri
|
|
12
|
+
------------
|
|
13
|
+
|
|
14
|
+
.. code-block:: python
|
|
15
|
+
|
|
16
|
+
Widget.to_data_uri(path)
|
|
17
|
+
|
|
18
|
+
Read a local file, base64-encode its contents, and return a
|
|
19
|
+
``data:<mime>;base64,…`` string. The MIME type is guessed from the file
|
|
20
|
+
extension (falls back to ``application/octet-stream``).
|
|
21
|
+
|
|
22
|
+
The returned string can be passed directly to any widget method that
|
|
23
|
+
expects a URL, such as ``set_image()``, ``set_icon()``, or
|
|
24
|
+
``add_cursor()``.
|
|
25
|
+
|
|
26
|
+
.. code-block:: python
|
|
27
|
+
|
|
28
|
+
# Load a local image into an Image widget
|
|
29
|
+
img.set_image(Widget.to_data_uri("/path/to/photo.png"))
|
|
30
|
+
|
|
31
|
+
# Set a button icon from a local file
|
|
32
|
+
btn.set_icon(Widget.to_data_uri("/path/to/icon.svg"))
|
|
33
|
+
|
|
34
|
+
.. note::
|
|
35
|
+
|
|
36
|
+
Methods listed in the ``_FILE_ARG_METHODS`` set (currently
|
|
37
|
+
``set_icon``, ``set_image``, and ``set_icon_gutter``) already detect
|
|
38
|
+
local file paths and convert them automatically. Use
|
|
39
|
+
``to_data_uri()`` explicitly when you need the data URI for other
|
|
40
|
+
purposes or want to cache/reuse it.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Buffer
|
|
44
|
+
------
|
|
45
|
+
|
|
46
|
+
.. code-block:: python
|
|
47
|
+
|
|
48
|
+
from pgwidgets import Buffer
|
|
49
|
+
|
|
50
|
+
pixels = numpy_array.tobytes() # 2048 * 2048 * 4 bytes
|
|
51
|
+
buf = Buffer(pixels,
|
|
52
|
+
shape=(2048, 2048, 4),
|
|
53
|
+
dtype="uint8")
|
|
54
|
+
viewer.load_buffer(buf, [2048, 2048], cache)
|
|
55
|
+
|
|
56
|
+
A ``Buffer`` wraps raw ``bytes`` with ``shape`` and ``dtype``
|
|
57
|
+
metadata, so a method receiving the buffer on the JavaScript side
|
|
58
|
+
gets a properly-sized typed array (``Uint8Array``, ``Float32Array``,
|
|
59
|
+
…) instead of a raw ``ArrayBuffer``. Use it for image pixel data,
|
|
60
|
+
scientific arrays, vertex buffers — anything where the receiver
|
|
61
|
+
needs to know how to interpret the bytes without a hand-rolled
|
|
62
|
+
convention per method.
|
|
63
|
+
|
|
64
|
+
A ``Buffer`` always ships via the chunked binary transport
|
|
65
|
+
(``binary-call-chunked`` + ``binary-chunk`` messages), so multi-
|
|
66
|
+
megabyte payloads stream in 512 KiB chunks and don't block other
|
|
67
|
+
WebSocket traffic. ``shape`` and ``dtype`` ride on the announce
|
|
68
|
+
header — see :doc:`architecture`.
|
|
69
|
+
|
|
70
|
+
Supported dtypes:
|
|
71
|
+
|
|
72
|
+
- ``"uint8"`` / ``"uint16"`` / ``"uint32"``
|
|
73
|
+
- ``"int8"`` / ``"int16"`` / ``"int32"``
|
|
74
|
+
- ``"float32"`` / ``"float64"``
|
|
75
|
+
|
|
76
|
+
Methods that don't opt into ``Buffer`` keep receiving plain
|
|
77
|
+
``bytes`` — :class:`Buffer` is purely additive. Construction
|
|
78
|
+
validates that the byte length equals
|
|
79
|
+
``prod(shape) * dtype.itemsize``; pass ``shape=(len(data),)`` and
|
|
80
|
+
``dtype="uint8"`` if you just want a flat byte buffer with a
|
|
81
|
+
known length.
|
|
@@ -596,6 +596,12 @@ are pushed per second. ``format`` is one of ``'jpeg'``, ``'png'``,
|
|
|
596
596
|
The latest frame is also stored in widget state so it is replayed
|
|
597
597
|
on reconnect.
|
|
598
598
|
|
|
599
|
+
Payloads larger than ~1 MiB automatically use the *chunked* binary
|
|
600
|
+
transport (``binary-call-chunked`` + per-chunk ``binary-chunk``
|
|
601
|
+
messages) so the WebSocket can interleave control traffic while a
|
|
602
|
+
large frame streams. No API change for callers — pass ``bytes``
|
|
603
|
+
and the framework picks the right transport.
|
|
604
|
+
|
|
599
605
|
Canvas
|
|
600
606
|
~~~~~~
|
|
601
607
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Flask multi-process pgwidgets demo
|
|
2
|
+
|
|
3
|
+
One pgwidgets `Application` per visitor, each in a fresh OS process
|
|
4
|
+
with its own WebSocket port.
|
|
5
|
+
|
|
6
|
+
- **`server.py`** — Flask front-end. Each request to `/` spawns a
|
|
7
|
+
child process running a fresh `Application`, waits for the child
|
|
8
|
+
to report its WebSocket port through a `multiprocessing.Queue`,
|
|
9
|
+
then returns an HTML page whose `RemoteInterface` connects back
|
|
10
|
+
to it.
|
|
11
|
+
- **`user_app.py`** — The per-visitor `Application` definition.
|
|
12
|
+
The child itself binds an ephemeral TCP socket, hands it directly
|
|
13
|
+
to `Application(ws_sock=...)` (so the port is held continuously —
|
|
14
|
+
no race with another process), and reports the resulting port
|
|
15
|
+
back through the queue. Edit the body of `setup(session)` to
|
|
16
|
+
build your UI. Can also be run standalone (`python user_app.py`)
|
|
17
|
+
for quick UI debugging without Flask in the loop; the port is
|
|
18
|
+
auto-allocated and logged.
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pip install Flask pgwidgets-python pgwidgets-js
|
|
24
|
+
python server.py
|
|
25
|
+
# browse to http://localhost:5000/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every browser tab triggers a brand-new process. When the tab is
|
|
29
|
+
closed (or the browser navigates away), the child waits a grace
|
|
30
|
+
period — `IDLE_GRACE_SECONDS` in `user_app.py`, default 5 minutes —
|
|
31
|
+
to absorb page refreshes, brief network drops, and the user
|
|
32
|
+
closing/reopening a laptop, then exits. Reconnecting within the
|
|
33
|
+
grace window cancels the pending shutdown.
|
|
34
|
+
|
|
35
|
+
### Listening on other interfaces
|
|
36
|
+
|
|
37
|
+
By default Flask and each per-visitor WebSocket bind to
|
|
38
|
+
``127.0.0.1`` (loopback only). To accept connections from other
|
|
39
|
+
machines:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
python server.py --host 0.0.0.0 --port 8080
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The bind interface and the WebSocket URL are decoupled — the
|
|
46
|
+
browser is given a ``ws://`` URL derived from the same hostname it
|
|
47
|
+
used to reach Flask, so the same server can be reached via
|
|
48
|
+
``localhost``, by IP address, or by DNS name with no further
|
|
49
|
+
configuration.
|
|
50
|
+
|
|
51
|
+
## What the browser loads
|
|
52
|
+
|
|
53
|
+
Each HTML response embeds the per-visitor WebSocket URL. The
|
|
54
|
+
pgwidgets-js bundle (`Widgets.js`, `Widgets.css`, the `modules/`
|
|
55
|
+
subtree, icons) is served by Flask itself, from the pip-installed
|
|
56
|
+
`pgwidgets-js` Python package — `pgwidgets_js.get_static_path()`
|
|
57
|
+
returns the directory and a `/pgwidgets-js/<path>` route in
|
|
58
|
+
`server.py` exposes it. No CDN, no network dependency at runtime,
|
|
59
|
+
and the version on the wire always matches the one in your venv.
|
|
60
|
+
|
|
61
|
+
To point at an in-progress working copy of pgwidgets-js instead of
|
|
62
|
+
the installed package, either ``pip install -e .`` from the
|
|
63
|
+
pgwidgets-js source tree (so `get_static_path()` resolves to the
|
|
64
|
+
working copy), or replace `PGWIDGETS_JS_ROOT` in `server.py` with
|
|
65
|
+
a hard path to that tree's `static/` directory.
|
|
66
|
+
|
|
67
|
+
## Caveats
|
|
68
|
+
|
|
69
|
+
- **Flask debug mode is off.** The reloader spawns a second
|
|
70
|
+
process, which combined with `multiprocessing` can produce
|
|
71
|
+
surprising behavior on Windows / macOS. Restart `server.py` by
|
|
72
|
+
hand after edits.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Flask + pgwidgets-python multi-process demo.
|
|
2
|
+
|
|
3
|
+
Each visitor to ``/`` is served a fresh pgwidgets Application
|
|
4
|
+
running in its own OS process with its own WebSocket port — so
|
|
5
|
+
sessions are fully isolated, and a slow handler in one session
|
|
6
|
+
can't block another.
|
|
7
|
+
|
|
8
|
+
Architecture::
|
|
9
|
+
|
|
10
|
+
browser ┌─────────────────────┐ HTTP / ┌─────────────┐
|
|
11
|
+
──────▶│ Flask (port 5000) │ ─────────────────▶│ user_app │
|
|
12
|
+
│ picks ws_port │ spawn Process │ process 1 │
|
|
13
|
+
│ spawns child │ ◀───────────── ws on 35211 ─────┘
|
|
14
|
+
│ returns HTML │ ┌─────────────┐
|
|
15
|
+
└─────────────────────┘ │ user_app │
|
|
16
|
+
│ process 2 │
|
|
17
|
+
(one child per request) ws on 41922 ────────┘
|
|
18
|
+
|
|
19
|
+
Run::
|
|
20
|
+
|
|
21
|
+
pip install Flask pgwidgets-python
|
|
22
|
+
python server.py
|
|
23
|
+
# browse to http://localhost:5000/
|
|
24
|
+
|
|
25
|
+
Each browser tab triggers a fresh per-visitor process.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import logging
|
|
30
|
+
import multiprocessing
|
|
31
|
+
import queue as _queue_mod
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import pgwidgets_js
|
|
36
|
+
from flask import (Flask, render_template_string, request,
|
|
37
|
+
send_from_directory)
|
|
38
|
+
|
|
39
|
+
# Make the sibling user_app.py importable regardless of the
|
|
40
|
+
# directory ``server.py`` is launched from. multiprocessing.spawn
|
|
41
|
+
# (the macOS / Windows default) re-imports the target's module in
|
|
42
|
+
# the child process, so the path must already be set up before
|
|
43
|
+
# Process.start() is called.
|
|
44
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
45
|
+
|
|
46
|
+
from user_app import build_app # noqa: E402 (import after sys.path tweak)
|
|
47
|
+
|
|
48
|
+
# Directory containing the bundled pgwidgets-js static assets
|
|
49
|
+
# (Widgets.js, Widgets.css, modules/, icons/, …). Served from
|
|
50
|
+
# Flask so the page does not depend on a CDN being reachable.
|
|
51
|
+
PGWIDGETS_JS_ROOT = pgwidgets_js.get_static_path()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- Flask boilerplate -----------------------------------------------
|
|
55
|
+
|
|
56
|
+
app = Flask(__name__)
|
|
57
|
+
log = logging.getLogger("flask-demo.server")
|
|
58
|
+
|
|
59
|
+
# Bind interface for both Flask and the per-visitor WebSocket
|
|
60
|
+
# children. Set by main() from --host; module-level default so
|
|
61
|
+
# ``flask run server`` / WSGI runners still work without CLI args.
|
|
62
|
+
_BIND_HOST = "127.0.0.1"
|
|
63
|
+
|
|
64
|
+
# Loaded HTML template — embeds the per-visitor WebSocket URL.
|
|
65
|
+
# pgwidgets-js is served from the local pip-installed package via
|
|
66
|
+
# the /pgwidgets-js/<path> route below, so the page doesn't depend
|
|
67
|
+
# on network access to a CDN and always matches whatever version
|
|
68
|
+
# of pgwidgets-js is pinned in your environment.
|
|
69
|
+
HTML_TEMPLATE = """\
|
|
70
|
+
<!DOCTYPE html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="UTF-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
75
|
+
<title>pgwidgets demo</title>
|
|
76
|
+
<link rel="stylesheet" href="/pgwidgets-js/Widgets.css">
|
|
77
|
+
<script type="importmap">
|
|
78
|
+
{"imports": {"pgwidgets": "/pgwidgets-js/Widgets.js"}}
|
|
79
|
+
</script>
|
|
80
|
+
<style>body { margin: 0; }</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<script type="module">
|
|
84
|
+
import { Widgets } from "pgwidgets";
|
|
85
|
+
new Widgets.RemoteInterface(Widgets, {
|
|
86
|
+
url: "ws://{{ ws_host }}:{{ ws_port }}",
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Keep references to spawned processes so they aren't garbage-
|
|
95
|
+
# collected (Process.__del__ doesn't kill the child, but we still
|
|
96
|
+
# want a tidy registry for shutdown). Production code should also
|
|
97
|
+
# reap idle children based on whether their pgwidgets Application
|
|
98
|
+
# has any live connections.
|
|
99
|
+
_children = []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.route("/")
|
|
103
|
+
def index():
|
|
104
|
+
"""Spawn a fresh per-visitor Application and return the HTML.
|
|
105
|
+
|
|
106
|
+
Port allocation happens inside the child: it binds an ephemeral
|
|
107
|
+
TCP socket, holds it, and reports the port back through a
|
|
108
|
+
``Queue``. The bound socket is then handed straight to
|
|
109
|
+
``Application(ws_sock=…)``, so the port is never released
|
|
110
|
+
between "discover" and "use" — no TOCTOU race with any other
|
|
111
|
+
process.
|
|
112
|
+
|
|
113
|
+
The WebSocket *bind* interface is whatever ``--host`` was set
|
|
114
|
+
to (default 127.0.0.1). The WebSocket *URL* given to the
|
|
115
|
+
browser is derived from ``request.host`` — i.e. whichever
|
|
116
|
+
hostname/IP the browser used to reach Flask — so the same
|
|
117
|
+
server can be reached as ``localhost``, by IP, or by DNS name
|
|
118
|
+
without configuration.
|
|
119
|
+
"""
|
|
120
|
+
comm_queue = multiprocessing.Queue()
|
|
121
|
+
child = multiprocessing.Process(
|
|
122
|
+
target=build_app, args=(comm_queue, _BIND_HOST), daemon=True,
|
|
123
|
+
name="pgwidgets-app",
|
|
124
|
+
)
|
|
125
|
+
child.start()
|
|
126
|
+
_children.append(child)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
ws_port = comm_queue.get(timeout=5.0)
|
|
130
|
+
except _queue_mod.Empty:
|
|
131
|
+
log.error("child pid=%d did not report its port within timeout",
|
|
132
|
+
child.pid)
|
|
133
|
+
return ("pgwidgets app process failed to start. "
|
|
134
|
+
"Check the server log.", 503)
|
|
135
|
+
|
|
136
|
+
# Extract hostname portion of the Host header (e.g.
|
|
137
|
+
# "example.com:5000" -> "example.com"). rsplit handles literal
|
|
138
|
+
# IPv4 + port; IPv6 hosts (``[::1]:5000``) aren't preserved here
|
|
139
|
+
# — extend with urllib.parse.urlsplit if you need them.
|
|
140
|
+
ws_host = request.host.rsplit(":", 1)[0] if ":" in request.host \
|
|
141
|
+
else request.host
|
|
142
|
+
|
|
143
|
+
log.info("child pid=%d bound on %s:%d, browser will connect to %s:%d",
|
|
144
|
+
child.pid, _BIND_HOST, ws_port, ws_host, ws_port)
|
|
145
|
+
return render_template_string(HTML_TEMPLATE,
|
|
146
|
+
ws_host=ws_host, ws_port=ws_port)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.route("/pgwidgets-js/<path:filename>")
|
|
150
|
+
def pgwidgets_js_static(filename):
|
|
151
|
+
"""Serve pgwidgets-js's bundled static assets from the pip-
|
|
152
|
+
installed copy. The Widgets.js entry point uses relative imports
|
|
153
|
+
(``./modules/Widget.js`` etc.), so the entire subtree has to be
|
|
154
|
+
reachable under one URL prefix — ``send_from_directory`` handles
|
|
155
|
+
that correctly out of the box."""
|
|
156
|
+
return send_from_directory(PGWIDGETS_JS_ROOT, filename)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.route("/favicon.ico")
|
|
160
|
+
def favicon():
|
|
161
|
+
"""Return an empty 204 so the browser stops 404-ing on every page."""
|
|
162
|
+
return ("", 204)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _shutdown_children():
|
|
166
|
+
"""Best-effort cleanup of spawned children on Flask exit."""
|
|
167
|
+
for p in _children:
|
|
168
|
+
if p.is_alive():
|
|
169
|
+
p.terminate()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
parser = argparse.ArgumentParser(
|
|
174
|
+
description="Flask + pgwidgets-python multi-process demo.")
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--host", default="127.0.0.1",
|
|
177
|
+
help="Interface to bind both Flask and the per-visitor "
|
|
178
|
+
"WebSocket children on. Use 0.0.0.0 to accept "
|
|
179
|
+
"connections from other machines, or a specific NIC IP "
|
|
180
|
+
"to restrict to one interface. (default: 127.0.0.1)")
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--port", type=int, default=5000,
|
|
183
|
+
help="HTTP port for Flask. WebSocket ports are allocated "
|
|
184
|
+
"ephemerally per visitor. (default: 5000)")
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
_BIND_HOST = args.host
|
|
188
|
+
|
|
189
|
+
logging.basicConfig(
|
|
190
|
+
level=logging.INFO,
|
|
191
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
192
|
+
)
|
|
193
|
+
log.info("Flask serving on http://%s:%d (WebSocket bind: %s)",
|
|
194
|
+
args.host, args.port, args.host)
|
|
195
|
+
|
|
196
|
+
# ``debug=False`` is important: Flask's reloader spawns two
|
|
197
|
+
# processes (parent + reloader child) and that confuses
|
|
198
|
+
# multiprocessing on some platforms. For interactive editing,
|
|
199
|
+
# restart the script manually.
|
|
200
|
+
try:
|
|
201
|
+
app.run(host=args.host, port=args.port, debug=False)
|
|
202
|
+
finally:
|
|
203
|
+
_shutdown_children()
|