from __future__ import annotations import asyncio import hashlib from collections.abc import AsyncGenerator, Awaitable, Callable from typing import cast import htpy as h from datastar_py.quart import DatastarResponse from datastar_py.sse import DatastarEvent from htpy import Renderable from quart import Quart, Response, request, url_for from repub.datastar import RefreshBroker, render_stream from repub.pages import ( create_source_page, dashboard_page, execution_logs_page, runs_page, shim_page, sources_page, ) REFRESH_BROKER_KEY = "repub.refresh_broker" RenderFunction = Callable[[], Awaitable[Renderable]] def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]: head = ( h.title["Republisher Admin UI"], h.link(rel="stylesheet", href=stylesheet_href), ) body = str(shim_page(datastar_src=datastar_src, head=head)) etag = hashlib.sha256(body.encode("utf-8")).hexdigest() return body, etag def create_app() -> Quart: app = Quart(__name__) app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() @app.get("/") @app.get("/sources") @app.get("/sources/create") @app.get("/runs") @app.get("/job//execution//logs") async def page_shim( job_id: int | None = None, execution_id: int | None = None ) -> Response: del job_id, execution_id body, etag = _render_shim_page( stylesheet_href=url_for("static", filename="app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), ) if request.if_none_match.contains(etag): response = Response(status=304) response.set_etag(etag) return response response = Response(body, mimetype="text/html") response.set_etag(etag) return response @app.post("/") async def dashboard_patch() -> DatastarResponse: return _page_patch_response(app, render_dashboard) @app.post("/sources") async def sources_patch() -> DatastarResponse: return _page_patch_response(app, render_sources) @app.post("/sources/create") async def create_source_patch() -> DatastarResponse: return _page_patch_response(app, render_create_source) @app.post("/runs") async def runs_patch() -> DatastarResponse: return _page_patch_response(app, render_runs) @app.post("/job//execution//logs") async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def render() -> Renderable: return await render_execution_logs(job_id=job_id, execution_id=execution_id) return _page_patch_response(app, render) return app def get_refresh_broker(app: Quart) -> RefreshBroker: return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY]) def trigger_refresh(app: Quart, event: object = "refresh-event") -> None: get_refresh_broker(app).publish(event) async def render_dashboard() -> Renderable: return dashboard_page() async def render_sources() -> Renderable: return sources_page() async def render_create_source() -> Renderable: return create_source_page() async def render_runs() -> Renderable: return runs_page() async def render_execution_logs(*, job_id: int, execution_id: int) -> Renderable: return execution_logs_page(job_id=job_id, execution_id=execution_id) def _page_patch_response(app: Quart, render: RenderFunction) -> DatastarResponse: queue = get_refresh_broker(app).subscribe() stream = render_stream( queue, render=render, last_event_id=request.headers.get("last-event-id"), ) return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) async def _unsubscribe_on_close( queue: object, stream: AsyncGenerator[DatastarEvent, None], app: Quart ) -> AsyncGenerator[DatastarEvent, None]: try: async for event in stream: yield event finally: get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue))