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 +11 -0
- fastui/__meta__.py +1 -0
- fastui/app.py +502 -0
- fastui/components.py +519 -0
- fastui/openapi/__init__.py +8 -0
- fastui/openapi/generator.py +163 -0
- fastui/openapi/swagger.py +107 -0
- fastui/openapi/urls.py +21 -0
- fastui/py.typed +0 -0
- fastui/router.py +160 -0
- fastui2-0.1.0.dist-info/METADATA +454 -0
- fastui2-0.1.0.dist-info/RECORD +14 -0
- fastui2-0.1.0.dist-info/WHEEL +5 -0
- fastui2-0.1.0.dist-info/top_level.txt +1 -0
fastui/__init__.py
ADDED
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()
|