From d9f99fa4d60602fb1317899615038d2d03c12984 Mon Sep 17 00:00:00 2001 From: kalisp Date: Fri, 21 May 2021 14:11:18 +0000 Subject: [PATCH 01/23] Create draft PR for #676 From ace5bcbb9aafd3cad416648e3e3498529850883f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 26 May 2021 19:47:41 +0200 Subject: [PATCH 02/23] Console to system tray - fixed missing order and non python handling --- openpype/hooks/pre_non_python_host_launch.py | 2 ++ openpype/hooks/pre_with_windows_shell.py | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index c16a72c5e5..7a04f02d5a 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -17,6 +17,8 @@ class NonPythonHostHook(PreLaunchHook): """ app_groups = ["harmony", "photoshop", "aftereffects"] + order = 20 + def execute(self): # Pop executable executable_path = self.launch_context.launch_args.pop(0) diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 0c10583b99..720b285ad4 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -1,6 +1,7 @@ import os import subprocess from openpype.lib import PreLaunchHook +from openpype.lib.applications import ApplicationLaunchContext class LaunchWithWindowsShell(PreLaunchHook): @@ -11,12 +12,15 @@ class LaunchWithWindowsShell(PreLaunchHook): instead. """ - # Should be as last hook becuase must change launch arguments to string + # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio"] + app_groups = ["nuke", "nukex", "hiero", "nukestudio", + "photoshop", "aftereffects", "harmony"] platforms = ["windows"] def execute(self): + launch_args = self.launch_context.clear_launch_args( + self.launch_context.launch_args) new_args = [ # Get comspec which is cmd.exe in most cases. os.environ.get("COMSPEC", "cmd.exe"), @@ -24,7 +28,7 @@ class LaunchWithWindowsShell(PreLaunchHook): "/c", # Convert arguments to command line arguments (as string) "\"{}\"".format( - subprocess.list2cmdline(self.launch_context.launch_args) + subprocess.list2cmdline(launch_args) ) ] # Convert list to string From 4e0210915ebfdc8bf49492b64c40a73fcc6d9d67 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 26 May 2021 20:02:18 +0200 Subject: [PATCH 03/23] Console to system tray - changed launch method for Photoshop --- openpype/scripts/non_python_host_launch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 506105d2ce..635513e689 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -81,7 +81,7 @@ def main(argv): host_name = os.environ["AVALON_APP"].lower() if host_name == "photoshop": - from avalon.photoshop.lib import launch + from avalon.photoshop.lib import main elif host_name == "aftereffects": from avalon.aftereffects.lib import launch elif host_name == "harmony": @@ -97,7 +97,7 @@ def main(argv): if launch_args: # Launch host implementation - launch(*launch_args) + main(*launch_args) else: # Show message box on_invalid_args(after_script_idx is None) From 0a7ee3b638a16b1fa6a53b15a13ca4ea8f1ba10c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 27 May 2021 19:16:06 +0200 Subject: [PATCH 04/23] Console to system tray - refactored and moved implementation to OpenPype --- openpype/hooks/pre_with_windows_shell.py | 2 +- openpype/tools/tray_app/app.py | 297 +++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 openpype/tools/tray_app/app.py diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 720b285ad4..7044bd12b2 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -15,7 +15,7 @@ class LaunchWithWindowsShell(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 app_groups = ["nuke", "nukex", "hiero", "nukestudio", - "photoshop", "aftereffects", "harmony"] + "aftereffects", "harmony"] platforms = ["windows"] def execute(self): diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py new file mode 100644 index 0000000000..37765cfb57 --- /dev/null +++ b/openpype/tools/tray_app/app.py @@ -0,0 +1,297 @@ +import sys +import re +import platform +import collections +import queue +from io import StringIO + +from avalon import style +from openpype import resources + +from Qt import QtWidgets, QtGui, QtCore + +class TrayApp(QtWidgets.QSystemTrayIcon): + """Application showing console for non python hosts instead of cmd""" + callback_queue = None + + sdict = { + r">>> ": + ' >>> ', + r"!!!(?!\sCRI|\sERR)": + ' !!! ', + r"\-\-\- ": + ' --- ', + r"\*\*\*(?!\sWRN)": + ' *** ', + r"\*\*\* WRN": + ' *** WRN', + r" \- ": + ' - ', + r"\[ ": + '[', + r"\]": + ']', + r"{": + '{', + r"}": + r"}", + r"\(": + '(', + r"\)": + r")", + r"^\.\.\. ": + ' ... ', + r"!!! ERR: ": + ' !!! ERR: ', + r"!!! CRI: ": + ' !!! CRI: ', + r"(?i)failed": + ' FAILED ', + r"(?i)error": + ' ERROR ' + } + + def __init__(self, host, parent=None): + super(TrayApp, self).__init__(parent) + self.host = host + + self.initialized = False + self.websocket_server = None + self.timer = None + self.subprocess_args = None + self.initializing = False + self.tray = False + self.launch_method = None + self.timer = None + + self.original_stdout_write = None + self.original_stderr_write = None + self.new_text = collections.deque() + + self.icons = self._select_icons(self.host) + self.status_texts = self._prepare_status_texts(self.host) + + timer = QtCore.QTimer() + timer.timeout.connect(self.on_timer) + timer.setInterval(200) + timer.start() + + self.timer = timer + + self.redirect_stds() + + menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) + # not working yet + # + # restart_server_action = QtWidgets.QAction("Restart communication", + # self) + # restart_server_action.triggered.connect(self.restart_server) + # menu.addAction(restart_server_action) + + # Add Exit action to menu + exit_action = QtWidgets.QAction("Exit", self) + exit_action.triggered.connect(self.exit) + menu.addAction(exit_action) + + self.menu = menu + + self.dialog = ConsoleDialog(self.new_text) + + # Catch activate event for left click if not on MacOS + # - MacOS has this ability by design so menu would be doubled + if platform.system().lower() != "darwin": + self.activated.connect(self.on_systray_activated) + + self.change_status("initializing") + self.setContextMenu(self.menu) + self.show() + + def on_timer(self): + """Called periodically to initialize and run function on main thread""" + self.dialog.append_text(self.new_text) + if not self.initialized: + if self.initializing: + return + TrayApp.callback_queue = queue.Queue() + self.initializing = True + + self.launch_method(*self.subprocess_args) + self.initialized = True + self.initializing = False + self.change_status("ready") + elif TrayApp.callback_queue: + try: + callback = TrayApp.callback_queue.get(block=False) + callback() + except queue.Empty: + pass + else: + if self.process.poll() is not None: + # Wait on Photoshop to close before closing the websocket serv. + self.process.wait() + self.websocket_server.stop() + self.timer.stop() + self.change_status("error") + + @classmethod + def execute_in_main_thread(cls, func_to_call_from_main_thread): + """Put function to the queue to be picked by 'on_timer'""" + if not cls.callback_queue: + cls.callback_queue = queue.Queue() + cls.callback_queue.put(func_to_call_from_main_thread) + + def on_systray_activated(self, reason): + if reason == QtWidgets.QSystemTrayIcon.Context: + position = QtGui.QCursor().pos() + self.menu.popup(position) + else: + self.open_console() + + @classmethod + def restart_server(self): + if TrayApp.websocket_server: + TrayApp.websocket_server.stop_server(restart=True) + + def open_console(self): + self.dialog.show() + + def exit(self): + """ Exit whole application. + + - Icon won't stay in tray after exit. + """ + TrayApp.process.kill() + self.hide() + QtCore.QCoreApplication.exit() + + def redirect_stds(self): + """Redirects standard out and error to own functions""" + if sys.stdout: + self.original_stdout_write = sys.stdout.write + else: + sys.stdout = StringIO() + + if sys.stderr: + self.original_stderr_write = sys.stderr.write + else: + sys.stderr = StringIO() + + sys.stdout.write = self.my_stdout_write + sys.stderr.write = self.my_stderr_write + + def my_stdout_write(self, text): + """Appends outputted text to queue, keep writing to original stdout""" + if self.original_stdout_write is not None: + self.original_stdout_write(text) + self.new_text.append(text) + + def my_stderr_write(self, text): + """Appends outputted text to queue, keep writing to original stderr""" + if self.original_stderr_write is not None: + self.original_stderr_write(text) + self.new_text.append(text) + + def _prepare_status_texts(self, host_name): + """Status text used as a tooltip""" + status_texts = { + 'initializing': "Starting communication with {}".format(host_name), + 'ready': "Communicating with {}".format(host_name), + 'error': "Error!" + } + + return status_texts + + def _select_icons(self, host_name): + """Use different icons per state and host_name""" + # use host_name + icons = { + 'initializing': QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ), + 'ready': QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ), + 'error': QtGui.QIcon( + resources.get_resource("icons", "circle_red.png") + ) + } + + return icons + + + @staticmethod + def _multiple_replace(text, adict): + """Replace multiple tokens defined in dict. + + Find and replace all occurances of strings defined in dict is + supplied string. + + Args: + text (str): string to be searched + adict (dict): dictionary with `{'search': 'replace'}` + + Returns: + str: string with replaced tokens + + """ + for r, v in adict.items(): + text = re.sub(r, v, text) + + return text + + @staticmethod + def color(message): + message = TrayApp._multiple_replace(message, TrayApp.sdict) + + return message + + def change_status(self, status): + """Updates tooltip and icon with new status""" + self._change_tooltip(status) + self._change_icon(status) + + def _change_tooltip(self, status): + status = self.status_texts.get(status) + if not status: + raise ValueError("Unknown state") + + self.setToolTip(status) + + def _change_icon(self, state): + icon = self.icons.get(state) + if not icon: + raise ValueError("Unknown state") + + self.setIcon(icon) + + +class ConsoleDialog(QtWidgets.QDialog): + """Qt dialog to show stdout instead of unwieldy cmd window""" + WIDTH = 720 + HEIGHT = 450 + + def __init__(self, text, parent=None): + super(ConsoleDialog, self).__init__(parent) + layout = QtWidgets.QHBoxLayout(parent) + + plain_text = QtWidgets.QPlainTextEdit(self) + plain_text.setReadOnly(True) + plain_text.resize(self.WIDTH, self.HEIGHT) + while text: + plain_text.appendPlainText(text.popleft().strip()) + + layout.addWidget(plain_text) + + self.setWindowTitle("Console output") + + self.plain_text = plain_text + + self.setStyleSheet(style.load_stylesheet()) + + self.resize(self.WIDTH, self.HEIGHT) + + def append_text(self, new_text): + while new_text: + self.plain_text.appendHtml( + TrayApp.color(new_text.popleft().strip())) From fb3ce7123b2a440c1284c3ae32982612b8c5d67a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 28 May 2021 13:32:51 +0200 Subject: [PATCH 05/23] Console to system tray - added explicit stdout and stderr for consoles in Qt application work --- openpype/hooks/pre_non_python_host_launch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 7a04f02d5a..393a878f76 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -1,4 +1,5 @@ import os +import subprocess from openpype.lib import ( PreLaunchHook, @@ -47,3 +48,6 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) + + self.launch_context.kwargs["stdout"] = subprocess.DEVNULL + self.launch_context.kwargs["stderr"] = subprocess.STDOUT From 3329ce9b6e6416469fca89b30af4b3b777b67ef0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 28 May 2021 13:33:51 +0200 Subject: [PATCH 06/23] Console to system tray - fixed exit, stderr streams Fixed loop handling --- openpype/tools/tray_app/__init__.py | 0 openpype/tools/tray_app/app.py | 97 +++++++++++++++++------------ 2 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 openpype/tools/tray_app/__init__.py diff --git a/openpype/tools/tray_app/__init__.py b/openpype/tools/tray_app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py index 37765cfb57..21ee0db0ce 100644 --- a/openpype/tools/tray_app/app.py +++ b/openpype/tools/tray_app/app.py @@ -10,7 +10,8 @@ from openpype import resources from Qt import QtWidgets, QtGui, QtCore -class TrayApp(QtWidgets.QSystemTrayIcon): + +class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): """Application showing console for non python hosts instead of cmd""" callback_queue = None @@ -51,18 +52,18 @@ class TrayApp(QtWidgets.QSystemTrayIcon): ' ERROR ' } - def __init__(self, host, parent=None): - super(TrayApp, self).__init__(parent) + def __init__(self, host, launch_method, subprocess_args, is_host_connected, + parent=None): + super(ConsoleTrayIcon, self).__init__(parent) self.host = host self.initialized = False self.websocket_server = None - self.timer = None - self.subprocess_args = None self.initializing = False self.tray = False - self.launch_method = None - self.timer = None + self.launch_method = launch_method + self.subprocess_args = subprocess_args + self.is_host_connected = is_host_connected self.original_stdout_write = None self.original_stderr_write = None @@ -78,7 +79,7 @@ class TrayApp(QtWidgets.QSystemTrayIcon): self.timer = timer - self.redirect_stds() + self.catch_std_outputs() menu = QtWidgets.QMenu() menu.setStyleSheet(style.load_stylesheet()) @@ -112,27 +113,33 @@ class TrayApp(QtWidgets.QSystemTrayIcon): self.dialog.append_text(self.new_text) if not self.initialized: if self.initializing: - return - TrayApp.callback_queue = queue.Queue() + host_connected = self.is_host_connected() + if host_connected is None: # keep trying + return + elif not host_connected: + print("{} process is not alive. Exiting".format(self.host)) + ConsoleTrayIcon.websocket_server.stop() + sys.exit(1) + elif host_connected: + self.initialized = True + self.initializing = False + self.change_status("ready") + + return + + ConsoleTrayIcon.callback_queue = queue.Queue() self.initializing = True self.launch_method(*self.subprocess_args) - self.initialized = True - self.initializing = False - self.change_status("ready") - elif TrayApp.callback_queue: + elif ConsoleTrayIcon.process.poll() is not None: + # Wait on Photoshop to close before closing the websocket serv + self.exit() + elif ConsoleTrayIcon.callback_queue: try: - callback = TrayApp.callback_queue.get(block=False) + callback = ConsoleTrayIcon.callback_queue.get(block=False) callback() except queue.Empty: pass - else: - if self.process.poll() is not None: - # Wait on Photoshop to close before closing the websocket serv. - self.process.wait() - self.websocket_server.stop() - self.timer.stop() - self.change_status("error") @classmethod def execute_in_main_thread(cls, func_to_call_from_main_thread): @@ -149,36 +156,44 @@ class TrayApp(QtWidgets.QSystemTrayIcon): self.open_console() @classmethod - def restart_server(self): - if TrayApp.websocket_server: - TrayApp.websocket_server.stop_server(restart=True) + def restart_server(cls): + if ConsoleTrayIcon.websocket_server: + ConsoleTrayIcon.websocket_server.stop_server(restart=True) def open_console(self): self.dialog.show() + self.dialog.raise_() + self.dialog.activateWindow() def exit(self): """ Exit whole application. - Icon won't stay in tray after exit. """ - TrayApp.process.kill() + self.dialog.append_text("Exiting!") + if ConsoleTrayIcon.websocket_server: + ConsoleTrayIcon.websocket_server.stop() + ConsoleTrayIcon.process.kill() + ConsoleTrayIcon.process.wait() + if self.timer: + self.timer.stop() + self.dialog.hide() self.hide() QtCore.QCoreApplication.exit() - def redirect_stds(self): + def catch_std_outputs(self): """Redirects standard out and error to own functions""" - if sys.stdout: + if not sys.stdout: + self.dialog.append_text("Cannot read from stdout!") + else: self.original_stdout_write = sys.stdout.write - else: - sys.stdout = StringIO() + sys.stdout.write = self.my_stdout_write - if sys.stderr: + if not sys.stderr: + self.dialog.append_text("Cannot read from stderr!") + else: self.original_stderr_write = sys.stderr.write - else: - sys.stderr = StringIO() - - sys.stdout.write = self.my_stdout_write - sys.stderr.write = self.my_stderr_write + sys.stderr.write = self.my_stderr_write def my_stdout_write(self, text): """Appends outputted text to queue, keep writing to original stdout""" @@ -202,7 +217,7 @@ class TrayApp(QtWidgets.QSystemTrayIcon): return status_texts - def _select_icons(self, host_name): + def _select_icons(self, _host_name): """Use different icons per state and host_name""" # use host_name icons = { @@ -219,7 +234,6 @@ class TrayApp(QtWidgets.QSystemTrayIcon): return icons - @staticmethod def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. @@ -242,7 +256,8 @@ class TrayApp(QtWidgets.QSystemTrayIcon): @staticmethod def color(message): - message = TrayApp._multiple_replace(message, TrayApp.sdict) + message = ConsoleTrayIcon._multiple_replace(message, + ConsoleTrayIcon.sdict) return message @@ -292,6 +307,8 @@ class ConsoleDialog(QtWidgets.QDialog): self.resize(self.WIDTH, self.HEIGHT) def append_text(self, new_text): + if isinstance(new_text, str): + new_text = collections.deque(new_text) while new_text: self.plain_text.appendHtml( - TrayApp.color(new_text.popleft().strip())) + ConsoleTrayIcon.color(new_text.popleft())) From c3f46d110e8e6515686f660c95a4e009089ec38f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 28 May 2021 14:51:14 +0200 Subject: [PATCH 07/23] Console to system tray - implemented for AE --- openpype/scripts/non_python_host_launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 635513e689..5357f18348 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -83,7 +83,7 @@ def main(argv): if host_name == "photoshop": from avalon.photoshop.lib import main elif host_name == "aftereffects": - from avalon.aftereffects.lib import launch + from avalon.aftereffects.lib import main elif host_name == "harmony": from avalon.harmony.lib import launch else: From a3805e1f46f44212254528b5ee34c2e64c1b279b Mon Sep 17 00:00:00 2001 From: Derek Severin Date: Mon, 31 May 2021 13:49:44 +0700 Subject: [PATCH 08/23] 'Action delivery' report fix + typos --- openpype/lib/delivery.py | 19 ++++---- .../event_handlers_user/action_delivery.py | 48 ++++++++++++------- openpype/plugins/load/delivery.py | 4 +- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index b7f8e0e252..943cd9fcaf 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -43,7 +43,7 @@ def sizeof_fmt(num, suffix='B'): return "%.1f%s%s" % (num, 'Yi', suffix) -def path_from_represenation(representation, anatomy): +def path_from_representation(representation, anatomy): from avalon import pipeline # safer importing try: @@ -126,18 +126,22 @@ def check_destination_path(repre_id, anatomy_filled = anatomy.format_all(anatomy_data) dest_path = anatomy_filled["delivery"][template_name] report_items = collections.defaultdict(list) - sub_msg = None + if not dest_path.solved: msg = ( "Missing keys in Representation's context" " for anatomy template \"{}\"." ).format(template_name) + sub_msg = ( + "Representation: {}
" + ).format(repre_id) + if dest_path.missing_keys: keys = ", ".join(dest_path.missing_keys) - sub_msg = ( - "Representation: {}
- Missing keys: \"{}\"
" - ).format(repre_id, keys) + sub_msg += ( + "- Missing keys: \"{}\"
" + ).format(keys) if dest_path.invalid_types: items = [] @@ -145,10 +149,9 @@ def check_destination_path(repre_id, items.append("\"{}\" {}".format(key, str(value))) keys = ", ".join(items) - sub_msg = ( - "Representation: {}
" + sub_msg += ( "- Invalid value DataType: \"{}\"
" - ).format(repre_id, keys) + ).format(keys) report_items[msg].append(sub_msg) diff --git a/openpype/modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py index f8553b2eac..06da71ea0a 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delivery.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delivery.py @@ -9,7 +9,7 @@ from openpype.api import Anatomy, config from openpype.modules.ftrack.lib import BaseAction, statics_icon from openpype.modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY from openpype.lib.delivery import ( - path_from_represenation, + path_from_representation, get_format_dict, check_destination_path, process_single_file, @@ -74,7 +74,7 @@ class Delivery(BaseAction): "value": project_name }) - # Prpeare anatomy data + # Prepare anatomy data anatomy = Anatomy(project_name) new_anatomies = [] first = None @@ -368,12 +368,18 @@ class Delivery(BaseAction): def launch(self, session, entities, event): if "values" not in event["data"]: - return + return { + "success": True, + "message": "Nothing to do" + } values = event["data"]["values"] skipped = values.pop("__skipped__") if skipped: - return None + return { + "success": False, + "message": "Action skipped" + } user_id = event["source"]["user"]["id"] user_entity = session.query( @@ -391,27 +397,35 @@ class Delivery(BaseAction): try: self.db_con.install() - self.real_launch(session, entities, event) - job["status"] = "done" + result = self.real_launch(session, entities, event) + if result["success"]: + job["status"] = "done" + else: + job["status"] = "failed" - except Exception: + except Exception as exc: + job["status"] = "failed" + result = { + "success": False, + "title": "Delivery failed", + "items": [{ + "type": "label", + "value": ( + "Error during delivery action process:
{}" + "

Check logs for more information." + ).format(str(exc)) + }] + } self.log.warning( "Failed during processing delivery action.", exc_info=True ) finally: - if job["status"] != "done": - job["status"] = "failed" session.commit() self.db_con.uninstall() - if job["status"] == "failed": - return { - "success": False, - "message": "Delivery failed. Check logs for more information." - } - return True + return result def real_launch(self, session, entities, event): self.log.info("Delivery action just started.") @@ -431,7 +445,7 @@ class Delivery(BaseAction): if not repre_names: return { "success": True, - "message": "Not selected components to deliver." + "message": "No selected components to deliver." } location_path = location_path.strip() @@ -479,7 +493,7 @@ class Delivery(BaseAction): if frame: repre["context"]["frame"] = len(str(frame)) * "#" - repre_path = path_from_represenation(repre, anatomy) + repre_path = path_from_representation(repre, anatomy) # TODO add backup solution where root of path from component # is replaced with root args = ( diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index 68b1f9a52a..3753f1bfc9 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -11,7 +11,7 @@ from openpype import resources from openpype.lib.delivery import ( sizeof_fmt, - path_from_represenation, + path_from_representation, get_format_dict, check_destination_path, process_single_file, @@ -170,7 +170,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): if repre["name"] not in selected_repres: continue - repre_path = path_from_represenation(repre, self.anatomy) + repre_path = path_from_representation(repre, self.anatomy) anatomy_data = copy.deepcopy(repre["context"]) new_report_items = check_destination_path(str(repre["_id"]), From b62b1dfab6069c7dc2ad88fb1f98115da51c90a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 31 May 2021 20:52:51 +0200 Subject: [PATCH 09/23] Console to system tray - added execute in main thread functionality Without this Service submenu actions won't get triggered and console shown --- openpype/modules/base.py | 19 +++++++++++++++++++ openpype/tools/tray/pype_tray.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b8d76aa028..cd2ec778c5 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -139,6 +139,25 @@ class ITrayModule: """ pass + def execute_in_main_thread(self, callback): + """ Pushes callback to the queue or process 'callback' on a main thread + + Some callbacks need to be processed on main thread (menu actions + must be added on main thread or they won't get triggered etc.) + """ + # called without initialized tray, still main thread needed + if not self.tray_initialized: + try: + callback = self._main_thread_callbacks.popleft() + callback() + except: + self.log.warning( + "Failed to execute {} in main thread".format(callback), + exc_info=True) + + return + self.manager.tray_manager.execute_in_main_thread(callback) + def show_tray_message(self, title, message, icon=None, msecs=None): """Show tray message. diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 534c99bd90..3a10bae3ca 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -1,3 +1,4 @@ +import collections import os import sys @@ -20,7 +21,6 @@ class TrayManager: def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget self.main_window = main_window - self.pype_info_widget = None self.log = Logger.get_logger(self.__class__.__name__) @@ -31,6 +31,28 @@ class TrayManager: self.errors = [] + self.main_thread_timer = None + self._main_thread_callbacks = collections.deque() + self._execution_in_progress = None + + def execute_in_main_thread(self, callback): + self._main_thread_callbacks.append(callback) + + def _main_thread_execution(self): + if self._execution_in_progress: + return + self._execution_in_progress = True + while self._main_thread_callbacks: + try: + callback = self._main_thread_callbacks.popleft() + callback() + except: + self.log.warning( + "Failed to execute {} in main thread".format(callback), + exc_info=True) + + self._execution_in_progress = False + def initialize_modules(self): """Add modules to tray.""" @@ -56,6 +78,14 @@ class TrayManager: # Print time report self.modules_manager.print_report() + # create timer loop to check callback functions + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(300) + main_thread_timer.timeout.connect(self._main_thread_execution) + main_thread_timer.start() + + self.main_thread_timer = main_thread_timer + def show_tray_message(self, title, message, icon=None, msecs=None): """Show tray message. From 29a144515b165541b7fc3a5b1496ba0efc8e3493 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 31 May 2021 20:53:40 +0200 Subject: [PATCH 10/23] Console to system tray - changed from TrayIcon to Service submenu Implemented websocket communication --- .../modules/webserver/webserver_module.py | 7 + openpype/tools/tray_app/app.py | 215 +++++++++--------- 2 files changed, 109 insertions(+), 113 deletions(-) diff --git a/openpype/modules/webserver/webserver_module.py b/openpype/modules/webserver/webserver_module.py index 59a0a08427..2d88aff40d 100644 --- a/openpype/modules/webserver/webserver_module.py +++ b/openpype/modules/webserver/webserver_module.py @@ -7,6 +7,8 @@ import six from openpype import resources from .. import PypeModule, ITrayService +from openpype.modules.webserver.host_console_listener import HostListener + @six.add_metaclass(ABCMeta) class IWebServerRoutes: @@ -23,6 +25,7 @@ class WebServerModule(PypeModule, ITrayService): def initialize(self, _module_settings): self.enabled = True self.server_manager = None + self._host_listener = None self.port = self.find_free_port() @@ -37,6 +40,7 @@ class WebServerModule(PypeModule, ITrayService): def tray_init(self): self.create_server_manager() self._add_resources_statics() + self._add_listeners() def tray_start(self): self.start_server() @@ -54,6 +58,9 @@ class WebServerModule(PypeModule, ITrayService): webserver_url, static_prefix ) + def _add_listeners(self): + self._host_listener = HostListener(self.server_manager, self) + def start_server(self): if self.server_manager: self.server_manager.start_server() diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py index 21ee0db0ce..a9046d35fe 100644 --- a/openpype/tools/tray_app/app.py +++ b/openpype/tools/tray_app/app.py @@ -1,19 +1,21 @@ import sys import re -import platform import collections import queue -from io import StringIO +import websocket +import json from avalon import style -from openpype import resources +from openpype.modules.webserver import host_console_listener from Qt import QtWidgets, QtGui, QtCore -class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): +class ConsoleTrayApp(): """Application showing console for non python hosts instead of cmd""" callback_queue = None + process = None + webserver_client = None sdict = { r">>> ": @@ -54,7 +56,6 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): def __init__(self, host, launch_method, subprocess_args, is_host_connected, parent=None): - super(ConsoleTrayIcon, self).__init__(parent) self.host = host self.initialized = False @@ -64,14 +65,12 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): self.launch_method = launch_method self.subprocess_args = subprocess_args self.is_host_connected = is_host_connected + self.tray_reconnect = True self.original_stdout_write = None self.original_stderr_write = None self.new_text = collections.deque() - self.icons = self._select_icons(self.host) - self.status_texts = self._prepare_status_texts(self.host) - timer = QtCore.QTimer() timer.timeout.connect(self.on_timer) timer.setInterval(200) @@ -81,62 +80,113 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): self.catch_std_outputs() - menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) - # not working yet - # - # restart_server_action = QtWidgets.QAction("Restart communication", - # self) - # restart_server_action.triggered.connect(self.restart_server) - # menu.addAction(restart_server_action) + def _connect(self): + """ Connect to Tray webserver to pass console output. """ + ws = websocket.WebSocket() + ws.connect("ws://localhost:8079/ws/host_listener") + ConsoleTrayApp.webserver_client = ws - # Add Exit action to menu - exit_action = QtWidgets.QAction("Exit", self) - exit_action.triggered.connect(self.exit) - menu.addAction(exit_action) + payload = { + "host": self.host, + "action": host_console_listener.MsgAction.CONNECTING, + "text": "Integration with {}".format(str.capitalize(self.host)) + } + self.tray_reconnect = False + self._send(payload) - self.menu = menu + def _connected(self): + """ Send to Tray console that host is ready - icon change. """ + print("Host {} connected".format(self.host)) + if not ConsoleTrayApp.webserver_client: + return - self.dialog = ConsoleDialog(self.new_text) + payload = { + "host": self.host, + "action": host_console_listener.MsgAction.INITIALIZED, + "text": "Integration with {}".format(str.capitalize(self.host)) + } + self.tray_reconnect = False + self._send(payload) - # Catch activate event for left click if not on MacOS - # - MacOS has this ability by design so menu would be doubled - if platform.system().lower() != "darwin": - self.activated.connect(self.on_systray_activated) + def _close(self): + """ Send to Tray that host is closing - remove from Services. """ + print("Host {} closing".format(self.host)) + if not ConsoleTrayApp.webserver_client: + return - self.change_status("initializing") - self.setContextMenu(self.menu) - self.show() + payload = { + "host": self.host, + "action": host_console_listener.MsgAction.CLOSE, + "text": "Integration with {}".format(str.capitalize(self.host)) + } + + self._send(payload) + self.tray_reconnect = False + ConsoleTrayApp.webserver_client.close() + + def _send_text(self, new_text): + """ Send console content. """ + if not ConsoleTrayApp.webserver_client: + return + + if isinstance(new_text, str): + new_text = collections.deque(new_text.split("\n")) + + payload = { + "host": self.host, + "action": host_console_listener.MsgAction.ADD, + "text": "\n".join(new_text) + } + + self._send(payload) + + def _send(self, payload): + """ Worker method to send to existing websocket connection. """ + if not ConsoleTrayApp.webserver_client: + return + + try: + ConsoleTrayApp.webserver_client.send(json.dumps(payload)) + except ConnectionResetError: # Tray closed + ConsoleTrayApp.webserver_client = None + self.tray_reconnect = True def on_timer(self): """Called periodically to initialize and run function on main thread""" - self.dialog.append_text(self.new_text) + if self.tray_reconnect: + self._connect() # reconnect + + if ConsoleTrayApp.webserver_client and self.new_text: + self._send_text(self.new_text) + self.new_text = collections.deque() + if not self.initialized: if self.initializing: host_connected = self.is_host_connected() if host_connected is None: # keep trying return elif not host_connected: - print("{} process is not alive. Exiting".format(self.host)) - ConsoleTrayIcon.websocket_server.stop() + text = "{} process is not alive. Exiting".format(self.host) + print(text) + self._send_text([text]) + ConsoleTrayApp.websocket_server.stop() sys.exit(1) elif host_connected: self.initialized = True self.initializing = False - self.change_status("ready") + self._connected() return - ConsoleTrayIcon.callback_queue = queue.Queue() + ConsoleTrayApp.callback_queue = queue.Queue() self.initializing = True self.launch_method(*self.subprocess_args) - elif ConsoleTrayIcon.process.poll() is not None: - # Wait on Photoshop to close before closing the websocket serv + elif ConsoleTrayApp.process.poll() is not None: self.exit() - elif ConsoleTrayIcon.callback_queue: + elif ConsoleTrayApp.callback_queue: try: - callback = ConsoleTrayIcon.callback_queue.get(block=False) + callback = ConsoleTrayApp.callback_queue.get(block=False) callback() except queue.Empty: pass @@ -148,37 +198,21 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): cls.callback_queue = queue.Queue() cls.callback_queue.put(func_to_call_from_main_thread) - def on_systray_activated(self, reason): - if reason == QtWidgets.QSystemTrayIcon.Context: - position = QtGui.QCursor().pos() - self.menu.popup(position) - else: - self.open_console() - @classmethod def restart_server(cls): - if ConsoleTrayIcon.websocket_server: - ConsoleTrayIcon.websocket_server.stop_server(restart=True) - - def open_console(self): - self.dialog.show() - self.dialog.raise_() - self.dialog.activateWindow() + if ConsoleTrayApp.websocket_server: + ConsoleTrayApp.websocket_server.stop_server(restart=True) + # obsolete def exit(self): - """ Exit whole application. - - - Icon won't stay in tray after exit. - """ - self.dialog.append_text("Exiting!") - if ConsoleTrayIcon.websocket_server: - ConsoleTrayIcon.websocket_server.stop() - ConsoleTrayIcon.process.kill() - ConsoleTrayIcon.process.wait() + """ Exit whole application. """ + self._close() + if ConsoleTrayApp.websocket_server: + ConsoleTrayApp.websocket_server.stop() + ConsoleTrayApp.process.kill() + ConsoleTrayApp.process.wait() if self.timer: self.timer.stop() - self.dialog.hide() - self.hide() QtCore.QCoreApplication.exit() def catch_std_outputs(self): @@ -207,33 +241,6 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): self.original_stderr_write(text) self.new_text.append(text) - def _prepare_status_texts(self, host_name): - """Status text used as a tooltip""" - status_texts = { - 'initializing': "Starting communication with {}".format(host_name), - 'ready': "Communicating with {}".format(host_name), - 'error': "Error!" - } - - return status_texts - - def _select_icons(self, _host_name): - """Use different icons per state and host_name""" - # use host_name - icons = { - 'initializing': QtGui.QIcon( - resources.get_resource("icons", "circle_orange.png") - ), - 'ready': QtGui.QIcon( - resources.get_resource("icons", "circle_green.png") - ), - 'error': QtGui.QIcon( - resources.get_resource("icons", "circle_red.png") - ) - } - - return icons - @staticmethod def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. @@ -256,30 +263,12 @@ class ConsoleTrayIcon(QtWidgets.QSystemTrayIcon): @staticmethod def color(message): - message = ConsoleTrayIcon._multiple_replace(message, - ConsoleTrayIcon.sdict) + """ Color message with html tags. """ + message = ConsoleTrayApp._multiple_replace(message, + ConsoleTrayApp.sdict) return message - def change_status(self, status): - """Updates tooltip and icon with new status""" - self._change_tooltip(status) - self._change_icon(status) - - def _change_tooltip(self, status): - status = self.status_texts.get(status) - if not status: - raise ValueError("Unknown state") - - self.setToolTip(status) - - def _change_icon(self, state): - icon = self.icons.get(state) - if not icon: - raise ValueError("Unknown state") - - self.setIcon(icon) - class ConsoleDialog(QtWidgets.QDialog): """Qt dialog to show stdout instead of unwieldy cmd window""" @@ -308,7 +297,7 @@ class ConsoleDialog(QtWidgets.QDialog): def append_text(self, new_text): if isinstance(new_text, str): - new_text = collections.deque(new_text) + new_text = collections.deque(new_text.split("\n")) while new_text: self.plain_text.appendHtml( - ConsoleTrayIcon.color(new_text.popleft())) + ConsoleTrayApp.color(new_text.popleft())) From d8df3efdd310ea7ab4250d65be91a527720f3669 Mon Sep 17 00:00:00 2001 From: Derek Severin Date: Tue, 1 Jun 2021 11:17:18 +0700 Subject: [PATCH 11/23] Error report fix (no action callback) --- .../event_handlers_user/action_delivery.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py index 06da71ea0a..9c50fb6037 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delivery.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delivery.py @@ -397,15 +397,10 @@ class Delivery(BaseAction): try: self.db_con.install() - result = self.real_launch(session, entities, event) - if result["success"]: - job["status"] = "done" - else: - job["status"] = "failed" + report = self.real_launch(session, entities, event) except Exception as exc: - job["status"] = "failed" - result = { + report = { "success": False, "title": "Delivery failed", "items": [{ @@ -425,7 +420,23 @@ class Delivery(BaseAction): session.commit() self.db_con.uninstall() - return result + if report["success"]: + job["status"] = "done" + + else: + job["status"] = "failed" + + self.show_interface( + items=report["items"], + title=report["title"], + event=event + ) + return { + "success": False, + "message": "Errors during delivery process. See report." + } + + return report def real_launch(self, session, entities, event): self.log.info("Delivery action just started.") @@ -516,7 +527,7 @@ class Delivery(BaseAction): def report(self, report_items): """Returns dict with final status of delivery (succes, fail etc.).""" items = [] - title = "Delivery report" + for msg, _items in report_items.items(): if not _items: continue @@ -547,9 +558,8 @@ class Delivery(BaseAction): return { "items": items, - "title": title, - "success": False, - "message": "Delivery Finished" + "title": "Delivery report", + "success": False } From e78697fe8ddebe9e8e1061320d163bc093d8791a Mon Sep 17 00:00:00 2001 From: Derek Severin Date: Tue, 1 Jun 2021 11:37:55 +0700 Subject: [PATCH 12/23] Job status before commit fix --- .../ftrack/event_handlers_user/action_delivery.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py index 9c50fb6037..2e7599647a 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delivery.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delivery.py @@ -417,15 +417,14 @@ class Delivery(BaseAction): ) finally: + if report["success"]: + job["status"] = "done" + else: + job["status"] = "failed" session.commit() self.db_con.uninstall() - if report["success"]: - job["status"] = "done" - - else: - job["status"] = "failed" - + if not report["success"]: self.show_interface( items=report["items"], title=report["title"], From 53361d6b14e3c161e8739e9c462abeb7ac669088 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 13:02:39 +0200 Subject: [PATCH 13/23] Console to system tray - do not explicitly create cmd for AE --- openpype/hooks/pre_with_windows_shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 7044bd12b2..16047ae66f 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -15,7 +15,7 @@ class LaunchWithWindowsShell(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 app_groups = ["nuke", "nukex", "hiero", "nukestudio", - "aftereffects", "harmony"] + "harmony"] platforms = ["windows"] def execute(self): From 0561940f3d8b0db1c94b4f09c8590c53989a9202 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 13:03:00 +0200 Subject: [PATCH 14/23] Console to system tray - Harmony not yet implemented --- openpype/scripts/non_python_host_launch.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 5357f18348..ba5ac5d035 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -80,12 +80,16 @@ def main(argv): launch_args = sys_args[after_script_idx:] host_name = os.environ["AVALON_APP"].lower() + launch_method = None if host_name == "photoshop": from avalon.photoshop.lib import main + launch_method = main elif host_name == "aftereffects": from avalon.aftereffects.lib import main + launch_method = main elif host_name == "harmony": from avalon.harmony.lib import launch + launch_method = launch else: title = "Unknown host name" message = ( @@ -97,7 +101,7 @@ def main(argv): if launch_args: # Launch host implementation - main(*launch_args) + launch_method(*launch_args) else: # Show message box on_invalid_args(after_script_idx is None) From 962d6e9079c3497b03dfb709388d2be55db07c44 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 13:04:06 +0200 Subject: [PATCH 15/23] 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)) From c664151ab2975ad19012f3deba083477b0042c40 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 13:39:40 +0200 Subject: [PATCH 16/23] Console to system tray - implemented Harmony --- openpype/hooks/pre_with_windows_shell.py | 3 +-- openpype/scripts/non_python_host_launch.py | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 16047ae66f..8049118680 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -14,8 +14,7 @@ class LaunchWithWindowsShell(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio", - "harmony"] + app_groups = ["nuke", "nukex", "hiero", "nukestudio"] platforms = ["windows"] def execute(self): diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index ba5ac5d035..32c4b23f4f 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -80,16 +80,12 @@ def main(argv): launch_args = sys_args[after_script_idx:] host_name = os.environ["AVALON_APP"].lower() - launch_method = None if host_name == "photoshop": from avalon.photoshop.lib import main - launch_method = main elif host_name == "aftereffects": from avalon.aftereffects.lib import main - launch_method = main elif host_name == "harmony": - from avalon.harmony.lib import launch - launch_method = launch + from avalon.harmony.lib import main else: title = "Unknown host name" message = ( @@ -101,7 +97,7 @@ def main(argv): if launch_args: # Launch host implementation - launch_method(*launch_args) + main(*launch_args) else: # Show message box on_invalid_args(after_script_idx is None) From aeddbfe2a26584ab006512e896ec154e402827f0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 14:06:16 +0200 Subject: [PATCH 17/23] Console to system tray - Hound --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index cfd4191e36..a68b893a9f 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit cfd4191e364b47de7364096f45d9d9d9a901692a +Subproject commit a68b893a9fc6772718c04934185b4206455df589 From 305f12fa287919c375f39bdd2aec4293f1c93896 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Jun 2021 14:14:00 +0200 Subject: [PATCH 18/23] Console to system tray - Hound --- openpype/hooks/pre_with_windows_shell.py | 1 - openpype/modules/webserver/host_console_listener.py | 10 ++++------ openpype/tools/tray_app/app.py | 10 +++++----- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/openpype/hooks/pre_with_windows_shell.py b/openpype/hooks/pre_with_windows_shell.py index 8049118680..441ab1a675 100644 --- a/openpype/hooks/pre_with_windows_shell.py +++ b/openpype/hooks/pre_with_windows_shell.py @@ -1,7 +1,6 @@ import os import subprocess from openpype.lib import PreLaunchHook -from openpype.lib.applications import ApplicationLaunchContext class LaunchWithWindowsShell(PreLaunchHook): diff --git a/openpype/modules/webserver/host_console_listener.py b/openpype/modules/webserver/host_console_listener.py index 68973ae10a..9dd7dcc9b6 100644 --- a/openpype/modules/webserver/host_console_listener.py +++ b/openpype/modules/webserver/host_console_listener.py @@ -3,9 +3,9 @@ from aiohttp import web import json import logging from concurrent.futures import CancelledError -from Qt import QtWidgets, QtCore +from Qt import QtWidgets -from openpype.modules import TrayModulesManager, ITrayService +from openpype.modules import ITrayService from openpype.tools.tray_app.app import ConsoleDialog log = logging.getLogger(__name__) @@ -102,11 +102,9 @@ class HostListener: 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 @@ -123,10 +121,10 @@ class HostListener: 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) + error_msg = str(exc) + widget.append_text(error_msg) return ws diff --git a/openpype/tools/tray_app/app.py b/openpype/tools/tray_app/app.py index 35422a7269..339e6343f8 100644 --- a/openpype/tools/tray_app/app.py +++ b/openpype/tools/tray_app/app.py @@ -11,7 +11,7 @@ from datetime import datetime from avalon import style from openpype.modules.webserver import host_console_listener -from Qt import QtWidgets, QtGui, QtCore +from Qt import QtWidgets, QtCore class ConsoleTrayApp: @@ -105,7 +105,7 @@ class ConsoleTrayApp: ConsoleTrayApp.webserver_client = ws payload = { - "host": self.host_id, + "host": self.host_id, "action": host_console_listener.MsgAction.CONNECTING, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -119,7 +119,7 @@ class ConsoleTrayApp: return payload = { - "host": self.host_id, + "host": self.host_id, "action": host_console_listener.MsgAction.INITIALIZED, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -133,7 +133,7 @@ class ConsoleTrayApp: return payload = { - "host": self.host_id, + "host": self.host_id, "action": host_console_listener.MsgAction.CLOSE, "text": "Integration with {}".format(str.capitalize(self.host)) } @@ -151,7 +151,7 @@ class ConsoleTrayApp: new_text = collections.deque(new_text.split("\n")) payload = { - "host": self.host_id, + "host": self.host_id, "action": host_console_listener.MsgAction.ADD, "text": "\n".join(new_text) } From bcff8348d22f6b296472691d6670ff0fb7c82c1c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 1 Jun 2021 17:23:36 +0200 Subject: [PATCH 19/23] prepare skip paths only for list template --- openpype/settings/entities/lib.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index a5c61a9dda..b0bfab8d7c 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -76,22 +76,22 @@ def _fill_schema_template_data( if key not in template_data: template_data[key] = value - # Store paths by first part if path - # - None value says that whole key should be skipped - skip_paths_by_first_key = {} - for path in skip_paths: - parts = path.split("/") - key = parts.pop(0) - if key not in skip_paths_by_first_key: - skip_paths_by_first_key[key] = [] - - value = "/".join(parts) - skip_paths_by_first_key[key].append(value or None) - if not template: output = template elif isinstance(template, list): + # Store paths by first part if path + # - None value says that whole key should be skipped + skip_paths_by_first_key = {} + for path in skip_paths: + parts = path.split("/") + key = parts.pop(0) + if key not in skip_paths_by_first_key: + skip_paths_by_first_key[key] = [] + + value = "/".join(parts) + skip_paths_by_first_key[key].append(value or None) + output = [] for item in template: # Get skip paths for children item From 55b2e990e983fff95c37079bfa45b4a3078dd36a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 1 Jun 2021 17:23:59 +0200 Subject: [PATCH 20/23] send original skip paths to wrappers --- openpype/settings/entities/lib.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index b0bfab8d7c..4583ed0de0 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -96,7 +96,13 @@ def _fill_schema_template_data( for item in template: # Get skip paths for children item _skip_paths = [] - if skip_paths_by_first_key and isinstance(item, dict): + if not isinstance(item, dict): + pass + + elif item.get("type") in WRAPPER_TYPES: + _skip_paths = copy.deepcopy(skip_paths) + + elif skip_paths_by_first_key: # Check if this item should be skipped key = item.get("key") if key and key in skip_paths_by_first_key: From 45e4244b847711675bdf33f5104f0c018566ecd4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 1 Jun 2021 17:24:12 +0200 Subject: [PATCH 21/23] skip filled template without result --- openpype/settings/entities/lib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 4583ed0de0..23e947e405 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -114,7 +114,8 @@ def _fill_schema_template_data( output_item = _fill_schema_template_data( item, template_data, _skip_paths, required_keys, missing_keys ) - output.append(output_item) + if output_item: + output.append(output_item) elif isinstance(template, dict): output = {} From 7c4a156cec7b13cf459cf9823d61f45b740fb269 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 1 Jun 2021 17:24:26 +0200 Subject: [PATCH 22/23] wrappers without children return empty dict --- openpype/settings/entities/lib.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 23e947e405..05f4ea64f8 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -123,6 +123,8 @@ def _fill_schema_template_data( output[key] = _fill_schema_template_data( value, template_data, skip_paths, required_keys, missing_keys ) + if output.get("type") in WRAPPER_TYPES and not output.get("children"): + return {} elif isinstance(template, STRING_TYPE): # TODO find much better way how to handle filling template data From 5d74db0d60901125c09117bb22d9a654cf9c296c Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 1 Jun 2021 20:06:13 +0200 Subject: [PATCH 23/23] change avalon core to develop --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index a68b893a9f..e9882d0fff 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit a68b893a9fc6772718c04934185b4206455df589 +Subproject commit e9882d0ffff27fed03a03459f496c29da0310cd2