pgwidgets-python 0.3.0__tar.gz → 0.3.1__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 (71) hide show
  1. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/PKG-INFO +1 -1
  2. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/WhatsNew.rst +109 -0
  3. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/async.rst +23 -0
  4. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/sync.rst +46 -0
  5. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/web-servers.rst +8 -0
  6. pgwidgets_python-0.3.1/examples/flask-multi-process/README.md +262 -0
  7. pgwidgets_python-0.3.1/examples/flask-multi-process/gunicorn.conf.py +76 -0
  8. pgwidgets_python-0.3.1/examples/flask-multi-process/nginx.conf +109 -0
  9. pgwidgets_python-0.3.1/examples/flask-multi-process/server.py +387 -0
  10. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/flask-multi-process/user_app.py +112 -74
  11. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/async_/application.py +151 -4
  12. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/async_/widget.py +9 -1
  13. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/extras/file_browser.py +7 -2
  14. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/method_types.py +11 -0
  15. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/sync/application.py +195 -2
  16. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/sync/widget.py +9 -1
  17. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets_python.egg-info/PKG-INFO +1 -1
  18. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets_python.egg-info/SOURCES.txt +4 -0
  19. pgwidgets_python-0.3.1/tests/test_fonts.py +173 -0
  20. pgwidgets_python-0.3.1/tests/test_resolve_kwargs.py +138 -0
  21. pgwidgets_python-0.3.0/examples/flask-multi-process/README.md +0 -72
  22. pgwidgets_python-0.3.0/examples/flask-multi-process/server.py +0 -203
  23. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/.flake8 +0 -0
  24. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/.github/workflows/tests.yml +0 -0
  25. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/.gitignore +0 -0
  26. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/.readthedocs.yaml +0 -0
  27. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/LICENSE.md +0 -0
  28. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/README.md +0 -0
  29. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/Makefile +0 -0
  30. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/api/async.rst +0 -0
  31. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/api/index.rst +0 -0
  32. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/api/sync.rst +0 -0
  33. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/architecture.rst +0 -0
  34. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/callbacks.rst +0 -0
  35. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/conf.py +0 -0
  36. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/extras.rst +0 -0
  37. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/getting-started.rst +0 -0
  38. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/index.rst +0 -0
  39. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/subclassing.rst +0 -0
  40. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/utilities.rst +0 -0
  41. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/docs/widgets.rst +0 -0
  42. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/README.md +0 -0
  43. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/all_widgets.py +0 -0
  44. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/all_widgets_async.py +0 -0
  45. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/demo_async.py +0 -0
  46. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/demo_sync.py +0 -0
  47. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/examples/demo_treeview.py +0 -0
  48. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/__init__.py +0 -0
  49. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/_json.py +0 -0
  50. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/async_/Widgets.py +0 -0
  51. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/async_/__init__.py +0 -0
  52. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/buffer.py +0 -0
  53. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/callbacks.py +0 -0
  54. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/defs.py +0 -0
  55. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/extras/__init__.py +0 -0
  56. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/sync/Widgets.py +0 -0
  57. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets/sync/__init__.py +0 -0
  58. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
  59. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets_python.egg-info/requires.txt +0 -0
  60. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pgwidgets_python.egg-info/top_level.txt +0 -0
  61. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/pyproject.toml +0 -0
  62. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/setup.cfg +0 -0
  63. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/test_pg2.py +0 -0
  64. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/__init__.py +0 -0
  65. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_defs.py +0 -0
  66. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_extras.py +0 -0
  67. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_protocol.py +0 -0
  68. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_reconstruct.py +0 -0
  69. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_session.py +0 -0
  70. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/tests/test_stateful.py +0 -0
  71. {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.1}/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.3.0
3
+ Version: 0.3.1
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,115 @@
1
1
  What's New
2
2
  ==========
3
3
 
4
+ Recent changes — since ``v0.3.0``
5
+ ---------------------------------
6
+
7
+ Custom fonts: ``Application.register_font`` / ``set_default_font``
8
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9
+
10
+ Two new methods on ``Application`` let the Python side ship
11
+ custom font files to the browser and apply a document-wide
12
+ default font.
13
+
14
+ .. code-block:: python
15
+
16
+ app.register_font('Roboto', '/path/to/Roboto-Regular.ttf')
17
+ app.register_font('Roboto', '/path/to/Roboto-Bold.ttf',
18
+ weight='bold')
19
+ app.set_default_font('Roboto', size=13)
20
+
21
+ * ``register_font(family, source, *, weight, style)`` -- stores
22
+ the font bytes in an in-memory registry keyed by a monotonic
23
+ id; the built-in HTTP server exposes them at
24
+ ``/_pgwidgets/font/<id>`` with ``Cache-Control: immutable``.
25
+ Accepts paths (``str`` / ``os.PathLike``) or raw ``bytes``.
26
+ * ``set_default_font(family, *, size, weight, style)`` --
27
+ writes ``--pg-default-font-{family,size,weight,style}`` CSS
28
+ variables on ``:root`` via a managed ``<style>`` element on
29
+ the JS side. The base widget stylesheet consumes them with
30
+ safe fallbacks so apps that never opt in see no change in
31
+ rendering.
32
+
33
+ Both methods broadcast to every connected session immediately
34
+ and replay the entire registry to every new / reconnecting
35
+ session before ``on_connect`` / ``reconstruct`` runs. That
36
+ guarantees a widget that's reconstructed with
37
+ ``set_font(family, ...)`` always finds the face already
38
+ declared on the JS side, even after a full page reload.
39
+
40
+ The JS handler normalises descriptive TTF weight names
41
+ (``thin``, ``light``, ``medium``, ``semibold``, ``extrabold``,
42
+ ``black``, ``heavy``, ...) to the numeric CSS values per the
43
+ CSS Fonts spec, so a font registry that knows weights by their
44
+ metadata name (e.g. Ginga's ``font_asst``) can pass weights
45
+ straight through.
46
+
47
+ The matching sync- and async-backend API are identical; the
48
+ async variant schedules sends via
49
+ ``asyncio.run_coroutine_threadsafe`` so ``register_font`` is
50
+ callable from outside the event loop.
51
+
52
+ See :doc:`sync` / :doc:`async` for the full API description.
53
+
54
+ Flask multi-process example
55
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
56
+
57
+ ``examples/flask-multi-process`` is a new end-to-end demo of
58
+ running pgwidgets-python behind Flask + gunicorn + nginx, with
59
+ one OS process per browser session. Highlights:
60
+
61
+ * ``Application`` accepts ``ws_sock=`` and a session can be
62
+ pre-warmed in the worker process before the browser opens
63
+ its WebSocket -- the worker spawns, runs the user-app's
64
+ ``build_ui`` against an empty session, and then waits for
65
+ the WS handshake.
66
+ * Session-aware routing so a Flask front-end can hand each
67
+ browser tab off to a dedicated worker by session ID.
68
+ * Dead-child reaping (``waitpid(WNOHANG)``) to avoid zombie
69
+ worker processes piling up.
70
+ * The user app is a Python class (``app.build_ui(session)``),
71
+ so worker pre-warming, multi-tab fanout, and reconstruction
72
+ reuse the same entry point.
73
+ * The gunicorn / nginx layer serves the bundled pgwidgets-js
74
+ static assets from the pip-installed package (no separate
75
+ jsdelivr fetch needed).
76
+
77
+ See ``examples/flask-multi-process/README.md`` for the full
78
+ production stack walkthrough.
79
+
80
+ file_browser: ``col_key`` on row-activated
81
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
82
+
83
+ ``pgwidgets.extras.file_browser`` matches the JS-side
84
+ TreeView/TableView ``activated`` callback signature change:
85
+ ``FileBrowser._on_row_activated`` now receives ``col_key`` as
86
+ the 4th argument, so handlers that double-click a file can see
87
+ which column they hit. Existing handlers (3-arg) keep working.
88
+
89
+ ``_resolve_kwargs``: skipped-positional kwargs
90
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
91
+
92
+ The kwarg resolver used by every generated widget method now
93
+ allows a caller to skip a positional argument and supply the
94
+ next one by keyword. For example, with a method declared as
95
+ ``set_color(bg=None, fg=None)``, callers may now write
96
+ ``label.set_color(fg="red")`` directly instead of
97
+ ``label.set_color(None, "red")``. Previously the resolver
98
+ would reject the keyword-only form for parameters that
99
+ preceded any supplied keyword.
100
+
101
+ method_types: TreeView colour + cell-selection methods classified as ACTION
102
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
103
+
104
+ Internal: the four ``set_*_color`` overrides and the new
105
+ ``select_cell`` / ``select_cells`` / ``clear_cell_selection``
106
+ methods are now ACTION-typed, so the wrapper layer dispatches
107
+ each call straight through to the JS side instead of trying to
108
+ collapse them into a single ``_state`` slot. Matters for
109
+ reconstruction-replay; rarely matters for application code.
110
+
111
+ ----
112
+
4
113
  Recent changes — since ``v0.2.3``
5
114
  ---------------------------------
6
115
 
@@ -56,6 +56,29 @@ Running
56
56
 
57
57
  ``await app.close()`` shuts down all sessions and causes ``run()`` to return.
58
58
 
59
+ Custom fonts
60
+ ~~~~~~~~~~~~
61
+
62
+ Same ``register_font`` / ``set_default_font`` API as the sync
63
+ backend (see :doc:`sync`); both are plain sync methods that
64
+ schedule the broadcast onto the event loop via
65
+ ``asyncio.run_coroutine_threadsafe``, so they're safe to call
66
+ from both inside and outside the loop.
67
+
68
+ .. code-block:: python
69
+
70
+ app.register_font('Roboto', '/path/to/Roboto-Regular.ttf')
71
+ app.register_font('Roboto', '/path/to/Roboto-Bold.ttf',
72
+ weight='bold')
73
+ app.set_default_font('Roboto', size=13)
74
+
75
+ Bytes are kept in memory on the ``Application`` and served by
76
+ the built-in HTTP server at ``/_pgwidgets/font/<id>``. Fonts
77
+ are replayed to every new and reconnecting session before
78
+ ``on_connect`` / ``reconstruct`` runs, so widgets reconstructed
79
+ with ``set_font(family, ...)`` always find the face already
80
+ declared.
81
+
59
82
  Session
60
83
  -------
61
84
 
@@ -71,6 +71,52 @@ Running
71
71
 
72
72
  ``app.close()`` shuts down all sessions and stops the servers.
73
73
 
74
+ Custom fonts
75
+ ~~~~~~~~~~~~
76
+
77
+ Two methods on ``Application`` push custom font files to the
78
+ browser and apply a document-wide default font. Both broadcast
79
+ to every connected session immediately, and replay the entire
80
+ registry + default font to every new / reconnecting session
81
+ *before* ``on_connect`` / ``reconstruct`` runs -- so any widget
82
+ that takes ``set_font(family, ...)`` always finds the face
83
+ already declared on the JS side.
84
+
85
+ .. code-block:: python
86
+
87
+ # Register one or more faces for a family
88
+ app.register_font('Roboto', '/path/to/Roboto-Regular.ttf')
89
+ app.register_font('Roboto', '/path/to/Roboto-Bold.ttf',
90
+ weight='bold')
91
+ app.register_font('Roboto', '/path/to/Roboto-Italic.ttf',
92
+ style='italic')
93
+
94
+ # Apply a document-wide default
95
+ app.set_default_font('Roboto', size=13)
96
+
97
+ # Later use it on a single widget (or anywhere CSS sees it)
98
+ label.set_font('Roboto', 14)
99
+
100
+ * ``source`` is a path (``str`` / ``os.PathLike``) or raw
101
+ ``bytes``. Accepted formats: ``.ttf``, ``.otf``, ``.woff``,
102
+ ``.woff2`` -- the Content-Type is set from the extension.
103
+ * ``weight`` accepts CSS keywords, numeric strings, *and*
104
+ common TTF metadata names (``thin``, ``light``, ``medium``,
105
+ ``semibold``, ``extrabold``, ``black``, ``heavy``, etc.) --
106
+ the JS handler normalises descriptive names to numeric CSS
107
+ values per the CSS Fonts spec.
108
+ * ``style`` accepts ``'normal'``, ``'italic'``, or
109
+ ``'oblique'``; synonyms (``'roman'``, ``'slanted'``) are
110
+ normalised too.
111
+ * ``set_default_font(family=None)`` clears the override.
112
+
113
+ Bytes are served from ``/_pgwidgets/font/<id>`` by the built-in
114
+ HTTP server with ``Cache-Control: immutable``; behind Flask /
115
+ nginx the same route is served from the Python process.
116
+
117
+ See also the JS-side write-up at :ref:`custom-fonts` in the
118
+ pgwidgets-js documentation.
119
+
74
120
  Session
75
121
  -------
76
122
 
@@ -276,6 +276,14 @@ Flask
276
276
  if __name__ == "__main__":
277
277
  web.run(host="0.0.0.0", port=8000)
278
278
 
279
+ For a production-style stack -- gunicorn workers, an nginx
280
+ reverse proxy, one OS process per browser session, and
281
+ session-aware routing -- see ``examples/flask-multi-process``.
282
+ That example also demonstrates pre-warming the per-session
283
+ worker with a Python-class user app before the browser opens
284
+ its WebSocket, and reaping dead child workers so zombies don't
285
+ accumulate.
286
+
279
287
 
280
288
  Apache
281
289
  ------
@@ -0,0 +1,262 @@
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. Returning visitors (refresh, bookmark,
5
+ second tab pointed at the same URL) are routed back to their
6
+ original process so pgwidgets-python's session-reconstruct path can
7
+ bring the UI back exactly where it was.
8
+
9
+ - **`server.py`** — Flask front-end. For each new visitor, spawns
10
+ a child process running a fresh `Application` and returns an HTML
11
+ page whose `RemoteInterface` connects to that child's WebSocket
12
+ port. Keeps a `(session_id, token) → (port, process)` registry
13
+ so subsequent visits carrying matching credentials in the URL
14
+ re-attach to the existing child instead of spawning a new one.
15
+ - **`user_app.py`** — The per-visitor `Application` definition.
16
+ Subclass `PGFlaskApp` and override `build_gui(session)` to build
17
+ your UI; the surrounding machinery (socket binding, idle-grace
18
+ self-shutdown, reporting credentials back to the parent) stays
19
+ the same. The child itself binds an ephemeral TCP socket and
20
+ hands it directly to `Application(ws_sock=...)` — the port is
21
+ held continuously, no TOCTOU race with another process. Can
22
+ also be run standalone (`python user_app.py`) for quick UI
23
+ debugging without Flask in the loop; the port is auto-allocated
24
+ and logged.
25
+
26
+ ## Run
27
+
28
+ ```
29
+ pip install Flask pgwidgets-python pgwidgets-js
30
+ python server.py
31
+ # browse to http://localhost:5000/
32
+ ```
33
+
34
+ A *new* visitor (no credentials on the URL) triggers a brand-new
35
+ process. A *returning* visitor whose URL still carries
36
+ `?session=N&token=ABC` from a previous visit is routed back to the
37
+ existing child for that session, if it's still alive — so a
38
+ refresh, a bookmark, or a second tab opened to the same URL all
39
+ re-attach to the same Application, and pgwidgets-python's session-
40
+ reconstruct path can restore the UI to where it was left.
41
+
42
+ When the last browser closes (or navigates away), the child waits
43
+ a grace period — `IDLE_GRACE_SECONDS` in `user_app.py`, default
44
+ 5 minutes — to absorb page refreshes, brief network drops, and
45
+ the user closing/reopening a laptop, then exits. A reconnect
46
+ within the grace window cancels the pending shutdown.
47
+
48
+ ### Session routing
49
+
50
+ The parent keeps a `(session_id, token) → (ws_port, process)`
51
+ registry. The wire protocol between parent and child is a single
52
+ tagged message:
53
+
54
+ ```
55
+ ("ready", ws_port, session_id, token)
56
+ ```
57
+
58
+ sent by the child once, **before** `app.run()` blocks. The child
59
+ pre-creates a session via `Application.create_session()` and
60
+ builds the UI on it before any browser arrives — so by the time
61
+ the parent responds to the visitor, the credentials are already
62
+ known and the UI is already constructed. The HTTP handler
63
+ embeds those credentials in the HTML response (via an inline
64
+ `history.replaceState` that runs before the pgwidgets-js module
65
+ script) so the browser's WebSocket handshake lands directly on
66
+ the pre-built session and pgwidgets-python's reconstruct path
67
+ replays the state.
68
+
69
+ `/` flow:
70
+
71
+ - credentials present in the URL (a refresh / bookmark / shared
72
+ link) **and** match an alive child → render HTML pointed at the
73
+ existing child;
74
+ - credentials missing → spawn a new child, read its `ready`
75
+ message, register `(session_id, token) → (port, process)`, and
76
+ render HTML that embeds those credentials onto the URL so the
77
+ next handshake re-attaches.
78
+
79
+ The registry is opportunistically cleaned along with the child-
80
+ reaper (any entry whose `Process` is no longer alive is dropped),
81
+ so dead-child credentials don't linger.
82
+
83
+ ### Why pre-create the session
84
+
85
+ Two reasons:
86
+
87
+ 1. **One round-trip.** Without prewarm, the child has no
88
+ credentials until the first browser handshake (where
89
+ `_ws_handler` creates the session), so the parent can't embed
90
+ them in the HTML. You'd either redirect (`302 → /?session=…`)
91
+ or wait for the credentials before responding. Prewarm makes
92
+ the credentials known synchronously at spawn time.
93
+ 2. **State preserved across the first refresh.** Once the
94
+ credentials are baked onto the URL, a refresh lands the
95
+ browser on the *same* Application; `do_reconstruct` replays
96
+ whatever the UI has accumulated by then. Without prewarm, the
97
+ first visit creates a session lazily and the credentials only
98
+ appear on the URL *after* the WebSocket has shaken hands —
99
+ leaving a small window where a very fast refresh would have
100
+ spawned a fresh process.
101
+
102
+ ### Try it (verify the reconnect path)
103
+
104
+ 1. Browse to `http://localhost:5000/`. The URL bar immediately
105
+ updates to include `?session=…&token=…` — that's the inline
106
+ `replaceState` baking the pre-warmed session's credentials
107
+ onto the URL before the pgwidgets-js module loads.
108
+ 2. Interact with the demo UI (click the button so the label
109
+ changes to "Clicked!", say).
110
+ 3. **Refresh the tab.** The label should still say "Clicked!" —
111
+ you've landed on the *same* Application process, and its
112
+ session reconstruct path has rebuilt the UI exactly where it
113
+ was. Confirm in DevTools → Network → WS that the WebSocket
114
+ URL's port is unchanged.
115
+ 4. Open the same URL (with the session/token query) in a *second*
116
+ tab. It joins the same Application — clicking the button in
117
+ either tab updates the label in both (multi-browser sync).
118
+
119
+ ### Listening on other interfaces
120
+
121
+ By default Flask and each per-visitor WebSocket bind to
122
+ ``127.0.0.1`` (loopback only). To accept connections from other
123
+ machines:
124
+
125
+ ```
126
+ python server.py --host 0.0.0.0 --port 8080
127
+ ```
128
+
129
+ The bind interface and the WebSocket URL are decoupled — the
130
+ browser is given a ``ws://`` URL derived from the same hostname it
131
+ used to reach Flask, so the same server can be reached via
132
+ ``localhost``, by IP address, or by DNS name with no further
133
+ configuration.
134
+
135
+ ## Production deployment (gunicorn + nginx)
136
+
137
+ The bundled `gunicorn.conf.py` and `nginx.conf` upgrade the demo
138
+ from Flask's dev server to a more production-shaped stack:
139
+
140
+ ```
141
+ pip install gunicorn
142
+ PGW_BIND_HOST=0.0.0.0 gunicorn -c gunicorn.conf.py server:app
143
+ ```
144
+
145
+ Then point nginx at it — see the comments at the top of
146
+ `nginx.conf` for placement (`sites-available` + symlink into
147
+ `sites-enabled`, then `nginx -t && systemctl reload nginx`).
148
+
149
+ ### `PGW_BIND_HOST` (env var)
150
+
151
+ Under gunicorn there's no CLI to pass `--host` through, so the
152
+ per-visitor WebSocket bind interface is read from the
153
+ `PGW_BIND_HOST` environment variable (default `127.0.0.1`). Set
154
+ it to `0.0.0.0` to accept connections from other machines.
155
+
156
+ ### Single gunicorn worker — non-negotiable
157
+
158
+ `gunicorn.conf.py` sets `workers = 1`. That's a *correctness*
159
+ requirement, not a tuning knob: the `(session_id, token) →
160
+ (port, Process)` registry lives in worker-local memory, so
161
+ multiple workers would each maintain an independent registry
162
+ and nginx would happily route a returning visitor's refresh to
163
+ a worker that has never heard of them — spawning a duplicate
164
+ child process every time.
165
+
166
+ Concurrency at the Flask layer comes from the worker's thread
167
+ pool (`gthread` + `threads = 16` by default). The real work —
168
+ the per-visitor pgwidgets sessions — runs in *separate
169
+ subprocesses* anyway, so a single Flask worker isn't a
170
+ bottleneck for typical demo workloads. If you outgrow it, the
171
+ right upgrade is to **externalise the registry**: a shared
172
+ Redis hash, a SQLite file with file-locking, or a small RPC
173
+ service. That lets you raise `workers` to whatever you need.
174
+
175
+ ### nginx ↔ gunicorn ↔ child processes
176
+
177
+ ```
178
+ ┌── 80/443 ──── nginx ─── 8000 ─── gunicorn ─── server.py (1 worker)
179
+ browser ───┤ │
180
+ └── ws://host:<child-port> ────────────────────────────┘
181
+ (per-visitor subprocesses
182
+ bound on ephemeral ports)
183
+ ```
184
+
185
+ nginx reverse-proxies the HTML page and the `/pgwidgets-js/`
186
+ static assets to gunicorn. The per-visitor WebSocket
187
+ connections **do not flow through nginx** — each child process
188
+ binds its own ephemeral TCP port and the browser connects to it
189
+ directly. Two practical consequences:
190
+
191
+ 1. The application host must have the child's port range
192
+ reachable from wherever your users connect (public internet,
193
+ VPN, internal LAN, …). `multiprocessing.Process` gets a
194
+ random ephemeral port from the OS by default; if you need a
195
+ bounded firewall range, change `user_app.py:build_app` to
196
+ bind from a fixed pool instead.
197
+ 2. TLS termination at nginx here covers HTTP only. An HTTPS
198
+ page that tries to open a plain `ws://` WebSocket runs into
199
+ browser mixed-content blocking — so for HTTPS deployments
200
+ you'd usually want to route WebSockets through a TLS-
201
+ terminating proxy as well. See the "WebSocket note" at the
202
+ bottom of `nginx.conf` for the rough shape of that change.
203
+
204
+ ### Subprocess start method
205
+
206
+ `server.py` calls `multiprocessing.set_start_method("spawn",
207
+ force=True)` at module load. Forking child processes from a
208
+ gunicorn worker drags the worker's file descriptors, signal
209
+ handlers, and thread state into every child — flaky. Spawn
210
+ re-execs a fresh Python interpreter for each child, which is
211
+ what `user_app.py` is written for anyway (it re-imports itself
212
+ in the child).
213
+
214
+ ## What the browser loads
215
+
216
+ Each HTML response embeds the per-visitor WebSocket URL. The
217
+ pgwidgets-js bundle (`Widgets.js`, `Widgets.css`, the `modules/`
218
+ subtree, icons) is served by Flask itself, from the pip-installed
219
+ `pgwidgets-js` Python package — `pgwidgets_js.get_static_path()`
220
+ returns the directory and a `/pgwidgets-js/<path>` route in
221
+ `server.py` exposes it. No CDN, no network dependency at runtime,
222
+ and the version on the wire always matches the one in your venv.
223
+
224
+ To point at an in-progress working copy of pgwidgets-js instead of
225
+ the installed package, either ``pip install -e .`` from the
226
+ pgwidgets-js source tree (so `get_static_path()` resolves to the
227
+ working copy), or replace `PGWIDGETS_JS_ROOT` in `server.py` with
228
+ a hard path to that tree's `static/` directory.
229
+
230
+ ## Caveats
231
+
232
+ - **Flask debug mode is off.** The reloader spawns a second
233
+ process, which combined with `multiprocessing` can produce
234
+ surprising behavior on Windows / macOS. Restart `server.py` by
235
+ hand after edits.
236
+ - **Trust model for the routing token.** The registry trusts the
237
+ `token` URL query parameter as proof of session identity — the
238
+ same threat model pgwidgets-python's `_ws_handler` uses for
239
+ WebSocket-level reconnect. Anyone who can read a session URL
240
+ can re-attach to that session. Tokens are randomly generated
241
+ and long enough that guessing is impractical, but URL theft
242
+ (shared link, browser-history snoop, server log) lets the
243
+ thief in. Don't expose this demo unmodified on the open
244
+ internet without thinking through how URLs leak in your
245
+ context.
246
+ - **Reaper is request-driven, not periodic.** A child that has
247
+ self-terminated after the idle grace will sit as a zombie until
248
+ the next `/` request triggers `_reap_dead_children()`. Fine for
249
+ a demo; for an idle production server you'd add a periodic
250
+ background reaper (e.g. a `threading.Timer` chain).
251
+ - **Single-message-per-tag protocol.** The child sends `("port", …)`
252
+ once and `("creds", …)` once. If you extend the demo to update
253
+ state in the parent over time, switch to a clear schema and
254
+ document it (and consider replacing the `Queue` with a `Pipe` or
255
+ a small RPC layer).
256
+ - **Per-worker registry under gunicorn.** The
257
+ `(session_id, token) → (port, Process)` registry is in-memory
258
+ inside the gunicorn worker, so the bundled `gunicorn.conf.py`
259
+ pins `workers = 1`. Raising the worker count silently breaks
260
+ session re-attach (every refresh routed to a fresh worker
261
+ spawns a duplicate child). See "Production deployment" above
262
+ for the upgrade path (externalised registry).
@@ -0,0 +1,76 @@
1
+ """Gunicorn config for the flask-multi-process pgwidgets demo.
2
+
3
+ Run with::
4
+
5
+ PGW_BIND_HOST=0.0.0.0 gunicorn -c gunicorn.conf.py server:app
6
+
7
+ Key constraint — *one worker only*.
8
+ ======================================
9
+
10
+ This demo keeps a per-process in-memory registry of
11
+ ``(session_id, token) -> (port, Process)`` so a returning visitor
12
+ (refresh, bookmark, second tab) re-attaches to their *existing*
13
+ child process within the idle grace period.
14
+
15
+ That registry is local to the gunicorn worker that handles the
16
+ request. If we ran multiple workers, each would have an
17
+ independent registry — and the moment nginx routed a refresh to
18
+ a different worker than the one that spawned the original child,
19
+ the demo would silently spawn a duplicate process.
20
+
21
+ So ``workers = 1`` is not a tuning knob; it's a correctness
22
+ requirement. Concurrency comes from:
23
+
24
+ * the worker's thread pool below (``gthread`` + ``threads = N``),
25
+ so multiple HTTP requests can be served at once even though
26
+ one of them is briefly blocked waiting on a child's
27
+ "ready" message; and
28
+ * the per-visitor subprocesses themselves — those do the
29
+ actual pgwidgets work and run independently of the Flask
30
+ layer.
31
+
32
+ If you need a multi-worker Flask layer, the upgrade path is to
33
+ externalise the registry (Redis, SQLite, etc.) so every worker
34
+ shares it. That's a bigger change; see the README.
35
+ """
36
+
37
+ import os
38
+
39
+ # ----- Bind ----------------------------------------------------
40
+ # Where gunicorn listens for HTTP. Behind nginx the default
41
+ # loopback bind is fine and most secure.
42
+ bind = os.environ.get("PGW_GUNICORN_BIND", "127.0.0.1:8000")
43
+
44
+ # ----- Concurrency --------------------------------------------
45
+ # See the docstring — *do not* increase ``workers`` without
46
+ # externalising the registry.
47
+ workers = 1
48
+ worker_class = "gthread"
49
+ threads = int(os.environ.get("PGW_GUNICORN_THREADS", "16"))
50
+
51
+ # Don't ``preload_app``. Each worker (we only have one) should
52
+ # import ``server.py`` itself, so ``multiprocessing.set_start_method``
53
+ # runs in the same process that later spawns children. Preloading
54
+ # would run the imports in the gunicorn master and then fork the
55
+ # worker — for start_method=spawn it doesn't matter much, but
56
+ # avoiding it keeps the model simpler to reason about.
57
+ preload_app = False
58
+
59
+ # ----- Timeouts -----------------------------------------------
60
+ # The ``/`` handler waits up to 5 s on a child's ``ready`` message
61
+ # (see ``server.py:index``). The default gunicorn worker timeout
62
+ # is 30 s, which already covers that — but bump it a little so a
63
+ # slow first-time import (e.g. cold-start of a per-visitor app
64
+ # with a heavy import graph) doesn't get the worker killed.
65
+ timeout = 60
66
+
67
+ # Keep gthread workers around long enough to absorb the burst of
68
+ # requests when many visitors hit at once.
69
+ graceful_timeout = 30
70
+ keepalive = 5
71
+
72
+ # ----- Logging ------------------------------------------------
73
+ # Send to stdout/stderr so journald / Docker pick it up naturally.
74
+ accesslog = "-"
75
+ errorlog = "-"
76
+ loglevel = os.environ.get("PGW_GUNICORN_LOGLEVEL", "info")
@@ -0,0 +1,109 @@
1
+ # Example nginx config for the flask-multi-process pgwidgets demo.
2
+ #
3
+ # nginx reverse-proxies the HTTP page (and optionally the
4
+ # pgwidgets-js static assets) to gunicorn. The per-visitor
5
+ # WebSocket connections do NOT flow through nginx — each child
6
+ # process binds its own ephemeral port and the browser connects
7
+ # directly. Make sure the host's firewall allows inbound traffic
8
+ # on the port range you give the children. See the "WebSocket
9
+ # note" at the bottom of this file for the wider-pattern options.
10
+ #
11
+ # Drop this into ``/etc/nginx/sites-available/pgwidgets.conf``
12
+ # (or wherever your distro keeps site configs), symlink into
13
+ # ``sites-enabled``, and ``nginx -t && systemctl reload nginx``.
14
+
15
+ upstream pgwidgets_app {
16
+ # ``127.0.0.1:8000`` matches the default ``bind`` in
17
+ # ``gunicorn.conf.py``. A unix socket would be slightly
18
+ # faster and more secure; the TCP loopback form keeps the
19
+ # example self-contained.
20
+ server 127.0.0.1:8000;
21
+
22
+ # Keep enough idle gunicorn connections around to avoid the
23
+ # connect-then-close churn for bursty traffic.
24
+ keepalive 16;
25
+ }
26
+
27
+ server {
28
+ listen 80;
29
+ server_name your-domain.example;
30
+
31
+ # Bump if pgwidgets-js's chunked-upload path needs to accept
32
+ # larger files (the demo doesn't, but real apps often do).
33
+ client_max_body_size 100m;
34
+
35
+ # All HTTP traffic — the HTML page and the inline JS that
36
+ # triggers the WebSocket connect — goes to gunicorn.
37
+ location / {
38
+ proxy_pass http://pgwidgets_app;
39
+ proxy_http_version 1.1;
40
+ proxy_set_header Connection "";
41
+ proxy_set_header Host $host;
42
+ proxy_set_header X-Real-IP $remote_addr;
43
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
44
+ proxy_set_header X-Forwarded-Proto $scheme;
45
+ # Slightly larger than gunicorn's worker timeout so a
46
+ # cold-start child has time to come up before nginx gives
47
+ # up on the request.
48
+ proxy_read_timeout 70s;
49
+ }
50
+
51
+ # Optional speed-up: serve the pgwidgets-js static assets
52
+ # directly from disk instead of round-tripping through
53
+ # gunicorn → Flask → send_from_directory. Point ``alias`` at
54
+ # the directory ``pgwidgets_js.get_static_path()`` returns
55
+ # inside the deployment's virtualenv:
56
+ #
57
+ # python -c "import pgwidgets_js; print(pgwidgets_js.get_static_path())"
58
+ #
59
+ # location /pgwidgets-js/ {
60
+ # alias /path/to/venv/lib/python3.x/site-packages/pgwidgets_js/static/;
61
+ # access_log off;
62
+ # expires 1h;
63
+ # }
64
+ }
65
+
66
+ # ─────────────────────────────────────────────────────────────────
67
+ # WebSocket note
68
+ # ─────────────────────────────────────────────────────────────────
69
+ # This demo's per-visitor WebSocket connections go straight from
70
+ # the browser to whichever ephemeral port the child process bound
71
+ # on the application host. The browser opens
72
+ # ``ws://your-domain.example:<child-port>`` — that connection is
73
+ # NOT proxied through nginx in this configuration. Implications:
74
+ #
75
+ # 1. The application host must have the child's port range
76
+ # reachable from wherever your users connect (public
77
+ # internet, VPN, internal network, ...). Each ``Process``
78
+ # gets a random ephemeral port from the OS; if you need to
79
+ # pin the range, change ``user_app.py:build_app`` to call
80
+ # ``sock.bind((host, port))`` from a pre-chosen pool.
81
+ #
82
+ # 2. TLS termination here is HTTP-only. Browsers may refuse to
83
+ # open an insecure ``ws://`` from an HTTPS page (mixed-
84
+ # content blocking), so for HTTPS deployments you'd usually
85
+ # want the WebSocket on a TLS-terminating proxy too.
86
+ #
87
+ # If you want everything to flow through nginx (single ingress,
88
+ # TLS termination for both HTTP and WebSockets), the path is:
89
+ #
90
+ # a. Pin the child WebSocket ports to a known range (rather
91
+ # than letting the OS pick). Modify ``user_app.py`` to
92
+ # iterate a free port from a fixed list, or to bind a
93
+ # specific ``(host, port)`` passed in by the parent.
94
+ #
95
+ # b. Either give each known port its own ``upstream`` /
96
+ # ``location`` block, or use an ``nginx-stream`` (Layer-4)
97
+ # configuration with a ``map`` directive that picks the
98
+ # upstream port from a session ID lookup.
99
+ #
100
+ # c. Add the standard WebSocket upgrade headers on those
101
+ # proxied locations:
102
+ #
103
+ # proxy_http_version 1.1;
104
+ # proxy_set_header Upgrade $http_upgrade;
105
+ # proxy_set_header Connection "upgrade";
106
+ # proxy_read_timeout 1h; # WebSockets are long-lived
107
+ #
108
+ # That's a bigger refactor than this example covers. Search
109
+ # "nginx websocket reverse proxy" for the standard patterns.