fastui2 0.1.0__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.
fastui/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .__meta__ import __version__
2
+ from .app import App
3
+ from .components import ui
4
+ from . import components
5
+
6
+ __all__ = (
7
+ "__version__",
8
+ "App",
9
+ "ui",
10
+ "components",
11
+ )
fastui/__meta__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
fastui/app.py ADDED
@@ -0,0 +1,502 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+ import webbrowser
9
+ from typing import Annotated, Callable
10
+ import re as _re
11
+ import unicodedata as _uc
12
+
13
+
14
+ from annotated_doc import Doc
15
+
16
+ from .components import (
17
+ DEFAULT_CSS,
18
+ ActionHandler,
19
+ Button,
20
+ Component,
21
+ Page,
22
+ )
23
+ from .openapi import generate_openapi_schema, get_docs_html
24
+ from .router import Route, Router
25
+
26
+ RELOAD_SCRIPT: str = (
27
+ '<script>'
28
+ '(function(){'
29
+ 'var v=0;'
30
+ 'setInterval(function(){'
31
+ 'var x=new XMLHttpRequest();'
32
+ "x.open('GET','/_ui/version',true);"
33
+ 'x.onload=function(){'
34
+ 'var n=parseInt(x.responseText,10);'
35
+ 'if(v&&n!==v)location.reload();'
36
+ 'v=n;};'
37
+ 'x.send();'
38
+ '},1000);'
39
+ '})();'
40
+ '</script>'
41
+ )
42
+
43
+ TEMPLATE: str = (
44
+ '<!DOCTYPE html>\n'
45
+ '<html lang="ru">\n'
46
+ '<head>\n'
47
+ ' <meta charset="utf-8">\n'
48
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
49
+ ' <title>{title}</title>\n'
50
+ ' {stylesheets}\n'
51
+ ' <style>{css}</style>\n'
52
+ '</head>\n'
53
+ '<body>\n'
54
+ '{body}\n'
55
+ '{reload_script}\n'
56
+ '</body>\n'
57
+ '</html>'
58
+ )
59
+
60
+
61
+ class _Handler(http.server.BaseHTTPRequestHandler):
62
+ """
63
+ Internal HTTP request handler.
64
+
65
+ Dispatches:
66
+ - ``GET`` requests to page handlers or internal API endpoints.
67
+ - ``POST`` requests to registered action handlers (``/_ui/action/<id>``).
68
+ """
69
+
70
+ app_instance: App | None = None
71
+
72
+ def do_GET(self) -> None:
73
+ """Handle incoming GET requests."""
74
+ path = self.path.rstrip("/") or "/"
75
+ app = self.app_instance
76
+ assert app is not None
77
+
78
+ if path == "/_ui/version":
79
+ self.send_json({"version": app._build_id})
80
+ return
81
+
82
+ if path == "/_ui/routes":
83
+ self.send_json(
84
+ {"routes": [r.pattern for r in app._router.routes]}
85
+ )
86
+ return
87
+
88
+ if app._docs_enabled:
89
+ if path == app._openapi_url:
90
+ schema = generate_openapi_schema(
91
+ app._router.routes,
92
+ title=app._docs_title,
93
+ version=app._docs_version,
94
+ description=app._docs_description,
95
+ docs_url=app._docs_url,
96
+ openapi_url=app._openapi_url,
97
+ )
98
+ self.send_json(schema)
99
+ self._log(200)
100
+ return
101
+ if path == app._docs_url:
102
+ html = get_docs_html(openapi_url=app._openapi_url)
103
+ self.send_html(html, 200)
104
+ self._log(200)
105
+ return
106
+
107
+ route = app._router.match(path)
108
+ if route:
109
+ html = app._render_page(route)
110
+ self.send_html(html, 200)
111
+ self._log(200)
112
+ elif app._primary_redirect_target:
113
+ self.send_redirect(app._primary_redirect_target)
114
+ self._log(302)
115
+ else:
116
+ self.send_html("<h1>404</h1>", 404)
117
+ self._log(404)
118
+
119
+ def do_POST(self) -> None:
120
+ """Handle incoming POST requests."""
121
+ path = self.path.rstrip("/") or "/"
122
+ app = self.app_instance
123
+ assert app is not None
124
+
125
+ if path.startswith("/_ui/action/"):
126
+ action_id = path.split("/_ui/action/")[-1]
127
+ handler = app._action_handlers.get(action_id)
128
+ if handler:
129
+ try:
130
+ components = handler()
131
+ html = app._render_fragment(components)
132
+ self.send_html(html, 200)
133
+ self._log(200)
134
+ return
135
+ except Exception as exc:
136
+ self.send_html(
137
+ f"<h1>500</h1><p>{exc}</p>", 500
138
+ )
139
+ self._log(500)
140
+ return
141
+
142
+ self.send_html("<h1>404</h1>", 404)
143
+ self._log(404)
144
+
145
+ def send_redirect(
146
+ self,
147
+ location: Annotated[str, Doc("Redirect target URL.")],
148
+ ) -> None:
149
+ self.send_response(302)
150
+ self.send_header("Location", location)
151
+ self.end_headers()
152
+
153
+ def send_html(
154
+ self,
155
+ html: Annotated[str, Doc("HTML string to send.")],
156
+ status: Annotated[int, Doc("HTTP status code.")],
157
+ ) -> None:
158
+ self.send_response(status)
159
+ self.send_header("Content-Type", "text/html; charset=utf-8")
160
+ self.end_headers()
161
+ self.wfile.write(html.encode("utf-8"))
162
+
163
+ def send_json(
164
+ self,
165
+ data: Annotated[dict, Doc("JSON-serialisable dictionary.")],
166
+ ) -> None:
167
+ self.send_response(200)
168
+ self.send_header("Content-Type", "application/json")
169
+ self.end_headers()
170
+ self.wfile.write(json.dumps(data).encode())
171
+
172
+ def _log(
173
+ self,
174
+ status: Annotated[int, Doc("HTTP status code to log.")],
175
+ ) -> None:
176
+ colour = "\033[92m" if status == 200 else "\033[91m"
177
+ reset = "\033[0m"
178
+ bold = "\033[1m"
179
+ blue = "\033[94m"
180
+ url = f"http://{self.server.server_name}:{self.server.server_port}{self.path}" # type: ignore[attr-defined]
181
+ print(f" {blue}{bold}{self.command}{reset} {url} {colour}{status}{reset}")
182
+
183
+ def log_message(
184
+ self, format: Annotated[str, Doc("Format string.")], *args: object
185
+ ) -> None:
186
+ pass
187
+
188
+
189
+ def _watch_files(
190
+ paths: Annotated[list[str], Doc("List of file paths to watch.")],
191
+ on_change: Annotated[
192
+ Callable[[str], None], Doc("Callback invoked when a file changes.")
193
+ ],
194
+ interval: Annotated[
195
+ float, Doc("Polling interval in seconds.")
196
+ ] = 1.0,
197
+ ) -> None:
198
+ mtimes: dict[str, float] = {}
199
+ for path in paths:
200
+ try:
201
+ mtimes[path] = os.stat(path).st_mtime
202
+ except FileNotFoundError:
203
+ pass
204
+
205
+ while True:
206
+ time.sleep(interval)
207
+ for path in paths:
208
+ try:
209
+ mtime = os.stat(path).st_mtime
210
+ if path in mtimes and mtime != mtimes[path]:
211
+ on_change(path)
212
+ mtimes[path] = mtime
213
+ except FileNotFoundError:
214
+ pass
215
+
216
+
217
+ def _collect_py_files(
218
+ root: Annotated[str, Doc("Root directory to scan.")],
219
+ ) -> list[str]:
220
+ files: list[str] = []
221
+ for dirpath, _, filenames in os.walk(root):
222
+ for filename in filenames:
223
+ if filename.endswith(".py"):
224
+ files.append(os.path.join(dirpath, filename))
225
+ return files
226
+
227
+
228
+ class App:
229
+ """
230
+ FastUI application — the main entry point for defining routes and
231
+ running the development server.
232
+ """
233
+
234
+ def __init__(
235
+ self,
236
+ css: Annotated[
237
+ str,
238
+ Doc("Custom CSS override. Falls back to built-in DEFAULT_CSS when empty.")
239
+ ] = "",
240
+ docs: Annotated[
241
+ bool, Doc("Enable OpenAPI documentation at ``/docs``.")
242
+ ] = True,
243
+ docs_url: Annotated[
244
+ str, Doc("URL path for the Swagger UI page.")
245
+ ] = "/docs",
246
+ openapi_url: Annotated[
247
+ str, Doc("URL path for the OpenAPI JSON schema.")
248
+ ] = "/openapi.json",
249
+ title: Annotated[
250
+ str, Doc("API title shown in Swagger UI.")
251
+ ] = "FastUI API",
252
+ version: Annotated[
253
+ str, Doc("API version string.")
254
+ ] = "0.1.0",
255
+ description: Annotated[
256
+ str, Doc("API description shown in Swagger UI.")
257
+ ] = "",
258
+ ) -> None:
259
+ self._router: Router = Router()
260
+ self.css: str = css or DEFAULT_CSS
261
+ self.stylesheets: list[str] = []
262
+ self._build_id: int = 0
263
+ self._hot_reload: bool = False
264
+ self._action_handlers: dict[str, ActionHandler] = {}
265
+ self._action_counter: int = 0
266
+ self._primary_redirect_target: str = ""
267
+ self._docs_enabled: bool = docs
268
+ self._docs_url: str = docs_url
269
+ self._openapi_url: str = openapi_url
270
+ self._docs_title: str = title
271
+ self._docs_version: str = version
272
+ self._docs_description: str = description
273
+
274
+ def page(
275
+ self,
276
+ pattern: Annotated[
277
+ str,
278
+ Doc(
279
+ "URL pattern. Supports static paths (``/about``) and "
280
+ "typed parameters (``/user/{id:int}``)."
281
+ ),
282
+ ],
283
+ title: Annotated[
284
+ str,
285
+ Doc(
286
+ "Optional page title rendered inside the HTML ``<title>`` tag."
287
+ ),
288
+ ] = "",
289
+ tags: Annotated[
290
+ list[str] | None,
291
+ Doc("OpenAPI tags for grouping routes in documentation."),
292
+ ] = None,
293
+ ) -> Callable:
294
+ """Register a page handler via decorator."""
295
+ def decorator(func: Callable) -> Callable:
296
+ self._router.add(pattern, func, title=title, tags=tags)
297
+ if getattr(func, "_fastui_primary", False):
298
+ if self._primary_redirect_target:
299
+ msg = (
300
+ f"primary_page already set on "
301
+ f"{self._primary_redirect_target!r}, cannot set "
302
+ f"on {pattern!r}"
303
+ )
304
+ raise ValueError(msg)
305
+ self._primary_redirect_target = pattern
306
+ return func
307
+ return decorator
308
+
309
+ def setter(
310
+ self,
311
+ primary_page: Annotated[
312
+ bool,
313
+ Doc(
314
+ "If True, 404 errors redirect to this route's URL."
315
+ ),
316
+ ] = True,
317
+ ) -> Callable:
318
+ """Mark the decorated function's route as the 404 redirect target.
319
+
320
+ Applied as a decorator **below** ``@app.page()``:
321
+
322
+ ```python
323
+ @app.page("/hello")
324
+ @app.setter(primary_page=True)
325
+ def handler(): ...
326
+ ```
327
+
328
+ When ``primary_page=True`` and a user visits a non-existent URL,
329
+ they are redirected to this route's URL instead of seeing a 404.
330
+ """
331
+ def decorator(func: Callable) -> Callable:
332
+ if primary_page:
333
+ func._fastui_primary = True # type: ignore
334
+ return func
335
+ return decorator
336
+
337
+ def action(
338
+ self,
339
+ handler: Annotated[ActionHandler, Doc("Zero-argument callable.")],
340
+ ) -> str:
341
+ """Register a server-side action handler and return its URL."""
342
+ self._action_counter += 1
343
+ action_id = f"a{self._action_counter}"
344
+ self._action_handlers[action_id] = handler
345
+ return f"/_ui/action/{action_id}"
346
+
347
+ def _walk_components(
348
+ self,
349
+ components: Annotated[list[Component], Doc("Component list to walk.")],
350
+ ) -> None:
351
+ """Walk a component tree and resolve callable action handlers."""
352
+ for i, comp in enumerate(components):
353
+ if isinstance(comp, Page):
354
+ self._walk_components(comp.components)
355
+ elif isinstance(comp, Button) and callable(comp.on_click):
356
+ comp.on_click = self.action(comp.on_click)
357
+ components[i] = comp
358
+
359
+ def _render_fragment(
360
+ self,
361
+ components: Annotated[list[Component], Doc("Component list to render.")],
362
+ ) -> str:
363
+ self._walk_components(components)
364
+ return "\n".join(c.to_html() for c in components)
365
+
366
+ def _render_page(self, route: Route) -> str:
367
+ kwargs = getattr(route, "_match_kwargs", {})
368
+ components = route.handler(**kwargs)
369
+
370
+ if components is None:
371
+ body = ""
372
+ elif isinstance(components, list):
373
+ body = self._render_fragment(components)
374
+ else:
375
+ body = components.to_html()
376
+
377
+ stylesheets = "\n".join(
378
+ f'<link rel="stylesheet" href="{url}">' for url in self.stylesheets
379
+ )
380
+
381
+ title = (
382
+ route.title
383
+ or route.pattern.strip("/").replace("-", " ").title()
384
+ or "FastUI"
385
+ )
386
+ reload_script = RELOAD_SCRIPT if self._hot_reload else ""
387
+ return TEMPLATE.format(
388
+ title=title,
389
+ css=self.css,
390
+ stylesheets=stylesheets,
391
+ body=body,
392
+ reload_script=reload_script,
393
+ )
394
+
395
+ def run(
396
+ self,
397
+ host: Annotated[str, Doc("Host address.")] = "127.0.0.1",
398
+ port: Annotated[int, Doc("TCP port.")] = 8000,
399
+ open_browser: Annotated[bool, Doc("Open browser on startup.")] = True,
400
+ css: Annotated[str, Doc("Override CSS for this session.")] = "",
401
+ hot_reload: Annotated[
402
+ bool,
403
+ Doc("Auto-refresh browser on file changes."),
404
+ ] = False,
405
+ ) -> None:
406
+ """Start the development server."""
407
+ if css:
408
+ self.css = css
409
+ self._hot_reload = hot_reload
410
+
411
+ if hot_reload:
412
+ _start_watcher(self)
413
+
414
+ _Handler.app_instance = self
415
+ server = http.server.HTTPServer((host, port), _Handler)
416
+
417
+ reset = "\033[0m"
418
+ bold = "\033[1m"
419
+ dim = "\033[2m"
420
+ green = "\033[92m"
421
+ cyan = "\033[96m"
422
+ yellow = "\033[93m"
423
+
424
+ W = 44
425
+
426
+ _ansi = _re.compile(r"\033\[[0-9;]*m")
427
+
428
+ def vlen(s: str) -> int:
429
+ plain = _ansi.sub("", s)
430
+ n = 0
431
+ for ch in plain:
432
+ ea = _uc.east_asian_width(ch)
433
+ n += 2 if ea in ("W", "F") else 1
434
+ return n
435
+
436
+ def inner(content: str = "") -> str:
437
+ return f" {bold}║{reset}{content}{' ' * (W - vlen(content))}{bold}║{reset}"
438
+
439
+ def top() -> str:
440
+ return f" {bold}╔{'═' * W}╗{reset}"
441
+
442
+ def mid() -> str:
443
+ return f" {bold}╠{'═' * W}╣{reset}"
444
+
445
+ def bot() -> str:
446
+ return f" {bold}╚{'═' * W}╝{reset}"
447
+
448
+ lines: list[str] = []
449
+ lines.append(top())
450
+ lines.append(inner(f" {cyan}FastUI Dev Server{reset}"))
451
+ lines.append(mid())
452
+ lines.append(inner())
453
+ lines.append(inner(f" {green}→ http://{host}:{port}{reset}"))
454
+ lines.append(inner())
455
+ if hot_reload:
456
+ lines.append(inner(f" {cyan}♻ Hot reload{reset}"))
457
+ lines.append(inner())
458
+ if self._docs_enabled:
459
+ lines.append(
460
+ inner(f" {cyan}📖 Docs{reset} {green}http://{host}:{port}{self._docs_url}{reset}")
461
+ )
462
+ lines.append(inner())
463
+ lines.append(inner(" Routes:"))
464
+ for r in self._router.routes:
465
+ lines.append(inner(f" {dim}•{reset} {yellow}{r.pattern}{reset}"))
466
+ lines.append(inner())
467
+ lines.append(bot())
468
+
469
+ print()
470
+ print("\n".join(lines))
471
+ print()
472
+
473
+ if open_browser:
474
+ threading.Timer(
475
+ 1.0, lambda: webbrowser.open(f"http://{host}:{port}/")
476
+ ).start()
477
+
478
+ try:
479
+ print(f" {dim}Ctrl+C to stop{reset}")
480
+ print()
481
+ server.serve_forever()
482
+ except KeyboardInterrupt:
483
+ print()
484
+ print(f" {yellow}Server stopped.{reset}")
485
+ server.server_close()
486
+
487
+
488
+ def _start_watcher(app: App) -> None:
489
+ cwd = os.getcwd()
490
+ package_dir = os.path.dirname(os.path.abspath(__file__))
491
+ paths = _collect_py_files(cwd) + _collect_py_files(package_dir)
492
+ paths = list(set(paths))
493
+
494
+ def on_change(path: str) -> None:
495
+ name = os.path.relpath(path, cwd)
496
+ print(f" \033[93m♻ changed: {name}\033[0m")
497
+ app._build_id += 1
498
+
499
+ watcher_thread = threading.Thread(
500
+ target=_watch_files, args=(paths, on_change), daemon=True
501
+ )
502
+ watcher_thread.start()