From 962d6e9079c3497b03dfb709388d2be55db07c44 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 13:04:06 +0200 Subject: [PATCH] Console to system tray - added max lines Added missed file Added unique host id for multiple items in submenu --- .../webserver/host_console_listener.py | 153 ++++++++++++++++++ openpype/tools/tray_app/app.py | 46 ++++-- 2 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 openpype/modules/webserver/host_console_listener.py diff --git a/openpype/modules/webserver/host_console_listener.py b/openpype/modules/webserver/host_console_listener.py new file mode 100644 index 0000000000..68973ae10a --- /dev/null +++ b/openpype/modules/webserver/host_console_listener.py @@ -0,0 +1,153 @@ +import aiohttp +from aiohttp import web +import json +import logging +from concurrent.futures import CancelledError +from Qt import QtWidgets, QtCore + +from openpype.modules import TrayModulesManager, ITrayService +from openpype.tools.tray_app.app import ConsoleDialog + +log = logging.getLogger(__name__) + + +class IconType: + IDLE = "idle" + RUNNING = "running" + FAILED = "failed" + + +class MsgAction: + CONNECTING = "connecting" + INITIALIZED = "initialized" + ADD = "add" + CLOSE = "close" + + +class HostListener: + def __init__(self, webserver, module): + self._window_per_id = {} + self.module = module + self.webserver = webserver + self._window_per_id = {} # dialogs per host name + self._action_per_id = {} # QAction per host name + + webserver.add_route('*', "/ws/host_listener", self.websocket_handler) + + def _host_is_connecting(self, host_name, label): + """ Initialize dialog, adds to submenu. """ + services_submenu = self.module._services_submenu + action = QtWidgets.QAction(label, services_submenu) + action.triggered.connect(lambda: self.show_widget(host_name)) + + services_submenu.addAction(action) + self._action_per_id[host_name] = action + self._set_host_icon(host_name, IconType.IDLE) + widget = ConsoleDialog("") + self._window_per_id[host_name] = widget + + def _set_host_icon(self, host_name, icon_type): + """Assigns icon to action for 'host_name' with 'icon_type'. + + Action must exist in self._action_per_id + + Args: + host_name (str) + icon_type (IconType) + """ + action = self._action_per_id.get(host_name) + if not action: + raise ValueError("Unknown host {}".format(host_name)) + + icon = None + if icon_type == IconType.IDLE: + icon = ITrayService.get_icon_idle() + elif icon_type == IconType.RUNNING: + icon = ITrayService.get_icon_running() + elif icon_type == IconType.FAILED: + icon = ITrayService.get_icon_failed() + else: + log.info("Unknown icon type {} for {}".format(icon_type, + host_name)) + action.setIcon(icon) + + def show_widget(self, host_name): + """Shows prepared widget for 'host_name'. + + Dialog get initialized when 'host_name' is connecting. + """ + self.module.execute_in_main_thread( + lambda: self._show_widget(host_name)) + + def _show_widget(self, host_name): + widget = self._window_per_id[host_name] + widget.show() + widget.raise_() + widget.activateWindow() + + async def websocket_handler(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + widget = None + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + host_name, action, text = self._parse_message(msg) + + if action == MsgAction.CONNECTING: + self._action_per_id[host_name] = None + # must be sent to main thread, or action wont trigger + self.module.execute_in_main_thread( + lambda: self._host_is_connecting(host_name, text)) + elif action == MsgAction.CLOSE: + # clean close + crashed = False + self._close(host_name) + await ws.close() + elif action == MsgAction.INITIALIZED: + initialized = True + self.module.execute_in_main_thread( + # must be queued as _host_is_connecting might not + # be triggered/finished yet + lambda: self._set_host_icon(host_name, + IconType.RUNNING)) + elif action == MsgAction.ADD: + self.module.execute_in_main_thread( + lambda: self._add_text(host_name, text)) + elif msg.type == aiohttp.WSMsgType.ERROR: + print('ws connection closed with exception %s' % + ws.exception()) + host_name, _, _ = self._parse_message(msg) + self._set_host_icon(host_name, IconType.FAILED) + except CancelledError: # recoverable + pass + except Exception as exc: + error_msg = str(exc) + log.warning("Exception during communication", exc_info=True) + if widget: + widget.append_text(text) + + return ws + + def _add_text(self, host_name, text): + widget = self._window_per_id[host_name] + widget.append_text(text) + + def _close(self, host_name): + """ Clean close - remove from menu, delete widget.""" + services_submenu = self.module._services_submenu + action = self._action_per_id.pop(host_name) + services_submenu.removeAction(action) + widget = self._window_per_id.pop(host_name) + if widget.isVisible(): + widget.hide() + widget.deleteLater() + + def _parse_message(self, msg): + data = json.loads(msg.data) + action = data.get("action") + host_name = data["host"] + value = data.get("text") + + return host_name, action, value diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py index a9046d35fe..35422a7269 100644 --- a/openpype/tools/tray_app/app.py +++ b/openpype/tools/tray_app/app.py @@ -1,9 +1,12 @@ +import os import sys import re import collections import queue import websocket import json +import itertools +from datetime import datetime from avalon import style from openpype.modules.webserver import host_console_listener @@ -11,12 +14,17 @@ from openpype.modules.webserver import host_console_listener from Qt import QtWidgets, QtGui, QtCore -class ConsoleTrayApp(): - """Application showing console for non python hosts instead of cmd""" +class ConsoleTrayApp: + """ + Application showing console in Services tray for non python hosts + instead of cmd window. + """ callback_queue = None process = None webserver_client = None + MAX_LINES = 10000 + sdict = { r">>> ": ' >>> ', @@ -79,15 +87,25 @@ class ConsoleTrayApp(): self.timer = timer self.catch_std_outputs() + date_str = datetime.now().strftime("%d%m%Y%H%M%S") + self.host_id = "{}_{}".format(self.host, date_str) def _connect(self): """ Connect to Tray webserver to pass console output. """ ws = websocket.WebSocket() - ws.connect("ws://localhost:8079/ws/host_listener") + webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") + + if not webserver_url: + print("Unknown webserver url, cannot connect to pass log") + self.tray_reconnect = False + return + + webserver_url = webserver_url.replace("http", "ws") + ws.connect("{}/ws/host_listener".format(webserver_url)) ConsoleTrayApp.webserver_client = ws payload = { - "host": self.host, + "host": self.host_id, "action": host_console_listener.MsgAction.CONNECTING, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -101,7 +119,7 @@ class ConsoleTrayApp(): return payload = { - "host": self.host, + "host": self.host_id, "action": host_console_listener.MsgAction.INITIALIZED, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -115,7 +133,7 @@ class ConsoleTrayApp(): return payload = { - "host": self.host, + "host": self.host_id, "action": host_console_listener.MsgAction.CLOSE, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -133,7 +151,7 @@ class ConsoleTrayApp(): new_text = collections.deque(new_text.split("\n")) payload = { - "host": self.host, + "host": self.host_id, "action": host_console_listener.MsgAction.ADD, "text": "\n".join(new_text) } @@ -160,6 +178,11 @@ class ConsoleTrayApp(): self._send_text(self.new_text) self.new_text = collections.deque() + if self.new_text: # no webserver_client, text keeps stashing + start = max(len(self.new_text) - self.MAX_LINES, 0) + self.new_text = itertools.islice(self.new_text, + start, self.MAX_LINES) + if not self.initialized: if self.initializing: host_connected = self.is_host_connected() @@ -274,6 +297,7 @@ class ConsoleDialog(QtWidgets.QDialog): """Qt dialog to show stdout instead of unwieldy cmd window""" WIDTH = 720 HEIGHT = 450 + MAX_LINES = 10000 def __init__(self, text, parent=None): super(ConsoleDialog, self).__init__(parent) @@ -282,6 +306,8 @@ class ConsoleDialog(QtWidgets.QDialog): plain_text = QtWidgets.QPlainTextEdit(self) plain_text.setReadOnly(True) plain_text.resize(self.WIDTH, self.HEIGHT) + plain_text.maximumBlockCount = self.MAX_LINES + while text: plain_text.appendPlainText(text.popleft().strip()) @@ -299,5 +325,7 @@ class ConsoleDialog(QtWidgets.QDialog): if isinstance(new_text, str): new_text = collections.deque(new_text.split("\n")) while new_text: - self.plain_text.appendHtml( - ConsoleTrayApp.color(new_text.popleft())) + text = new_text.popleft() + if text: + self.plain_text.appendHtml( + ConsoleTrayApp.color(text))