From 237126d20ef974a07278b7156c51627e8a49ba38 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Tue, 21 Oct 2025 22:51:46 +0200 Subject: [PATCH 01/11] Added lifespan handler for custom server --- src/uiwiz/server/_server.py | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/uiwiz/server/_server.py diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py new file mode 100644 index 0000000..11cbdfe --- /dev/null +++ b/src/uiwiz/server/_server.py @@ -0,0 +1,62 @@ +import asyncio +from asyncio import Queue, Event +from typing import Any +from pydantic import BaseModel + +from uiwiz.app import UiwizApp +import logging + +logger = logging.getLogger(__name__) + +class Config(BaseModel): + host: str + port: int + app: UiwizApp + + +class LifespanHandler: + + def __init__(self, config: Config): + self.config = config + self.receive_queue: Queue = Queue() + self.state: dict[str, Any] = {} + self.startup_done_event = Event() + self.shutdown_done_event = Event() + + async def startup(self) -> None: + logger.info("Calling lifespan") + loop = asyncio.get_event_loop() + lifespan_task = loop.create_task(self.execute()) + await self.receive_queue.put({"type": "lifespan.startup"}) + await self.startup_done_event.wait() + + logger.info("Startup complete") + + async def execute(self) -> None: + app = self.config.app + scope = { + "type": "lifespan", + "asgi": {"version": "3", "spec_version": "2.0"}, + "state": self.state, + } + await app(scope, self.receive_queue, self.send) + + async def send(self, message: dict) -> None: + task = { + "lifespan.startup.complete": lambda: self.startup_done_event.set(), + "lifespan.startup.failed": lambda: self.startup_done_event.set(), + "lifespan.shutdown.complete": lambda: self.shutdown_done_event.set(), + "lifespan.shutdown.failed": lambda: self.shutdown_done_event.set() + } + task.get(message["type"], lambda: 1)() + + +def create_socket(): + pass + + + +class Server: + def __init__(self): + pass + From 4de5b43f1a2ff81796447d6164e4465d08a049e3 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Wed, 22 Oct 2025 23:23:00 +0200 Subject: [PATCH 02/11] Implement custom server progress. Missing calling app flow --- src/uiwiz/server/_server.py | 243 +++++++++++++++++++++++++++++++++--- src/uiwiz/server/main.py | 8 ++ 2 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 src/uiwiz/server/main.py diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 11cbdfe..3b4b8a9 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -1,39 +1,54 @@ import asyncio -from asyncio import Queue, Event -from typing import Any +from asyncio import Queue, Event, TimerHandle +from typing import Any, Optional from pydantic import BaseModel +import httptools +import http +import importlib +from dataclasses import dataclass from uiwiz.app import UiwizApp import logging +logging.basicConfig(level=logging.DEBUG) + logger = logging.getLogger(__name__) -class Config(BaseModel): + +@dataclass +class Config: host: str port: int - app: UiwizApp + app: Optional[UiwizApp] + app_instance: Optional[UiwizApp] = None +def import_app_instance(config: Config) -> None: + if isinstance(config.app, str): + module_name, _, app = config.app.partition(":") + module = importlib.import_module(module_name) + config.app_instance = getattr(module, app) + else: + config.app_instance = config.app class LifespanHandler: - def __init__(self, config: Config): self.config = config self.receive_queue: Queue = Queue() self.state: dict[str, Any] = {} self.startup_done_event = Event() self.shutdown_done_event = Event() - + async def startup(self) -> None: logger.info("Calling lifespan") - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() lifespan_task = loop.create_task(self.execute()) await self.receive_queue.put({"type": "lifespan.startup"}) await self.startup_done_event.wait() logger.info("Startup complete") - + async def execute(self) -> None: - app = self.config.app + app = self.config.app_instance scope = { "type": "lifespan", "asgi": {"version": "3", "spec_version": "2.0"}, @@ -46,17 +61,217 @@ async def send(self, message: dict) -> None: "lifespan.startup.complete": lambda: self.startup_done_event.set(), "lifespan.startup.failed": lambda: self.startup_done_event.set(), "lifespan.shutdown.complete": lambda: self.shutdown_done_event.set(), - "lifespan.shutdown.failed": lambda: self.shutdown_done_event.set() + "lifespan.shutdown.failed": lambda: self.shutdown_done_event.set(), } task.get(message["type"], lambda: 1)() -def create_socket(): - pass +class HttpToolsImpl(asyncio.Protocol): + def __init__(self, config: Config): + self.config = config + self.lifespan = LifespanHandler(config) + self.app = config.app + self.state: dict[str, Any] = {} + self.loop = asyncio.get_event_loop() + self.parser = httptools.HttpRequestParser(self) + self.timeout_keep_alive_task: TimerHandle | None = None + self.transport: asyncio.Transport = None + self.app_state = dict() + # self.cycle: RequestResponseCycle = None + + def data_received(self, data: bytes) -> None: + self._unset_keepalive_if_required() + logger.info("Got request") + + try: + self.parser.feed_data(data) + except httptools.HttpParserError: + msg = "Invalid HTTP request received." + logger.warning(msg) + self.send_400_response(msg) + return + except httptools.HttpParserUpgrade: + if self._should_upgrade(): + self.handle_websocket_upgrade() + else: + self._unsupported_upgrade_warning() + + def eof_received(self) -> None: + ... + def _unset_keepalive_if_required(self) -> None: + if self.timeout_keep_alive_task is not None: + self.timeout_keep_alive_task.cancel() + self.timeout_keep_alive_task = None + + def connection_made(self, transport: asyncio.Transport) -> None: + self.transport = transport + # self.flow = FlowControl(transport) should not be relevant + self.server = self.get_local_addr() + self.client = self.get_remote_addr() + self.scheme = "https" if bool(transport.get_extra_info("sslcontext")) else "http" + + def connection_lost(self, exc: Exception | None) -> None: + + if self.cycle and not self.cycle.response_complete: + self.cycle.disconnected = True + if self.cycle is not None: + self.cycle.message_event.set() + if self.flow is not None: + self.flow.resume_writing() + if exc is None: + self.transport.close() + self._unset_keepalive_if_required() + + self.parser = None + + def on_url(self, url: bytes) -> None: + self.url += url + + def on_header(self, name: bytes, value: bytes) -> None: + name = name.lower() + if name == b"expect" and value.lower() == b"100-continue": + self.expect_100_continue = True + self.headers.append((name, value)) + + + + def on_message_begin(self) -> None: + import_app_instance(config) + # asyncio.get_running_loop().create_task(self.lifespan.startup()) + + self.url = b"" + self.expect_100_continue = False + self.headers = [] + self.scope = { # type: ignore[typeddict-item] + "type": "http", + "asgi": {"version": "asgi3", "spec_version": "2.3"}, + "http_version": "1.1", + "server": self.server, + "client": self.client, + "scheme": self.scheme, # type: ignore[typeddict-item] + "root_path": "", + "headers": self.headers, + "state": self.app_state, + } + + + def on_response_complete(self) -> None: + if self.transport.is_closing(): + return + + self._unset_keepalive_if_required() + + # Unpause data reads if needed. + self.flow.resume_reading() + + # Unblock any pipelined events. If there are none, arm the + # Keep-Alive timeout instead. + if self.pipeline: + cycle, app = self.pipeline.pop() + task = self.loop.create_task(cycle.run_asgi(app)) + task.add_done_callback(self.tasks.discard) + self.tasks.add(task) + else: + self.timeout_keep_alive_task = self.loop.call_later( + self.timeout_keep_alive, self.timeout_keep_alive_handler + ) + + def _get_upgrade(self) -> Optional[bytes]: + connection = [] + upgrade = None + for name, value in self.headers: + if name == b"connection": + connection = [token.lower().strip() for token in value.split(b",")] + if name == b"upgrade": + upgrade = value.lower() + if b"upgrade" in connection: + return upgrade + return None # pragma: full coverage + + def _should_upgrade_to_ws(self) -> bool: + if self.ws_protocol_class is None: + return False + return True + + def _unsupported_upgrade_warning(self) -> None: + logger.warning("Unsupported upgrade request.") + if not self._should_upgrade_to_ws(): + msg = "No supported WebSocket library detected. Please use \"pip install 'uvicorn[standard]'\", or install 'websockets' or 'wsproto' manually." # noqa: E501 + logger.warning(msg) + + def _should_upgrade(self) -> bool: + upgrade = self._get_upgrade() + return upgrade == b"websocket" and self._should_upgrade_to_ws() + + + def get_local_addr(self) -> Optional[tuple[str, int]]: + socket_info = self.transport.get_extra_info("socket") + if socket_info is not None: + info = socket_info.getsockname() + + return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None + info = self.transport.get_extra_info("sockname") + if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: + return (str(info[0]), int(info[1])) + return None + + + def get_remote_addr(self) -> Optional[tuple[str, int]]: + socket_info = self.transport.get_extra_info("socket") + if socket_info is not None: + try: + info = socket_info.getpeername() + return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None + except OSError: + return None + + info = self.transport.get_extra_info("peername") + if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: + return (str(info[0]), int(info[1])) + return None + + def send_400_response(self, msg: str) -> None: + message = [http.HTTPStatus(400).phrase.encode()] + # for name, value in self.server_state.default_headers: + # message.extend([name, b": ", value, b"\r\n"]) # pragma: full coverage + message.extend( + [ + b"content-type: text/plain; charset=utf-8\r\n", + b"content-length: " + str(len(msg)).encode("ascii") + b"\r\n", + b"connection: close\r\n", + b"\r\n", + msg.encode("ascii"), + ] + ) + self.transport.write(b"".join(message)) + self.transport.close() class Server: - def __init__(self): - pass + def __init__(self, config: Config): + self.config = config + import_app_instance(config) + + def run(self) -> None: + return asyncio.run(self._serve(), debug=True) + + async def _serve(self) -> None: + server = await asyncio.get_running_loop().create_server( + lambda: HttpToolsImpl(self.config), + host=self.config.host, + port=self.config.port + ) + async with server: + await server.serve_forever() + + +if __name__ == "__main__": + config = Config( + host="localhost", + port=8080, + app="uiwiz.server.main:app" + ) + server = Server(config) + server.run() \ No newline at end of file diff --git a/src/uiwiz/server/main.py b/src/uiwiz/server/main.py new file mode 100644 index 0000000..34022e4 --- /dev/null +++ b/src/uiwiz/server/main.py @@ -0,0 +1,8 @@ +from uiwiz import ui, UiwizApp + +app = UiwizApp() + +@app.page("/") +def index(): + ui.element(content="Meme") + return {} \ No newline at end of file From 13c33a8d05aab19dc211b59315b209ba671e2a7f Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Sun, 2 Nov 2025 14:13:03 +0100 Subject: [PATCH 03/11] work on server --- src/uiwiz/server/_server.py | 271 +++++++++++++++++++++++++++++++++--- 1 file changed, 250 insertions(+), 21 deletions(-) diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 3b4b8a9..7176497 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -1,12 +1,24 @@ import asyncio from asyncio import Queue, Event, TimerHandle -from typing import Any, Optional +import re +from typing import Any, Callable, Optional, cast +import urllib from pydantic import BaseModel import httptools import http import importlib from dataclasses import dataclass +from uvicorn._types import ( + ASGI3Application, + ASGIReceiveEvent, + ASGISendEvent, + HTTPRequestEvent, + HTTPResponseStartEvent, + HTTPScope, +) + +from uvicorn.protocols.http.flow_control import CLOSE_HEADER, HIGH_WATER_LIMIT, FlowControl, service_unavailable from uiwiz.app import UiwizApp import logging @@ -14,6 +26,8 @@ logger = logging.getLogger(__name__) +HEADER_RE = re.compile(b'[\x00-\x1f\x7f()<>@,;:[]={} \t\\"]') +HEADER_VALUE_RE = re.compile(b"[\x00-\x08\x0a-\x1f\x7f]") @dataclass class Config: @@ -21,6 +35,8 @@ class Config: port: int app: Optional[UiwizApp] app_instance: Optional[UiwizApp] = None + root_path: str + def import_app_instance(config: Config) -> None: if isinstance(config.app, str): @@ -30,6 +46,7 @@ def import_app_instance(config: Config) -> None: else: config.app_instance = config.app + class LifespanHandler: def __init__(self, config: Config): self.config = config @@ -77,7 +94,8 @@ def __init__(self, config: Config): self.timeout_keep_alive_task: TimerHandle | None = None self.transport: asyncio.Transport = None self.app_state = dict() - # self.cycle: RequestResponseCycle = None + self.root_path = config.root_path + self.cycle: RequestResponseCycle = None def data_received(self, data: bytes) -> None: self._unset_keepalive_if_required() @@ -96,8 +114,7 @@ def data_received(self, data: bytes) -> None: else: self._unsupported_upgrade_warning() - def eof_received(self) -> None: - ... + def eof_received(self) -> None: ... def _unset_keepalive_if_required(self) -> None: if self.timeout_keep_alive_task is not None: @@ -112,7 +129,6 @@ def connection_made(self, transport: asyncio.Transport) -> None: self.scheme = "https" if bool(transport.get_extra_info("sslcontext")) else "http" def connection_lost(self, exc: Exception | None) -> None: - if self.cycle and not self.cycle.response_complete: self.cycle.disconnected = True if self.cycle is not None: @@ -127,14 +143,55 @@ def connection_lost(self, exc: Exception | None) -> None: def on_url(self, url: bytes) -> None: self.url += url - + def on_header(self, name: bytes, value: bytes) -> None: name = name.lower() if name == b"expect" and value.lower() == b"100-continue": self.expect_100_continue = True self.headers.append((name, value)) - + def on_headers_complete(self) -> None: + http_version = self.parser.get_http_version() + method = self.parser.get_method() + self.scope["method"] = method.decode("ascii") + if http_version != "1.1": + self.scope["http_version"] = http_version + if self.parser.should_upgrade() and self._should_upgrade(): + return + parsed_url = httptools.parse_url(self.url) + raw_path = parsed_url.path + path = raw_path.decode("ascii") + if "%" in path: + path = urllib.parse.unquote(path) + full_path = self.root_path + path + full_raw_path = self.root_path.encode("ascii") + raw_path + self.scope["path"] = full_path + self.scope["raw_path"] = full_raw_path + self.scope["query_string"] = parsed_url.query or b"" + + existing_cycle = self.cycle + self.cycle = RequestResponseCycle( + scope=self.scope, + transport=self.transport, + flow=self.flow, + logger=self.logger, + access_logger=self.access_logger, + access_log=self.access_log, + default_headers=self.server_state.default_headers, + message_event=asyncio.Event(), + expect_100_continue=self.expect_100_continue, + keep_alive=http_version != "1.0", + on_response=self.on_response_complete, + ) + if existing_cycle is None or existing_cycle.response_complete: + # Standard case - start processing the request. + task = self.loop.create_task(self.cycle.run_asgi(self.config.app_instance)) + task.add_done_callback(self.tasks.discard) + self.tasks.add(task) + else: + # Pipelined HTTP requests need to be queued up. + self.flow.pause_reading() + self.pipeline.appendleft((self.cycle, self.config.app_instance)) def on_message_begin(self) -> None: import_app_instance(config) @@ -155,7 +212,6 @@ def on_message_begin(self) -> None: "state": self.app_state, } - def on_response_complete(self) -> None: if self.transport.is_closing(): return @@ -204,7 +260,6 @@ def _should_upgrade(self) -> bool: upgrade = self._get_upgrade() return upgrade == b"websocket" and self._should_upgrade_to_ws() - def get_local_addr(self) -> Optional[tuple[str, int]]: socket_info = self.transport.get_extra_info("socket") if socket_info is not None: @@ -215,7 +270,6 @@ def get_local_addr(self) -> Optional[tuple[str, int]]: if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: return (str(info[0]), int(info[1])) return None - def get_remote_addr(self) -> Optional[tuple[str, int]]: socket_info = self.transport.get_extra_info("socket") @@ -230,7 +284,7 @@ def get_remote_addr(self) -> Optional[tuple[str, int]]: if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: return (str(info[0]), int(info[1])) return None - + def send_400_response(self, msg: str) -> None: message = [http.HTTPStatus(400).phrase.encode()] # for name, value in self.server_state.default_headers: @@ -248,6 +302,187 @@ def send_400_response(self, msg: str) -> None: self.transport.close() +class RequestResponseCycle: + def __init__( + self, + scope: dict, + transport: asyncio.Transport, + default_headers: list, + message_event: asyncio.Event, + on_response: Callable, + ): + self.scope = scope + self.transport = transport + self.default_headers = default_headers + self.message_event = message_event + self.on_response = on_response + + async def run_asgi(self, app: ASGI3Application) -> None: + try: + result = await app( # type: ignore[func-returns-value] + self.scope, self.receive, self.send + ) + except BaseException as exc: + msg = "Exception in ASGI application\n" + self.logger.error(msg, exc_info=exc) + if not self.response_started: + await self.send_500_response() + else: + self.transport.close() + else: + if result is not None: + msg = "ASGI callable should return None, but returned '%s'." + self.logger.error(msg, result) + self.transport.close() + elif not self.response_started and not self.disconnected: + msg = "ASGI callable returned without starting response." + self.logger.error(msg) + await self.send_500_response() + elif not self.response_complete and not self.disconnected: + msg = "ASGI callable returned without completing response." + self.logger.error(msg) + self.transport.close() + finally: + self.on_response = lambda: None + + async def send_500_response(self) -> None: + await self.send( + { + "type": "http.response.start", + "status": 500, + "headers": [ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", b"21"), + (b"connection", b"close"), + ], + } + ) + await self.send({"type": "http.response.body", "body": b"Internal Server Error", "more_body": False}) + + # ASGI interface + async def send(self, message: ASGISendEvent) -> None: + message_type = message["type"] + + if self.flow.write_paused and not self.disconnected: + await self.flow.drain() # pragma: full coverage + + if self.disconnected: + return # pragma: full coverage + + if not self.response_started: + # Sending response status line and headers + if message_type != "http.response.start": + msg = "Expected ASGI message 'http.response.start', but got '%s'." + raise RuntimeError(msg % message_type) + message = cast("HTTPResponseStartEvent", message) + + self.response_started = True + self.waiting_for_100_continue = False + + status_code = message["status"] + headers = self.default_headers + list(message.get("headers", [])) + + if CLOSE_HEADER in self.scope["headers"] and CLOSE_HEADER not in headers: + headers = headers + [CLOSE_HEADER] + + if self.access_log: + self.access_logger.info( + '%s - "%s %s HTTP/%s" %d', + get_client_addr(self.scope), + self.scope["method"], + get_path_with_query_string(self.scope), + self.scope["http_version"], + status_code, + ) + + # Write response status line and headers + content = [STATUS_LINE[status_code]] + + for name, value in headers: + if HEADER_RE.search(name): + raise RuntimeError("Invalid HTTP header name.") # pragma: full coverage + if HEADER_VALUE_RE.search(value): + raise RuntimeError("Invalid HTTP header value.") + + name = name.lower() + if name == b"content-length" and self.chunked_encoding is None: + self.expected_content_length = int(value.decode()) + self.chunked_encoding = False + elif name == b"transfer-encoding" and value.lower() == b"chunked": + self.expected_content_length = 0 + self.chunked_encoding = True + elif name == b"connection" and value.lower() == b"close": + self.keep_alive = False + content.extend([name, b": ", value, b"\r\n"]) + + if self.chunked_encoding is None and self.scope["method"] != "HEAD" and status_code not in (204, 304): + # Neither content-length nor transfer-encoding specified + self.chunked_encoding = True + content.append(b"transfer-encoding: chunked\r\n") + + content.append(b"\r\n") + self.transport.write(b"".join(content)) + + elif not self.response_complete: + # Sending response body + if message_type != "http.response.body": + msg = "Expected ASGI message 'http.response.body', but got '%s'." + raise RuntimeError(msg % message_type) + + body = cast(bytes, message.get("body", b"")) + more_body = message.get("more_body", False) + + # Write response body + if self.scope["method"] == "HEAD": + self.expected_content_length = 0 + elif self.chunked_encoding: + if body: + content = [b"%x\r\n" % len(body), body, b"\r\n"] + else: + content = [] + if not more_body: + content.append(b"0\r\n\r\n") + self.transport.write(b"".join(content)) + else: + num_bytes = len(body) + if num_bytes > self.expected_content_length: + raise RuntimeError("Response content longer than Content-Length") + else: + self.expected_content_length -= num_bytes + self.transport.write(body) + + # Handle response completion + if not more_body: + if self.expected_content_length != 0: + raise RuntimeError("Response content shorter than Content-Length") + self.response_complete = True + self.message_event.set() + if not self.keep_alive: + self.transport.close() + self.on_response() + + else: + # Response already sent + msg = "Unexpected ASGI message '%s' sent, after response already completed." + raise RuntimeError(msg % message_type) + + async def receive(self) -> ASGIReceiveEvent: + if self.waiting_for_100_continue and not self.transport.is_closing(): + self.transport.write(b"HTTP/1.1 100 Continue\r\n\r\n") + self.waiting_for_100_continue = False + + if not self.disconnected and not self.response_complete: + self.flow.resume_reading() + await self.message_event.wait() + self.message_event.clear() + + if self.disconnected or self.response_complete: + return {"type": "http.disconnect"} + message: HTTPRequestEvent = {"type": "http.request", "body": self.body, "more_body": self.more_body} + self.body = b"" + return message + + class Server: def __init__(self, config: Config): self.config = config @@ -255,23 +490,17 @@ def __init__(self, config: Config): def run(self) -> None: return asyncio.run(self._serve(), debug=True) - + async def _serve(self) -> None: server = await asyncio.get_running_loop().create_server( - lambda: HttpToolsImpl(self.config), - host=self.config.host, - port=self.config.port + lambda: HttpToolsImpl(self.config), host=self.config.host, port=self.config.port ) async with server: await server.serve_forever() if __name__ == "__main__": - config = Config( - host="localhost", - port=8080, - app="uiwiz.server.main:app" - ) + config = Config(host="localhost", port=8080, app="uiwiz.server.main:app", root_path="") server = Server(config) - server.run() \ No newline at end of file + server.run() From 67f4faaa50fa9d07c6fa40aca62bc3e535d87746 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Sun, 2 Nov 2025 22:10:06 +0100 Subject: [PATCH 04/11] More work on server --- src/uiwiz/server/_server.py | 269 +++++++++++++++--------------------- src/uiwiz/server/main.py | 22 ++- 2 files changed, 129 insertions(+), 162 deletions(-) diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 7176497..c52b0b6 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -3,11 +3,11 @@ import re from typing import Any, Callable, Optional, cast import urllib -from pydantic import BaseModel import httptools import http import importlib from dataclasses import dataclass +from collections import deque from uvicorn._types import ( ASGI3Application, @@ -17,6 +17,9 @@ HTTPResponseStartEvent, HTTPScope, ) +from uvicorn.protocols.http.flow_control import FlowControl +from uvicorn.protocols.http.httptools_impl import RequestResponseCycle +from time import perf_counter from uvicorn.protocols.http.flow_control import CLOSE_HEADER, HIGH_WATER_LIMIT, FlowControl, service_unavailable from uiwiz.app import UiwizApp @@ -33,18 +36,23 @@ class Config: host: str port: int + root_path: str app: Optional[UiwizApp] app_instance: Optional[UiwizApp] = None - root_path: str def import_app_instance(config: Config) -> None: + start = perf_counter() if isinstance(config.app, str): module_name, _, app = config.app.partition(":") + module = importlib.import_module(module_name) + module = importlib.reload(module) config.app_instance = getattr(module, app) else: config.app_instance = config.app + end = perf_counter() + logger.info(f"Module reloaded in: {end - start}") class LifespanHandler: @@ -61,9 +69,22 @@ async def startup(self) -> None: lifespan_task = loop.create_task(self.execute()) await self.receive_queue.put({"type": "lifespan.startup"}) await self.startup_done_event.wait() + await lifespan_task logger.info("Startup complete") + async def shutdown(self) -> None: + logger.info("Waiting for application shutdown.") + shutdown_event = {"type": "lifespan.shutdown"} + await self.receive_queue.put(shutdown_event) + await self.shutdown_done_event.wait() + + # if self.shutdown_failed or (self.error_occured and self.config.lifespan == "on"): + # self.logger.error("Application shutdown failed. Exiting.") + # self.should_exit = True + # else: + # self.logger.info("Application shutdown complete.") + async def execute(self) -> None: app = self.config.app_instance scope = { @@ -71,7 +92,7 @@ async def execute(self) -> None: "asgi": {"version": "3", "spec_version": "2.0"}, "state": self.state, } - await app(scope, self.receive_queue, self.send) + await app(scope, self.receive, self.send) async def send(self, message: dict) -> None: task = { @@ -82,6 +103,15 @@ async def send(self, message: dict) -> None: } task.get(message["type"], lambda: 1)() + async def receive(self): + return await self.receive_queue.get() + +class ServerState: + def __init__(self): + self.total_requests = 0 + self.connections = set() + self.tasks: set[asyncio.Task[None]] = set() + self.default_headers: list[tuple[bytes, bytes]] = [] class HttpToolsImpl(asyncio.Protocol): def __init__(self, config: Config): @@ -97,6 +127,16 @@ def __init__(self, config: Config): self.root_path = config.root_path self.cycle: RequestResponseCycle = None + self.timeout_keep_alive = 10 + + self.flow: FlowControl = None # type: ignore[assignment] + self.pipeline: deque[tuple[RequestResponseCycle, ASGI3Application]] = deque() + self.logger = logger + self.access_logger = logger + self.access_log = logger.hasHandlers() + self.server_state = ServerState() + self.tasks = self.server_state.tasks + def data_received(self, data: bytes) -> None: self._unset_keepalive_if_required() logger.info("Got request") @@ -123,12 +163,12 @@ def _unset_keepalive_if_required(self) -> None: def connection_made(self, transport: asyncio.Transport) -> None: self.transport = transport - # self.flow = FlowControl(transport) should not be relevant + self.flow = FlowControl(transport) self.server = self.get_local_addr() self.client = self.get_remote_addr() self.scheme = "https" if bool(transport.get_extra_info("sslcontext")) else "http" - def connection_lost(self, exc: Exception | None) -> None: + def connection_lost(self, exc: Optional[Exception]) -> None: if self.cycle and not self.cycle.response_complete: self.cycle.disconnected = True if self.cycle is not None: @@ -169,8 +209,15 @@ def on_headers_complete(self) -> None: self.scope["raw_path"] = full_raw_path self.scope["query_string"] = parsed_url.query or b"" + shutdown_task = None + if config.app_instance: + shutdown_task = asyncio.get_event_loop().create_task(self.lifespan.shutdown()) + import_app_instance(config) + startup_task = asyncio.get_running_loop().create_task(self.lifespan.startup()) + + existing_cycle = self.cycle - self.cycle = RequestResponseCycle( + self.cycle = RRCycle( scope=self.scope, transport=self.transport, flow=self.flow, @@ -181,6 +228,8 @@ def on_headers_complete(self) -> None: message_event=asyncio.Event(), expect_100_continue=self.expect_100_continue, keep_alive=http_version != "1.0", + shutdown_task=shutdown_task, + startup_task=startup_task, on_response=self.on_response_complete, ) if existing_cycle is None or existing_cycle.response_complete: @@ -194,9 +243,6 @@ def on_headers_complete(self) -> None: self.pipeline.appendleft((self.cycle, self.config.app_instance)) def on_message_begin(self) -> None: - import_app_instance(config) - # asyncio.get_running_loop().create_task(self.lifespan.startup()) - self.url = b"" self.expect_100_continue = False self.headers = [] @@ -212,6 +258,31 @@ def on_message_begin(self) -> None: "state": self.app_state, } + + def shutdown(self) -> None: + """ + Called by the server to commence a graceful shutdown. + """ + if self.cycle is None or self.cycle.response_complete: + self.transport.close() + else: + self.cycle.keep_alive = False + + def on_body(self, body: bytes) -> None: + if (self.parser.should_upgrade() and self._should_upgrade()) or self.cycle.response_complete: + return + self.cycle.body += body + if len(self.cycle.body) > HIGH_WATER_LIMIT: + self.flow.pause_reading() + self.cycle.message_event.set() + + + def on_message_complete(self) -> None: + if (self.parser.should_upgrade() and self._should_upgrade()) or self.cycle.response_complete: + return + self.cycle.more_body = False + self.cycle.message_event.set() + def on_response_complete(self) -> None: if self.transport.is_closing(): return @@ -301,23 +372,38 @@ def send_400_response(self, msg: str) -> None: self.transport.write(b"".join(message)) self.transport.close() + def pause_writing(self) -> None: + """ + Called by the transport when the write buffer exceeds the high water mark. + """ + self.flow.pause_writing() # pragma: full coverage + + def resume_writing(self) -> None: + """ + Called by the transport when the write buffer drops below the low water mark. + """ + self.flow.resume_writing() # pragma: full coverage + + def timeout_keep_alive_handler(self) -> None: + """ + Called on a keep-alive connection if no new data is received after a short + delay. + """ + if not self.transport.is_closing(): + self.transport.close() -class RequestResponseCycle: - def __init__( - self, - scope: dict, - transport: asyncio.Transport, - default_headers: list, - message_event: asyncio.Event, - on_response: Callable, - ): - self.scope = scope - self.transport = transport - self.default_headers = default_headers - self.message_event = message_event - self.on_response = on_response +class RRCycle(RequestResponseCycle): + + def __init__(self, *args, **kwargs): + self.shutdown_task = kwargs.pop("shutdown_task") + self.startup_task = kwargs.pop("startup_task") + super().__init__(*args, **kwargs) + # ASGI exception wrapper async def run_asgi(self, app: ASGI3Application) -> None: + if self.shutdown_task: + await self.shutdown_task + await self.startup_task try: result = await app( # type: ignore[func-returns-value] self.scope, self.receive, self.send @@ -345,143 +431,6 @@ async def run_asgi(self, app: ASGI3Application) -> None: finally: self.on_response = lambda: None - async def send_500_response(self) -> None: - await self.send( - { - "type": "http.response.start", - "status": 500, - "headers": [ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", b"21"), - (b"connection", b"close"), - ], - } - ) - await self.send({"type": "http.response.body", "body": b"Internal Server Error", "more_body": False}) - - # ASGI interface - async def send(self, message: ASGISendEvent) -> None: - message_type = message["type"] - - if self.flow.write_paused and not self.disconnected: - await self.flow.drain() # pragma: full coverage - - if self.disconnected: - return # pragma: full coverage - - if not self.response_started: - # Sending response status line and headers - if message_type != "http.response.start": - msg = "Expected ASGI message 'http.response.start', but got '%s'." - raise RuntimeError(msg % message_type) - message = cast("HTTPResponseStartEvent", message) - - self.response_started = True - self.waiting_for_100_continue = False - - status_code = message["status"] - headers = self.default_headers + list(message.get("headers", [])) - - if CLOSE_HEADER in self.scope["headers"] and CLOSE_HEADER not in headers: - headers = headers + [CLOSE_HEADER] - - if self.access_log: - self.access_logger.info( - '%s - "%s %s HTTP/%s" %d', - get_client_addr(self.scope), - self.scope["method"], - get_path_with_query_string(self.scope), - self.scope["http_version"], - status_code, - ) - - # Write response status line and headers - content = [STATUS_LINE[status_code]] - - for name, value in headers: - if HEADER_RE.search(name): - raise RuntimeError("Invalid HTTP header name.") # pragma: full coverage - if HEADER_VALUE_RE.search(value): - raise RuntimeError("Invalid HTTP header value.") - - name = name.lower() - if name == b"content-length" and self.chunked_encoding is None: - self.expected_content_length = int(value.decode()) - self.chunked_encoding = False - elif name == b"transfer-encoding" and value.lower() == b"chunked": - self.expected_content_length = 0 - self.chunked_encoding = True - elif name == b"connection" and value.lower() == b"close": - self.keep_alive = False - content.extend([name, b": ", value, b"\r\n"]) - - if self.chunked_encoding is None and self.scope["method"] != "HEAD" and status_code not in (204, 304): - # Neither content-length nor transfer-encoding specified - self.chunked_encoding = True - content.append(b"transfer-encoding: chunked\r\n") - - content.append(b"\r\n") - self.transport.write(b"".join(content)) - - elif not self.response_complete: - # Sending response body - if message_type != "http.response.body": - msg = "Expected ASGI message 'http.response.body', but got '%s'." - raise RuntimeError(msg % message_type) - - body = cast(bytes, message.get("body", b"")) - more_body = message.get("more_body", False) - - # Write response body - if self.scope["method"] == "HEAD": - self.expected_content_length = 0 - elif self.chunked_encoding: - if body: - content = [b"%x\r\n" % len(body), body, b"\r\n"] - else: - content = [] - if not more_body: - content.append(b"0\r\n\r\n") - self.transport.write(b"".join(content)) - else: - num_bytes = len(body) - if num_bytes > self.expected_content_length: - raise RuntimeError("Response content longer than Content-Length") - else: - self.expected_content_length -= num_bytes - self.transport.write(body) - - # Handle response completion - if not more_body: - if self.expected_content_length != 0: - raise RuntimeError("Response content shorter than Content-Length") - self.response_complete = True - self.message_event.set() - if not self.keep_alive: - self.transport.close() - self.on_response() - - else: - # Response already sent - msg = "Unexpected ASGI message '%s' sent, after response already completed." - raise RuntimeError(msg % message_type) - - async def receive(self) -> ASGIReceiveEvent: - if self.waiting_for_100_continue and not self.transport.is_closing(): - self.transport.write(b"HTTP/1.1 100 Continue\r\n\r\n") - self.waiting_for_100_continue = False - - if not self.disconnected and not self.response_complete: - self.flow.resume_reading() - await self.message_event.wait() - self.message_event.clear() - - if self.disconnected or self.response_complete: - return {"type": "http.disconnect"} - message: HTTPRequestEvent = {"type": "http.request", "body": self.body, "more_body": self.more_body} - self.body = b"" - return message - class Server: def __init__(self, config: Config): diff --git a/src/uiwiz/server/main.py b/src/uiwiz/server/main.py index 34022e4..caeda82 100644 --- a/src/uiwiz/server/main.py +++ b/src/uiwiz/server/main.py @@ -1,8 +1,26 @@ from uiwiz import ui, UiwizApp +import logging +from contextlib import asynccontextmanager +logger = logging.getLogger(__name__) + +@asynccontextmanager +async def lifespan(app): + logger.info("Start app") + yield + logger.info("Closing app") + + +app = UiwizApp(lifespan=lifespan) + + + -app = UiwizApp() @app.page("/") def index(): - ui.element(content="Meme") + ui.element(content="Memasdsade") + ui.element(content="Memasdsade") + + logger.info("Calling index") + return {} \ No newline at end of file From 79ea17759a42c210032020e32f5927d82d3a7729 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Tue, 4 Nov 2025 23:05:07 +0100 Subject: [PATCH 05/11] Implemented a custom server that reloads on every request --- src/uiwiz/server/_server.py | 36 +++++++++++++++++++----------------- src/uiwiz/server/main.py | 7 ++----- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index c52b0b6..6094502 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -69,7 +69,6 @@ async def startup(self) -> None: lifespan_task = loop.create_task(self.execute()) await self.receive_queue.put({"type": "lifespan.startup"}) await self.startup_done_event.wait() - await lifespan_task logger.info("Startup complete") @@ -83,7 +82,7 @@ async def shutdown(self) -> None: # self.logger.error("Application shutdown failed. Exiting.") # self.should_exit = True # else: - # self.logger.info("Application shutdown complete.") + logger.info("Application shutdown complete.") async def execute(self) -> None: app = self.config.app_instance @@ -209,12 +208,8 @@ def on_headers_complete(self) -> None: self.scope["raw_path"] = full_raw_path self.scope["query_string"] = parsed_url.query or b"" - shutdown_task = None - if config.app_instance: - shutdown_task = asyncio.get_event_loop().create_task(self.lifespan.shutdown()) import_app_instance(config) - startup_task = asyncio.get_running_loop().create_task(self.lifespan.startup()) - + startup_task = self.loop.create_task(self.lifespan.startup()) existing_cycle = self.cycle self.cycle = RRCycle( @@ -228,20 +223,31 @@ def on_headers_complete(self) -> None: message_event=asyncio.Event(), expect_100_continue=self.expect_100_continue, keep_alive=http_version != "1.0", - shutdown_task=shutdown_task, - startup_task=startup_task, on_response=self.on_response_complete, ) if existing_cycle is None or existing_cycle.response_complete: # Standard case - start processing the request. - task = self.loop.create_task(self.cycle.run_asgi(self.config.app_instance)) - task.add_done_callback(self.tasks.discard) - self.tasks.add(task) + startup_task.add_done_callback(self.run_asgi_when_done) + self.tasks.add(startup_task) + pass else: # Pipelined HTTP requests need to be queued up. self.flow.pause_reading() self.pipeline.appendleft((self.cycle, self.config.app_instance)) + def run_asgi_when_done(self, task): + task = self.loop.create_task(self.cycle.run_asgi(self.config.app_instance)) + task.add_done_callback(self._shutdown) + self.tasks.add(task) + + def _shutdown(self, *args): + task = self.loop.create_task(self.lifespan.shutdown()) + task.add_done_callback(self.tasks.discard) + self.tasks.add(task) + + done = {ta for ta in set(self.tasks) if ta.done()} + self.tasks.difference_update(done) + def on_message_begin(self) -> None: self.url = b"" self.expect_100_continue = False @@ -395,15 +401,11 @@ def timeout_keep_alive_handler(self) -> None: class RRCycle(RequestResponseCycle): def __init__(self, *args, **kwargs): - self.shutdown_task = kwargs.pop("shutdown_task") - self.startup_task = kwargs.pop("startup_task") super().__init__(*args, **kwargs) # ASGI exception wrapper async def run_asgi(self, app: ASGI3Application) -> None: - if self.shutdown_task: - await self.shutdown_task - await self.startup_task + print("Run asgi") try: result = await app( # type: ignore[func-returns-value] self.scope, self.receive, self.send diff --git a/src/uiwiz/server/main.py b/src/uiwiz/server/main.py index caeda82..ee05dde 100644 --- a/src/uiwiz/server/main.py +++ b/src/uiwiz/server/main.py @@ -13,13 +13,10 @@ async def lifespan(app): app = UiwizApp(lifespan=lifespan) - - - @app.page("/") def index(): - ui.element(content="Memasdsade") - ui.element(content="Memasdsade") + ui.element(content="This is pretty cool") + ui.element(content="Custom server working") logger.info("Calling index") From f5781abe8ba7c40e2c91897c77da16152c0fe99b Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Mon, 15 Dec 2025 21:48:04 +0100 Subject: [PATCH 06/11] More work on the server --- src/uiwiz/server/_server.py | 32 ++++++++++++++++++++++++-------- src/uiwiz/server/main.py | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 6094502..7bf99a8 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -1,7 +1,7 @@ import asyncio from asyncio import Queue, Event, TimerHandle import re -from typing import Any, Callable, Optional, cast +from typing import Any, Optional import urllib import httptools import http @@ -11,23 +11,25 @@ from uvicorn._types import ( ASGI3Application, - ASGIReceiveEvent, - ASGISendEvent, - HTTPRequestEvent, - HTTPResponseStartEvent, - HTTPScope, ) from uvicorn.protocols.http.flow_control import FlowControl from uvicorn.protocols.http.httptools_impl import RequestResponseCycle from time import perf_counter -from uvicorn.protocols.http.flow_control import CLOSE_HEADER, HIGH_WATER_LIMIT, FlowControl, service_unavailable +from uvicorn.protocols.http.flow_control import HIGH_WATER_LIMIT from uiwiz.app import UiwizApp import logging +formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(name)s - %(lineno)d - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" +) logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) +sc = logging.StreamHandler() +sc.setFormatter(formatter) +logger.addHandler(sc) HEADER_RE = re.compile(b'[\x00-\x1f\x7f()<>@,;:[]={} \t\\"]') HEADER_VALUE_RE = re.compile(b"[\x00-\x08\x0a-\x1f\x7f]") @@ -138,7 +140,6 @@ def __init__(self, config: Config): def data_received(self, data: bytes) -> None: self._unset_keepalive_if_required() - logger.info("Got request") try: self.parser.feed_data(data) @@ -451,6 +452,21 @@ async def _serve(self) -> None: if __name__ == "__main__": + import threading + import time + + def run(): + while True: + print(1) + time.sleep(2) + start = time.perf_counter() + t = threading.Thread(target=run) + t.start() + end = time.perf_counter() + print(f"thread start time: {(end - start):2f}") + + + config = Config(host="localhost", port=8080, app="uiwiz.server.main:app", root_path="") server = Server(config) diff --git a/src/uiwiz/server/main.py b/src/uiwiz/server/main.py index ee05dde..3e955bf 100644 --- a/src/uiwiz/server/main.py +++ b/src/uiwiz/server/main.py @@ -16,7 +16,7 @@ async def lifespan(app): @app.page("/") def index(): ui.element(content="This is pretty cool") - ui.element(content="Custom server working") + ui.element(content="Custom server working!") logger.info("Calling index") From a17163f98029b5c72d6dc6b534309419bc912e34 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Mon, 22 Dec 2025 22:14:32 +0100 Subject: [PATCH 07/11] Minor fix on reload. --- examples/ace_editor.py | 13 +++++++++++-- src/uiwiz/server/__init__.py | 7 +++++++ src/uiwiz/server/_server.py | 35 +++++++++-------------------------- 3 files changed, 27 insertions(+), 28 deletions(-) create mode 100644 src/uiwiz/server/__init__.py diff --git a/examples/ace_editor.py b/examples/ace_editor.py index d785bcc..6afa4bd 100644 --- a/examples/ace_editor.py +++ b/examples/ace_editor.py @@ -1,11 +1,20 @@ +from contextlib import asynccontextmanager import uvicorn from pydantic import BaseModel from uiwiz import UiwizApp, ui +from uiwiz.server import run -app = UiwizApp() +@asynccontextmanager +async def lifespan(app): + print("") + yield + print("") + +app = UiwizApp(lifespan=lifespan) + class DataInput(BaseModel): ace_data: str @@ -35,4 +44,4 @@ async def home_page(): if __name__ == "__main__": - uvicorn.run("ace_editor:app", reload=True) + run("ace_editor:app") diff --git a/src/uiwiz/server/__init__.py b/src/uiwiz/server/__init__.py new file mode 100644 index 0000000..a560aaf --- /dev/null +++ b/src/uiwiz/server/__init__.py @@ -0,0 +1,7 @@ +from uiwiz.server._server import Config, Server + +def run(app: str, host: str = "localhost", port:int = 8080): + config = Config(host=host, port=port, app=app, root_path="") + + server = Server(config) + server.run() \ No newline at end of file diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 7bf99a8..2500211 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -209,7 +209,7 @@ def on_headers_complete(self) -> None: self.scope["raw_path"] = full_raw_path self.scope["query_string"] = parsed_url.query or b"" - import_app_instance(config) + import_app_instance(self.config) startup_task = self.loop.create_task(self.lifespan.startup()) existing_cycle = self.cycle @@ -406,7 +406,6 @@ def __init__(self, *args, **kwargs): # ASGI exception wrapper async def run_asgi(self, app: ASGI3Application) -> None: - print("Run asgi") try: result = await app( # type: ignore[func-returns-value] self.scope, self.receive, self.send @@ -441,33 +440,17 @@ def __init__(self, config: Config): import_app_instance(config) def run(self) -> None: - return asyncio.run(self._serve(), debug=True) + try: + return asyncio.run(self._serve(), debug=True) + except KeyboardInterrupt: + return async def _serve(self) -> None: server = await asyncio.get_running_loop().create_server( lambda: HttpToolsImpl(self.config), host=self.config.host, port=self.config.port ) async with server: - await server.serve_forever() - - -if __name__ == "__main__": - import threading - import time - - def run(): - while True: - print(1) - time.sleep(2) - start = time.perf_counter() - t = threading.Thread(target=run) - t.start() - end = time.perf_counter() - print(f"thread start time: {(end - start):2f}") - - - - config = Config(host="localhost", port=8080, app="uiwiz.server.main:app", root_path="") - - server = Server(config) - server.run() + try: + await server.serve_forever() + except asyncio.exceptions.CancelledError: + return From e3ea702614d0483f7ad6c67c6955791a1ffaf5a1 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Wed, 28 Jan 2026 22:07:51 +0100 Subject: [PATCH 08/11] Server complete with auto reloading of module without reloading the lifespan. --- examples/ace_editor.py | 1 - examples/dict_example.py | 4 ++-- examples/drawer_example.py | 4 ++-- examples/echart_example.py | 4 ++-- examples/edit_table.py | 4 ++-- src/uiwiz/server/_server.py | 42 +++++++++++++++++-------------------- 6 files changed, 27 insertions(+), 32 deletions(-) diff --git a/examples/ace_editor.py b/examples/ace_editor.py index 6afa4bd..22d32f2 100644 --- a/examples/ace_editor.py +++ b/examples/ace_editor.py @@ -1,5 +1,4 @@ from contextlib import asynccontextmanager -import uvicorn from pydantic import BaseModel from uiwiz import UiwizApp, ui diff --git a/examples/dict_example.py b/examples/dict_example.py index 181543e..04e42fc 100644 --- a/examples/dict_example.py +++ b/examples/dict_example.py @@ -1,4 +1,4 @@ -import uvicorn +from uiwiz import server from uiwiz import UiwizApp, ui @@ -42,4 +42,4 @@ async def test(): if __name__ == "__main__": - uvicorn.run("dict_example:app", reload=True) + server.run("dict_example:app") diff --git a/examples/drawer_example.py b/examples/drawer_example.py index 1ca3463..1403349 100644 --- a/examples/drawer_example.py +++ b/examples/drawer_example.py @@ -1,6 +1,6 @@ import logging -import uvicorn +from uiwiz import server import uiwiz.ui as ui from uiwiz.app import UiwizApp @@ -57,4 +57,4 @@ async def test(): if __name__ == "__main__": - uvicorn.run("drawer_example:app", reload=True) + server.run("drawer_example:app") diff --git a/examples/echart_example.py b/examples/echart_example.py index 0de2d12..42c8421 100644 --- a/examples/echart_example.py +++ b/examples/echart_example.py @@ -1,6 +1,6 @@ from random import randint -import uvicorn +from uiwiz import server from uiwiz import UiwizApp, ui @@ -90,4 +90,4 @@ async def test(): if __name__ == "__main__": - uvicorn.run("echart_example:app", reload=True) + server.run("echart_example:app") diff --git a/examples/edit_table.py b/examples/edit_table.py index 8dece39..8be6905 100644 --- a/examples/edit_table.py +++ b/examples/edit_table.py @@ -1,6 +1,6 @@ from typing import Annotated -import uvicorn +from uiwiz import server from fastapi import Request from pydantic import BaseModel, Field @@ -79,4 +79,4 @@ async def test(request: Request): if __name__ == "__main__": - uvicorn.run("edit_table:app", reload=True) + server.run("edit_table:app") diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index 2500211..c47231d 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -8,6 +8,7 @@ import importlib from dataclasses import dataclass from collections import deque +from contextlib import suppress from uvicorn._types import ( ASGI3Application, @@ -39,7 +40,7 @@ class Config: host: str port: int root_path: str - app: Optional[UiwizApp] + app: Optional[str] = None app_instance: Optional[UiwizApp] = None @@ -47,7 +48,6 @@ def import_app_instance(config: Config) -> None: start = perf_counter() if isinstance(config.app, str): module_name, _, app = config.app.partition(":") - module = importlib.import_module(module_name) module = importlib.reload(module) config.app_instance = getattr(module, app) @@ -80,10 +80,6 @@ async def shutdown(self) -> None: await self.receive_queue.put(shutdown_event) await self.shutdown_done_event.wait() - # if self.shutdown_failed or (self.error_occured and self.config.lifespan == "on"): - # self.logger.error("Application shutdown failed. Exiting.") - # self.should_exit = True - # else: logger.info("Application shutdown complete.") async def execute(self) -> None: @@ -105,19 +101,21 @@ async def send(self, message: dict) -> None: task.get(message["type"], lambda: 1)() async def receive(self): - return await self.receive_queue.get() + with suppress(asyncio.CancelledError): + return await self.receive_queue.get() class ServerState: - def __init__(self): + def __init__(self, config: Config): self.total_requests = 0 self.connections = set() self.tasks: set[asyncio.Task[None]] = set() self.default_headers: list[tuple[bytes, bytes]] = [] + self.init_load: bool = True + self.lifespan = LifespanHandler(config) class HttpToolsImpl(asyncio.Protocol): - def __init__(self, config: Config): + def __init__(self, config: Config, server_state: ServerState): self.config = config - self.lifespan = LifespanHandler(config) self.app = config.app self.state: dict[str, Any] = {} self.loop = asyncio.get_event_loop() @@ -135,7 +133,7 @@ def __init__(self, config: Config): self.logger = logger self.access_logger = logger self.access_log = logger.hasHandlers() - self.server_state = ServerState() + self.server_state = server_state self.tasks = self.server_state.tasks def data_received(self, data: bytes) -> None: @@ -210,7 +208,6 @@ def on_headers_complete(self) -> None: self.scope["query_string"] = parsed_url.query or b"" import_app_instance(self.config) - startup_task = self.loop.create_task(self.lifespan.startup()) existing_cycle = self.cycle self.cycle = RRCycle( @@ -227,20 +224,12 @@ def on_headers_complete(self) -> None: on_response=self.on_response_complete, ) if existing_cycle is None or existing_cycle.response_complete: - # Standard case - start processing the request. - startup_task.add_done_callback(self.run_asgi_when_done) - self.tasks.add(startup_task) - pass + self.tasks.add(self.loop.create_task(self.cycle.run_asgi(self.config.app_instance))) else: # Pipelined HTTP requests need to be queued up. self.flow.pause_reading() self.pipeline.appendleft((self.cycle, self.config.app_instance)) - def run_asgi_when_done(self, task): - task = self.loop.create_task(self.cycle.run_asgi(self.config.app_instance)) - task.add_done_callback(self._shutdown) - self.tasks.add(task) - def _shutdown(self, *args): task = self.loop.create_task(self.lifespan.shutdown()) task.add_done_callback(self.tasks.discard) @@ -437,6 +426,7 @@ async def run_asgi(self, app: ASGI3Application) -> None: class Server: def __init__(self, config: Config): self.config = config + self.server_state = None import_app_instance(config) def run(self) -> None: @@ -446,11 +436,17 @@ def run(self) -> None: return async def _serve(self) -> None: - server = await asyncio.get_running_loop().create_server( - lambda: HttpToolsImpl(self.config), host=self.config.host, port=self.config.port + loop = asyncio.get_running_loop() + self.server_state = ServerState(self.config) + server = await loop.create_server( + lambda: HttpToolsImpl(self.config, self.server_state), host=self.config.host, port=self.config.port ) async with server: + await loop.create_task(self.server_state.lifespan.startup()) try: await server.serve_forever() except asyncio.exceptions.CancelledError: + await self.server_state.lifespan.shutdown() return + await self.server_state.lifespan.shutdown() + From 5423ac37a2501a8d98e08581a155383d0f203737 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Wed, 28 Jan 2026 22:08:26 +0100 Subject: [PATCH 09/11] Work on a minor problem --- examples/input_example.py | 10 ++++------ src/uiwiz/page_definition.py | 8 +++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/input_example.py b/examples/input_example.py index edab438..dfa8451 100644 --- a/examples/input_example.py +++ b/examples/input_example.py @@ -3,8 +3,8 @@ from typing import Annotated, Optional import pandas as pd -import uvicorn -from fastapi import Depends +from uiwiz import server +from fastapi import Depends, Request from pydantic import BaseModel from uiwiz import Element, PageDefinition, PageRouter, UiwizApp, ui @@ -59,12 +59,10 @@ def footer(self, content): route = PageRouter(page_definition_class=MyDefinition) -PageRouter() - # set static page title @route.page("/", title="Input Example") -async def test(page: Annotated[MyDefinition, Depends()]): +async def test(page: Annotated[MyDefinition, Depends()], request): # set dynamic page title page.title = "Dynamic title" # set dynamic page lang @@ -152,4 +150,4 @@ async def test(page: Annotated[MyDefinition, Depends()]): if __name__ == "__main__": - uvicorn.run("input_example:app", reload=True) + server.run("input_example:app") diff --git a/src/uiwiz/page_definition.py b/src/uiwiz/page_definition.py index fbf1f55..4ee27b4 100644 --- a/src/uiwiz/page_definition.py +++ b/src/uiwiz/page_definition.py @@ -10,6 +10,7 @@ from uiwiz.version import __version__ + class PageDefinition: html_ele: Element header_ele: Element @@ -51,6 +52,7 @@ def footer(self, content: Element) -> None: """ self._lang: str = "en" + self._title_ele: Optional[Element] = None @property def lang(self) -> str: @@ -63,11 +65,11 @@ def lang(self, value: str) -> None: @property def title(self) -> str: - return self.title_ele.content + return self._title_ele.content @title.setter def title(self, value: str) -> None: - self.title_ele.content = value + self._title_ele.content = value async def render( self, @@ -102,7 +104,7 @@ def render(self): Element("meta", charset="utf-8") Element("meta", description=frame.meta_description_content) - self.title_ele = Element("title", content=page_title) + self._title_ele = Element("title", content=page_title) Element("link", href=f"/_static/{__version__}/libs/output.css", rel="stylesheet", type="text/css") Element("link", href=f"/_static/{__version__}/libs/daisyui.css", rel="stylesheet", type="text/css") From 8ed042b2464ee4fc24f68f4de19573ff5b797ac8 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Thu, 29 Jan 2026 22:57:58 +0100 Subject: [PATCH 10/11] Fixed an issue with the page.render not using the instance of the page definition by uiwiz and instead use the fastapi instance provided by depends. Made it possible to have different names for request and reponse in endpoint params --- src/uiwiz/page_route.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/uiwiz/page_route.py b/src/uiwiz/page_route.py index 0cab9f6..d1e028b 100644 --- a/src/uiwiz/page_route.py +++ b/src/uiwiz/page_route.py @@ -175,8 +175,13 @@ async def decorated(*dec_args, **dec_kwargs: DecKwargs) -> Response: Frame.get_stack().del_stack() # Create frame before function is called - request = dec_kwargs["request"] - response = dec_kwargs["response"] + request = None + response = None + for value in dec_kwargs.values(): + if isinstance(value, Request): + request = value + if isinstance(value, Response): + response = value if self.page_definition_class is None: self.page_definition_class = request.app.page_definition_class @@ -185,7 +190,7 @@ async def decorated(*dec_args, **dec_kwargs: DecKwargs) -> Response: page = page_class() - dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} + dec_kwargs = {k: v if not isinstance(v, PageDefinition) else page for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} user_method = partial(func, *dec_args, **dec_kwargs) result = await page.render(user_method=user_method, request=request, title=cap_title) if isinstance(result, Response): From 8b01678b63d18e4076a078fa6269ce03287ac170 Mon Sep 17 00:00:00 2001 From: Ditlev Stjerne Date: Thu, 29 Jan 2026 22:58:04 +0100 Subject: [PATCH 11/11] Updated examples --- examples/input_example.py | 2 +- examples/multipage/main.py | 14 +++++++------- examples/run_simple.py | 4 ++-- examples/run_tabs.py | 6 ++---- examples/sample.py | 7 ++----- examples/validate_form_example.py | 5 ++--- 6 files changed, 16 insertions(+), 22 deletions(-) diff --git a/examples/input_example.py b/examples/input_example.py index dfa8451..715e37b 100644 --- a/examples/input_example.py +++ b/examples/input_example.py @@ -62,7 +62,7 @@ def footer(self, content): # set static page title @route.page("/", title="Input Example") -async def test(page: Annotated[MyDefinition, Depends()], request): +async def test(page: Annotated[MyDefinition, Depends()], req: Request): # set dynamic page title page.title = "Dynamic title" # set dynamic page lang diff --git a/examples/multipage/main.py b/examples/multipage/main.py index 856fc6a..fb10acc 100644 --- a/examples/multipage/main.py +++ b/examples/multipage/main.py @@ -1,8 +1,7 @@ -import uvicorn +from uiwiz import server -from examples.multipage.second_page import router +from multipage.second_page import router from uiwiz import UiwizApp, ui -from uiwiz.shared import page_map app = UiwizApp() app.include_router(router) @@ -13,10 +12,11 @@ async def test(): with ui.element().classes("col lg:px-80"): with ui.element().classes("w-full"): ui.element(content="Hello, world!") - for route in page_map.values(): - with ui.col(): - ui.link(route, route) + with ui.col(): + ui.link("Home", "/") + with ui.col(): + ui.link("Second page", "/second_page") if __name__ == "__main__": - uvicorn.run("examples.multipage.main:app", reload=True, port=8000) + server.run("multipage.main:app") diff --git a/examples/run_simple.py b/examples/run_simple.py index a0d80e1..4eb5195 100644 --- a/examples/run_simple.py +++ b/examples/run_simple.py @@ -1,11 +1,11 @@ from io import BytesIO import pandas as pd -import uvicorn from fastapi import Request, UploadFile import uiwiz.ui as ui from uiwiz.app import UiwizApp +from uiwiz import server app = UiwizApp(theme="aqua") @@ -62,4 +62,4 @@ async def test(request: Request): if __name__ == "__main__": - uvicorn.run("run_simple:app", reload=True) + server.run("run_simple:app") diff --git a/examples/run_tabs.py b/examples/run_tabs.py index 7249252..92c3750 100644 --- a/examples/run_tabs.py +++ b/examples/run_tabs.py @@ -1,7 +1,5 @@ -import uvicorn - import uiwiz.ui as ui -from uiwiz.app import UiwizApp +from uiwiz import UiwizApp, server app = UiwizApp() @@ -35,4 +33,4 @@ async def test(): if __name__ == "__main__": - uvicorn.run("run_tabs:app", reload=True) + server.run("run_tabs:app") diff --git a/examples/sample.py b/examples/sample.py index 379da09..2bf3e2a 100644 --- a/examples/sample.py +++ b/examples/sample.py @@ -1,7 +1,4 @@ -import uvicorn - -from uiwiz import ui -from uiwiz.app import UiwizApp +from uiwiz import ui, UiwizApp, server app = UiwizApp() @@ -12,4 +9,4 @@ async def home_page(): if __name__ == "__main__": - uvicorn.run(app) + server.run(app) diff --git a/examples/validate_form_example.py b/examples/validate_form_example.py index a13696f..ad2dfff 100644 --- a/examples/validate_form_example.py +++ b/examples/validate_form_example.py @@ -2,11 +2,10 @@ from datetime import date from typing import Annotated, Literal -import uvicorn from pydantic import BaseModel, Field import uiwiz.ui as ui -from uiwiz.app import UiwizApp +from uiwiz import UiwizApp, server from uiwiz.models.model_handler import UiAnno app = UiwizApp(auto_close_toast_error=False) @@ -90,4 +89,4 @@ async def test(): if __name__ == "__main__": app_name = os.path.basename(__file__).replace(".py", "") - uvicorn.run(app=f"{app_name}:app", host="0.0.0.0", port=8080, workers=1, reload=True) + server.run(app=f"{app_name}:app")