glue-ui 0.6.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glue/__init__.py ADDED
@@ -0,0 +1,1048 @@
1
+ from __future__ import annotations
2
+
3
+ import json as jsn
4
+ import traceback
5
+ import warnings
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import bottle as btl
10
+ import gevent as gvt
11
+
12
+ from glue.types import OptionsDictT, WebSocketT, WindowGeometryT
13
+
14
+ try:
15
+ import bottle_websocket as wbs
16
+ except ImportError:
17
+ import bottle.ext.websocket as wbs
18
+ import mimetypes
19
+ import os
20
+ import random as rnd
21
+ import re as rgx
22
+ import socket
23
+ import sys
24
+ import threading
25
+ import time
26
+ from importlib.resources import as_file, files
27
+
28
+ import pyparsing as pp
29
+
30
+ import glue.browsers as brw
31
+ import glue.settings as _settings
32
+
33
+ __version__ = '0.6.6'
34
+
35
+
36
+ mimetypes.add_type('application/javascript', '.js')
37
+
38
+ _glue_js_reference = files('glue') / 'glue.js'
39
+ with as_file(_glue_js_reference) as _glue_js_path:
40
+ _glue_js: str = _glue_js_path.read_text(encoding='utf-8')
41
+
42
+ _websockets: list[tuple[Any, WebSocketT]] = []
43
+ _call_return_values: dict[Any, Any] = {}
44
+ _call_return_callbacks: dict[float, tuple[Callable[..., Any], Callable[..., Any] | None]] = {}
45
+ _call_number: int = 0
46
+ _exposed_functions: dict[Any, Any] = {}
47
+ _js_functions: list[Any] = []
48
+ _mock_queue: list[Any] = []
49
+ _mock_queue_done: set[Any] = set()
50
+ _shutdown: gvt.Greenlet | None = None # Later assigned as global by _websocket_close()
51
+ root_path: str # Later assigned as global by init()
52
+
53
+ # The maximum time (in milliseconds) that Python will try to retrieve a return value for functions executing in JS
54
+ # Can be overridden through `glue.init` with the kwarg `js_result_timeout` (default: 10000)
55
+ _js_result_timeout: int = 10000
56
+
57
+ # Attribute holding the start args from calls to glue.start()
58
+ _start_args: OptionsDictT = {}
59
+
60
+
61
+ def _merge_webview_options(base: dict[str, Any] | None, **first_class: Any) -> dict[str, Any]:
62
+ """Merge escape-hatch dict with first-class kwargs (explicit values win)."""
63
+ merged = dict(base or {})
64
+ for key, value in first_class.items():
65
+ if value is not None:
66
+ merged[key] = value
67
+ return merged
68
+
69
+
70
+ _DEFAULT_ALLOWED_EXTENSIONS: list[str] = ['.js', '.html', '.txt', '.htm', '.xhtml', '.vue']
71
+ _DEFAULT_CMDLINE_ARGS: list[str] = ['--disable-http-cache']
72
+ _JS_NAME_RE = rgx.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
73
+ _this_module = sys.modules[__name__]
74
+
75
+
76
+ # Public functions
77
+
78
+
79
+ def expose(name_or_function: Callable[..., Any] | None = None) -> Callable[..., Any]:
80
+ """Decorator to expose Python callables via Glue's JavaScript API.
81
+
82
+ When an exposed function is called, a callback function can be passed
83
+ immediately afterwards. This callback will be called asynchronously with
84
+ the return value (possibly `None`) when the Python function has finished
85
+ executing.
86
+
87
+ Blocking calls to the exposed function from the JavaScript side are only
88
+ possible using the :code:`await` keyword inside an :code:`async function`.
89
+ These still have to make a call to the response, i.e.
90
+ :code:`await glue.py_random()();` inside an :code:`async function` will work,
91
+ but just :code:`await glue.py_random();` will not.
92
+
93
+ :Example:
94
+
95
+ In Python do:
96
+
97
+ .. code-block:: python
98
+
99
+ @expose
100
+ def say_hello_py(name: str = 'You') -> None:
101
+ print(f'{name} said hello from the JavaScript world!')
102
+
103
+ In JavaScript do:
104
+
105
+ .. code-block:: javascript
106
+
107
+ glue.say_hello_py('Alice')();
108
+
109
+ Expected output on the Python console::
110
+
111
+ Alice said hello from the JavaScript world!
112
+
113
+ """
114
+ # Deal with '@glue.expose()' - treat as '@glue.expose'
115
+ if name_or_function is None:
116
+ return expose
117
+
118
+ if isinstance(name_or_function, str): # Called as '@glue.expose("my_name")'
119
+ name = name_or_function
120
+
121
+ def decorator(function: Callable[..., Any]) -> Any:
122
+ _expose(name, function)
123
+ return function
124
+
125
+ return decorator
126
+ else:
127
+ function = name_or_function
128
+ _expose(function.__name__, function)
129
+ return function
130
+
131
+
132
+ # PyParsing grammar for parsing exposed functions in JavaScript code
133
+ # Examples: `glue.expose(w, "func_name")`, `glue.expose(func_name)`, `glue.expose((function (e){}), "func_name")`
134
+ EXPOSED_JS_FUNCTIONS: pp.ZeroOrMore = pp.ZeroOrMore(
135
+ pp.Suppress(
136
+ pp.SkipTo(pp.Literal('glue.expose('))
137
+ + pp.Literal('glue.expose(')
138
+ + pp.Optional(
139
+ pp.Or([pp.nested_expr(), pp.Word(pp.printables, exclude_chars=',')]) + pp.Literal(',')
140
+ )
141
+ )
142
+ + pp.Suppress(pp.Regex(r'["\']?'))
143
+ + pp.Word(pp.printables, exclude_chars='"\')')
144
+ + pp.Suppress(pp.Regex(r'["\']?\s*\)')),
145
+ )
146
+
147
+
148
+ def init(
149
+ path: str = 'ui',
150
+ allowed_extensions: list[str] | None = None,
151
+ js_result_timeout: int = 10000,
152
+ app_name: str | None = None,
153
+ ) -> None:
154
+ """Initialise Glue.
155
+
156
+ This function should be called before :func:`start()` to initialise the
157
+ parameters for the web interface, such as the path to the files to be
158
+ served.
159
+
160
+ :param path: Sets the path on the filesystem where files to be served to
161
+ the browser are located. *Default:* :file:`ui`.
162
+ :param allowed_extensions: A list of filename extensions which will be
163
+ parsed for exposed glue functions which should be callable from python.
164
+ Files with extensions not in *allowed_extensions* will still be served,
165
+ but any JavaScript functions, even if marked as exposed, will not be
166
+ accessible from python.
167
+ *Default:* :code:`['.js', '.html', '.txt', '.htm', '.xhtml', '.vue']`.
168
+ :param js_result_timeout: How long Glue should be waiting to register the
169
+ results from a call to Glue's JavaScript API before before timing out.
170
+ *Default:* :code:`10000` milliseconds.
171
+ :param app_name: Optional. When set, unlocks the default user settings
172
+ path :file:`~/.{app_name}/{app_name}.json` for :func:`settings` /
173
+ :func:`save_settings`. Does not create any files. *Default:* `None`.
174
+ """
175
+ global root_path, _js_functions, _js_result_timeout
176
+ root_path = _get_real_path(path)
177
+
178
+ if allowed_extensions is None:
179
+ allowed_extensions = list(_DEFAULT_ALLOWED_EXTENSIONS)
180
+
181
+ js_functions = set()
182
+ for root, _, filenames in os.walk(root_path):
183
+ for name in filenames:
184
+ if not any(name.endswith(ext) for ext in allowed_extensions):
185
+ continue
186
+
187
+ try:
188
+ with open(os.path.join(root, name), encoding='utf-8') as file:
189
+ contents = file.read()
190
+ expose_calls = set()
191
+ matches = EXPOSED_JS_FUNCTIONS.parse_string(contents).as_list()
192
+ for expose_call in matches:
193
+ expose_calls.add(_validate_js_name(expose_call))
194
+ js_functions.update(expose_calls)
195
+ except UnicodeDecodeError:
196
+ pass # Malformed file probably
197
+
198
+ _js_functions = list(js_functions)
199
+ for js_function in _js_functions:
200
+ _mock_js_function(js_function)
201
+
202
+ _js_result_timeout = js_result_timeout
203
+ _settings.configure(app_name)
204
+
205
+
206
+ def settings(path: str | None = None) -> dict[str, Any]:
207
+ """Load app settings JSON as a plain dict.
208
+
209
+ With no *path*, uses :file:`~/.{app_name}/{app_name}.json` (requires
210
+ :code:`app_name` on :func:`init`). Missing file returns :code:`{}` and
211
+ does not create anything on disk. Pass an explicit *path* to load any
212
+ JSON file (works without :code:`app_name`).
213
+
214
+ Mutate the dict as usual, then :func:`save_settings` to persist.
215
+ """
216
+ return _settings.load(path)
217
+
218
+
219
+ def save_settings(data: dict[str, Any], path: str | None = None) -> str:
220
+ """Write settings dict as JSON. Creates parent directories on first save.
221
+
222
+ With no *path*, writes to the last path used by :func:`settings`, or the
223
+ default path when :code:`app_name` is set. Returns the path written.
224
+ """
225
+ return str(_settings.save(data, path))
226
+
227
+
228
+ def settings_path() -> str:
229
+ """Return the default settings file path (requires :code:`app_name`)."""
230
+ return str(_settings.settings_path())
231
+
232
+
233
+ def start(
234
+ *start_urls: str,
235
+ mode: str | None = 'auto',
236
+ host: str = 'localhost',
237
+ port: int = 8000,
238
+ block: bool = True,
239
+ jinja_templates: str | None = None,
240
+ cmdline_args: list[str] | None = None,
241
+ size: tuple[int, int] | None = None,
242
+ position: tuple[int, int] | None = None,
243
+ geometry: dict[str, WindowGeometryT] | None = None,
244
+ close_callback: Callable[..., Any] | None = None,
245
+ app_mode: bool = True,
246
+ all_interfaces: bool = False,
247
+ disable_cache: bool = True,
248
+ default_path: str = 'index.html',
249
+ app: btl.Bottle | None = None,
250
+ shutdown_delay: float = 1.0,
251
+ title: str | None = None,
252
+ resizable: bool = True,
253
+ frameless: bool | None = None,
254
+ easy_drag: bool | None = None,
255
+ shadow: bool | None = None,
256
+ debug: bool | None = None,
257
+ confirm_close: bool | None = None,
258
+ fullscreen: bool | None = None,
259
+ minimized: bool | None = None,
260
+ maximized: bool | None = None,
261
+ on_top: bool | None = None,
262
+ min_size: tuple[int, int] | None = None,
263
+ icon: str | None = None,
264
+ gui: str | None = None,
265
+ menu: list[Any] | None = None,
266
+ webview_options: dict[str, Any] | None = None,
267
+ ) -> None:
268
+ """Start the Glue app.
269
+
270
+ Suppose you put all the frontend files in a directory called
271
+ :file:`ui`, including your start page :file:`index.html`, then the app
272
+ is started like this:
273
+
274
+ .. code-block:: python
275
+
276
+ import glue
277
+ glue.init()
278
+ glue.start()
279
+
280
+ This will start a webserver on the default settings
281
+ (http://localhost:8000) and open a native window (or browser) to
282
+ http://localhost:8000/index.html. Pass one or more page paths to open
283
+ something else, e.g. :code:`glue.start('main.html')`.
284
+
285
+ By default (:code:`mode='auto'`), Glue prefers **PyWebView** for a real
286
+ desktop window (menus, minimize/maximize/close under your control), then
287
+ falls back to Chrome/Chromium in app mode, then Microsoft Edge on Windows
288
+ only. Use :code:`block=False` with :code:`mode='auto'` to skip PyWebView
289
+ (its GUI loop must own the main thread) and launch Chrome/Edge instead.
290
+
291
+ :param start_urls: One or more relative page paths to open. *Default:*
292
+ :code:`('index.html',)`.
293
+ :param mode: Window host selection. :code:`'auto'` (default) tries
294
+ PyWebView, then Chrome/Chromium, then Edge on Windows. Force
295
+ :code:`'webview'`, :code:`'chrome'`, or
296
+ :code:`'edge'`; use :code:`'custom'` with :code:`cmdline_args` as a
297
+ full :func:`subprocess.Popen` argv list; or :code:`None` for no window.
298
+ :param host: Hostname used for Bottle server. *Default:*
299
+ :code:`'localhost'`.
300
+ :param port: Port used for Bottle server. Use :code:`0` for port to be
301
+ picked automatically. *Default:* :code:`8000`.
302
+ :param block: Whether the call to :func:`start()` blocks the calling
303
+ thread. *Default:* `True`. PyWebView requires :code:`True` when used
304
+ as the window host.
305
+ :param jinja_templates: Folder for :mod:`jinja2` templates, e.g.
306
+ :file:`my_templates`. *Default:* `None`.
307
+ :param cmdline_args: Extra flags for Chrome/Edge (appended to the browser
308
+ command). With :code:`mode='custom'`, this is the **full** argv passed
309
+ to :func:`subprocess.Popen` (executable + args). Example for Chrome:
310
+ :code:`glue.start('main.html', mode='chrome', port=8080,
311
+ cmdline_args=['--start-fullscreen', '--browser-startup-dialog'])`.
312
+ *Default:* :code:`['--disable-http-cache']`.
313
+ :param size: Tuple specifying the (width, height) of the main window in
314
+ pixels. Applied on PyWebView via window kwargs and on Chrome/Edge via
315
+ :code:`window.resizeTo` in :file:`/glue.js`. *Default:* `None`
316
+ (Glue uses :code:`(1280, 720)`).
317
+ :param position: Tuple specifying the (left, top) position of the main
318
+ window in pixels. Chrome/Edge use :code:`window.moveTo` in
319
+ :file:`/glue.js`. *Default*: `None` (host chooses, usually centered).
320
+ :param geometry: Per-page size/position map (page path →
321
+ :code:`{'size': (w, h), 'position': (x, y)}`, either key optional).
322
+ Applied on PyWebView and via :file:`/glue.js` for Chrome/Edge.
323
+ *Default:* :code:`{}`.
324
+ :param close_callback: A lambda or function that is called when a websocket
325
+ or window closes (i.e. when the user closes the window). It should take
326
+ two arguments: a string which is the relative path of the page that
327
+ just closed, and a list of the other websockets that are still open.
328
+ *Default:* `None`.
329
+ :param app_mode: Whether to run Edge/Chrome in App Mode (:code:`--app`).
330
+ *Default:* :code:`True`. Ignored for PyWebView.
331
+ :param all_interfaces: Whether to allow the :mod:`bottle` server to listen
332
+ for connections on all interfaces (:code:`0.0.0.0`) and accept Glue
333
+ WebSocket clients from non-loopback peers. **Warning:** any client that
334
+ can reach this host can invoke every :func:`expose`d Python function
335
+ over the WebSocket. Use only on trusted networks. When :code:`False`
336
+ (default), non-loopback WebSocket peers are rejected. *Default:*
337
+ :code:`False`.
338
+ :param disable_cache: Sets the no-store response header when serving
339
+ assets.
340
+ :param default_path: The default file to retrieve for the root URL.
341
+ :param app: An instance of :class:`bottle.Bottle` which will be used rather
342
+ than creating a fresh one. This can be used to install middleware on
343
+ the instance before starting Glue, e.g. for session management,
344
+ authentication, etc. If *app* is not a :class:`bottle.Bottle` instance,
345
+ you will need to call :code:`glue.register_glue_routes(app)` on your
346
+ custom app instance.
347
+ :param shutdown_delay: Timer configurable for Glue's shutdown detection
348
+ mechanism, whereby when any websocket closes, it waits *shutdown_delay*
349
+ seconds, and then checks if there are now any websocket connections.
350
+ If not, then Glue closes. In case the user has closed the browser and
351
+ wants to exit the program. *Default:* :code:`1.0` seconds.
352
+ :param title: Window title. Applied to PyWebView's native/in-page chrome
353
+ and, when set, to ``document.title`` via :file:`/glue.js` so Chrome/Edge
354
+ app-mode captions match. When omitted, Chrome/Edge keep each page's
355
+ ``<title>``. *Default:* :code:`None` (PyWebView falls back to
356
+ :code:`'Glue'`).
357
+ :param resizable: Whether the user can resize the window (PyWebView).
358
+ *Default:* :code:`True`. Ignored for Chrome/Edge app windows unless
359
+ you pass matching browser flags yourself.
360
+ :param frameless: PyWebView: hide native caption bar (Glue draws an
361
+ in-page title bar). *Default when unset:* :code:`True`.
362
+ :param easy_drag: PyWebView: drag window from anywhere when frameless.
363
+ *Default when unset:* :code:`False` (drag via title-bar region).
364
+ :param shadow: PyWebView window shadow (Windows). *Default when unset:*
365
+ :code:`True` on Windows.
366
+ :param debug: PyWebView: DevTools / browser accelerators. *Default when
367
+ unset:* :code:`False`.
368
+ :param confirm_close: PyWebView: native close confirmation. *Default when
369
+ unset:* :code:`False`.
370
+ :param fullscreen: PyWebView start fullscreen. *Default when unset:*
371
+ :code:`False`.
372
+ :param minimized: PyWebView start minimized. *Default when unset:*
373
+ :code:`False`.
374
+ :param maximized: PyWebView start maximized. *Default when unset:*
375
+ :code:`False`.
376
+ :param on_top: PyWebView always-on-top. *Default when unset:* :code:`False`.
377
+ :param min_size: PyWebView ``(width, height)`` minimum. *Default when
378
+ unset:* :code:`(200, 100)`.
379
+ :param icon: Path to ``.ico`` (Windows) / ``.icns`` (macOS) for the native
380
+ window. *Default when unset:* ``ui/favicon.ico`` if present.
381
+ :param gui: Force PyWebView backend (``edgechromium``, ``qt``, ``gtk``,
382
+ …). *Default when unset:* auto.
383
+ :param menu: PyWebView application :class:`Menu` list. *Default when
384
+ unset:* no menus (``[]``).
385
+ :param webview_options: Escape hatch for other PyWebView
386
+ :func:`webview.create_window` / :func:`webview.start` kwargs (e.g.
387
+ :code:`private_mode`, :code:`transparent`). Explicit first-class
388
+ kwargs above win over the same key here. *Default:* :code:`None`.
389
+ """
390
+ if cmdline_args is None:
391
+ cmdline_args = list(_DEFAULT_CMDLINE_ARGS)
392
+ if geometry is None:
393
+ geometry = {}
394
+ webview_options = _merge_webview_options(
395
+ webview_options,
396
+ frameless=frameless,
397
+ easy_drag=easy_drag,
398
+ shadow=shadow,
399
+ debug=debug,
400
+ confirm_close=confirm_close,
401
+ fullscreen=fullscreen,
402
+ minimized=minimized,
403
+ maximized=maximized,
404
+ on_top=on_top,
405
+ min_size=min_size,
406
+ icon=icon,
407
+ gui=gui,
408
+ menu=menu,
409
+ )
410
+ if app is None:
411
+ app = btl.default_app()
412
+ if not start_urls:
413
+ start_urls = ('index.html',)
414
+
415
+ if all_interfaces:
416
+ warnings.warn(
417
+ 'all_interfaces=True binds on 0.0.0.0 and allows non-loopback '
418
+ 'Glue WebSocket clients; any client that can reach this host can '
419
+ 'call @glue.expose Python functions. Use only on trusted networks.',
420
+ UserWarning,
421
+ stacklevel=2,
422
+ )
423
+
424
+ # Omit / None → DEFAULT_WINDOW_SIZE (Chrome/Edge resizeTo + PyWebView create_window).
425
+ if size is None:
426
+ import glue.webview as webview
427
+
428
+ size = webview.DEFAULT_WINDOW_SIZE
429
+
430
+ _start_args.update(
431
+ {
432
+ 'mode': mode,
433
+ 'host': host,
434
+ 'port': port,
435
+ 'block': block,
436
+ 'jinja_templates': jinja_templates,
437
+ 'cmdline_args': cmdline_args,
438
+ 'size': size,
439
+ 'position': position,
440
+ 'geometry': geometry,
441
+ 'title': title,
442
+ 'resizable': resizable,
443
+ 'webview_options': webview_options,
444
+ 'close_callback': close_callback,
445
+ 'app_mode': app_mode,
446
+ 'all_interfaces': all_interfaces,
447
+ 'disable_cache': disable_cache,
448
+ 'default_path': default_path,
449
+ 'app': app,
450
+ 'shutdown_delay': shutdown_delay,
451
+ }
452
+ )
453
+
454
+ if _start_args['port'] == 0:
455
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
456
+ sock.bind(('localhost', 0))
457
+ _start_args['port'] = sock.getsockname()[1]
458
+ sock.close()
459
+
460
+ if _start_args['jinja_templates'] is not None:
461
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
462
+
463
+ if not isinstance(_start_args['jinja_templates'], str):
464
+ raise TypeError("'jinja_templates' start_arg/option must be of type str")
465
+ templates_path = os.path.join(root_path, _start_args['jinja_templates'])
466
+ _start_args['jinja_env'] = Environment(
467
+ loader=FileSystemLoader(templates_path), autoescape=select_autoescape(['html', 'xml'])
468
+ )
469
+
470
+ # verify shutdown_delay is correct value
471
+ if not isinstance(_start_args['shutdown_delay'], (int, float)):
472
+ raise ValueError(
473
+ '`shutdown_delay` must be a number, got a {}'.format(
474
+ type(_start_args['shutdown_delay'])
475
+ )
476
+ )
477
+
478
+ def run_lambda() -> None:
479
+ if _start_args['all_interfaces'] is True:
480
+ HOST = '0.0.0.0'
481
+ else:
482
+ if not isinstance(_start_args['host'], str):
483
+ raise TypeError("'host' start_arg/option must be of type str")
484
+ HOST = _start_args['host']
485
+
486
+ app = _start_args['app']
487
+
488
+ if isinstance(app, btl.Bottle):
489
+ register_glue_routes(app)
490
+ else:
491
+ register_glue_routes(btl.default_app())
492
+
493
+ btl.run(
494
+ host=HOST,
495
+ port=_start_args['port'],
496
+ server=wbs.GeventWebSocketServer,
497
+ quiet=True,
498
+ app=app,
499
+ ) # Always returns None
500
+
501
+ def _wait_for_server(*, cooperative: bool = True) -> None:
502
+ """Wait until the server is accepting connections, then open the window.
503
+
504
+ When the Bottle server runs as a gevent greenlet on this hub, use
505
+ cooperative :func:`gevent.sleep` so the server can accept during the
506
+ wait. When it runs in another OS thread (PyWebView), use
507
+ :func:`time.sleep` so we do not depend on this thread's hub.
508
+ """
509
+ sleep = gvt.sleep if cooperative else time.sleep
510
+ host = _start_args['host'] if not _start_args['all_interfaces'] else '127.0.0.1'
511
+ if not isinstance(host, str):
512
+ host = '127.0.0.1'
513
+ # Bottle on 0.0.0.0 is reached via loopback for the readiness probe
514
+ if host in ('0.0.0.0', '::'):
515
+ host = '127.0.0.1'
516
+ port = _start_args['port']
517
+ if not isinstance(port, int):
518
+ raise TypeError("'port' start_arg/option must be of type int")
519
+ ready = False
520
+ for _ in range(100):
521
+ try:
522
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
523
+ sock.settimeout(0.2)
524
+ sock.connect((host, port))
525
+ sock.close()
526
+ ready = True
527
+ break
528
+ except OSError:
529
+ sleep(0.05)
530
+ if not ready:
531
+ raise RuntimeError(
532
+ 'Glue server did not become ready on %s:%s '
533
+ '(not accepting connections after startup wait)' % (host, port)
534
+ )
535
+ show(*start_urls)
536
+
537
+ def _should_run_server_in_thread() -> bool:
538
+ """PyWebView blocks the main thread; a gevent server greenlet would starve."""
539
+ mode = _start_args.get('mode')
540
+ if mode in brw.WEBVIEW_MODES:
541
+ return bool(_start_args.get('block', True))
542
+ if mode == 'auto':
543
+ import glue.webview as webview
544
+
545
+ return webview.should_try(_start_args)
546
+ return False
547
+
548
+ # Start the webserver first, then open the window once listening (avoids race).
549
+ # PyWebView's GUI loop blocks the main thread, so the Bottle server must run
550
+ # in a real OS thread in that case — not a greenlet on the same hub.
551
+ if _should_run_server_in_thread():
552
+ server_thread = threading.Thread(target=run_lambda, name='glue-bottle', daemon=True)
553
+ server_thread.start()
554
+ _wait_for_server(cooperative=False)
555
+ # GUI closed: exit so we do not hang joining a daemon server thread.
556
+ if brw.webview_session_completed():
557
+ sys.exit(0)
558
+ if _start_args['block']:
559
+ # PyWebView failed over to Chrome/Edge; wait until websockets exit.
560
+ server_thread.join()
561
+ else:
562
+ server_greenlet = spawn(run_lambda)
563
+ _wait_for_server(cooperative=True)
564
+ if brw.webview_session_completed():
565
+ sys.exit(0)
566
+ if _start_args['block']:
567
+ server_greenlet.join()
568
+
569
+
570
+ def get_webview_windows() -> list[Any]:
571
+ """Return PyWebView window instances for the current Glue session.
572
+
573
+ Empty when Chrome/Edge (or no window) is used. Useful for menus, title,
574
+ and other native window control via the PyWebView API.
575
+ """
576
+ import glue.webview as webview
577
+
578
+ return webview.get_windows()
579
+
580
+
581
+ def show(*start_urls: str) -> None:
582
+ """Show the specified URL(s) in the browser.
583
+
584
+ Suppose you have two files in your :file:`ui` folder. The file
585
+ :file:`hello.html` regularly includes :file:`glue.js` and provides
586
+ interactivity, and the file :file:`goodbye.html` does not include
587
+ :file:`glue.js` and simply provides plain HTML content not reliant on Glue.
588
+
589
+ First, we define a callback function to be called when the browser
590
+ window is closed:
591
+
592
+ .. code-block:: python
593
+
594
+ def last_calls():
595
+ glue.show('goodbye.html')
596
+
597
+ Now we initialise and start Glue, with a :code:`close_callback` to our
598
+ function:
599
+
600
+ .. code-block:: python
601
+
602
+ glue.init()
603
+ glue.start('hello.html', mode='auto', close_callback=last_calls)
604
+
605
+ When the websocket from :file:`hello.html` is closed (e.g. because the
606
+ user closed the browser window), Glue will wait *shutdown_delay* seconds
607
+ (by default 1 second), then call our :code:`last_calls()` function, which
608
+ opens another window with the :file:`goodbye.html` shown before our Glue app
609
+ terminates.
610
+
611
+ :param start_urls: One or more URLs to be opened.
612
+ """
613
+ brw.open(list(start_urls), _start_args)
614
+
615
+
616
+ def sleep(seconds: int | float) -> None:
617
+ """A non-blocking sleep call compatible with the Gevent event loop.
618
+
619
+ .. note::
620
+ While this function simply wraps :func:`gevent.sleep()`, it is better
621
+ to call :func:`glue.sleep()` in your Glue app, as this will ensure future
622
+ compatibility in case the implementation of Glue should change in some
623
+ respect.
624
+
625
+ :param seconds: The number of seconds to sleep.
626
+ """
627
+ gvt.sleep(seconds)
628
+
629
+
630
+ def spawn(function: Callable[..., Any], *args: Any, **kwargs: Any) -> gvt.Greenlet:
631
+ """Spawn a new Greenlet.
632
+
633
+ Calling this function will spawn a new :class:`gevent.Greenlet` running
634
+ *function* asynchronously.
635
+
636
+ .. caution::
637
+ If you spawn your own Greenlets to run in addition to those spawned by
638
+ Glue's internal core functionality, you will have to ensure that those
639
+ Greenlets will terminate as appropriate (either by returning or by
640
+ being killed via Gevent's kill mechanism), otherwise your app may not
641
+ terminate correctly when Glue itself terminates.
642
+
643
+ :param function: The function to be called and run as the Greenlet.
644
+ :param *args: Any positional arguments that should be passed to *function*.
645
+ :param **kwargs: Any key-word arguments that should be passed to
646
+ *function*.
647
+ """
648
+ return gvt.spawn(function, *args, **kwargs)
649
+
650
+
651
+ # Bottle Routes
652
+
653
+
654
+ def _favicon_href() -> str | None:
655
+ """URL for ``ui/favicon.ico`` with mtime cache-bust (Chrome favicon DB ignores Cache-Control)."""
656
+ import glue.webview as webview
657
+
658
+ path = webview._default_favicon_path()
659
+ if not path:
660
+ return None
661
+ try:
662
+ version = int(os.path.getmtime(path))
663
+ except OSError:
664
+ version = 0
665
+ return '/favicon.ico?v=%d' % version
666
+
667
+
668
+ _ICON_LINK_RE = rgx.compile(
669
+ r'<link\b[^>]*\brel\s*=\s*["\'](?:shortcut\s+)?icon["\']',
670
+ rgx.IGNORECASE,
671
+ )
672
+ _HEAD_RE = rgx.compile(r'<head\b[^>]*>', rgx.IGNORECASE)
673
+
674
+
675
+ def _inject_html_favicon(html: str) -> str:
676
+ """Insert a favicon ``<link>`` after ``<head>`` when ``ui/favicon.ico`` exists.
677
+
678
+ Chromium ``--app`` windows read the icon from the first HTML document. Doing
679
+ this server-side (not in ``glue.js``) puts the tag in the bytes before render.
680
+ """
681
+ href = _favicon_href()
682
+ if not href or _ICON_LINK_RE.search(html):
683
+ return html
684
+ match = _HEAD_RE.search(html)
685
+ if not match:
686
+ return html
687
+ link = '<link rel="icon" type="image/x-icon" href="%s">' % href
688
+ return html[: match.end()] + '\n ' + link + html[match.end() :]
689
+
690
+
691
+ def _response_body_text(response: btl.Response) -> str | None:
692
+ """Best-effort decode of a Bottle response body as text."""
693
+ body = getattr(response, 'body', None)
694
+ if body is None:
695
+ return None
696
+ if isinstance(body, str):
697
+ return body
698
+ if isinstance(body, bytes):
699
+ try:
700
+ return body.decode('utf-8')
701
+ except UnicodeDecodeError:
702
+ return None
703
+ read = getattr(body, 'read', None)
704
+ if callable(read):
705
+ data = read()
706
+ if isinstance(data, str):
707
+ return data
708
+ if isinstance(data, bytes):
709
+ try:
710
+ return data.decode('utf-8')
711
+ except UnicodeDecodeError:
712
+ return None
713
+ return None
714
+
715
+
716
+ def _maybe_inject_favicon_html(path: str, response: btl.Response) -> btl.Response:
717
+ """Rewrite HTML responses to include ``/favicon.ico`` when present on disk."""
718
+ lower = path.lower().split('?', 1)[0]
719
+ if not (lower.endswith('.html') or lower.endswith('.htm')):
720
+ return response
721
+ status = getattr(response, 'status_code', None)
722
+ if status is None:
723
+ status = int(str(getattr(response, 'status', '200')).split()[0])
724
+ if int(status) >= 400:
725
+ return response
726
+ text = _response_body_text(response)
727
+ if text is None:
728
+ return response
729
+ # Always rebuild: static_file bodies are file-like and consumed by the read above.
730
+ injected = _inject_html_favicon(text)
731
+ out = btl.HTTPResponse(injected, status=status)
732
+ content_type = response.get_header('Content-Type') or 'text/html; charset=UTF-8'
733
+ out.set_header('Content-Type', content_type)
734
+ for name, value in response.headers.items():
735
+ if name.lower() in ('content-type', 'content-length'):
736
+ continue
737
+ out.set_header(name, value)
738
+ return out
739
+
740
+
741
+ def _glue() -> str:
742
+ import glue.webview as webview
743
+
744
+ start_geometry = {
745
+ 'default': {'size': _start_args['size'], 'position': _start_args['position']},
746
+ 'pages': _start_args['geometry'],
747
+ }
748
+
749
+ page = _glue_js.replace(
750
+ '/** _py_functions **/', '_py_functions: %s,' % _safe_json(list(_exposed_functions.keys()))
751
+ )
752
+ page = page.replace(
753
+ '/** _start_geometry **/', '_start_geometry: %s,' % _safe_json(start_geometry)
754
+ )
755
+ page = page.replace('/** _webview **/', '_webview: %s,' % _safe_json(webview.titlebar_config()))
756
+ window_title = _start_args.get('title')
757
+ if not isinstance(window_title, str) or not window_title.strip():
758
+ window_title = None
759
+ else:
760
+ window_title = window_title.strip()
761
+ page = page.replace(
762
+ '/** _window_title **/', '_window_title: %s,' % _safe_json(window_title)
763
+ )
764
+ page = page.replace(
765
+ '/** _favicon_href **/', '_favicon_href: %s,' % _safe_json(_favicon_href())
766
+ )
767
+ btl.response.content_type = 'application/javascript'
768
+ _set_response_headers(btl.response)
769
+ return page
770
+
771
+
772
+ def _root() -> btl.Response:
773
+ if not isinstance(_start_args['default_path'], str):
774
+ raise TypeError("'default_path' start_arg/option must be of type str")
775
+ return _static(_start_args['default_path'])
776
+
777
+
778
+ def _static(path: str) -> btl.Response:
779
+ response = None
780
+ if 'jinja_env' in _start_args and 'jinja_templates' in _start_args:
781
+ if not isinstance(_start_args['jinja_templates'], str):
782
+ raise TypeError("'jinja_templates' start_arg/option must be of type str")
783
+ template_prefix = _start_args['jinja_templates'] + '/'
784
+ if path.startswith(template_prefix):
785
+ n = len(template_prefix)
786
+ template = _start_args['jinja_env'].get_template(path[n:])
787
+ response = btl.HTTPResponse(template.render())
788
+
789
+ if response is None:
790
+ response = btl.static_file(path, root=root_path)
791
+
792
+ response = _maybe_inject_favicon_html(path, response)
793
+ _set_response_headers(response)
794
+ return response
795
+
796
+
797
+ def _is_loopback_addr(addr: str | None) -> bool:
798
+ """True if *addr* is a loopback client address (IPv4/IPv6 / mapped)."""
799
+ if not addr:
800
+ return False
801
+ a = addr.strip().lower()
802
+ if a in ('127.0.0.1', '::1', 'localhost'):
803
+ return True
804
+ return bool(a.startswith('::ffff:') and a.rsplit(':', 1)[-1] == '127.0.0.1')
805
+
806
+
807
+ def _websocket(ws: WebSocketT) -> None:
808
+ global _websockets
809
+
810
+ # Default: only loopback may open the Glue bridge (remote needs all_interfaces=True).
811
+ if not _start_args.get('all_interfaces'):
812
+ peer = btl.request.environ.get('REMOTE_ADDR')
813
+ if not _is_loopback_addr(peer if isinstance(peer, str) else None):
814
+ try:
815
+ ws.close()
816
+ except Exception:
817
+ pass
818
+ return
819
+
820
+ for js_function in _js_functions:
821
+ _import_js_function(js_function)
822
+
823
+ page = btl.request.query.page
824
+ if page not in _mock_queue_done:
825
+ for call in _mock_queue:
826
+ _repeated_send(ws, _safe_json(call))
827
+ _mock_queue_done.add(page)
828
+
829
+ _websockets += [(page, ws)]
830
+
831
+ while True:
832
+ msg = ws.receive()
833
+ if msg is not None:
834
+ message = jsn.loads(msg)
835
+ spawn(_process_message, message, ws)
836
+ else:
837
+ _websockets.remove((page, ws))
838
+ break
839
+
840
+ _websocket_close(page)
841
+
842
+
843
+ BOTTLE_ROUTES: dict[str, tuple[Callable[..., Any], dict[Any, Any]]] = {
844
+ '/glue.js': (_glue, dict()),
845
+ '/': (_root, dict()),
846
+ '/<path:path>': (_static, dict()),
847
+ '/glue': (_websocket, dict(apply=[wbs.websocket])),
848
+ }
849
+
850
+
851
+ def register_glue_routes(app: btl.Bottle) -> None:
852
+ """Register the required Glue routes with `app`.
853
+
854
+ .. note::
855
+
856
+ :func:`glue.register_glue_routes()` is normally invoked implicitly by
857
+ :func:`glue.start()` and does not need to be called explicitly in most
858
+ cases. Registering the Glue routes explicitly is only needed if you are
859
+ passing something other than an instance of :class:`bottle.Bottle` to
860
+ :func:`glue.start()`.
861
+
862
+ :Example:
863
+
864
+ >>> app = bottle.Bottle()
865
+ >>> glue.register_glue_routes(app)
866
+ >>> middleware = beaker.middleware.SessionMiddleware(app)
867
+ >>> glue.start(app=middleware)
868
+
869
+ """
870
+ for route_path, route_params in BOTTLE_ROUTES.items():
871
+ route_func, route_kwargs = route_params
872
+ app.route(path=route_path, callback=route_func, **route_kwargs)
873
+
874
+
875
+ # Private functions
876
+
877
+
878
+ def _safe_json(obj: Any) -> str:
879
+ return jsn.dumps(obj, default=lambda o: None)
880
+
881
+
882
+ def _repeated_send(ws: WebSocketT, msg: str) -> None:
883
+ for _attempt in range(100):
884
+ try:
885
+ ws.send(msg)
886
+ break
887
+ except Exception:
888
+ sleep(0.001)
889
+
890
+
891
+ def _process_message(message: dict[str, Any], ws: WebSocketT) -> None:
892
+ if 'call' in message:
893
+ error_info: dict[str, str] = {}
894
+ return_val: Any = None
895
+ try:
896
+ name = message['name']
897
+ if name not in _exposed_functions:
898
+ raise KeyError('Function %r is not exposed' % (name,))
899
+ return_val = _exposed_functions[name](*message['args'])
900
+ status = 'ok'
901
+ except Exception as e:
902
+ traceback.print_exc() # server-side only — do not send stacks to the client
903
+ return_val = None
904
+ status = 'error'
905
+ error_info['errorText'] = repr(e)
906
+ _repeated_send(
907
+ ws,
908
+ _safe_json(
909
+ {
910
+ 'return': message['call'],
911
+ 'status': status,
912
+ 'value': return_val,
913
+ 'error': error_info,
914
+ }
915
+ ),
916
+ )
917
+ elif 'return' in message:
918
+ call_id = message['return']
919
+ if call_id in _call_return_callbacks:
920
+ callback, error_callback = _call_return_callbacks.pop(call_id)
921
+ if message['status'] == 'ok':
922
+ callback(message['value'])
923
+ elif message['status'] == 'error' and error_callback is not None:
924
+ err = message.get('error')
925
+ # Wire shape from JS: {'errorText', 'errorTraceback'} or legacy string+stack
926
+ if isinstance(err, dict):
927
+ error_callback(err.get('errorText'), err.get('errorTraceback'))
928
+ else:
929
+ error_callback(err, message.get('stack'))
930
+ else:
931
+ # Sync waiters: unblock even on error (value may be None)
932
+ _call_return_values[call_id] = message.get('value')
933
+
934
+ else:
935
+ print('Invalid message received: ', message)
936
+
937
+
938
+ def _get_real_path(path: str) -> str:
939
+ if getattr(sys, 'frozen', False):
940
+ return os.path.join(sys._MEIPASS, path) # type: ignore # sys._MEIPASS is dynamically added by PyInstaller
941
+ else:
942
+ return os.path.abspath(path)
943
+
944
+
945
+ def _validate_js_name(name: str) -> str:
946
+ if not _JS_NAME_RE.fullmatch(name):
947
+ raise ValueError(
948
+ 'Invalid JavaScript function name from glue.expose(): %r '
949
+ '(expected a Python/JS identifier)' % (name,)
950
+ )
951
+ return name
952
+
953
+
954
+ def _mock_js_function(f: str) -> None:
955
+ name = _validate_js_name(f)
956
+ setattr(_this_module, name, lambda *args, _n=name: _mock_call(_n, args))
957
+
958
+
959
+ def _import_js_function(f: str) -> None:
960
+ name = _validate_js_name(f)
961
+ setattr(_this_module, name, lambda *args, _n=name: _js_call(_n, args))
962
+
963
+
964
+ def _call_object(name: str, args: Any) -> dict[str, Any]:
965
+ global _call_number
966
+ _call_number += 1
967
+ call_id = _call_number + rnd.random()
968
+ return {'call': call_id, 'name': name, 'args': args}
969
+
970
+
971
+ def _mock_call(
972
+ name: str, args: Any
973
+ ) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
974
+ call_object = _call_object(name, args)
975
+ global _mock_queue
976
+ _mock_queue += [call_object]
977
+ return _call_return(call_object)
978
+
979
+
980
+ def _js_call(
981
+ name: str, args: Any
982
+ ) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
983
+ """Call a JS function on the most recently connected Glue page.
984
+
985
+ Multi-window note: Python→JS calls are sent to the last websocket only
986
+ (not broadcast), so return values are unambiguous. Open pages that need
987
+ the call must be that latest connection, or call from that page's JS.
988
+ """
989
+ call_object = _call_object(name, args)
990
+ if _websockets:
991
+ _, ws = _websockets[-1]
992
+ _repeated_send(ws, _safe_json(call_object))
993
+ return _call_return(call_object)
994
+
995
+
996
+ def _call_return(
997
+ call: dict[str, Any],
998
+ ) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
999
+ global _js_result_timeout
1000
+ call_id = call['call']
1001
+
1002
+ def return_func(
1003
+ callback: Callable[..., Any] | None = None, error_callback: Callable[..., Any] | None = None
1004
+ ) -> Any:
1005
+ if callback is not None:
1006
+ _call_return_callbacks[call_id] = (callback, error_callback)
1007
+ else:
1008
+ for _w in range(_js_result_timeout):
1009
+ if call_id in _call_return_values:
1010
+ return _call_return_values.pop(call_id)
1011
+ sleep(0.001)
1012
+
1013
+ return return_func
1014
+
1015
+
1016
+ def _expose(name: str, function: Callable[..., Any]) -> None:
1017
+ name = _validate_js_name(name)
1018
+ if name in _exposed_functions:
1019
+ raise ValueError('Already exposed function with name "%s"' % name)
1020
+ _exposed_functions[name] = function
1021
+
1022
+
1023
+ def _detect_shutdown() -> None:
1024
+ if len(_websockets) == 0:
1025
+ sys.exit()
1026
+
1027
+
1028
+ def _websocket_close(page: str) -> None:
1029
+ global _shutdown
1030
+
1031
+ close_callback = _start_args.get('close_callback')
1032
+
1033
+ if close_callback is not None:
1034
+ if not callable(close_callback):
1035
+ raise TypeError("'close_callback' start_arg/option must be callable or None")
1036
+ sockets = [p for _, p in _websockets]
1037
+ close_callback(page, sockets)
1038
+ else:
1039
+ if isinstance(_shutdown, gvt.Greenlet):
1040
+ _shutdown.kill()
1041
+
1042
+ _shutdown = gvt.spawn_later(_start_args['shutdown_delay'], _detect_shutdown)
1043
+
1044
+
1045
+ def _set_response_headers(response: btl.Response) -> None:
1046
+ if _start_args['disable_cache']:
1047
+ # https://stackoverflow.com/a/24748094/280852
1048
+ response.set_header('Cache-Control', 'no-store')