from __future__ import annotations import asyncio import logging import os from pathlib import Path import signal import sys from typing import Any import aiohttp_jinja2 from aiohttp import web from aiohttp import WSMsgType from aiohttp.web_runner import GracefulExit import jinja2 from importlib.metadata import version from rich.console import Console from rich.logging import RichHandler from rich.highlighter import RegexHighlighter from webshare.download_manager import DownloadManager from .app_service import AppService log = logging.getLogger("textual-serve") LOGO = r"""[bold magenta]___ ____ _ _ ___ _ _ ____ _ ____ ____ ____ _ _ ____ | |___ \/ | | | |__| | __ [__ |___ |__/ | | |___ | |___ _/\_ | |__| | | |___ ___] |___ | \ \/ |___ [not bold]VVVVV """.replace("VVVVV", f"v{version('textual-serve')}") WINDOWS = sys.platform == "WINDOWS" class LogHighlighter(RegexHighlighter): base_style = "repr." highlights = [ r"(?P(?\[.*?\])", r"(?b?'''.*?(? int: """Convert to an integer, or return a default if that's not possible. Args: number: A string possibly containing a decimal. default: Default value if value can't be decoded. Returns: Integer. """ try: return int(value) except ValueError: return default class Server: """Serve a Textual app.""" def __init__( self, command: str, host: str = "localhost", port: int = 8000, title: str | None = None, public_url: str | None = None, statics_path: str | os.PathLike = "./static", templates_path: str | os.PathLike = "./templates", ): """ Args: app_factory: A callable that returns a new App instance. host: Host of web application. port: Port for server. statics_path: Path to statics folder. May be absolute or relative to server.py. templates_path" Path to templates folder. May be absolute or relative to server.py. """ self.command = command self.host = host self.port = port self.title = title or command self.debug = False if public_url is None: if self.port == 80: self.public_url = f"http://{self.host}" elif self.port == 443: self.public_url = f"https://{self.host}" else: self.public_url = f"http://{self.host}:{self.port}" else: self.public_url = public_url base_path = (Path(__file__) / "../").resolve().absolute() self.statics_path = base_path / statics_path self.templates_path = base_path / templates_path self.console = Console() self.download_manager = DownloadManager() def initialize_logging(self) -> None: """Initialize logging. May be overridden in a subclass. """ FORMAT = "%(message)s" logging.basicConfig( level="DEBUG" if self.debug else "INFO", format=FORMAT, datefmt="[%X]", handlers=[ RichHandler( show_path=False, show_time=False, rich_tracebacks=True, tracebacks_show_locals=True, highlighter=LogHighlighter(), console=self.console, ) ], ) def request_exit(self) -> None: """Gracefully exit the app.""" raise GracefulExit() async def _make_app(self) -> web.Application: """Make the aiohttp web.Application. Returns: New aiohttp web application. """ app = web.Application() aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path)) ROUTES = [ web.get("/", self.handle_index, name="index"), web.get("/ws", self.handle_websocket, name="websocket"), web.get("/download/{key}", self.handle_download, name="download"), web.static("/static", self.statics_path, show_index=True, name="static"), ] app.add_routes(ROUTES) app.on_startup.append(self.on_startup) app.on_shutdown.append(self.on_shutdown) return app async def handle_download(self, request: web.Request) -> web.StreamResponse: """Handle a download request.""" key = request.match_info["key"] try: download_meta = await self.download_manager.get_download_metadata(key) except KeyError: raise web.HTTPNotFound(text=f"Download with key {key!r} not found") response = web.StreamResponse() mime_type = download_meta.mime_type content_type = mime_type if download_meta.encoding: content_type += f"; charset={download_meta.encoding}" response.headers["Content-Type"] = content_type disposition = ( "inline" if download_meta.open_method == "browser" else "attachment" ) response.headers["Content-Disposition"] = ( f"{disposition}; filename={download_meta.file_name}" ) await response.prepare(request) async for chunk in self.download_manager.download(key): await response.write(chunk) await response.write_eof() return response async def on_shutdown(self, app: web.Application) -> None: """Called on shutdown. Args: app: App instance. """ async def on_startup(self, app: web.Application) -> None: """Called on startup. Args: app: App instance. """ self.console.print(LOGO, highlight=False) self.console.print(f"Serving {self.command!r} on {self.public_url}") self.console.print("\n[cyan]Press Ctrl+C to quit") def serve(self, debug: bool = False) -> None: """Serve the Textual application. This will run a local webserver until it is closed with Ctrl+C """ self.debug = debug self.initialize_logging() loop = asyncio.get_event_loop() try: loop.add_signal_handler(signal.SIGINT, self.request_exit) loop.add_signal_handler(signal.SIGTERM, self.request_exit) except NotImplementedError: pass if self.debug: log.info("Running in debug mode. You may use textual dev tools.") web.run_app( self._make_app(), host=self.host, port=self.port, handle_signals=False, loop=loop, print=lambda *args: None, ) @aiohttp_jinja2.template("app_index.html") async def handle_index(self, request: web.Request) -> dict[str, Any]: """Serves the HTML for an app. Args: request: Request object. Returns: Template data. """ router = request.app.router font_size = to_int(request.query.get("fontsize", "16"), 16) def get_url(route: str, **args) -> str: """Get a URL from the aiohttp router.""" path = router[route].url_for(**args) return f"{self.public_url}{path}" def get_websocket_url(route: str, **args) -> str: """Get a URL with a websocket prefix.""" url = get_url(route, **args) if self.public_url.startswith("https"): return "wss:" + url.split(":", 1)[1] else: return "ws:" + url.split(":", 1)[1] context = { "font_size": font_size, "app_websocket_url": get_websocket_url("websocket"), } context["config"] = { "static": { "url": get_url("static", filename="/").rstrip("/") + "/", }, } context["application"] = { "name": self.title, } return context async def _process_messages( self, websocket: web.WebSocketResponse, app_service: AppService ) -> None: """Process messages from the client browser websocket. Args: websocket: Websocket instance. app_service: App service. """ TEXT = WSMsgType.TEXT async for message in websocket: if message.type != TEXT: continue envelope = message.json() assert isinstance(envelope, list) type_ = envelope[0] if type_ == "stdin": data = envelope[1] await app_service.send_bytes(data.encode("utf-8")) elif type_ == "resize": data = envelope[1] await app_service.set_terminal_size(data["width"], data["height"]) elif type_ == "ping": data = envelope[1] await websocket.send_json(["pong", data]) elif type_ == "blur": await app_service.blur() elif type_ == "focus": await app_service.focus() async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse: """Handle the websocket that drives the remote process. This is called when the browser connects to the websocket. Args: request: Request object. Returns: Websocket response. """ websocket = web.WebSocketResponse(heartbeat=15) width = to_int(request.query.get("width", "80"), 80) height = to_int(request.query.get("height", "24"), 24) app_service: AppService | None = None try: await websocket.prepare(request) app_service = AppService( self.command, write_bytes=websocket.send_bytes, write_str=websocket.send_str, close=websocket.close, download_manager=self.download_manager, debug=self.debug, ) await app_service.start(width, height) try: await self._process_messages(websocket, app_service) finally: await app_service.stop() except asyncio.CancelledError: await websocket.close() except Exception as error: log.exception(error) finally: if app_service is not None: await app_service.stop() return websocket