kwebui 0.11.1__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.
- kwebui/__init__.py +33 -0
- kwebui/app.py +264 -0
- kwebui/events.py +19 -0
- kwebui/frontend/static/css/base.css +678 -0
- kwebui/frontend/static/js/app.js +9 -0
- kwebui/frontend/static/js/core.js +91 -0
- kwebui/frontend/static/js/renderer.js +53 -0
- kwebui/frontend/static/js/shortkeys.js +94 -0
- kwebui/frontend/static/js/vendor/vue.global.js +18844 -0
- kwebui/frontend/static/js/websocket.js +35 -0
- kwebui/frontend/static/js/widgets/alert.js +16 -0
- kwebui/frontend/static/js/widgets/badge.js +17 -0
- kwebui/frontend/static/js/widgets/button.js +29 -0
- kwebui/frontend/static/js/widgets/checkbox.js +16 -0
- kwebui/frontend/static/js/widgets/columns.js +24 -0
- kwebui/frontend/static/js/widgets/container.js +106 -0
- kwebui/frontend/static/js/widgets/empty.js +9 -0
- kwebui/frontend/static/js/widgets/file_uploader.js +49 -0
- kwebui/frontend/static/js/widgets/html.js +6 -0
- kwebui/frontend/static/js/widgets/image.js +23 -0
- kwebui/frontend/static/js/widgets/imagestream.js +19 -0
- kwebui/frontend/static/js/widgets/json.js +6 -0
- kwebui/frontend/static/js/widgets/listbox.js +41 -0
- kwebui/frontend/static/js/widgets/popup.js +46 -0
- kwebui/frontend/static/js/widgets/progressbar.js +10 -0
- kwebui/frontend/static/js/widgets/sidebar.js +51 -0
- kwebui/frontend/static/js/widgets/slider.js +43 -0
- kwebui/frontend/static/js/widgets/spinner.js +21 -0
- kwebui/frontend/static/js/widgets/table.js +28 -0
- kwebui/frontend/static/js/widgets/text.js +18 -0
- kwebui/frontend/static/js/widgets/textedit.js +50 -0
- kwebui/frontend/static/js/widgets/toast.js +27 -0
- kwebui/frontend/static/js/widgets/topbar.js +22 -0
- kwebui/frontend/static/js/widgets/workflow_tracker.js +32 -0
- kwebui/frontend/templates/index.html +23 -0
- kwebui/page.py +45 -0
- kwebui/plugin.py +82 -0
- kwebui/registry.py +57 -0
- kwebui/renderer.py +24 -0
- kwebui/router.py +80 -0
- kwebui/session.py +33 -0
- kwebui/state.py +50 -0
- kwebui/theme.py +20 -0
- kwebui/themes/dark.css +9 -0
- kwebui/themes/light.css +9 -0
- kwebui/websocket.py +55 -0
- kwebui/widget.py +112 -0
- kwebui/widgets/__init__.py +6 -0
- kwebui/widgets/alert.py +85 -0
- kwebui/widgets/badge.py +66 -0
- kwebui/widgets/button.py +108 -0
- kwebui/widgets/checkbox.py +47 -0
- kwebui/widgets/columns.py +147 -0
- kwebui/widgets/container.py +306 -0
- kwebui/widgets/empty.py +68 -0
- kwebui/widgets/file_uploader.py +108 -0
- kwebui/widgets/html.py +30 -0
- kwebui/widgets/image.py +69 -0
- kwebui/widgets/imagestream.py +156 -0
- kwebui/widgets/json.py +26 -0
- kwebui/widgets/listbox.py +59 -0
- kwebui/widgets/popup.py +90 -0
- kwebui/widgets/progressbar.py +34 -0
- kwebui/widgets/sidebar.py +80 -0
- kwebui/widgets/slider.py +57 -0
- kwebui/widgets/spinner.py +69 -0
- kwebui/widgets/table.py +123 -0
- kwebui/widgets/text.py +39 -0
- kwebui/widgets/textedit.py +64 -0
- kwebui/widgets/toast.py +62 -0
- kwebui/widgets/topbar.py +76 -0
- kwebui/widgets/workflow_tracker.py +114 -0
- kwebui-0.11.1.dist-info/METADATA +311 -0
- kwebui-0.11.1.dist-info/RECORD +77 -0
- kwebui-0.11.1.dist-info/WHEEL +5 -0
- kwebui-0.11.1.dist-info/licenses/LICENSE +201 -0
- kwebui-0.11.1.dist-info/top_level.txt +1 -0
kwebui/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""kwebui: build web UIs in pure Python, no HTML/CSS/JS required.
|
|
2
|
+
|
|
3
|
+
from kwebui import KApp
|
|
4
|
+
|
|
5
|
+
class Demo(KApp):
|
|
6
|
+
def build(self) -> None:
|
|
7
|
+
self.text("Hello World", size=28)
|
|
8
|
+
self.button("Click Me", on_click=lambda: print("Clicked"))
|
|
9
|
+
|
|
10
|
+
Demo(title="Demo").run()
|
|
11
|
+
|
|
12
|
+
See docs/architecture.md for how the plugin system, rendering pipeline,
|
|
13
|
+
and session lifecycle fit together.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
|
|
18
|
+
from .app import KApp
|
|
19
|
+
from .events import Event
|
|
20
|
+
from .plugin import WidgetPlugin
|
|
21
|
+
from .widget import Widget
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Single source of truth: pyproject.toml's [project].version, read from
|
|
25
|
+
# the installed package's own metadata -- not hand-duplicated here, so
|
|
26
|
+
# bumping the version in one place can't leave this constant stale.
|
|
27
|
+
__version__ = version("kwebui")
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
# Running from a raw checkout that was never pip-installed (editable or
|
|
30
|
+
# otherwise) -- there is no installed metadata to read.
|
|
31
|
+
__version__ = "0.0.0+unknown"
|
|
32
|
+
|
|
33
|
+
__all__ = ["KApp", "Event", "Widget", "WidgetPlugin", "__version__"]
|
kwebui/app.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""The public entry point: ``from kwebui import KApp``.
|
|
2
|
+
|
|
3
|
+
``KApp`` deliberately contains no widget-specific code. Calls like
|
|
4
|
+
``app.text(...)`` or ``app.button(...)`` do not exist as real methods --
|
|
5
|
+
they are resolved dynamically through ``__getattr__`` against whatever
|
|
6
|
+
plugins the registry discovered. This is what "core knows nothing about
|
|
7
|
+
individual widgets" means in practice: delete every file under
|
|
8
|
+
``widgets/`` and ``KApp`` still imports and runs, it just has no widget
|
|
9
|
+
methods left.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextvars
|
|
16
|
+
import itertools
|
|
17
|
+
import re
|
|
18
|
+
import socket
|
|
19
|
+
import traceback
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .page import Page
|
|
24
|
+
from .registry import WidgetRegistry
|
|
25
|
+
from .renderer import serialize_widget
|
|
26
|
+
from .session import Session
|
|
27
|
+
from .theme import DEFAULT_THEME, available_themes
|
|
28
|
+
from .widget import Widget
|
|
29
|
+
|
|
30
|
+
_current_session: contextvars.ContextVar[Session | None] = contextvars.ContextVar("current_session", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sanitize_theme_name(stem: str) -> str:
|
|
34
|
+
"""Turn a CSS file's stem into a safe theme name: it becomes both a
|
|
35
|
+
URL path segment (``/themes/<name>.css``) and a dict key, so anything
|
|
36
|
+
outside ``[a-zA-Z0-9_-]`` (spaces, dots from a second extension, ...)
|
|
37
|
+
is collapsed to ``_`` rather than passed through as-is."""
|
|
38
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", stem)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bind_free_port(host: str, start_port: int) -> socket.socket:
|
|
42
|
+
"""Bind and return a listening socket on the first free port at or
|
|
43
|
+
after ``start_port``. The socket is handed straight to uvicorn (see
|
|
44
|
+
``KApp.run``) instead of just reporting the port number, so there is
|
|
45
|
+
no gap between "found a free port" and "claimed it" for another
|
|
46
|
+
process to race into.
|
|
47
|
+
"""
|
|
48
|
+
port = start_port
|
|
49
|
+
while True:
|
|
50
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
51
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
52
|
+
try:
|
|
53
|
+
sock.bind((host, port))
|
|
54
|
+
except OSError:
|
|
55
|
+
sock.close()
|
|
56
|
+
port += 1
|
|
57
|
+
continue
|
|
58
|
+
return sock
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class KApp:
|
|
62
|
+
"""A kwebui application.
|
|
63
|
+
|
|
64
|
+
Subclass it and override ``build()`` to construct the page once by
|
|
65
|
+
calling widget methods (``self.text(...)``, ``self.button(...)``,
|
|
66
|
+
...) -- ``build()`` runs automatically at the end of ``__init__``.
|
|
67
|
+
Each call adds a live ``Widget`` to the page and returns it so you
|
|
68
|
+
can store it on ``self`` and mutate it later from a callback
|
|
69
|
+
(``widget.update(...)`` or a plugin's typed setters).
|
|
70
|
+
|
|
71
|
+
class Demo(KApp):
|
|
72
|
+
def build(self) -> None:
|
|
73
|
+
self.text("Hello World", size=28)
|
|
74
|
+
|
|
75
|
+
Demo(title="Demo").run()
|
|
76
|
+
|
|
77
|
+
``width`` caps the main content column's width in CSS pixels (the
|
|
78
|
+
``#app-root`` element) -- e.g. ``Demo(title="Demo", width=900).run()``.
|
|
79
|
+
Left at the default ``None``, the page uses its original fixed 720px
|
|
80
|
+
cap (see ``base.css``'s ``--sg-app-width`` fallback); this is a one-time,
|
|
81
|
+
page-load-time layout setting, not a live widget prop -- there is no
|
|
82
|
+
``set_width()`` for it, unlike the per-widget ``width``/``stretch``
|
|
83
|
+
convention (`container`, `table`, `image`, ...) where ``-1``/``0`` means
|
|
84
|
+
"size to content". A `sidebar()`/`topbar()` on the page still narrows the
|
|
85
|
+
available space the same way regardless of this cap (see `docs/architecture.md`).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, title: str = "kwebui app", width: float | None = None) -> None:
|
|
89
|
+
self.title = title
|
|
90
|
+
self.width = width
|
|
91
|
+
self.page = Page()
|
|
92
|
+
self.registry = WidgetRegistry().discover()
|
|
93
|
+
self.theme = DEFAULT_THEME
|
|
94
|
+
self._custom_themes: dict[str, Path] = {}
|
|
95
|
+
self._sessions: list[Session] = []
|
|
96
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
97
|
+
self._id_counter = itertools.count(1)
|
|
98
|
+
self.build()
|
|
99
|
+
|
|
100
|
+
def build(self) -> None:
|
|
101
|
+
"""Override in a subclass to construct the page's widgets. No-op by default."""
|
|
102
|
+
|
|
103
|
+
# -- dynamic widget factory -------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def __getattr__(self, name: str) -> Any:
|
|
106
|
+
if name.startswith("_"):
|
|
107
|
+
raise AttributeError(name)
|
|
108
|
+
registry = self.__dict__.get("registry")
|
|
109
|
+
if registry is None or name not in registry.names():
|
|
110
|
+
raise AttributeError(name)
|
|
111
|
+
|
|
112
|
+
def factory(*args: Any, **kwargs: Any) -> Widget:
|
|
113
|
+
widget = registry.get(name).create(self._next_id(), *args, **kwargs)
|
|
114
|
+
widget._app = self
|
|
115
|
+
self.page.add(widget)
|
|
116
|
+
# Broadcast on creation too, not just on later .update() calls,
|
|
117
|
+
# so a widget created from inside a callback (after the initial
|
|
118
|
+
# page load) actually reaches already-connected browsers -- the
|
|
119
|
+
# frontend mounts an unrecognized widget id fresh on first sight.
|
|
120
|
+
self._on_widget_changed(widget)
|
|
121
|
+
return widget
|
|
122
|
+
|
|
123
|
+
return factory
|
|
124
|
+
|
|
125
|
+
def _next_id(self) -> str:
|
|
126
|
+
return f"w{next(self._id_counter)}"
|
|
127
|
+
|
|
128
|
+
# -- theming ------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def set_theme(self, name: str) -> None:
|
|
131
|
+
"""Switch the active theme, live -- every already-connected browser
|
|
132
|
+
swaps its stylesheet immediately, no page reload.
|
|
133
|
+
|
|
134
|
+
``name`` is either a bundled theme name (``"light"``/``"dark"`` out
|
|
135
|
+
of the box, see ``available_themes()``) or a path to your own CSS
|
|
136
|
+
file, anywhere on disk -- absolute, or relative to the current
|
|
137
|
+
working directory. Passing a path is what lets an app supply a
|
|
138
|
+
fully custom theme without touching the installed kwebui package
|
|
139
|
+
itself: the file is read from wherever it actually lives, each
|
|
140
|
+
time a browser (or a later-connecting one) requests it, so editing
|
|
141
|
+
it on disk and calling ``set_theme()`` again picks up the change.
|
|
142
|
+
The theme's name becomes the file's own stem, sanitized
|
|
143
|
+
(``my_theme.css`` -> ``"my_theme"``) -- pass that derived name (or
|
|
144
|
+
the same path again) to switch back to it later; a name collision
|
|
145
|
+
with a bundled theme is resolved in the custom file's favor for
|
|
146
|
+
this app. A custom theme file must define the same ``--sg-*``
|
|
147
|
+
custom properties as ``kwebui/themes/light.css``/``dark.css`` (see
|
|
148
|
+
`docs/user-guide.md`'s Themes section) -- kwebui does not validate
|
|
149
|
+
that; a variable a custom theme forgets to define just falls back
|
|
150
|
+
to the browser's own initial/inherited value wherever it's used,
|
|
151
|
+
rather than erroring.
|
|
152
|
+
"""
|
|
153
|
+
if name in available_themes() or name in self._custom_themes:
|
|
154
|
+
self.theme = name
|
|
155
|
+
self._broadcast({"op": "theme", "name": name})
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
path = Path(name)
|
|
159
|
+
if path.is_file():
|
|
160
|
+
theme_name = _sanitize_theme_name(path.stem)
|
|
161
|
+
self._custom_themes[theme_name] = path
|
|
162
|
+
self.theme = theme_name
|
|
163
|
+
self._broadcast({"op": "theme", "name": theme_name})
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"Unknown theme {name!r}: not a bundled theme "
|
|
168
|
+
f"({', '.join(available_themes())}) and not an existing CSS file path."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# -- session access for advanced users -----------------------------------
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def session(self) -> Session | None:
|
|
175
|
+
"""The session handling the event currently being processed, or None."""
|
|
176
|
+
return _current_session.get()
|
|
177
|
+
|
|
178
|
+
# -- internals used by websocket.py / router.py / Widget.update ---------
|
|
179
|
+
|
|
180
|
+
def _add_session(self, session: Session) -> None:
|
|
181
|
+
self._sessions.append(session)
|
|
182
|
+
|
|
183
|
+
def _remove_session(self, session: Session) -> None:
|
|
184
|
+
if session in self._sessions:
|
|
185
|
+
self._sessions.remove(session)
|
|
186
|
+
|
|
187
|
+
async def _dispatch_event(self, session: Session, widget_id: str, event_type: str, payload: dict[str, Any]) -> None:
|
|
188
|
+
from .events import Event
|
|
189
|
+
|
|
190
|
+
widget = self.page.find(widget_id)
|
|
191
|
+
if widget is None:
|
|
192
|
+
return
|
|
193
|
+
plugin = self.registry.get(widget.widget_type)
|
|
194
|
+
event = Event(widget_id=widget_id, type=event_type, payload=payload)
|
|
195
|
+
token = _current_session.set(session)
|
|
196
|
+
try:
|
|
197
|
+
# Off the event loop: a blocking callback (time.sleep, a
|
|
198
|
+
# camera read, ...) must not freeze broadcasting to every
|
|
199
|
+
# other connected browser while it runs. asyncio.to_thread
|
|
200
|
+
# propagates contextvars, so app.session still resolves
|
|
201
|
+
# correctly inside the callback despite running on another thread.
|
|
202
|
+
await asyncio.to_thread(plugin.handle_event, widget, event)
|
|
203
|
+
except Exception:
|
|
204
|
+
# A bug in one widget's callback (or a hardware/library error an
|
|
205
|
+
# app author didn't guard against) must not end this browser's
|
|
206
|
+
# entire live session. The only thing reading further events
|
|
207
|
+
# from this browser is the while-loop in websocket.py, and it
|
|
208
|
+
# only tolerates WebSocketDisconnect -- anything else escaping
|
|
209
|
+
# from here would kill the WebSocket, leaving every OTHER
|
|
210
|
+
# widget on the page unresponsive too until the page is
|
|
211
|
+
# reloaded. Printed (not swallowed) so the traceback is still
|
|
212
|
+
# visible server-side, same as an uncaught exception would be.
|
|
213
|
+
traceback.print_exc()
|
|
214
|
+
finally:
|
|
215
|
+
_current_session.reset(token)
|
|
216
|
+
|
|
217
|
+
def _on_widget_changed(self, widget: Widget) -> None:
|
|
218
|
+
message = {"op": "update", "widget": serialize_widget(widget, self.registry)}
|
|
219
|
+
self._broadcast(message)
|
|
220
|
+
|
|
221
|
+
def _remove_widget(self, widget: Widget) -> None:
|
|
222
|
+
"""Drop a one-shot widget (e.g. an answered popup) from the page
|
|
223
|
+
tree and tell every connected browser to remove it too."""
|
|
224
|
+
self.page.remove(widget.id)
|
|
225
|
+
self._broadcast({"op": "remove", "widget_id": widget.id})
|
|
226
|
+
|
|
227
|
+
def _broadcast(self, message: dict[str, Any]) -> None:
|
|
228
|
+
if not self._sessions or self._loop is None:
|
|
229
|
+
return
|
|
230
|
+
for session in list(self._sessions):
|
|
231
|
+
asyncio.run_coroutine_threadsafe(self._safe_send(session, message), self._loop)
|
|
232
|
+
|
|
233
|
+
async def _safe_send(self, session: Session, message: dict[str, Any]) -> None:
|
|
234
|
+
try:
|
|
235
|
+
await session.send(message)
|
|
236
|
+
except Exception:
|
|
237
|
+
self._remove_session(session)
|
|
238
|
+
|
|
239
|
+
# -- serving --------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def run(self, host: str = "127.0.0.1", port: int = 8701, *, log_level: str = "info") -> None:
|
|
242
|
+
"""Start the web server. Blocks until interrupted.
|
|
243
|
+
|
|
244
|
+
If ``port`` is already in use, tries ``port + 1``, ``port + 2``, ...
|
|
245
|
+
until one is free.
|
|
246
|
+
"""
|
|
247
|
+
import uvicorn
|
|
248
|
+
|
|
249
|
+
from .router import build_fastapi_app
|
|
250
|
+
|
|
251
|
+
sock = _bind_free_port(host, port)
|
|
252
|
+
bound_port = sock.getsockname()[1]
|
|
253
|
+
if bound_port != port:
|
|
254
|
+
print(f"Port {port} is in use; using {bound_port} instead.", flush=True)
|
|
255
|
+
|
|
256
|
+
fastapi_app = build_fastapi_app(self)
|
|
257
|
+
config = uvicorn.Config(fastapi_app, log_level=log_level)
|
|
258
|
+
# uvicorn skips its own "Uvicorn running on ..." banner whenever
|
|
259
|
+
# sockets= is passed explicitly (it assumes a multi-worker setup
|
|
260
|
+
# already logged that via config.bind_socket()) -- print it
|
|
261
|
+
# ourselves instead, since that's the one line that actually
|
|
262
|
+
# matters for finding the app.
|
|
263
|
+
print(f"kwebui app running on http://{host}:{bound_port} (Press CTRL+C to quit)", flush=True)
|
|
264
|
+
uvicorn.Server(config).run(sockets=[sock])
|
kwebui/events.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The event value object passed from the WebSocket layer to plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Event:
|
|
11
|
+
"""A single browser-originated event addressed to one widget.
|
|
12
|
+
|
|
13
|
+
``type`` is widget-defined (e.g. "click", "change", "select") -- the
|
|
14
|
+
core never inspects it, only routes it to the target widget's plugin.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
widget_id: str
|
|
18
|
+
type: str
|
|
19
|
+
payload: dict[str, Any] = field(default_factory=dict)
|