/** * Python driver that keeps a Textual app alive and executes commands sent as * JSON documents on stdin, one per line. Responses are JSON documents on * stdout: "update" lines during an actions command, then the "result" line. * * The app runs in a background task created from app.run_async(). Commands are * driven through a textual.pilot.Pilot while the app's message loop runs. The * driver exits when stdin closes (EOF) or the app exits on its own. */ export interface SessionDriverConfig { /** Absolute path of the Python file that defines the Textual app. */ appPath: string; /** Initial terminal size as [width, height]. */ size: [number, number]; } const SESSION_DRIVER_TEMPLATE = String.raw`import asyncio import base64 import json import os import sys import traceback sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import pi_textual_lib as lib CONFIG = json.loads(base64.b64decode("{{CONFIG}}").decode("utf-8")) def emit(document): # Textual swaps sys.stdout for a _PrintCapture while the app runs, and # that capture buffers into sys.__stdout__ without flushing it. Write # straight to the original stream so results reach the parent promptly. line = json.dumps(document) + "\n" if sys.__stdout__ is not None: sys.__stdout__.write(line) sys.__stdout__.flush() else: os.write(1, line.encode("utf-8")) async def run_actions(app, pilot, command): actions = command.get("actions") or [] screenshot_path = command.get("screenshotPath") for index, action in enumerate(actions): if app._exit or app._exception is not None: break try: await lib.apply_action(pilot, app, action, screenshot_path) except Exception as error: return { "error": f"action {index} ({action}): {type(error).__name__}: {error}" } if app._exit or app._exception is not None: break await pilot.wait_for_animation() await pilot.pause(0.02) emit({"kind": "update", "index": index, "text": lib.capture_text(app)}) if app._exception is not None: error = app._exception return {"error": f"{type(error).__name__}: {error}"} if app._exit: return {"closed": True, "appExitValue": app.return_value} await pilot.wait_for_scheduled_animations() await pilot.pause(0.05) result = { "text": lib.capture_text(app), "size": [app.size.width, app.size.height], } if command.get("tree"): result["tree"] = lib.widget_tree(app) result["focused"] = lib.focus_chain(app) if command.get("query"): result["widgets"] = lib.widget_states(app, command["query"]) if command.get("at"): result["widgetAt"] = lib.widget_at(app, *command["at"]) if screenshot_path is not None: svg = app.export_screenshot() result["svgPath"] = screenshot_path result["svgBytes"] = len(svg.encode("utf-8")) with open(screenshot_path, "w", encoding="utf-8") as file: file.write(svg) return result async def handle_command(app, pilot, command, collector): kind = command.get("command") if kind == "actions": result = await run_actions(app, pilot, command) elif kind == "screen": # Force a compositor update so the frame reflects the current state. await pilot.pause(0.05) result = { "text": lib.capture_text(app), "size": [app.size.width, app.size.height], } elif kind == "tree": result = {"tree": lib.widget_tree(app)} elif kind == "query": result = {"widgets": lib.widget_states(app, command.get("selectors") or [])} elif kind == "screenshot": path = command.get("path") if not path: result = {"error": "screenshot command requires a 'path'"} else: svg = app.export_screenshot() with open(path, "w", encoding="utf-8") as file: file.write(svg) result = {"svgPath": path, "svgBytes": len(svg.encode("utf-8"))} elif kind == "close": result = {"closed": True} else: result = {"error": f"unknown command: {kind!r}"} if command.get("messages"): result["messages"] = collector.summary() collector.reset() return result async def main(): try: app = lib.load_app(CONFIG["appPath"]) except Exception as error: emit( { "kind": "result", "error": f"{type(error).__name__}: {error}\n{traceback.format_exc()[-4000:]}", } ) return collector = lib.MessageCollector() # Must run before the app task starts: the message pumps capture the # context at task creation, so a hook installed later never fires. lib.install_message_hook(collector) run_task = asyncio.create_task( app.run_async(headless=True, size=tuple(CONFIG["size"]), mouse=True) ) while not app._running and not run_task.done(): await asyncio.sleep(0.01) if not app._running: if app._exception is not None: error = app._exception emit({"kind": "result", "error": f"{type(error).__name__}: {error}"}) else: emit({"kind": "result", "closed": True, "appExitValue": app.return_value}) return from textual.pilot import Pilot pilot = Pilot(app) emit({"kind": "ready", "app": type(app).__name__}) # Read commands from stdin on the event loop. A thread blocked on # readline() would deadlock interpreter shutdown (asyncio.run joins the # default executor, and CPython joins non-daemon threads at exit), leaving # the process alive and the parent waiting on it forever. Registering the # fd with the loop keeps the driver free of stdin threads. loop = asyncio.get_running_loop() stdin_lines = [] stdin_buffer = "" stdin_eof = False stdin_event = asyncio.Event() def on_stdin(): nonlocal stdin_buffer, stdin_eof try: data = os.read(0, 4096) except OSError: data = b"" if not data: stdin_eof = True try: loop.remove_reader(0) except Exception: pass stdin_event.set() return stdin_buffer += data.decode("utf-8", errors="replace") while "\n" in stdin_buffer: line, stdin_buffer = stdin_buffer.split("\n", 1) stdin_lines.append(line) stdin_event.set() try: os.set_blocking(0, False) except OSError: pass try: loop.add_reader(0, on_stdin) except OSError: stdin_eof = True while True: if not stdin_lines: if stdin_eof: break stdin_event.clear() done, _ = await asyncio.wait( {asyncio.ensure_future(stdin_event.wait()), run_task}, return_when=asyncio.FIRST_COMPLETED, ) if run_task in done: break continue line = stdin_lines.pop(0).strip() if not line: continue command_id = None try: command = json.loads(line) command_id = command.get("id") result = await handle_command(app, pilot, command, collector) except Exception as error: result = {"error": f"{type(error).__name__}: {error}"} if result.get("closed"): try: app.exit(None) except Exception: pass emit( { "kind": "result", "id": command_id, "closed": True, **{ key: value for key, value in result.items() if key != "closed" }, } ) return emit({"kind": "result", "id": command_id, **result}) if run_task.done(): if app._exception is not None: error = app._exception emit({"kind": "result", "error": f"{type(error).__name__}: {error}"}) else: emit( { "kind": "result", "closed": True, "appExitValue": app.return_value, } ) return if app._running: try: app.exit(None) except Exception: pass try: await asyncio.wait_for(run_task, timeout=10) except (asyncio.TimeoutError, asyncio.CancelledError): pass asyncio.run(main()) `; /** * Builds the session driver source. The configuration is embedded as base64 * so it cannot break out of the Python literal. */ export function buildSessionDriver(config: SessionDriverConfig): string { const encoded = Buffer.from(JSON.stringify(config)).toString("base64"); return SESSION_DRIVER_TEMPLATE.replace("{{CONFIG}}", encoded); }