From eae6d364211431d0d028edf2cd0f4da0b7d66c6f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Feb 2022 18:45:59 +0100 Subject: [PATCH] OP-2414 - reworked launch logic, introduced ProcessContext class --- openpype/hosts/harmony/api/lib.py | 201 +++++++++++++++---------- openpype/hosts/harmony/api/pipeline.py | 4 +- openpype/hosts/harmony/api/server.py | 4 +- openpype/hosts/harmony/api/workio.py | 27 ++-- 4 files changed, 142 insertions(+), 94 deletions(-) diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 4fee7ab07d..d4d963774f 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -14,36 +14,47 @@ import json import signal import time from uuid import uuid4 -from Qt import QtWidgets +from Qt import QtWidgets, QtCore, QtGui import queue +import collections +import platform from .server import Server from openpype.tools.stdout_broker.app import StdOutBroker from openpype.tools.utils import host_tools +from openpype import style -# TODO refactor -self = sys.modules[__name__] -self.server = None -self.pid = None -self.application_path = None -self.callback_queue = None -self.workfile_path = None -self.port = None -self.stdout_broker = None # Setup logging. -self.log = logging.getLogger(__name__) -self.log.setLevel(logging.DEBUG) +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) -def execute_in_main_thread(func_to_call_from_main_thread): - self.callback_queue.put(func_to_call_from_main_thread) +class ProcessContext: + server = None + pid = None + process = None + application_path = None + callback_queue = collections.deque() + workfile_path = None + port = None + stdout_broker = None + workfile_tool = None + @classmethod + def execute_in_main_thread(cls, func_to_call_from_main_thread): + cls.callback_queue.append(func_to_call_from_main_thread) -def main_thread_listen(): - callback = self.callback_queue.get() - callback() + @classmethod + def main_thread_listen(cls): + if cls.callback_queue: + callback = cls.callback_queue.popleft() + callback() + if cls.process is not None and cls.process.poll() is not None: + log.info("Server is not running, closing") + ProcessContext.stdout_broker.exit() + sys.exit() def signature(postfix="func") -> str: @@ -75,11 +86,19 @@ def main(*subprocess_args): os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" app = QtWidgets.QApplication([]) app.setQuitOnLastWindowClosed(False) + icon = QtGui.QIcon(style.get_app_icon_path()) + app.setWindowIcon(icon) - self.stdout_broker = StdOutBroker('harmony') + ProcessContext.stdout_broker = StdOutBroker('harmony') launch(*subprocess_args) + loop_timer = QtCore.QTimer() + loop_timer.setInterval(20) + + loop_timer.timeout.connect(ProcessContext.main_thread_listen) + loop_timer.start() + sys.exit(app.exec_()) @@ -97,7 +116,8 @@ def setup_startup_scripts(): * Use TB_sceneOpenedUI.js instead to manage startup logic * Add their startup logic to avalon/harmony/TB_sceneOpened.js """ - avalon_dcc_dir = os.path.dirname(os.path.dirname(__file__)) + avalon_dcc_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), + "api") startup_js = "TB_sceneOpened.js" if os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"): @@ -111,8 +131,8 @@ def setup_startup_scripts(): try: shutil.copy(avalon_harmony_startup, env_harmony_startup) except Exception as e: - self.log.error(e) - self.log.warning( + log.error(e) + log.warning( "Failed to copy {0} to {1}! " "Defaulting to Avalon TOONBOOM_GLOBAL_SCRIPT_LOCATION." .format(avalon_harmony_startup, env_harmony_startup)) @@ -148,7 +168,7 @@ def check_libs(): return else: - self.log.error(("Cannot find OpenHarmony library. " + log.error(("Cannot find OpenHarmony library. " "Please set path to it in LIB_OPENHARMONY_PATH " "environment variable.")) raise RuntimeError("Missing OpenHarmony library.") @@ -170,35 +190,41 @@ def launch(application_path, *args): api.install(harmony) - self.port = random.randrange(49152, 65535) - os.environ["AVALON_HARMONY_PORT"] = str(self.port) - self.application_path = application_path + ProcessContext.port = random.randrange(49152, 65535) + os.environ["AVALON_HARMONY_PORT"] = str(ProcessContext.port) + ProcessContext.application_path = application_path # Launch Harmony. setup_startup_scripts() check_libs() - if os.environ.get("AVALON_HARMONY_WORKFILES_ON_LAUNCH", False): - host_tools.show_workfiles(save=False) + if not os.environ.get("AVALON_HARMONY_WORKFILES_ON_LAUNCH", False): + open_empty_workfile() + return - # No launch through Workfiles happened. - if not self.workfile_path: - zip_file = os.path.join(os.path.dirname(__file__), "temp.zip") - temp_path = get_local_harmony_path(zip_file) - if os.path.exists(temp_path): - self.log.info(f"removing existing {temp_path}") - try: - shutil.rmtree(temp_path) - except Exception as e: - self.log.critical(f"cannot clear {temp_path}") - raise Exception(f"cannot clear {temp_path}") from e + ProcessContext.workfile_tool = host_tools.get_tool_by_name("workfiles") + host_tools.show_workfiles(save=False) + ProcessContext.execute_in_main_thread(check_workfiles_tool) - launch_zip_file(zip_file) +def check_workfiles_tool(): + if ProcessContext.workfile_tool.isVisible(): + ProcessContext.execute_in_main_thread(check_workfiles_tool) + elif not ProcessContext.workfile_path: + open_empty_workfile() - self.callback_queue = queue.Queue() - while True: - main_thread_listen() +def open_empty_workfile(): + zip_file = os.path.join(os.path.dirname(__file__), "temp.zip") + temp_path = get_local_harmony_path(zip_file) + if os.path.exists(temp_path): + log.info(f"removing existing {temp_path}") + try: + shutil.rmtree(temp_path) + except Exception as e: + log.critical(f"cannot clear {temp_path}") + raise Exception(f"cannot clear {temp_path}") from e + + launch_zip_file(zip_file) def get_local_harmony_path(filepath): """From the provided path get the equivalent local Harmony path.""" @@ -226,7 +252,7 @@ def launch_zip_file(filepath): try: shutil.rmtree(temp_path) except Exception as e: - self.log.error(e) + log.error(e) raise Exception("Cannot delete working folder") from e unzip = True else: @@ -237,22 +263,22 @@ def launch_zip_file(filepath): zip_ref.extractall(temp_path) # Close existing scene. - if self.pid: - os.kill(self.pid, signal.SIGTERM) + if ProcessContext.pid: + os.kill(ProcessContext.pid, signal.SIGTERM) # Stop server. - if self.server: - self.server.stop() + if ProcessContext.server: + ProcessContext.server.stop() # Launch Avalon server. - self.server = Server(self.port) - self.server.start() + ProcessContext.server = Server(ProcessContext.port) + ProcessContext.server.start() # thread = threading.Thread(target=self.server.start) # thread.daemon = True # thread.start() # Save workfile path for later. - self.workfile_path = filepath + ProcessContext.workfile_path = filepath # find any xstage files is directory, prefer the one with the same name # as directory (plus extension) @@ -264,7 +290,7 @@ def launch_zip_file(filepath): if not os.path.basename("temp.zip"): if not xstage_files: - self.server.stop() + ProcessContext.server.stop() print("no xstage file was found") return @@ -284,18 +310,21 @@ def launch_zip_file(filepath): if not os.path.exists(scene_path): print("error: cannot determine scene file") - self.server.stop() + ProcessContext.server.stop() return print("Launching {}".format(scene_path)) + #kwargs = get_non_python_app_args() + + kwargs = _get_kwargs() process = subprocess.Popen( - [self.application_path, scene_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + [ProcessContext.application_path, scene_path], + **kwargs ) - self.pid = process.pid - self.stdout_broker.host_connected() + ProcessContext.pid = process.pid + ProcessContext.process = process + ProcessContext.stdout_broker.host_connected() def on_file_changed(path, threaded=True): @@ -303,19 +332,19 @@ def on_file_changed(path, threaded=True): This method is called when the `.xstage` file is changed. """ - self.log.debug("File changed: " + path) + log.debug("File changed: " + path) - if self.workfile_path is None: + if ProcessContext.workfile_path is None: return if threaded: thread = threading.Thread( target=zip_and_move, - args=(os.path.dirname(path), self.workfile_path) + args=(os.path.dirname(path), ProcessContext.workfile_path) ) thread.start() else: - zip_and_move(os.path.dirname(path), self.workfile_path) + zip_and_move(os.path.dirname(path), ProcessContext.workfile_path) def zip_and_move(source, destination): @@ -332,7 +361,7 @@ def zip_and_move(source, destination): if zr.testzip() is not None: raise Exception("File archive is corrupted.") shutil.move(os.path.basename(source) + ".zip", destination) - self.log.debug(f"Saved '{source}' to '{destination}'") + log.debug(f"Saved '{source}' to '{destination}'") def show(module_name): @@ -360,7 +389,7 @@ def show(module_name): if tool_name == "loader": kwargs["use_context"] = True - execute_in_main_thread( + ProcessContext.execute_in_main_thread( lambda: host_tools.show_tool_by_name(tool_name, **kwargs) ) @@ -370,7 +399,7 @@ def show(module_name): def get_scene_data(): try: - return self.send( + return send( { "function": "AvalonHarmony.getSceneData" })["result"] @@ -390,7 +419,7 @@ def set_scene_data(data): """ # Write scene data. - self.send( + send( { "function": "AvalonHarmony.setSceneData", "args": data @@ -427,7 +456,7 @@ def remove(node_id): def delete_node(node): """ Physically delete node from scene. """ - self.send( + send( { "function": "AvalonHarmony.deleteNode", "args": node @@ -466,7 +495,7 @@ def imprint(node_id, data, remove=False): def maintained_selection(): """Maintain selection during context.""" - selected_nodes = self.send( + selected_nodes = send( { "function": "AvalonHarmony.getSelectedNodes" })["result"] @@ -474,7 +503,7 @@ def maintained_selection(): try: yield selected_nodes finally: - selected_nodes = self.send( + selected_nodes = send( { "function": "AvalonHarmony.selectNodes", "args": selected_nodes @@ -484,12 +513,12 @@ def maintained_selection(): def send(request): """Public method for sending requests to Harmony.""" - return self.server.send(request) + return ProcessContext.server.send(request) def select_nodes(nodes): """ Selects nodes in Node View """ - _ = self.send( + _ = send( { "function": "AvalonHarmony.selectNodes", "args": nodes @@ -501,13 +530,13 @@ def select_nodes(nodes): def maintained_nodes_state(nodes): """Maintain nodes states during context.""" # Collect current state. - states = self.send( + states = send( { "function": "AvalonHarmony.areEnabled", "args": nodes })["result"] # Disable all nodes. - self.send( + send( { "function": "AvalonHarmony.disableNodes", "args": nodes }) @@ -515,7 +544,7 @@ def maintained_nodes_state(nodes): try: yield finally: - self.send( + send( { "function": "AvalonHarmony.setState", "args": [nodes, states] @@ -533,21 +562,21 @@ def save_scene(): """ # Need to turn off the backgound watcher else the communication with # the server gets spammed with two requests at the same time. - scene_path = self.send( + scene_path = send( {"function": "AvalonHarmony.saveScene"})["result"] # Manually update the remote file. - self.on_file_changed(scene_path, threaded=False) + on_file_changed(scene_path, threaded=False) # Re-enable the background watcher. - self.send({"function": "AvalonHarmony.enableFileWather"}) + send({"function": "AvalonHarmony.enableFileWather"}) def save_scene_as(filepath): """Save Harmony scene as `filepath`.""" scene_dir = os.path.dirname(filepath) destination = os.path.join( - os.path.dirname(self.workfile_path), + os.path.dirname(ProcessContext.workfile_path), os.path.splitext(os.path.basename(filepath))[0] + ".zip" ) @@ -555,7 +584,7 @@ def save_scene_as(filepath): try: shutil.rmtree(scene_dir) except Exception as e: - self.log.error(f"Cannot remove {scene_dir}") + log.error(f"Cannot remove {scene_dir}") raise Exception(f"Cannot remove {scene_dir}") from e send( @@ -564,7 +593,7 @@ def save_scene_as(filepath): zip_and_move(scene_dir, destination) - self.workfile_path = destination + ProcessContext.workfile_path = destination send( {"function": "AvalonHarmony.addPathToWatcher", "args": filepath} @@ -594,3 +623,17 @@ def find_node_by_name(name, node_type): return node return None + + +def _get_kwargs(): + """Explicitly handle openpype_gui no not show console.""" + kwargs = {} + if platform.system().lower() == "windows": + if "openpype_gui" in os.environ.get("OPENPYPE_EXECUTABLE"): + kwargs.update({ + "creationflags": subprocess.CREATE_NO_WINDOW, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL + }) + print("kwargs:: {}".format(kwargs)) + return kwargs diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 306f270410..17d2870876 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -257,7 +257,7 @@ def list_instances(remove_orphaned=True): Returns: (list) of dictionaries matching instances format """ - objects = lib.get_scene_data() or {} + objects = harmony.get_scene_data() or {} instances = [] for key, data in objects.items(): # Skip non-tagged objects. @@ -272,7 +272,7 @@ def list_instances(remove_orphaned=True): if remove_orphaned: node_name = key.split("/")[-1] - located_node = lib.find_node_by_name(node_name, 'WRITE') + located_node = harmony.find_node_by_name(node_name, 'WRITE') if not located_node: print("Removing orphaned instance {}".format(key)) harmony.remove(key) diff --git a/openpype/hosts/harmony/api/server.py b/openpype/hosts/harmony/api/server.py index cb9a13f00d..88cfe54521 100644 --- a/openpype/hosts/harmony/api/server.py +++ b/openpype/hosts/harmony/api/server.py @@ -9,8 +9,8 @@ import functools import time import struct from datetime import datetime -from . import lib import threading +from . import lib class Server(threading.Thread): @@ -76,7 +76,7 @@ class Server(threading.Thread): kwargs = request.get("kwargs", {}) partial_method = functools.partial(method, *args, **kwargs) - lib.execute_in_main_thread(partial_method) + lib.ProcessContext.execute_in_main_thread(partial_method) except Exception: self.log.error(traceback.format_exc()) diff --git a/openpype/hosts/harmony/api/workio.py b/openpype/hosts/harmony/api/workio.py index d7b0e48d42..2473dedbca 100644 --- a/openpype/hosts/harmony/api/workio.py +++ b/openpype/hosts/harmony/api/workio.py @@ -2,7 +2,12 @@ import os import shutil -from . import lib +from .lib import ( + ProcessContext, + get_local_harmony_path, + zip_and_move, + launch_zip_file +) from avalon import api # used to lock saving until previous save is done. @@ -14,8 +19,8 @@ def file_extensions(): def has_unsaved_changes(): - if lib.server: - return lib.server.send({"function": "scene.isDirty"})["result"] + if ProcessContext.server: + return ProcessContext.server.send({"function": "scene.isDirty"})["result"] return False @@ -23,34 +28,34 @@ def has_unsaved_changes(): def save_file(filepath): global save_disabled if save_disabled: - return lib.server.send( + return ProcessContext.server.send( { "function": "show_message", "args": "Saving in progress, please wait until it finishes." })["result"] save_disabled = True - temp_path = lib.get_local_harmony_path(filepath) + temp_path = get_local_harmony_path(filepath) - if lib.server: + if ProcessContext.server: if os.path.exists(temp_path): try: shutil.rmtree(temp_path) except Exception as e: raise Exception(f"cannot delete {temp_path}") from e - lib.server.send( + ProcessContext.server.send( {"function": "scene.saveAs", "args": [temp_path]} )["result"] - lib.zip_and_move(temp_path, filepath) + zip_and_move(temp_path, filepath) - lib.workfile_path = filepath + ProcessContext.workfile_path = filepath scene_path = os.path.join( temp_path, os.path.basename(temp_path) + ".xstage" ) - lib.server.send( + ProcessContext.server.send( {"function": "AvalonHarmony.addPathToWatcher", "args": scene_path} ) else: @@ -60,7 +65,7 @@ def save_file(filepath): def open_file(filepath): - lib.launch_zip_file(filepath) + launch_zip_file(filepath) def current_file():