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.
Files changed (77) hide show
  1. kwebui/__init__.py +33 -0
  2. kwebui/app.py +264 -0
  3. kwebui/events.py +19 -0
  4. kwebui/frontend/static/css/base.css +678 -0
  5. kwebui/frontend/static/js/app.js +9 -0
  6. kwebui/frontend/static/js/core.js +91 -0
  7. kwebui/frontend/static/js/renderer.js +53 -0
  8. kwebui/frontend/static/js/shortkeys.js +94 -0
  9. kwebui/frontend/static/js/vendor/vue.global.js +18844 -0
  10. kwebui/frontend/static/js/websocket.js +35 -0
  11. kwebui/frontend/static/js/widgets/alert.js +16 -0
  12. kwebui/frontend/static/js/widgets/badge.js +17 -0
  13. kwebui/frontend/static/js/widgets/button.js +29 -0
  14. kwebui/frontend/static/js/widgets/checkbox.js +16 -0
  15. kwebui/frontend/static/js/widgets/columns.js +24 -0
  16. kwebui/frontend/static/js/widgets/container.js +106 -0
  17. kwebui/frontend/static/js/widgets/empty.js +9 -0
  18. kwebui/frontend/static/js/widgets/file_uploader.js +49 -0
  19. kwebui/frontend/static/js/widgets/html.js +6 -0
  20. kwebui/frontend/static/js/widgets/image.js +23 -0
  21. kwebui/frontend/static/js/widgets/imagestream.js +19 -0
  22. kwebui/frontend/static/js/widgets/json.js +6 -0
  23. kwebui/frontend/static/js/widgets/listbox.js +41 -0
  24. kwebui/frontend/static/js/widgets/popup.js +46 -0
  25. kwebui/frontend/static/js/widgets/progressbar.js +10 -0
  26. kwebui/frontend/static/js/widgets/sidebar.js +51 -0
  27. kwebui/frontend/static/js/widgets/slider.js +43 -0
  28. kwebui/frontend/static/js/widgets/spinner.js +21 -0
  29. kwebui/frontend/static/js/widgets/table.js +28 -0
  30. kwebui/frontend/static/js/widgets/text.js +18 -0
  31. kwebui/frontend/static/js/widgets/textedit.js +50 -0
  32. kwebui/frontend/static/js/widgets/toast.js +27 -0
  33. kwebui/frontend/static/js/widgets/topbar.js +22 -0
  34. kwebui/frontend/static/js/widgets/workflow_tracker.js +32 -0
  35. kwebui/frontend/templates/index.html +23 -0
  36. kwebui/page.py +45 -0
  37. kwebui/plugin.py +82 -0
  38. kwebui/registry.py +57 -0
  39. kwebui/renderer.py +24 -0
  40. kwebui/router.py +80 -0
  41. kwebui/session.py +33 -0
  42. kwebui/state.py +50 -0
  43. kwebui/theme.py +20 -0
  44. kwebui/themes/dark.css +9 -0
  45. kwebui/themes/light.css +9 -0
  46. kwebui/websocket.py +55 -0
  47. kwebui/widget.py +112 -0
  48. kwebui/widgets/__init__.py +6 -0
  49. kwebui/widgets/alert.py +85 -0
  50. kwebui/widgets/badge.py +66 -0
  51. kwebui/widgets/button.py +108 -0
  52. kwebui/widgets/checkbox.py +47 -0
  53. kwebui/widgets/columns.py +147 -0
  54. kwebui/widgets/container.py +306 -0
  55. kwebui/widgets/empty.py +68 -0
  56. kwebui/widgets/file_uploader.py +108 -0
  57. kwebui/widgets/html.py +30 -0
  58. kwebui/widgets/image.py +69 -0
  59. kwebui/widgets/imagestream.py +156 -0
  60. kwebui/widgets/json.py +26 -0
  61. kwebui/widgets/listbox.py +59 -0
  62. kwebui/widgets/popup.py +90 -0
  63. kwebui/widgets/progressbar.py +34 -0
  64. kwebui/widgets/sidebar.py +80 -0
  65. kwebui/widgets/slider.py +57 -0
  66. kwebui/widgets/spinner.py +69 -0
  67. kwebui/widgets/table.py +123 -0
  68. kwebui/widgets/text.py +39 -0
  69. kwebui/widgets/textedit.py +64 -0
  70. kwebui/widgets/toast.py +62 -0
  71. kwebui/widgets/topbar.py +76 -0
  72. kwebui/widgets/workflow_tracker.py +114 -0
  73. kwebui-0.11.1.dist-info/METADATA +311 -0
  74. kwebui-0.11.1.dist-info/RECORD +77 -0
  75. kwebui-0.11.1.dist-info/WHEEL +5 -0
  76. kwebui-0.11.1.dist-info/licenses/LICENSE +201 -0
  77. 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)