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.
Files changed (66) hide show
  1. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/PKG-INFO +1 -1
  2. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/WhatsNew.rst +70 -0
  3. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/architecture.rst +3 -1
  4. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/callbacks.rst +19 -13
  5. pgwidgets_python-0.3.0/docs/utilities.rst +81 -0
  6. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/widgets.rst +6 -0
  7. pgwidgets_python-0.3.0/examples/flask-multi-process/README.md +72 -0
  8. pgwidgets_python-0.3.0/examples/flask-multi-process/server.py +203 -0
  9. pgwidgets_python-0.3.0/examples/flask-multi-process/user_app.py +230 -0
  10. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/__init__.py +2 -0
  11. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/application.py +144 -27
  12. pgwidgets_python-0.3.0/pgwidgets/buffer.py +125 -0
  13. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/method_types.py +34 -2
  14. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/application.py +183 -28
  15. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/PKG-INFO +1 -1
  16. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/SOURCES.txt +4 -0
  17. pgwidgets_python-0.2.3/docs/utilities.rst +0 -40
  18. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.flake8 +0 -0
  19. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.github/workflows/tests.yml +0 -0
  20. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.gitignore +0 -0
  21. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/.readthedocs.yaml +0 -0
  22. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/LICENSE.md +0 -0
  23. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/README.md +0 -0
  24. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/Makefile +0 -0
  25. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/async.rst +0 -0
  26. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/index.rst +0 -0
  27. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/api/sync.rst +0 -0
  28. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/async.rst +0 -0
  29. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/conf.py +0 -0
  30. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/extras.rst +0 -0
  31. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/getting-started.rst +0 -0
  32. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/index.rst +0 -0
  33. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/subclassing.rst +0 -0
  34. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/sync.rst +0 -0
  35. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/docs/web-servers.rst +0 -0
  36. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/README.md +0 -0
  37. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/all_widgets.py +0 -0
  38. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/all_widgets_async.py +0 -0
  39. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_async.py +0 -0
  40. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_sync.py +0 -0
  41. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/examples/demo_treeview.py +0 -0
  42. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/_json.py +0 -0
  43. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/Widgets.py +0 -0
  44. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/__init__.py +0 -0
  45. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/async_/widget.py +0 -0
  46. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/callbacks.py +0 -0
  47. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/defs.py +0 -0
  48. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/extras/__init__.py +0 -0
  49. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/extras/file_browser.py +0 -0
  50. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/Widgets.py +0 -0
  51. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/__init__.py +0 -0
  52. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets/sync/widget.py +0 -0
  53. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
  54. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/requires.txt +0 -0
  55. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pgwidgets_python.egg-info/top_level.txt +0 -0
  56. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/pyproject.toml +0 -0
  57. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/setup.cfg +0 -0
  58. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/test_pg2.py +0 -0
  59. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/__init__.py +0 -0
  60. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_defs.py +0 -0
  61. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_extras.py +0 -0
  62. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_protocol.py +0 -0
  63. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_reconstruct.py +0 -0
  64. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_session.py +0 -0
  65. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_stateful.py +0 -0
  66. {pgwidgets_python-0.2.3 → pgwidgets_python-0.3.0}/tests/test_widget_classes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Python bindings for the pgwidgets JavaScript widget library
5
5
  Author: PGWidgets Developers
6
6
  License: BSD-3-Clause
@@ -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": "file-chunk", ...}`` -- chunked file data (see :doc:`callbacks`).
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 ``file-chunk`` messages with base64-encoded data.
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 data and fires 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 with base64 data URIs:
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
- data_uri = f["data"] # "data:<mime>;base64,<data>"
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
- b64 = f["data"].split(",", 1)[1]
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()