pgwidgets-python 0.3.0__tar.gz → 0.3.2__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.3.0 → pgwidgets_python-0.3.2}/PKG-INFO +1 -1
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/WhatsNew.rst +143 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/async.rst +27 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/sync.rst +59 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/web-servers.rst +8 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/widgets.rst +6 -0
- pgwidgets_python-0.3.2/examples/flask-multi-process/README.md +262 -0
- pgwidgets_python-0.3.2/examples/flask-multi-process/gunicorn.conf.py +76 -0
- pgwidgets_python-0.3.2/examples/flask-multi-process/nginx.conf +109 -0
- pgwidgets_python-0.3.2/examples/flask-multi-process/server.py +387 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/flask-multi-process/user_app.py +112 -74
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/async_/application.py +169 -5
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/async_/widget.py +9 -1
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/extras/file_browser.py +7 -2
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/method_types.py +12 -1
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/sync/application.py +236 -10
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/sync/widget.py +9 -1
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets_python.egg-info/PKG-INFO +1 -1
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets_python.egg-info/SOURCES.txt +5 -0
- pgwidgets_python-0.3.2/tests/test_async_session.py +68 -0
- pgwidgets_python-0.3.2/tests/test_fonts.py +173 -0
- pgwidgets_python-0.3.2/tests/test_resolve_kwargs.py +138 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_session.py +50 -0
- pgwidgets_python-0.3.0/examples/flask-multi-process/README.md +0 -72
- pgwidgets_python-0.3.0/examples/flask-multi-process/server.py +0 -203
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/.flake8 +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/.github/workflows/tests.yml +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/.gitignore +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/.readthedocs.yaml +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/LICENSE.md +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/README.md +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/Makefile +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/api/async.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/api/index.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/api/sync.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/architecture.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/callbacks.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/conf.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/extras.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/getting-started.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/index.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/subclassing.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/docs/utilities.rst +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/README.md +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/all_widgets.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/all_widgets_async.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/demo_async.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/demo_sync.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/examples/demo_treeview.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/__init__.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/_json.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/async_/Widgets.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/async_/__init__.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/buffer.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/callbacks.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/defs.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/extras/__init__.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/sync/Widgets.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets/sync/__init__.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets_python.egg-info/dependency_links.txt +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets_python.egg-info/requires.txt +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pgwidgets_python.egg-info/top_level.txt +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/pyproject.toml +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/setup.cfg +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/test_pg2.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/__init__.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_defs.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_extras.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_protocol.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_reconstruct.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_stateful.py +0 -0
- {pgwidgets_python-0.3.0 → pgwidgets_python-0.3.2}/tests/test_widget_classes.py +0 -0
|
@@ -1,6 +1,149 @@
|
|
|
1
1
|
What's New
|
|
2
2
|
==========
|
|
3
3
|
|
|
4
|
+
Recent changes — since ``v0.3.0``
|
|
5
|
+
---------------------------------
|
|
6
|
+
|
|
7
|
+
``Application.open_url``
|
|
8
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
9
|
+
|
|
10
|
+
A new method asks the connected browser(s) to open a URL in a new
|
|
11
|
+
tab/window (``window.open``):
|
|
12
|
+
|
|
13
|
+
.. code-block:: python
|
|
14
|
+
|
|
15
|
+
app.open_url('https://example.com/')
|
|
16
|
+
|
|
17
|
+
This opens the link in the *user's* browser rather than on the host
|
|
18
|
+
running Python. It is a fire-and-forget, per-session broadcast (like
|
|
19
|
+
``set_default_font``) and is available on both the sync and async
|
|
20
|
+
``Application``. (Note: a browser popup blocker may suppress it if
|
|
21
|
+
the originating user-activation has lapsed.)
|
|
22
|
+
|
|
23
|
+
Containers report their children: ``get_children``
|
|
24
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
25
|
+
|
|
26
|
+
``Frame``, ``Expander``, ``TopLevel`` and ``Page`` (single-child
|
|
27
|
+
containers) and the menu / toolbar widgets ``Menu``, ``MenuBar`` and
|
|
28
|
+
``ToolBar`` are now container widgets, so their generated wrappers
|
|
29
|
+
expose the container method set -- ``get_children`` / ``num_children``
|
|
30
|
+
/ ``remove`` / ``remove_widget`` / ``remove_all``. ``get_children()``
|
|
31
|
+
returns the contained widgets (a 0- or 1-element list for the
|
|
32
|
+
single-child containers; the menu actions / submenus / toolbar items
|
|
33
|
+
for the menu/toolbar widgets).
|
|
34
|
+
|
|
35
|
+
TreeView / TableView: ``set_row_spacing`` / ``set_column_spacing``
|
|
36
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
37
|
+
|
|
38
|
+
Two new methods set the vertical (row) and horizontal (column) cell
|
|
39
|
+
padding (in pixels). ``set_row_spacing(0)`` yields tight rows.
|
|
40
|
+
|
|
41
|
+
Custom fonts: ``Application.register_font`` / ``set_default_font``
|
|
42
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
43
|
+
|
|
44
|
+
Two new methods on ``Application`` let the Python side ship
|
|
45
|
+
custom font files to the browser and apply a document-wide
|
|
46
|
+
default font.
|
|
47
|
+
|
|
48
|
+
.. code-block:: python
|
|
49
|
+
|
|
50
|
+
app.register_font('Roboto', '/path/to/Roboto-Regular.ttf')
|
|
51
|
+
app.register_font('Roboto', '/path/to/Roboto-Bold.ttf',
|
|
52
|
+
weight='bold')
|
|
53
|
+
app.set_default_font('Roboto', size=13)
|
|
54
|
+
|
|
55
|
+
* ``register_font(family, source, *, weight, style)`` -- stores
|
|
56
|
+
the font bytes in an in-memory registry keyed by a monotonic
|
|
57
|
+
id; the built-in HTTP server exposes them at
|
|
58
|
+
``/_pgwidgets/font/<id>`` with ``Cache-Control: immutable``.
|
|
59
|
+
Accepts paths (``str`` / ``os.PathLike``) or raw ``bytes``.
|
|
60
|
+
* ``set_default_font(family, *, size, weight, style)`` --
|
|
61
|
+
writes ``--pg-default-font-{family,size,weight,style}`` CSS
|
|
62
|
+
variables on ``:root`` via a managed ``<style>`` element on
|
|
63
|
+
the JS side. The base widget stylesheet consumes them with
|
|
64
|
+
safe fallbacks so apps that never opt in see no change in
|
|
65
|
+
rendering.
|
|
66
|
+
|
|
67
|
+
Both methods broadcast to every connected session immediately
|
|
68
|
+
and replay the entire registry to every new / reconnecting
|
|
69
|
+
session before ``on_connect`` / ``reconstruct`` runs. That
|
|
70
|
+
guarantees a widget that's reconstructed with
|
|
71
|
+
``set_font(family, ...)`` always finds the face already
|
|
72
|
+
declared on the JS side, even after a full page reload.
|
|
73
|
+
|
|
74
|
+
The JS handler normalises descriptive TTF weight names
|
|
75
|
+
(``thin``, ``light``, ``medium``, ``semibold``, ``extrabold``,
|
|
76
|
+
``black``, ``heavy``, ...) to the numeric CSS values per the
|
|
77
|
+
CSS Fonts spec, so a font registry that knows weights by their
|
|
78
|
+
metadata name (e.g. Ginga's ``font_asst``) can pass weights
|
|
79
|
+
straight through.
|
|
80
|
+
|
|
81
|
+
The matching sync- and async-backend API are identical; the
|
|
82
|
+
async variant schedules sends via
|
|
83
|
+
``asyncio.run_coroutine_threadsafe`` so ``register_font`` is
|
|
84
|
+
callable from outside the event loop.
|
|
85
|
+
|
|
86
|
+
See :doc:`sync` / :doc:`async` for the full API description.
|
|
87
|
+
|
|
88
|
+
Flask multi-process example
|
|
89
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
90
|
+
|
|
91
|
+
``examples/flask-multi-process`` is a new end-to-end demo of
|
|
92
|
+
running pgwidgets-python behind Flask + gunicorn + nginx, with
|
|
93
|
+
one OS process per browser session. Highlights:
|
|
94
|
+
|
|
95
|
+
* ``Application`` accepts ``ws_sock=`` and a session can be
|
|
96
|
+
pre-warmed in the worker process before the browser opens
|
|
97
|
+
its WebSocket -- the worker spawns, runs the user-app's
|
|
98
|
+
``build_ui`` against an empty session, and then waits for
|
|
99
|
+
the WS handshake.
|
|
100
|
+
* Session-aware routing so a Flask front-end can hand each
|
|
101
|
+
browser tab off to a dedicated worker by session ID.
|
|
102
|
+
* Dead-child reaping (``waitpid(WNOHANG)``) to avoid zombie
|
|
103
|
+
worker processes piling up.
|
|
104
|
+
* The user app is a Python class (``app.build_ui(session)``),
|
|
105
|
+
so worker pre-warming, multi-tab fanout, and reconstruction
|
|
106
|
+
reuse the same entry point.
|
|
107
|
+
* The gunicorn / nginx layer serves the bundled pgwidgets-js
|
|
108
|
+
static assets from the pip-installed package (no separate
|
|
109
|
+
jsdelivr fetch needed).
|
|
110
|
+
|
|
111
|
+
See ``examples/flask-multi-process/README.md`` for the full
|
|
112
|
+
production stack walkthrough.
|
|
113
|
+
|
|
114
|
+
file_browser: ``col_key`` on row-activated
|
|
115
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
116
|
+
|
|
117
|
+
``pgwidgets.extras.file_browser`` matches the JS-side
|
|
118
|
+
TreeView/TableView ``activated`` callback signature change:
|
|
119
|
+
``FileBrowser._on_row_activated`` now receives ``col_key`` as
|
|
120
|
+
the 4th argument, so handlers that double-click a file can see
|
|
121
|
+
which column they hit. Existing handlers (3-arg) keep working.
|
|
122
|
+
|
|
123
|
+
``_resolve_kwargs``: skipped-positional kwargs
|
|
124
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
125
|
+
|
|
126
|
+
The kwarg resolver used by every generated widget method now
|
|
127
|
+
allows a caller to skip a positional argument and supply the
|
|
128
|
+
next one by keyword. For example, with a method declared as
|
|
129
|
+
``set_color(bg=None, fg=None)``, callers may now write
|
|
130
|
+
``label.set_color(fg="red")`` directly instead of
|
|
131
|
+
``label.set_color(None, "red")``. Previously the resolver
|
|
132
|
+
would reject the keyword-only form for parameters that
|
|
133
|
+
preceded any supplied keyword.
|
|
134
|
+
|
|
135
|
+
method_types: TreeView colour + cell-selection methods classified as ACTION
|
|
136
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
137
|
+
|
|
138
|
+
Internal: the four ``set_*_color`` overrides and the new
|
|
139
|
+
``select_cell`` / ``select_cells`` / ``clear_cell_selection``
|
|
140
|
+
methods are now ACTION-typed, so the wrapper layer dispatches
|
|
141
|
+
each call straight through to the JS side instead of trying to
|
|
142
|
+
collapse them into a single ``_state`` slot. Matters for
|
|
143
|
+
reconstruction-replay; rarely matters for application code.
|
|
144
|
+
|
|
145
|
+
----
|
|
146
|
+
|
|
4
147
|
Recent changes — since ``v0.2.3``
|
|
5
148
|
---------------------------------
|
|
6
149
|
|
|
@@ -56,6 +56,33 @@ 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
|
+
|
|
82
|
+
``open_url(url)`` is also available (same as the sync backend; see
|
|
83
|
+
:doc:`sync`) -- it asks the connected browser(s) to open ``url`` in a
|
|
84
|
+
new tab via ``window.open``.
|
|
85
|
+
|
|
59
86
|
Session
|
|
60
87
|
-------
|
|
61
88
|
|
|
@@ -71,6 +71,65 @@ 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
|
+
|
|
120
|
+
Opening a URL in the browser
|
|
121
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
122
|
+
|
|
123
|
+
.. code-block:: python
|
|
124
|
+
|
|
125
|
+
app.open_url('https://example.com/')
|
|
126
|
+
|
|
127
|
+
``open_url(url)`` asks the connected browser(s) to open ``url`` in a
|
|
128
|
+
new tab/window (``window.open``) -- i.e. in the *user's* browser, not
|
|
129
|
+
on the host running Python. It is a fire-and-forget, per-session
|
|
130
|
+
broadcast. (A browser popup blocker may suppress it if the
|
|
131
|
+
user-activation from the originating click has already lapsed.)
|
|
132
|
+
|
|
74
133
|
Session
|
|
75
134
|
-------
|
|
76
135
|
|
|
@@ -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
|
------
|
|
@@ -631,6 +631,7 @@ Hierarchical tree/list display.
|
|
|
631
631
|
``select_all(state)``,
|
|
632
632
|
``set_column_width(col_key, width)``,
|
|
633
633
|
``set_optimal_column_widths()``,
|
|
634
|
+
``set_row_spacing(px)``, ``set_column_spacing(px)``,
|
|
634
635
|
``sort_by_column(col_key, ascending)``, ``scroll_to_path(path)``,
|
|
635
636
|
``scroll_to_end()``, ``get_column_count()``, ``get_row_count()``,
|
|
636
637
|
``set_show_grid(tf)``, ``set_show_row_numbers(tf)``,
|
|
@@ -815,6 +816,11 @@ Browser file open/save dialog.
|
|
|
815
816
|
Menus and Toolbars
|
|
816
817
|
------------------
|
|
817
818
|
|
|
819
|
+
``MenuBar``, ``Menu`` and ``ToolBar`` are container widgets: the menu
|
|
820
|
+
actions, submenus and toolbar items they hold are reported by
|
|
821
|
+
``get_children()`` / ``num_children()`` (separators and spacers
|
|
822
|
+
excluded).
|
|
823
|
+
|
|
818
824
|
MenuBar
|
|
819
825
|
~~~~~~~
|
|
820
826
|
|
|
@@ -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")
|